Skip to content

Commit 156b551

Browse files
committed
feat: multi-user identity context support
1 parent 86bd8e6 commit 156b551

7 files changed

Lines changed: 125 additions & 31 deletions

File tree

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Hono } from 'hono'
1212
import { cors } from 'hono/cors'
1313
import { throttle } from 'kontroll'
1414
import { z } from 'zod'
15+
import { buildAiSdkMessage } from '../../utils/message'
1516
import { api, components, internal } from '../_generated/api'
1617

1718
const orModels = process.env.AI_MODELS_LIST?.split(',') ?? ['qwen/qwen3-32b:free']
@@ -59,6 +60,9 @@ aiApp
5960
model: z.string(),
6061
apiKey: z.string(),
6162
content: z.optional(z.string()),
63+
context: z.optional(z.object({
64+
from: z.optional(z.string()),
65+
})),
6266
resumeStreamId: z.optional(z.string()),
6367
finishOnly: z.optional(z.boolean()),
6468
lockerKey: z.optional(z.string()),
@@ -73,6 +77,7 @@ aiApp
7377
model,
7478
apiKey,
7579
content,
80+
context = {},
7681
resumeStreamId,
7782
finishOnly,
7883
lockerKey,
@@ -140,17 +145,18 @@ aiApp
140145
streamId = `${Date.now()}_${randomStr(10)}`
141146

