Skip to content

Commit 50595c5

Browse files
Merge pull request #646 from simstudioai/feat/ask-docs
feat(yaml workflow): yaml workflow representation + doc embeddings
2 parents df4971a + 3c61bc1 commit 50595c5

File tree

49 files changed

+18116
-424
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+18116
-424
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { getSession } from '@/lib/auth'
4+
import {
5+
type CopilotChat,
6+
type CopilotMessage,
7+
createChat,
8+
generateChatTitle,
9+
generateDocsResponse,
10+
getChat,
11+
updateChat,
12+
} from '@/lib/copilot/service'
13+
import { createLogger } from '@/lib/logs/console-logger'
14+
15+
const logger = createLogger('CopilotDocsAPI')
16+
17+
// Schema for docs queries
18+
const DocsQuerySchema = z.object({
19+
query: z.string().min(1, 'Query is required'),
20+
topK: z.number().min(1).max(20).default(5),
21+
provider: z.string().optional(),
22+
model: z.string().optional(),
23+
stream: z.boolean().optional().default(false),
24+
chatId: z.string().optional(),
25+
workflowId: z.string().optional(),
26+
createNewChat: z.boolean().optional().default(false),
27+
})
28+
29+
/**
30+
* POST /api/copilot/docs
31+
* Ask questions about documentation using RAG
32+
*/
33+
export async function POST(req: NextRequest) {
34+
const requestId = crypto.randomUUID()
35+
36+
try {
37+
const session = await getSession()
38+
if (!session?.user?.id) {
39+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
40+
}
41+
42+
const body = await req.json()
43+
const { query, topK, provider, model, stream, chatId, workflowId, createNewChat } =
44+
DocsQuerySchema.parse(body)
45+
46+
logger.info(`[${requestId}] Docs RAG query: "${query}"`, {
47+
provider,
48+
model,
49+
topK,
50+
chatId,
51+
workflowId,
52+
createNewChat,
53+
userId: session.user.id,
54+
})
55+
56+
// Handle chat context
57+
let currentChat: CopilotChat | null = null
58+
let conversationHistory: CopilotMessage[] = []
59+
60+
if (chatId) {
61+
// Load existing chat
62+
currentChat = await getChat(chatId, session.user.id)
63+
if (currentChat) {
64+
conversationHistory = currentChat.messages
65+
}
66+
} else if (createNewChat && workflowId) {
67+
// Create new chat
68+
currentChat = await createChat(session.user.id, workflowId)
69+
}
70+
71+
// Generate docs response
72+
const result = await generateDocsResponse(query, conversationHistory, {
73+
topK,
74+
provider,
75+
model,
76+
stream,
77+
workflowId,
78+
requestId,
79+
})
80+
81+
if (stream && result.response instanceof ReadableStream) {
82+
// Handle streaming response with docs sources
83+
logger.info(`[${requestId}] Returning streaming docs response`)
84+
85+
const encoder = new TextEncoder()
86+
87+
return new Response(
88+
new ReadableStream({
89+
async start(controller) {
90+
const reader = (result.response as ReadableStream).getReader()
91+
let accumulatedResponse = ''
92+
93+
try {
94+
// Send initial metadata including sources
95+
const metadata = {
96+
type: 'metadata',
97+
chatId: currentChat?.id,
98+
sources: result.sources,
99+
citations: result.sources.map((source, index) => ({
100+
id: index + 1,
101+
title: source.title,
102+
url: source.url,
103+
})),
104+
metadata: {
105+
requestId,
106+
chunksFound: result.sources.length,
107+
query,
108+
topSimilarity: result.sources[0]?.similarity,
109+
provider,
110+
model,
111+
},
112+
}
113+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`))
114+
115+
while (true) {
116+
const { done, value } = await reader.read()
117+
if (done) break
118+
119+
const chunk = new TextDecoder().decode(value)
120+
// Clean up any object serialization artifacts in streaming content
121+
const cleanedChunk = chunk.replace(/\[object Object\],?/g, '')
122+
accumulatedResponse += cleanedChunk
123+
124+
const contentChunk = {
125+
type: 'content',
126+
content: cleanedChunk,
127+
}
128+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`))
129+
}
130+
131+
// Send completion marker first to unblock the user
132+
controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`))
133+
134+
// Save conversation to database asynchronously (non-blocking)
135+
if (currentChat) {
136+
// Fire-and-forget database save to avoid blocking stream completion
137+
Promise.resolve()
138+
.then(async () => {
139+
try {
140+
const userMessage: CopilotMessage = {
141+
id: crypto.randomUUID(),
142+
role: 'user',
143+
content: query,
144+
timestamp: new Date().toISOString(),
145+
}
146+
147+
const assistantMessage: CopilotMessage = {
148+
id: crypto.randomUUID(),
149+
role: 'assistant',
150+
content: accumulatedResponse,
151+
timestamp: new Date().toISOString(),
152+
citations: result.sources.map((source, index) => ({
153+
id: index + 1,
154+
title: source.title,
155+
url: source.url,
156+
})),
157+
}
158+
159+
const updatedMessages = [
160+
...conversationHistory,
161+
userMessage,
162+
assistantMessage,
163+
]
164+
165+
// Generate title if this is the first message
166+
let updatedTitle = currentChat.title ?? undefined
167+
if (!updatedTitle && conversationHistory.length === 0) {
168+
updatedTitle = await generateChatTitle(query)
169+
}
170+
171+
// Update the chat in database
172+
await updateChat(currentChat.id, session.user.id, {
173+
title: updatedTitle,
174+
messages: updatedMessages,
175+
})
176+
177+
logger.info(
178+
`[${requestId}] Updated chat ${currentChat.id} with new docs messages`
179+
)
180+
} catch (dbError) {
181+
logger.error(`[${requestId}] Failed to save chat to database:`, dbError)
182+
// Database errors don't affect the user's streaming experience
183+
}
184+
})
185+
.catch((error) => {
186+
logger.error(`[${requestId}] Unexpected error in async database save:`, error)
187+
})
188+
}
189+
} catch (error) {
190+
logger.error(`[${requestId}] Docs streaming error:`, error)
191+
try {
192+
const errorChunk = {
193+
type: 'error',
194+
error: 'Streaming failed',
195+
}
196+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`))
197+
} catch (enqueueError) {
198+
logger.error(`[${requestId}] Failed to enqueue error response:`, enqueueError)
199+
}
200+
} finally {
201+
controller.close()
202+
}
203+
},
204+
}),
205+
{
206+
headers: {
207+
'Content-Type': 'text/event-stream',
208+
'Cache-Control': 'no-cache',
209+
Connection: 'keep-alive',
210+
},
211+
}
212+
)
213+
}
214+
215+
// Handle non-streaming response
216+
logger.info(`[${requestId}] Docs RAG response generated successfully`)
217+
218+
// Save conversation to database if we have a chat
219+
if (currentChat) {
220+
const userMessage: CopilotMessage = {
221+
id: crypto.randomUUID(),
222+
role: 'user',
223+
content: query,
224+
timestamp: new Date().toISOString(),
225+
}
226+
227+
const assistantMessage: CopilotMessage = {
228+
id: crypto.randomUUID(),
229+
role: 'assistant',
230+
content: typeof result.response === 'string' ? result.response : '[Streaming Response]',
231+
timestamp: new Date().toISOString(),
232+
citations: result.sources.map((source, index) => ({
233+
id: index + 1,
234+
title: source.title,
235+
url: source.url,
236+
})),
237+
}
238+
239+
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
240+
241+
// Generate title if this is the first message
242+
let updatedTitle = currentChat.title ?? undefined
243+
if (!updatedTitle && conversationHistory.length === 0) {
244+
updatedTitle = await generateChatTitle(query)
245+
}
246+
247+
// Update the chat in database
248+
await updateChat(currentChat.id, session.user.id, {
249+
title: updatedTitle,
250+
messages: updatedMessages,
251+
})
252+
253+
logger.info(`[${requestId}] Updated chat ${currentChat.id} with new docs messages`)
254+
}
255+
256+
return NextResponse.json({
257+
success: true,
258+
response: result.response,
259+
sources: result.sources,
260+
chatId: currentChat?.id,
261+
metadata: {
262+
requestId,
263+
chunksFound: result.sources.length,
264+
query,
265+
topSimilarity: result.sources[0]?.similarity,
266+
provider,
267+
model,
268+
},
269+
})
270+
} catch (error) {
271+
if (error instanceof z.ZodError) {
272+
return NextResponse.json(
273+
{ error: 'Invalid request data', details: error.errors },
274+
{ status: 400 }
275+
)
276+
}
277+
278+
logger.error(`[${requestId}] Copilot docs error:`, error)
279+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
280+
}
281+
}

0 commit comments

Comments
 (0)