Skip to content

Commit bf195e2

Browse files
committed
feat: interactive multi-agent chat
1 parent db932c5 commit bf195e2

13 files changed

Lines changed: 341 additions & 123 deletions

File tree

apps/backend-convex/convex/http/ai.ts

Lines changed: 50 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,61 +4,32 @@ import type { ActionCtx } from '../_generated/server'
44
import { createOpenAI } from '@ai-sdk/openai'
55
import RateLimiter, { MINUTE } from '@convex-dev/rate-limiter'
66
import { zValidator } from '@hono/zod-validator'
7-
import { randomStr, sample, sleep } from '@namesmt/utils'
7+
import { randomStr, sleep } from '@namesmt/utils'
88
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
99
import { streamText } from 'ai'
1010
import { ConvexError } from 'convex/values'
1111
import { Hono } from 'hono'
1212
import { cors } from 'hono/cors'
1313
import { throttle } from 'kontroll'
1414
import { z } from 'zod'
15+
import { getAgentModel } from '../../utils/agent'
1516
import { buildAiSdkMessage } from '../../utils/message'
1617
import { api, components, internal } from '../_generated/api'
1718

18-
const orModels = process.env.AI_MODELS_LIST?.split(',') ?? ['qwen/qwen3-32b:free']
19-
const openrouter = createOpenRouter({
20-
apiKey: process.env.OPENROUTER_API_KEY,
21-
})
22-
23-
const aiMessagesSchema = z.array(
24-
z.object({
25-
role: z.enum(['user', 'assistant']),
26-
content: z.string(),
27-
}),
28-
)
29-
3019
const rateLimiter = new RateLimiter(components.rateLimiter, {
3120
aiChat: { kind: 'token bucket', rate: 10, period: MINUTE, capacity: 3 },
3221
})
3322

