Skip to content

Commit a9ed7d1

Browse files
committed
feat: thread title gen, some general fixes
1 parent 11ef97d commit a9ed7d1

7 files changed

Lines changed: 117 additions & 16 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ aiApp
126126
// If finishOnly is true, just mark as finished and return
127127
if (finishOnly) {
128128
await c.env.runMutation(internal.messages.finishStreaming, { streamId })
129-
await c.env.runMutation(api.threads.updateLastMessage, { threadId, lockerKey })
129+
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, lastMessageAt: Date.now(), lockerKey })
130130
c.text('OK')
131131
}
132132
}
@@ -232,7 +232,10 @@ aiApp
232232

233233
// Finish streaming
234234
await c.env.runMutation(internal.messages.finishStreaming, { streamId })
235-
await c.env.runMutation(api.threads.updateLastMessage, { threadId, lockerKey })
235+
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, lastMessageAt: Date.now(), lockerKey })
236+
237+
// // Generate new thread title
238+
// await c.env.runAction(api.threads.generateThreadTitle, { threadId, lockerKey, apiKey })
236239

237240
controller.enqueue(encoder.encode(`o: ${JSON.stringify({ done: true })}\n`))
238241
controller.close()

apps/backend-convex/convex/threads.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
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'
5+
import { clearUndefined } from '@namesmt/utils'
6+
import { openrouter } from '@openrouter/ai-sdk-provider'
7+
import { generateText } from 'ai'
48
import { ConvexError, v } from 'convex/values'
5-
import { internalMutation, mutation, query } from './_generated/server'
9+
import { api, internal } from './_generated/api'
10+
import { action, internalMutation, mutation, query } from './_generated/server'
611

