Skip to content

Commit a9f94e3

Browse files
committed
feat: milestone 3: support anonymous user
1 parent d056db4 commit a9f94e3

8 files changed

Lines changed: 114 additions & 42 deletions

File tree

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,13 @@ aiApp
7878
lockerKey,
7979
} = c.req.valid('json')
8080

81-
const userIdentity = await c.env.auth.getUserIdentity()
82-
if (userIdentity === null)
81+
// getUserIdentity on HTTP Action will throw if not authenticated 🤦‍♂️
82+
const userIdentity = await c.env.auth.getUserIdentity().catch(() => null)
83+
84+
if (userIdentity === null && !lockerKey)
8385
throw new ConvexError({ msg: 'Not authenticated' })
8486

85-
await rateLimiter.limit(c.env, 'aiChat', { key: userIdentity.subject, throws: true })
87+
await rateLimiter.limit(c.env, 'aiChat', { key: userIdentity?.subject ?? lockerKey, throws: true })
8688

8789
const _providerSdk = (() => {
8890
switch (provider) {
@@ -126,7 +128,7 @@ aiApp
126128
// If finishOnly is true, just mark as finished and return
127129
if (finishOnly) {
128130
await c.env.runMutation(internal.messages.finishStreaming, { streamId })
129-
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, lastMessageAt: Date.now(), lockerKey })
131+
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, lastMessageAt: Date.now() })
130132
c.text('OK')
131133
}
132134
}
@@ -232,7 +234,7 @@ aiApp
232234

233235
// Finish streaming
234236
await c.env.runMutation(internal.messages.finishStreaming, { streamId })
235-
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, lastMessageAt: Date.now(), lockerKey })
237+
await c.env.runMutation(internal.threads.updateThreadInfo, { threadId, lastMessageAt: Date.now() })
236238

237239
// // Generate new thread title
238240
// await c.env.runAction(api.threads.generateThreadTitle, { threadId, lockerKey, apiKey })