3423
export const aiApp: HonoWithConvex<ActionCtx> = new Hono()
3524
aiApp
3625
.use(cors())
37-
// Old endpoint for minimal testing, should use `/chat/stream` in consumer side
38-
.post('/chat', zValidator('json', z.object({ messages: aiMessagesSchema })), async (c) => {
39-
const userIdentity = await c.env.auth.getUserIdentity()
40-
if (userIdentity === null)
41-
throw new ConvexError({ msg: 'Not authenticated' })
42-
43-
await rateLimiter.limit(c.env, 'aiChat', { key: userIdentity.subject, throws: true })
44-
45-
const { messages } = c.req.valid('json')
46-
47-
const result = streamText({
48-
model: openrouter(sample(orModels, 1)[0]),
49-
messages,
50-
system: 'Return response in markdown format, remember to response cleanly with linebreaks instead of endless paragraphs.',
51-
})
52-
53-
return result.toDataStreamResponse()
54-
})
5526
.post(
5627
'/chat/stream',
5728
zValidator('json', z.object({
5829
threadId: z.string(),
5930
provider: z.string(),
6031
model: z.string(),
61-
apiKey: z.string(),
32+
apiKey: z.optional(z.string()),
6233
content: z.optional(z.string()),
6334
context: z.optional(z.object({
6435
from: z.optional(z.string()),
@@ -174,18 +145,46 @@ aiApp
174145
// Get conversation history
175146
const messages = await c.env.runQuery(api.messages.listByThread, { threadId, lockerKey })
176147

177-
// Prepare messages for AI API (exclude the streaming messages)
148+
// Prepare messages for AI API
178149
const messagesContext = messages
179-
.filter(msg => !msg.isStreaming)
150+
.filter(msg => msg._id !== streamingMessageId)
180151
.map(buildAiSdkMessage)
181152

153+
let aiResponse = existingMessage ? existingMessage.content : ''
154+
155+
let pendingSave = false
156+
function doSave() {
157+
pendingSave = true
158+
throttle(
159+
500,
160+
async () => {
161+
await c.env.runMutation(internal.messages.updateStreamingMessage, {
162+
messageId: streamingMessageId,
163+
content: aiResponse,
164+
isStreaming: true,
165+
lockerKey,
166+
}).finally(() => {
167+
pendingSave = false
168+
})
169+
},
170+
{ trailing: true },
171+
)
172+
}
173+
174+
async function waitForSave() {
175+
if (pendingSave)
176+
await sleep(1000)
177+
if (pendingSave)
178+
await sleep(5000)
179+
if (pendingSave)
180+
console.error('Save was stuck')
181+
}
182+
182183
// Create streaming response
183184
const encoder = new TextEncoder()
184185
const stream = new ReadableStream({
185186
async start(controller) {
186187
try {
187-
let aiResponse = existingMessage ? existingMessage.content : ''
188-
189188
// Send session ID first
190189
controller.enqueue(encoder.encode(`o: ${JSON.stringify({
191190
messageId: streamingMessageId,
@@ -201,45 +200,27 @@ aiApp
201200
})}\n`))
202201
}
203202

204-
// Call AI provider
205-
206-
// Using hosted provider and model for now, switch to user configured BYOK when UI is ready for it
207-
const providerStream = streamText({
208-
model: openrouter(sample(orModels, 1)[0]),
203+
const aiStream = streamText({
204+
model: getAgentModel({ provider, model, apiKey }),
205+
system: 'You are inside a chat room of multiple users and multiple agents, each message have an auto-added `System Context` block, which contains important information of each message, for example: which agent, which user sent which message,..., You can use it for context.\nIMPORTANT: NEVER add / include the `System Context` block in your response yourself, it will be automatically added later.',
209206
messages: messagesContext,
210-
onError: (e) => {
211-
throw e.error
207+
onError: (ev) => {
208+
console.error(ev.error)
209+
throw ev.error
212210
},
213211
})
214212

215-
let pendingSave = false
216-
for await (const textDelta of providerStream.textStream) {
213+
for await (const textDelta of aiStream.textStream) {
217214
if (textDelta) {
218215
aiResponse += textDelta
219216
controller.enqueue(encoder.encode(`t: ${textDelta}`))
220217

221-
pendingSave = true
222-
throttle(
223-
500,
224-
async () => {
225-
await c.env.runMutation(internal.messages.updateStreamingMessage, {
226-
messageId: streamingMessageId,
227-
content: aiResponse,
228-
isStreaming: true,
229-
lockerKey,
230-
}).finally(() => {
231-
pendingSave = false
232-
})
233-
},
234-
{ trailing: true },
235-
)
218+
doSave()
236219
}
237220
}
238-
// eslint-disable-next-line no-unmodified-loop-condition
239-
while (pendingSave)
240-
await sleep(1000)
241221

242222
// Finish streaming
223+
await waitForSave()
243224
await c.env.runMutation(internal.messages.finishStreaming, { streamId })
244225
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, timestamp: Date.now() })
245226

@@ -250,7 +231,12 @@ aiApp
250231
controller.close()
251232
}
252233
catch (error: any) {
253-
// Mark streaming as finished on error
234+
console.error(error)
235+
236+
aiResponse += `\n\nError encountered, stream stopped`
237+
238+
doSave()
239+
await waitForSave()
254240
await c.env.runMutation(internal.messages.finishStreaming, { streamId })
255241

256242
controller.enqueue(encoder.encode(`o: ${JSON.stringify({ error: error.message })}\n`))

apps/backend-convex/convex/threads.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { UserIdentity } from 'convex/server'
22
import type { Doc } from './_generated/dataModel'
33
import type { GenericCtx } from './_generated/server'
4-
import { simpleMessagesToString } from '@local/common/src/utils/aisdk'
4+
import { simpleMessagesToString } from '@local/common/src/aisdk'
55
import { clearUndefined } from '@namesmt/utils'
66
import { openrouter } from '@openrouter/ai-sdk-provider'
77
import { generateText } from 'ai'

apps/backend-convex/utils/agent.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { AgentObject } from '@local/common/src/aisdk'
2+
import type { LanguageModelV1 } from '@openrouter/ai-sdk-provider'
3+
import { createOpenRouter, openrouter } from '@openrouter/ai-sdk-provider'
4+
import { ConvexError } from 'convex/values'
5+
6+
export function getAgentModel({ provider, model, apiKey }: AgentObject): LanguageModelV1 {
7+
if (provider === 'hosted') {
8+
if (!['qwen3-32b', 'deepseek-v3'].includes(model))
9+
throw new ConvexError('Invalid model for hosted provider')
10+
11+
if (model === 'qwen3-32b')
12+
model = 'qwen/qwen3-32b:free'
13+
14+
if (model === 'deepseek-v3')
15+
model = 'deepseek/deepseek-chat-v3-0324:free'
16+
17+
return openrouter(model)
18+
}
19+
else {
20+
return (() => {
21+
switch (provider) {
22+
case 'openrouter':
23+
return createOpenRouter({ apiKey })(model)
24+
default:
25+
throw new Error(`Unknown provider: ${provider}`)
26+
}
27+
})()
28+
}
29+
}

apps/backend-convex/utils/message.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,51 @@ export function buildAiSdkMessage(message: Doc<'messages'>): BuiltMessage {
1414
case 'assistant':
1515
return {
1616
role: 'assistant',
17-
content: message.content,
17+
content: buildAssistantMessageContent(message),
1818
}
1919
default:
2020
throw new Error(`Unknown message role: ${message.role}`)
2121
}
2222
}
2323

24-
export function buildUserMessageContent({ content, context }: Pick<Doc<'messages'>, 'content' | 'context'>) {
24+
export function buildUserMessageContent({ content, context }: Pick<
25+
Doc<'messages'>,
26+
'content' | 'context'
27+
>) {
2528
let builtContent = ''
2629

2730
if (context && Object.keys(context)) {
28-
builtContent += `@--System Context Start@\n`
31+
builtContent += `### System context start\n`
2932

3033
if (context.from)
3134
builtContent += `From: "${context.from}"\n`
3235

3336
if (context.uid)
3437
builtContent += `UID: "${context.uid}"\n`
3538

36-
builtContent += `@System Context End--@\n\n`
39+
builtContent += `### System context end\n---\n\n`
3740
}
3841

3942
builtContent += content
4043

4144
return builtContent
4245
}
46+
47+
export function buildAssistantMessageContent({ content, model, provider, isStreaming }: Pick<
48+
Doc<'messages'>,
49+
'content' | 'model' | 'provider' | 'isStreaming'
50+
>) {
51+
let builtContent = ''
52+
53+
builtContent += `### System context start\n`
54+
builtContent += `From: "${provider}/${model}"\n`
55+
56+
if (isStreaming)
57+
builtContent += `This message is still streaming, content is not finalized\n`
58+
59+
builtContent += `### System context end\n---\n\n`
60+
61+
builtContent += content
62+
63+
return builtContent
64+
}

0 commit comments

Comments
 (0)