Skip to content

Commit 5502c3a

Browse files
authored
feat: add research agent (#65)
* feat: init research api route * feat: add sources-list, get parts * feat: update structuredOutputs * fix: parts * feat: update prose MessageContent * feat: update runResearchAgent * feat: add exa, plug real search * feat: update SourcesList UI * feat: add motion source list * feat: update source-list UI * feat: init card zola research * feat: fetch zola-research from db * feat: fetch research agent, add handleZolaResearch * fix: handleZolaResearch submit * feat: add research status, placeholder research * feat: improve research * feat: update research * feat: improve runResearchAgent * feat: update research * fix: save research prompt * fix: ensureChatExists when agent * feat: improve prompt * feat: update subtopics prompt * feat: update runResearchAgent * fix: build
1 parent 7d9bee0 commit 5502c3a

22 files changed

+848
-58
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ MISTRAL_API_KEY=your_mistral_api_key
1111

1212
# CSRF
1313
CSRF_SECRET=your_csrf_secret
14+
15+
# Exa
16+
EXA_API_KEY=your_exa_api_key

app/agents/page.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { AgentsPage } from "@/app/components/agents/agents-page"
22
import { LayoutApp } from "@/app/components/layout/layout-app"
33
import { MessagesProvider } from "@/lib/chat-store/messages/provider"
4-
import { ZOLA_AGENT_SLUGS } from "@/lib/config"
4+
import { ZOLA_AGENTS_SLUGS, ZOLA_SPECIAL_AGENTS_SLUGS } from "@/lib/config"
55
import { createClient } from "@/lib/supabase/server"
66

77
export const dynamic = "force-dynamic"
88

9+
const ZOLA_ALL_AGENTS_SLUGS = [
10+
...ZOLA_AGENTS_SLUGS,
11+
...ZOLA_SPECIAL_AGENTS_SLUGS,
12+
]
13+
914
export default async function Page() {
1015
const supabase = await createClient()
1116

@@ -14,7 +19,7 @@ export default async function Page() {
1419
.select(
1520
"id, name, description, avatar_url, example_inputs, creator_id, slug"
1621
)
17-
.in("slug", ZOLA_AGENT_SLUGS)
22+
.in("slug", ZOLA_ALL_AGENTS_SLUGS)
1823

1924
if (agentsError) {
2025
console.error(agentsError)

app/api/chat/route.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ export async function POST(req: Request) {
4040

4141
const supabase = await validateUserIdentity(userId, isAuthenticated)
4242

43-
// First check if the user is within their usage limits
4443
await checkUsage(supabase, userId)
4544

4645
const userMessage = messages[messages.length - 1]
@@ -57,8 +56,6 @@ export async function POST(req: Request) {
5756
console.error("Error saving user message:", msgError)
5857
} else {
5958
console.log("User message saved successfully.")
60-
61-
// Increment usage only after confirming the message was saved
6259
await incrementUsage(supabase, userId)
6360
}
6461
}

app/api/research/route.ts

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import {
2+
checkSpecialAgentUsage,
3+
checkUsage,
4+
incrementSpecialAgentUsage,
5+
incrementUsage,
6+
SpecialAgentLimitError,
7+
UsageLimitError,
8+
} from "@/lib/api"
9+
import { sanitizeUserInput } from "@/lib/sanitize"
10+
import { validateUserIdentity } from "@/lib/server/api"
11+
import { openai } from "@ai-sdk/openai"
12+
import { generateObject } from "ai"
13+
import Exa from "exa-js"
14+
import { z } from "zod"
15+
16+
const exa = new Exa(process.env.EXA_API_KEY!)
17+
18+
async function generateReportTitle(prompt: string) {
19+
const { object: titleObj } = await generateObject({
20+
model: openai("gpt-4.1-nano", { structuredOutputs: true }),
21+
schema: z.object({ title: z.string() }),
22+
prompt: `Write a short report title (max 12 words) for:
23+
"${prompt}". Only capitalize the first word; no trailing punctuation; avoid the word “report”.`,
24+
})
25+
26+
return titleObj.title
27+
}
28+
29+
async function generateSearchQueries(prompt: string) {
30+
const { object: queries } = await generateObject({
31+
model: openai("gpt-4.1-nano", { structuredOutputs: true }),
32+
schema: z.object({ queries: z.array(z.string()) }),
33+
prompt: `Generate exactly 3 search queries for "${prompt}" that would make good H2 sections.`,
34+
})
35+
36+
return queries
37+
}
38+
39+
async function fetchSearchResults(queries: string[]) {
40+
const searchResults = await Promise.all(
41+
queries.map(async (query) => {
42+
const { results } = await exa.searchAndContents(query, {
43+
livecrawl: "always",
44+
numResults: 3,
45+
})
46+
const seen = new Set<string>()
47+
const unique = results
48+
.filter((r) => r.url && !seen.has(r.url) && seen.add(r.url))
49+
.slice(0, 2)
50+
51+
return {
52+
query,
53+
sources: unique.map((r) => ({
54+
title: r.title ?? "Untitled",
55+
url: r.url!,
56+
snippet: (r.text ?? "").slice(0, 350),
57+
})),
58+
}
59+
})
60+
)
61+
62+
return searchResults
63+
}
64+
65+
async function summarizeSources(
66+
searchResults: {
67+
query: string
68+
sources: { title: string; url: string; snippet: string }[]
69+
}[]
70+
) {
71+
const summaries = await Promise.all(
72+
searchResults.map(async ({ query, sources }) => {
73+
const bulletedSources = sources
74+
.map((s, i) => `${i + 1}. "${s.title}": ${s.snippet}`)
75+
.join("\n")
76+
77+
const { object } = await generateObject({
78+
model: openai("gpt-4.1-mini"),
79+
prompt: `Summarize the key insights about "${query}" as **exactly 2-6 bullets**.
80+
• Each bullet **must start with "-" "** (hyphen + space) – no other bullet symbols.
81+
• One concise sentence per bullet; no intro, no conclusion, no extra paragraphs.
82+
• Base the bullets only on the information below, do not include links.
83+
• Focus on specific ideas, patterns, or tactics, not general claims.
84+
• Do not sound AI-generated, sound like a human writing a report.
85+
86+
${bulletedSources}`,
87+
system: `You are a senior research writer.
88+
89+
Your job is to extract only the most useful and practical insights from a given source.
90+
91+
Write in a clear, direct tone. Avoid filler. No introductions or conclusions.
92+
93+
Always return 3–6 markdown bullet points starting with "- ".
94+
95+
Be specific. If nothing useful is in the snippet, say: "- No relevant insight found."
96+
`,
97+
schema: z.object({
98+
summary: z.string(),
99+
}),
100+
})
101+
102+
return {
103+
query,
104+
summary: object.summary.trim(),
105+
citations: sources,
106+
}
107+
})
108+
)
109+
110+
return summaries
111+
}
112+
113+
type ResearchFinding = {
114+
query: string
115+
summary: string
116+
citations: { title: string; url: string; snippet: string }[]
117+
}
118+
119+
async function analyzeSufficiency(findings: ResearchFinding[], topic: string) {
120+
const content = findings
121+
.map((f) => f.summary)
122+
.join("\n\n")
123+
.slice(0, 8000) // limit tokens
124+
125+
const { object } = await generateObject({
126+
model: openai("gpt-4.1-mini"),
127+
prompt: `You are reviewing the following findings for the research topic: "${topic}".
128+
129+
<findings>
130+
${content}
131+
</findings>
132+
133+
Answer:
134+
- Is this content sufficient to write a useful and specific report?
135+
- If not, what important information is still missing?
136+
- Suggest up to 3 follow-up search queries to complete the missing parts.
137+
138+
Respond clearly as JSON. Be pragmatic, not perfectionist.`,
139+
system: `
140+
You are a senior research analyst.
141+
142+
Your job is to assess whether the findings are **enough to produce a helpful report**.
143+
144+
Be practical: assume the user wants to move fast. If the findings include specific, diverse, and actionable content—even if imperfect—mark it as sufficient.
145+
146+
If something important is missing, suggest targeted queries to close the gap.
147+
148+
Only return structured JSON. No filler.
149+
`,
150+
schema: z.object({
151+
sufficient: z.boolean(),
152+
missing: z.array(z.string()).optional(),
153+
followupQueries: z.array(z.string()).optional(),
154+
}),
155+
})
156+
157+
return {
158+
sufficient: object.sufficient,
159+
missing: object.missing ?? [],
160+
followupQueries: object.followupQueries ?? [],
161+
}
162+
}
163+
164+
async function generateReport(findings: ResearchFinding[], title: string) {
165+
const content = findings
166+
.map((f) => f.summary)
167+
.join("\n\n")
168+
.slice(0, 8000)
169+
170+
const { object } = await generateObject({
171+
model: openai("gpt-4.1-mini"),
172+
prompt: `Write a concise, well-structured markdown report titled "${title}".
173+
Use the research notes below. If anything is missing, fill the gaps with your knowledge.
174+
175+
<research>
176+
${content}
177+
</research>
178+
179+
Return only markdown content. No intro or explanation outside the report.`,
180+
system: `
181+
You are a senior technical writer with deep domain knowledge.
182+
183+
Write a report in markdown. Keep it:
184+
- Structured (H1, H2, H3)
185+
- Only capitalize the first word of each sentence
186+
- Clear and direct
187+
- Based on the provided findings
188+
- Filled with real, practical insights
189+
- Not AI-generic, sound sharp and human
190+
191+
Use:
192+
# Title
193+
## Section
194+
### Subsection
195+
- Bullet points when useful
196+
- Code blocks if relevant
197+
198+
Do not explain the task. Just return the markdown. Start immediately with "#".
199+
`,
200+
schema: z.object({ markdown: z.string() }),
201+
})
202+
203+
return object.markdown.trim()
204+
}
205+
206+
async function runResearchAgent(prompt: string) {
207+
const reportTitle = await generateReportTitle(prompt)
208+
const searchQueries = await generateSearchQueries(prompt)
209+
const searchResults = await fetchSearchResults(searchQueries.queries)
210+
const summaries = await summarizeSources(searchResults)
211+
// const { sufficient, missing, followupQueries } = await analyzeSufficiency(
212+
// summaries,
213+
// prompt
214+
// )
215+
const report = await generateReport(summaries, reportTitle)
216+
217+
return {
218+
markdown: report,
219+
parts: summaries.flatMap(({ citations }, i) =>
220+
citations.map((src, j) => ({
221+
type: "source",
222+
source: {
223+
sourceType: "url",
224+
id: `src-${i}-${j}`,
225+
url: src.url,
226+
title: src.title,
227+
},
228+
}))
229+
),
230+
}
231+
}
232+
233+
function jsonRes(
234+
body: Record<string, unknown>,
235+
status = 200,
236+
headers: HeadersInit = {}
237+
) {
238+
return new Response(JSON.stringify(body), {
239+
status,
240+
headers: { "Content-Type": "application/json", ...headers },
241+
})
242+
}
243+
244+
export async function POST(req: Request) {
245+
const start = Date.now()
246+
try {
247+
/* ---------- 0. basic validation ---------- */
248+
const { prompt, chatId, userId, isAuthenticated } = await req.json()
249+
if (!prompt || !chatId || !userId) {
250+
return jsonRes({ error: "Missing data" }, 400)
251+
}
252+
253+
/* ---------- 1. auth + limit checks ---------- */
254+
let supabase
255+
try {
256+
supabase = await validateUserIdentity(userId, isAuthenticated)
257+
await checkUsage(supabase, userId)
258+
await checkSpecialAgentUsage(supabase, userId)
259+
} catch (e) {
260+
if (e instanceof UsageLimitError || e instanceof SpecialAgentLimitError) {
261+
return jsonRes({ error: e.message, code: e.code }, 403)
262+
}
263+
console.error("❌ Identity / limit check failed", e)
264+
return jsonRes({ error: "Auth or quota check failed" }, 401)
265+
}
266+
267+
const sanitizedPrompt = sanitizeUserInput(prompt)
268+
269+
/* ---------- 2. persist user message ---------- */
270+
const { error: saveUserErr } = await supabase.from("messages").insert({
271+
chat_id: chatId,
272+
role: "user",
273+
content: sanitizedPrompt,
274+
user_id: userId,
275+
})
276+
if (saveUserErr) {
277+
console.error("❌ DB insert (user msg) failed", saveUserErr)
278+
return jsonRes({ error: "Database error when saving message" }, 502)
279+
}
280+
281+
/* ---------- 3. run the research agent ---------- */
282+
let result
283+
try {
284+
result = await runResearchAgent(sanitizedPrompt)
285+
} catch (e) {
286+
console.error("❌ runResearchAgent failed", e)
287+
return jsonRes({ error: "Research generation failed" }, 502)
288+
}
289+
290+
/* ---------- 4. persist assistant message ---------- */
291+
const { error: saveAssistantErr } = await supabase.from("messages").insert({
292+
chat_id: chatId,
293+
role: "assistant",
294+
content: result.markdown,
295+
user_id: userId,
296+
parts: result.parts,
297+
})
298+
if (saveAssistantErr) {
299+
console.error("❌ DB insert (assistant msg) failed", saveAssistantErr)
300+
return jsonRes(
301+
{ error: "Database error when saving assistant reply" },
302+
502
303+
)
304+
}
305+
306+
/* ---------- 5. update counters ---------- */
307+
await Promise.all([
308+
incrementUsage(supabase, userId),
309+
incrementSpecialAgentUsage(supabase, userId),
310+
])
311+
312+
console.info(
313+
`✅ /api/research done in ${Date.now() - start} ms (chat ${chatId})`
314+
)
315+
return jsonRes(result, 200)
316+
} catch (err) {
317+
// fallback: truly unexpected error
318+
console.error("🛑 /api/research fatal error", err)
319+
return jsonRes(
320+
{
321+
error: "Internal server error",
322+
detail: err instanceof Error ? err.message : String(err),
323+
},
324+
500
325+
)
326+
}
327+
}

app/components/agents/agent-card.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,7 @@ export function AgentCard({
3232
!isAvailable && "cursor-not-allowed opacity-50"
3333
)}
3434
type="button"
35-
onClick={() => {
36-
if (isAvailable && onClick) {
37-
onClick()
38-
}
39-
40-
null
41-
}}
35+
onClick={() => isAvailable && onClick?.()}
4236
>
4337
<div className="flex items-center space-x-4">
4438
<div className="flex-shrink-0">

0 commit comments

Comments
 (0)