apps/backend-convex/convex/schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ const tasksTables = {
99

1010
const aiChatTables = {
1111
threads: defineTable({
12-
initSessionId: v.string(), // TODO: Ability to view all threads created in the same use session using this id.
12+
// The initial session ID of the user that created the thread, warning: also used as "password" to list threads for now.
13+
sessionId: v.string(),
1314
title: v.string(),
1415
lastMessageAt: v.number(),
1516
userId: v.optional(v.string()),
1617
lockerKey: v.optional(v.string()),
1718
})
19+
.index('by_user_id', ['userId'])
20+
.index('by_session_id', ['sessionId'])
1821
.index('by_last_message', ['lastMessageAt']),
1922

2023
messages: defineTable({

apps/backend-convex/convex/threads.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ConvexError, v } from 'convex/values'
99
import { api, internal } from './_generated/api'
1010
import { action, internalMutation, mutation, query } from './_generated/server'
1111

12+
// TODO: maybe allow threads sharing of a whole account or remove userId arg
1213
export const list = query({
1314
args: {
1415
userId: v.optional(v.string()),
@@ -27,7 +28,21 @@ export const list = query({
2728
.query('threads')
2829
.withIndex('by_last_message', q => q)
2930
.order('desc')
30-
.take(50)
31+
.collect()
32+
},
33+
})
34+
35+
export const listBySessionId = query({
36+
args: {
37+
sessionId: v.string(),
38+
},
39+
handler: async (ctx, args) => {
40+
return await ctx.db
41+
.query('threads')
42+
.withIndex('by_session_id', q => q.eq('sessionId', args.sessionId))
43+
.order('desc')
44+
.collect()
45+
.then(threads => threads.map(t => ({ ...t, lockerKey: undefined })))
3146
},
3247
})
3348

@@ -50,12 +65,12 @@ export const get = query({
5065
export const create = mutation({
5166
args: {
5267
title: v.string(),
53-
initSessionId: v.string(),
68+
sessionId: v.string(),
5469
lockerKey: v.optional(v.string()),
5570
},
5671
handler: async (ctx, args) => {
5772
return await ctx.db.insert('threads', {
58-
initSessionId: args.initSessionId,
73+
sessionId: args.sessionId,
5974
title: args.title,
6075
lastMessageAt: Date.now(),
6176
lockerKey: args.lockerKey,
@@ -67,13 +82,18 @@ export const create = mutation({
6782
export const del = mutation({
6883
args: {
6984
threadId: v.id('threads'),
85+
lockerKey: v.optional(v.string()),
7086
},
7187
handler: async (ctx, args) => {
7288
const thread = await ctx.db.get(args.threadId)
7389
if (!thread)
7490
throw new ConvexError('Thread not found')
7591

76-
await assertThreadAccess(ctx, { thread })
92+
// Only allow thread to be deleted via lockerKey if its anonymous
93+
if (thread.userId && args.lockerKey)
94+
throw new ConvexError(`"lockerKey" is not allowed to use to delete a thread that is assigned to a user`)
95+
96+
await assertThreadAccess(ctx, { thread, lockerKey: args.lockerKey })
7797

7898
await ctx.db.delete(args.threadId)
7999
},
@@ -94,7 +114,7 @@ export const generateThreadTitle = action({
94114
const { text } = await generateText({
95115
model: openrouter('qwen/qwen3-8b:free'),
96116
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${[
117+
prompt: `Generate a new title for this thread, infer the language from the info's detail, here is the structured previous info: \n${[
98118
`Title: "${thread.title}"`,
99119
...(messages.length
100120
? [`Messages:\n${await simpleMessagesToString(messages.map(m => ({
@@ -109,7 +129,6 @@ export const generateThreadTitle = action({
109129
await ctx.runMutation(internal.threads.updateThreadInfo, {
110130
title: text.trim(),
111131
threadId: args.threadId,
112-
lockerKey: args.lockerKey,
113132
})
114133
},
115134
})
@@ -119,15 +138,12 @@ export const updateThreadInfo = internalMutation({
119138
threadId: v.id('threads'),
120139
title: v.optional(v.string()),
121140
lastMessageAt: v.optional(v.number()),
122-
lockerKey: v.optional(v.string()),
123141
},
124142
handler: async (ctx, args) => {
125143
const thread = await ctx.db.get(args.threadId)
126144
if (!thread)
127145
throw new Error('Thread not found')
128146

129-
await assertThreadAccess(ctx, { thread, lockerKey: args.lockerKey })
130-
131147
await ctx.db.patch(args.threadId, {
132148
...clearUndefined({
133149
title: args.title,
@@ -147,11 +163,14 @@ export async function assertThreadAccess(ctx: GenericCtx, { thread, lockerKey, u
147163
if (lockerKey && thread.lockerKey === lockerKey) {
148164
;
149165
}
150-
// Check permission other means
166+
// Check permission by other means (JWT)
151167
else {
152168
userIdentity ??= await ctx.auth.getUserIdentity()
153-
if (!userIdentity || (thread.userId !== userIdentity.subject))
169+
if (!userIdentity || (thread.userId !== userIdentity.subject)) {
170+
if (lockerKey)
171+
console.error(`"lockerKey" available but incorrect`)
154172
throw new ConvexError('You are not authorized to view this thread')
173+
}
155174
}
156175
}
157176

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

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const { ignoreUpdates: ignorePathUpdate } = watchIgnorable(
4646
4747
if (threadId) {
4848
isFetching.value = true
49-
await convex.query(api.messages.list, { threadId: threadId as Doc<'threads'>['_id'] })
49+
await convex.query(api.messages.list, { threadId: threadId as Doc<'threads'>['_id'], lockerKey: getLockerKey(threadId) })
5050
.then((existingMessages) => {
5151
if (threadIdRef.value === threadId) {
5252
messages.value = existingMessages.map(customMessageTransform)
@@ -113,14 +113,18 @@ async function handleSubmit({ input, confirmMultiStream = false }: HandleSubmitA
113113
if (!threadIdRef.value) {
114114
// Set lockerKey to maintain permission if user is anonymous
115115
const lockerKey = $auth.loggedIn ? undefined : getRandomLockerKey()
116-
const newThread = await createNewThread(convex, {
116+
const newThreadId = await createNewThread(convex, {
117117
title: userInput,
118118
lockerKey,
119119
})
120-
ignorePathUpdate(() => { threadIdRef.value = newThread })
120+
ignorePathUpdate(() => { threadIdRef.value = newThreadId })
121+
122+
// Store lockerKey locally
123+
if (lockerKey)
124+
setLockerKey(newThreadId, lockerKey)
121125
122126
// Asynchronously generates a new initial thread title
123-
generateThreadTitle(convex, { threadId: newThread, lockerKey })
127+
generateThreadTitle(convex, { threadId: newThreadId, lockerKey })
124128
}
125129
126130
await streamToMessage({ message: messages.value[messages.value.length - 1]!, content: userInput })
@@ -151,7 +155,10 @@ async function pollToMessage({ message, resumeStreamId, threadId = threadIdRef.v
151155
152156
const messageId = message.id as Id<'messages'>
153157
154-
const messageFromConvex = await convex.query(api.messages.get, { messageId: messageId as Id<'messages'> })
158+
const messageFromConvex = await convex.query(api.messages.get, {
159+
messageId: messageId as Id<'messages'>,
160+
lockerKey: getLockerKey(threadId),
161+
})
155162
Object.assign(message, customMessageTransform(messageFromConvex))
156163
157164
if (message.isStreaming) {
@@ -162,7 +169,8 @@ async function pollToMessage({ message, resumeStreamId, threadId = threadIdRef.v
162169
console.log('Poll completed')
163170
}
164171
165-
setTimeout(() => { doScrollBottom({ maybe: true }) }, 100)
172+
if (isNearBottom())
173+
setTimeout(() => { doScrollBottom({ forceTries: 1 }) }, 100)
166174
}
167175
168176
interface StreamToMessageArgs {
@@ -189,7 +197,7 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
189197
apiKey: 'dummy',
190198
content,
191199
resumeStreamId,
192-
// lockerKey: undefined,
200+
lockerKey: getLockerKey(currentThreadId),
193201
}),
194202
signal: abortController.signal,
195203
})
@@ -250,7 +258,8 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
250258
if (state.content)
251259
message.content += state.content
252260
253-
setTimeout(() => { doScrollBottom({ maybe: true }) }, 100)
261+
if (isNearBottom())
262+
setTimeout(() => { doScrollBottom({ forceTries: 1 }) }, 100)
254263
}
255264
256265
message.isStreaming = false
@@ -276,9 +285,7 @@ function doScrollBottom({ smooth = true, maybe = false, forceTries = 0, lastScro
276285
if (l.$el.scrollTop < lastScrollTop)
277286
forceTries = 0
278287
279-
lastScrollTop = l.$el.scrollTop
280-
281-
if (!forceTries && maybe && ((l.$el.scrollTop + l.$el.clientHeight) < (scrollHeight - 456)))
288+
if (!forceTries && maybe && !isNearBottom())
282289
return
283290
284291
if (scrollHeight !== l.lenis.limit + l.$el.clientHeight)
@@ -288,13 +295,25 @@ function doScrollBottom({ smooth = true, maybe = false, forceTries = 0, lastScro
288295
? l.lenis.scrollTo(scrollHeight)
289296
: l.$el.scrollTop = scrollHeight
290297
298+
lastScrollTop = l.$el.scrollTop
299+
291300
if (forceTries) {
292301
countdown(200, () => {
293302
sleep(0).then(() => doScrollBottom({ smooth, maybe, forceTries: forceTries - 1, lastScrollTop }))
294303
}, { key: 'dSB', replace: true })
295304
}
296305
}
297306
307+
function isNearBottom() {
308+
if (!lenisRef.value)
309+
return
310+
311+
const l = lenisRef.value
312+
const scrollHeight = l.$el.scrollHeight
313+
314+
return (l.$el.scrollTop + l.$el.clientHeight) > (scrollHeight - 345)
315+
}
316+
298317
const multiStreamConfirmDialogOpen = ref(false)
299318
let savedChatInput = ''
300319
function alertIsStreaming(input: string) {

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from '@/lib/shadcn/components/ui/sidebar'
2525
import { Button } from '~/lib/shadcn/components/ui/button'
2626
27-
const { $auth } = useNuxtApp()
27+
const { $auth, $init } = useNuxtApp()
2828
const colorMode = useColorMode()
2929
const convex = useConvexClient()
3030
@@ -40,10 +40,11 @@ const isFetching = ref(false)
4040
if ($auth.loggedIn) {
4141
const { data: threadsFromConvex, isLoading: fetchingFromConvex } = useConvexQuery(api.threads.list)
4242
watch(threadsFromConvex, (tFC) => {
43-
// Must reconstruct tFC or else it cant be cloned to IDB after modifications
44-
const newArr: typeof tFC = JSON.parse(JSON.stringify(tFC))
4543
// Keep threads that are not assigned to any users
46-
threads.value = [...newArr, ...threads.value.filter(t => !t.userId)]
44+
// Must reconstruct array or else it cant be cloned to IDB.
45+
threads.value = JSON.parse(JSON.stringify(
46+
[...tFC, ...threads.value.filter(t => !t.userId)],
47+
))
4748
})
4849
watch(fetchingFromConvex, (fFC) => {
4950
isFetching.value = fFC
@@ -54,6 +55,23 @@ if ($auth.loggedIn) {
5455
// TODO
5556
})
5657
}
58+
// For anonymous users, subscribe to threads via sessionId
59+
else {
60+
const { data: threadsFromConvex, isLoading: fetchingFromConvex } = useConvexQuery(api.threads.listBySessionId, { sessionId: $init.sessionId })
61+
watch(threadsFromConvex, (tFC) => {
62+
// Keep threads from other sessionIds, that are not assigned to any users
63+
// Must reconstruct array or else it cant be cloned to IDB.
64+
threads.value = JSON.parse(JSON.stringify(
65+
[
66+
...tFC.map(t => ({ ...t, lockerKey: getLockerKey(t._id) })),
67+
...threads.value.filter(t => !t.userId && t.sessionId !== $init.sessionId),
68+
],
69+
))
70+
})
71+
watch(fetchingFromConvex, (fFC) => {
72+
isFetching.value = fFC
73+
})
74+
}
5775
5876
// Fuck TS in these situations
5977
// [other, pinned]
@@ -92,7 +110,7 @@ function unpinThread(thread: Doc<'threads'>) {
92110
93111
async function _deleteThread(thread: Doc<'threads'>) {
94112
threads.value.splice(threads.value.indexOf(thread), 1)
95-
await deleteThread(convex, thread._id)
113+
await deleteThread(convex, { threadId: thread._id, lockerKey: $auth.loggedIn ? undefined : thread.lockerKey })
96114
}
97115
98116
const [DefineDeleteBtn, ReuseDeleteBtn] = createReusableTemplate<{ thread: Doc<'threads'> }>()
@@ -147,7 +165,7 @@ const [DefineThreadLiItem, ReuseThreadLiItem] = createReusableTemplate<{ thread:
147165
<div class="hidden">
148166
<DefineDeleteBtn v-slot="{ thread }">
149167
<AlertDialog>
150-
<AlertDialogTrigger as-child>
168+
<AlertDialogTrigger v-show="!thread.userId || (thread.userId === $auth?.user?.sub)" as-child>
151169
<Button tabindex="-1" variant="ghost" size="icon" class="size-7 transition-none" @pointerdown.stop.prevent>
152170
<div class="i-hugeicons:delete-put-back" />
153171
</Button>

apps/frontend/app/pages/chat/[...all].vue

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,15 @@ import { SidebarProvider } from '@/lib/shadcn/components/ui/sidebar'
44
definePageMeta({
55
layout: 'basic',
66
})
7-
8-
const { $auth } = useNuxtApp()
97
</script>
108

119
<template>
1210
<div class="w-full flex items-center justify-center">
13-
<div v-if="!$auth.loggedIn" class="h-full w-full flex items-center justify-center text-xl">
11+
<!-- <div v-if="!$auth.loggedIn" class="h-full w-full flex items-center justify-center text-xl">
1412
{{ $t('pages.chat.loginPrompt') }}
15-
</div>
13+
</div> -->
1614

17-
<SidebarProvider v-else>
15+
<SidebarProvider>
1816
<ChatSidebar />
1917
<ChatInterface class="h-full w-full" />
2018
<ChatFloatingMenu class="absolute left-2 top-2 z-10" />

apps/frontend/app/plugins/init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
* Just a simple plugin that provides a mandatory global state and initializations for the app
33
*/
44

5+
import { randomStr } from '@namesmt/utils'
6+
57
export default defineNuxtPlugin({
68
enforce: 'pre',
79
name: 'local-init',
810
parallel: true,
911
async setup() {
1012
const state = reactive({
1113
mounted: false,
14+
sessionId: `session_${Date.now()}_${randomStr(8)}`,
1215
})
1316
return {
1417
provide: {

0 commit comments

Comments
 (0)