712
export const list = query({
813
args: {
@@ -74,10 +79,47 @@ export const del = mutation({
7479
},
7580
})
7681

77-
export const updateLastMessage = mutation({
82+
// Todo: maybe rate limit
83+
export const generateThreadTitle = action({
7884
args: {
7985
threadId: v.id('threads'),
8086
lockerKey: v.optional(v.string()),
87+
apiKey: v.optional(v.string()),
88+
},
89+
handler: async (ctx, args) => {
90+
const thread = await ctx.runQuery(api.threads.get, { threadId: args.threadId, lockerKey: args.lockerKey })
91+
92+
const messages = await ctx.runQuery(api.messages.list, { threadId: args.threadId, lockerKey: args.lockerKey })
93+
94+
const { text } = await generateText({
95+
model: openrouter('qwen/qwen3-8b:free'),
96+
system: `You are a helpful assistant, generating concise, informative, and clear titles for a given context, keep the generated title under 40 characters, do not use any quotes and markdown syntax.`,
97+
prompt: `Generate a new title for this thread, here is the previous info: \n${[
98+
`Title: "${thread.title}"`,
99+
...(messages.length
100+
? [`Messages:\n${await simpleMessagesToString(messages.map(m => ({
101+
id: m._id,
102+
role: m.role,
103+
content: m.content,
104+
})))}`]
105+
: []),
106+
].join('\n')}`,
107+
})
108+
109+
await ctx.runMutation(internal.threads.updateThreadInfo, {
110+
title: text.trim(),
111+
threadId: args.threadId,
112+
lockerKey: args.lockerKey,
113+
})
114+
},
115+
})
116+
117+
export const updateThreadInfo = internalMutation({
118+
args: {
119+
threadId: v.id('threads'),
120+
title: v.optional(v.string()),
121+
lastMessageAt: v.optional(v.number()),
122+
lockerKey: v.optional(v.string()),
81123
},
82124
handler: async (ctx, args) => {
83125
const thread = await ctx.db.get(args.threadId)
@@ -87,7 +129,10 @@ export const updateLastMessage = mutation({
87129
await assertThreadAccess(ctx, { thread, lockerKey: args.lockerKey })
88130

89131
await ctx.db.patch(args.threadId, {
90-
lastMessageAt: Date.now(),
132+
...clearUndefined({
133+
title: args.title,
134+
lastMessageAt: args.lastMessageAt,
135+
}),
91136
})
92137
},
93138
})

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

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<script setup lang="ts">
33
import type { Doc, Id } from 'backend-convex/convex/_generated/dataModel'
44
import type Lenis from 'lenis'
5-
import { keyBy, randomStr, sleep, uniquePromise } from '@namesmt/utils'
5+
import { keyBy, sleep, uniquePromise } from '@namesmt/utils'
66
import { api } from 'backend-convex/convex/_generated/api'
77
import { useConvexClient } from 'convex-vue'
88
import { countdown } from 'kontroll'
@@ -107,16 +107,20 @@ async function handleSubmit({ input, confirmMultiStream = false }: HandleSubmitA
107107
108108
chatInput.value = ''
109109
110-
doScrollBottom()
110+
nextTick(() => { doScrollBottom() })
111111
112112
// Create new thread
113113
if (!threadIdRef.value) {
114+
// Set lockerKey to maintain permission if user is anonymous
115+
const lockerKey = $auth.loggedIn ? undefined : getRandomLockerKey()
114116
const newThread = await createNewThread(convex, {
115117
title: userInput,
116-
// Set lockerKey to maintain permission if user is anonymous
117-
lockerKey: $auth.loggedIn ? undefined : randomStr(32),
118+
lockerKey,
118119
})
119120
ignorePathUpdate(() => { threadIdRef.value = newThread })
121+
122+
// Asynchronously generates a new initial thread title
123+
generateThreadTitle(convex, { threadId: newThread, lockerKey })
120124
}
121125
122126
await streamToMessage({ message: messages.value[messages.value.length - 1]!, content: userInput })
@@ -137,16 +141,22 @@ async function resumeStreamProcess(streamSessionId: string, messageId: string) {
137141
interface PollToMessageArgs {
138142
message: CustomMessage
139143
resumeStreamId: string
144+
threadId?: string
140145
}
141-
async function pollToMessage({ message, resumeStreamId }: PollToMessageArgs) {
146+
async function pollToMessage({ message, resumeStreamId, threadId = threadIdRef.value }: PollToMessageArgs) {
147+
if (threadId && threadId !== threadIdRef.value) {
148+
console.warn('User changed thread, poll stopped.')
149+
return
150+
}
151+
142152
const messageId = message.id as Id<'messages'>
143153
144154
const messageFromConvex = await convex.query(api.messages.get, { messageId: messageId as Id<'messages'> })
145155
Object.assign(message, customMessageTransform(messageFromConvex))
146156
147157
if (message.isStreaming) {
148158
sleep(400)
149-
.then(() => { pollToMessage({ message, resumeStreamId }) })
159+
.then(() => { pollToMessage({ message, resumeStreamId, threadId }) })
150160
}
151161
else {
152162
console.log('Poll completed')
@@ -164,21 +174,24 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
164174
try {
165175
++streamingMessages.value
166176
177+
const currentThreadId = threadIdRef.value
178+
const abortController = new AbortController()
167179
const response = await fetch(`${convexApiUrl}/api/ai/chat/stream`, {
168180
method: 'POST',
169181
headers: {
170182
'Content-Type': 'application/json',
171183
'Authorization': `Bearer ${$auth.token}`,
172184
},
173185
body: JSON.stringify({
174-
threadId: threadIdRef.value,
186+
threadId: currentThreadId,
175187
provider: 'openrouter',
176188
model: 'deepseek/deepseek-chat:free',
177189
apiKey: 'dummy',
178190
content,
179191
resumeStreamId,
180192
// lockerKey: undefined,
181193
}),
194+
signal: abortController.signal,
182195
})
183196
184197
if (!response.ok) {
@@ -196,6 +209,12 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
196209
message.isStreaming = true
197210
198211
while (true) {
212+
if (currentThreadId !== threadIdRef.value) {
213+
console.warn('User changed thread, stopping stream...')
214+
abortController.abort()
215+
break
216+
}
217+
199218
const { done, value } = await reader.read()
200219
if (done)
201220
break
@@ -299,7 +318,7 @@ function alertIsStreaming(input: string) {
299318

300319
<div
301320
v-if="!messages.length"
302-
class="relative z-2 whitespace-pre-wrap px-2 text-center text-5xl text-gray-400 font-medium tracking-tighter dark:text-gray-500 dark:text-white"
321+
class="relative z-2 whitespace-pre-wrap px-2 text-center text-4xl text-gray-400 font-medium tracking-tighter dark:text-gray-500 dark:text-white"
303322
>
304323
<p>
305324
{{ threadIdRef ? $t('chat.interface.sendToStart') : $t('chat.interface.selectOrStart') }}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ const [DefineThreadLiItem, ReuseThreadLiItem] = createReusableTemplate<{ thread:
176176
:to="`/chat/${thread._id}`"
177177
class="group/thread relative block overflow-hidden rounded-md p-2 px-3 [&.router-link-exact-active]:bg-primary/10 hover:bg-primary/20"
178178
>
179-
<div>
179+
<div class="line-clamp-1">
180180
{{ thread.title }}
181181
</div>
182182
<LiquidGlassDiv

apps/frontend/app/utils/chat.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Message } from '@ai-sdk/vue'
22
import type { Doc, Id } from 'backend-convex/convex/_generated/dataModel'
33
import type { ConvexClient, ConvexHttpClient } from 'convex/browser'
4+
import { randomStr } from '@namesmt/utils'
45
import { api } from 'backend-convex/convex/_generated/api'
56

67
export interface CreateNewThreadArgs {
@@ -12,7 +13,7 @@ export async function createNewThread(convex: ConvexClient | ConvexHttpClient, {
1213
lockerKey,
1314
}: CreateNewThreadArgs) {
1415
const newThreadId = await convex.mutation(api.threads.create, {
15-
title: `${title.substring(0, 20)}...`,
16+
title,
1617
initSessionId: `session_${Date.now()}`,
1718
lockerKey,
1819
})
@@ -26,6 +27,20 @@ export async function deleteThread(convex: ConvexClient | ConvexHttpClient, thre
2627
})
2728
}
2829

30+
export interface GenerateThreadTitleArgs {
31+
threadId: Id<'threads'>
32+
lockerKey?: string
33+
}
34+
export async function generateThreadTitle(convex: ConvexClient | ConvexHttpClient, {
35+
threadId,
36+
lockerKey,
37+
}: GenerateThreadTitleArgs) {
38+
await convex.action(api.threads.generateThreadTitle, {
39+
threadId,
40+
lockerKey,
41+
})
42+
}
43+
2944
export interface CustomMessage {
3045
id: string
3146
role: 'user' | 'assistant'
@@ -45,3 +60,15 @@ export function customMessageTransform(message: Doc<'messages'>): CustomMessage
4560
streamId: message.streamId,
4661
}
4762
}
63+
64+
export function getRandomLockerKey() {
65+
return `locker_${Date.now()}_${randomStr(8)}`
66+
}
67+
68+
export function setLockerKey(kid: string, lockerKey: string) {
69+
localStorage.setItem(`locker_${kid}`, lockerKey)
70+
}
71+
72+
export function getLockerKey(kid: string) {
73+
return localStorage.getItem(`locker_${kid}`)
74+
}

apps/frontend/nuxt.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export default defineNuxtConfig({
127127
mdc: {
128128
highlight: {
129129
wrapperStyle: true,
130-
// noApiRoute: true,
130+
noApiRoute: true,
131131
langs: bundledLanguagesInfo.map(l => l.id) as BundledLanguage[],
132132
},
133133
keepComments: true,

locals/common/src/utils/aisdk.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export async function simpleMessagesToString(messages: {
2+
id: string
3+
role: string
4+
content: string
5+
}[]) {
6+
return messages.map(m => `@-- Message ID: ${m.id}\nRole: ${m.role}\nContent:\n${m.content}`).join('\n--@\n')
7+
}

0 commit comments

Comments
 (0)