142147
// Add user message to thread
143-
await c.env.runMutation(api.messages.add, {
148+
await c.env.runMutation(internal.messages.add, {
144149
threadId,
145150
role: 'user',
146151
content,
152+
context: { ...context, uid: userIdentity?.subject ?? 'UNKNOWN' },
147153
provider,
148154
model,
149155
lockerKey,
150156
})
151157

152-
// Create initial streaming message
153-
streamingMessageId = await c.env.runMutation(api.messages.add, {
158+
// Add assistant message to thread (initial streaming)
159+
streamingMessageId = await c.env.runMutation(internal.messages.add, {
154160
threadId,
155161
role: 'assistant',
156162
content: '',
@@ -171,10 +177,7 @@ aiApp
171177
// Prepare messages for AI API (exclude the streaming messages)
172178
const messagesContext = messages
173179
.filter(msg => !msg.isStreaming)
174-
.map(msg => ({
175-
role: msg.role,
176-
content: msg.content,
177-
}))
180+
.map(buildAiSdkMessage)
178181

179182
// Create streaming response
180183
const encoder = new TextEncoder()
@@ -219,7 +222,7 @@ aiApp
219222
throttle(
220223
500,
221224
async () => {
222-
await c.env.runMutation(api.messages.updateStreamingMessage, {
225+
await c.env.runMutation(internal.messages.updateStreamingMessage, {
223226
messageId: streamingMessageId,
224227
content: aiResponse,
225228
isStreaming: true,

apps/backend-convex/convex/messages.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { objectPick } from '@namesmt/utils'
22
import { ConvexError, v } from 'convex/values'
33
import { messagesInThreadCounter } from './_counters'
4-
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
4+
import { internalMutation, internalQuery, query } from './_generated/server'
55
import { assertThreadAccess } from './threads'
66

77
export const listByThread = query({
@@ -61,11 +61,15 @@ export const get = query({
6161
},
6262
})
6363

64-
export const add = mutation({
64+
export const add = internalMutation({
6565
args: {
6666
threadId: v.id('threads'),
6767
role: v.union(v.literal('user'), v.literal('assistant')),
6868
content: v.string(),
69+
context: v.optional(v.object({
70+
from: v.optional(v.string()),
71+
uid: v.optional(v.string()),
72+
})),
6973
isStreaming: v.optional(v.boolean()),
7074
streamId: v.optional(v.string()),
7175
provider: v.string(),
@@ -82,13 +86,13 @@ export const add = mutation({
8286
await messagesInThreadCounter.inc(ctx, args.threadId)
8387

8488
return await ctx.db.insert('messages', {
85-
...objectPick(args, ['threadId', 'role', 'content', 'isStreaming', 'streamId', 'provider', 'model']),
89+
...objectPick(args, ['threadId', 'role', 'content', 'context', 'isStreaming', 'streamId', 'provider', 'model']),
8690
timestamp: Date.now(),
8791
})
8892
},
8993
})
9094

91-
export const updateStreamingMessage = mutation({
95+
export const updateStreamingMessage = internalMutation({
9296
args: {
9397
messageId: v.id('messages'),
9498
content: v.string(),
@@ -147,6 +151,7 @@ export const finishStreaming = internalMutation({
147151
return
148152

149153
await ctx.db.patch(message._id, {
154+
streamId: undefined,
150155
isStreaming: false,
151156
})
152157
},

apps/backend-convex/convex/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const aiChatTables = {
2929
role: v.union(v.literal('user'), v.literal('assistant')),
3030
timestamp: v.number(),
3131
content: v.string(),
32+
context: v.optional(v.object({
33+
from: v.optional(v.string()),
34+
uid: v.optional(v.string()),
35+
})),
3236
streamId: v.optional(v.string()),
3337
isStreaming: v.optional(v.boolean()),
3438
provider: v.string(),

apps/backend-convex/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
"vitest"
77
]
88
},
9-
"include": ["./convex/**/*"],
9+
"include": ["./convex/**/*", "./utils/**/*"],
1010
"exclude": ["./convex/_generated"]
1111
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Doc } from '../convex/_generated/dataModel'
2+
3+
export interface BuiltMessage {
4+
role: 'user' | 'assistant'
5+
content: string
6+
}
7+
export function buildAiSdkMessage(message: Doc<'messages'>): BuiltMessage {
8+
switch (message.role) {
9+
case 'user':
10+
return {
11+
role: 'user',
12+
content: buildUserMessageContent(message),
13+
}
14+
case 'assistant':
15+
return {
16+
role: 'assistant',
17+
content: message.content,
18+
}
19+
default:
20+
throw new Error(`Unknown message role: ${message.role}`)
21+
}
22+
}
23+
24+
export function buildUserMessageContent({ content, context }: Pick<Doc<'messages'>, 'content' | 'context'>) {
25+
let builtContent = ''
26+
27+
if (context && Object.keys(context)) {
28+
builtContent += `@--System context start@\n`
29+
30+
if (context.from)
31+
builtContent += `From: "${context.from}"\n`
32+
33+
if (context.uid)
34+
builtContent += `UID: "${context.uid}"\n`
35+
36+
builtContent += `@System context end--@\n\n`
37+
}
38+
39+
builtContent += content
40+
41+
return builtContent
42+
}

apps/frontend/app/components/chat/ChatInterface.vue

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import LiquidGlassDiv from '../LiquidGlassDiv.vue'
1616
1717
const isDev = import.meta.dev
1818
const { $auth } = useNuxtApp()
19-
const { convexApiUrl } = useRuntimeConfig().public
2019
const convex = useConvexClient()
2120
const chatContext = useChatContext()
2221
const { toast } = useToast()
@@ -155,6 +154,7 @@ async function handleSubmit({ input, confirmMultiStream = false }: HandleSubmitA
155154
id: `user-${Date.now()}`,
156155
role: 'user',
157156
content: userInput,
157+
context: { from: getUserName() },
158158
} as any as CustomMessage)
159159
messages.value.push({
160160
id: `assistant-${Date.now()}`,
@@ -252,23 +252,13 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
252252
++streamingMessages.value
253253
254254
const currentThreadId = threadIdRef.value
255-
const abortController = new AbortController()
256-
const response = await fetch(`${convexApiUrl}/api/ai/chat/stream`, {
257-
method: 'POST',
258-
headers: {
259-
'Content-Type': 'application/json',
260-
'Authorization': `Bearer ${$auth.token}`,
261-
},
262-
body: JSON.stringify({
263-
threadId: currentThreadId,
264-
provider: 'openrouter',
265-
model: 'deepseek/deepseek-chat:free',
266-
apiKey: 'dummy',
267-
content,
268-
resumeStreamId,
269-
lockerKey: getLockerKey(currentThreadId),
270-
}),
271-
signal: abortController.signal,
255+
const { response, abortController } = await postChatStream({
256+
threadId: currentThreadId as Id<'threads'>,
257+
provider: 'openrouter',
258+
model: 'deepseek/deepseek-chat:free',
259+
apiKey: 'dummy',
260+
content,
261+
resumeStreamId,
272262
})
273263
274264
if (!response.ok) {
@@ -477,6 +467,15 @@ function alertIsStreaming(input: string) {
477467
</Card>
478468
</component>
479469

470+
<div
471+
v-if="m.role === 'user'"
472+
class="absolute right-2 top-100% flex gap-1 opacity-0 transition-opacity group-hover/message:opacity-100"
473+
>
474+
<div v-if="m.context?.from" class="text-xs">
475+
{{ m.context.from }}
476+
</div>
477+
</div>
478+
480479
<div
481480
v-if="m.role !== 'user'"
482481
class="absolute bottom-2 left-2 flex gap-1 opacity-0 transition-opacity group-hover/message:opacity-100"

apps/frontend/app/utils/chat.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,47 @@ export async function threadSetLockerKey(convex: ConvexClient | ConvexHttpClient
118118
})
119119
}
120120

121+
export function getUserName() {
122+
const { $auth } = useNuxtApp()
123+
124+
return localStorage.getItem('chat/user-nickname') || $auth?.user?.name || 'Anonymous'
125+
}
126+
127+
export interface PostChatStreamArgs {
128+
threadId: Id<'threads'>
129+
provider: string
130+
model: string
131+
apiKey: string
132+
content?: string
133+
resumeStreamId?: string
134+
finishOnly?: boolean
135+
abortController?: AbortController
136+
}
137+
export async function postChatStream(args: PostChatStreamArgs) {
138+
const {
139+
abortController = new AbortController(),
140+
} = args
141+
142+
const { convexApiUrl } = useRuntimeConfig().public
143+
const { $auth } = useNuxtApp()
144+
145+
const response = await fetch(`${convexApiUrl}/api/ai/chat/stream`, {
146+
method: 'POST',
147+
headers: {
148+
'Content-Type': 'application/json',
149+
'Authorization': `Bearer ${$auth.token}`,
150+
},
151+
body: JSON.stringify({
152+
...args,
153+
context: { from: getUserName() },
154+
lockerKey: getLockerKey(args.threadId),
155+
}),
156+
signal: abortController.signal,
157+
})
158+
159+
return { response, abortController }
160+
}
161+
121162
export interface CustomMessage extends Doc<'messages'> {
122163
id: string
123164
}

0 commit comments

Comments
 (0)