Skip to content

Commit 85ae3a7

Browse files
committed
Improve chat streaming and harden UI updates
1 parent 4ece477 commit 85ae3a7

22 files changed

Lines changed: 1512 additions & 559 deletions

api-docs.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,25 @@ data: {
165165
}
166166
```
167167

168-
4. **`error`** - Sent if an error occurs during generation
168+
4. **`title_updated`** - Sent when server-side title generation finishes
169+
170+
```
171+
event: title_updated
172+
data: {"conversation_id":"conv_abc123","title":"France Capital Question"}
173+
```
174+
175+
5. **`follow_ups_updated`** - Sent when server-side follow-up suggestions are stored
176+
177+
```
178+
event: follow_ups_updated
179+
data: {
180+
"conversation_id":"conv_abc123",
181+
"message_id":"msg_xyz789",
182+
"suggestions":["What is the population of Paris?","What language is spoken there?"]
183+
}
184+
```
185+
186+
6. **`error`** - Sent if an error occurs during generation
169187

170188
```
171189
event: error
@@ -181,6 +199,7 @@ On an `error`, the server also persists the error on the assistant message (when
181199
- Messages are still saved to the database, preserving conversation history.
182200
- Client disconnection is handled gracefully (generation is cancelled).
183201
- Supports all features of the non-streaming endpoint (web search, MCP tools, reasoning models, etc.).
202+
- Title generation and follow-up suggestions are produced server-side and streamed back as additional SSE events when available.
184203

185204
**CURL Example**:
186205

@@ -1872,6 +1891,7 @@ Get messages for a conversation.
18721891

18731892
- `conversationId`: (Required) Conversation ID.
18741893
- `public`: Set to `"true"` for public conversations.
1894+
- `limit`: Optional positive integer. Returns only the most recent `limit` messages in ascending order.
18751895

18761896
**Response**:
18771897

@@ -1896,7 +1916,7 @@ Get messages for a conversation.
18961916
**CURL Example**:
18971917

18981918
```bash
1899-
curl -X GET "http://localhost:3432/api/db/messages?conversationId=conv_abc123" \
1919+
curl -X GET "http://localhost:3432/api/db/messages?conversationId=conv_abc123&limit=121" \
19001920
-b "session_cookie=your_session"
19011921
```
19021922

src/lib/cache/cached-query.svelte.ts

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface QueryResult<T> {
2121
refetch?: () => Promise<void>;
2222
}
2323

24+
type QueryArgs = Record<string, unknown> | undefined;
25+
2426
const globalCache = new SessionStorageCache('query-cache');
2527
const inFlightRequests = new Map<string, Promise<unknown>>();
2628

@@ -41,8 +43,14 @@ async function dedupedFetch<TResult>(
4143
}
4244
}
4345

44-
type Listener = (key: string) => void;
46+
type CacheEvent =
47+
| { type: 'invalidate'; key: string }
48+
| { type: 'update'; key: string };
49+
50+
type Listener = (event: CacheEvent) => void;
4551
const listeners = new Set<Listener>();
52+
let pendingEvents: CacheEvent[] = [];
53+
let cacheFlushScheduled = false;
4654

4755
function subscribeToCacheInvalidation(listener: Listener) {
4856
listeners.add(listener);
@@ -51,8 +59,60 @@ function subscribeToCacheInvalidation(listener: Listener) {
5159
};
5260
}
5361

62+
function flushCacheEvents() {
63+
cacheFlushScheduled = false;
64+
const events = pendingEvents;
65+
pendingEvents = [];
66+
67+
for (const event of events) {
68+
listeners.forEach((listener) => listener(event));
69+
}
70+
}
71+
72+
function enqueueCacheEvent(event: CacheEvent) {
73+
pendingEvents.push(event);
74+
75+
if (cacheFlushScheduled) {
76+
return;
77+
}
78+
79+
cacheFlushScheduled = true;
80+
queueMicrotask(flushCacheEvents);
81+
}
82+
5483
function notifyInvalidation(key: string) {
55-
listeners.forEach((listener) => listener(key));
84+
enqueueCacheEvent({ type: 'invalidate', key });
85+
}
86+
87+
function notifyUpdate(key: string) {
88+
enqueueCacheEvent({ type: 'update', key });
89+
}
90+
91+
function buildQueryKey(queryConfig: QueryConfig, queryArgs: QueryArgs): string {
92+
return `${queryConfig.url}:${JSON.stringify(queryArgs || {})}`;
93+
}
94+
95+
function matchesCacheKey(eventKey: string, currentKey: string): boolean {
96+
return eventKey === currentKey || (eventKey.endsWith('*') && currentKey.startsWith(eventKey.slice(0, -1)));
97+
}
98+
99+
function parseCacheKey(key: string): { url: string; args: QueryArgs } | null {
100+
const separatorIndex = key.indexOf(':');
101+
if (separatorIndex === -1) {
102+
return null;
103+
}
104+
105+
const url = key.slice(0, separatorIndex);
106+
const rawArgs = key.slice(separatorIndex + 1);
107+
108+
try {
109+
return {
110+
url,
111+
args: rawArgs ? (JSON.parse(rawArgs) as QueryArgs) : undefined,
112+
};
113+
} catch {
114+
return null;
115+
}
56116
}
57117

58118
/**
@@ -77,7 +137,7 @@ export function useCachedQuery<TResult>(
77137
let isStale = $state(false);
78138

79139
const getArgs = () => (typeof queryArgs === 'function' ? queryArgs() : queryArgs);
80-
const getCacheKey = () => cacheKey || `${queryConfig.url}:${JSON.stringify(getArgs())}`;
140+
const getCacheKey = () => cacheKey || buildQueryKey(queryConfig, getArgs());
81141
const isEnabled = () => (typeof enabled === 'function' ? enabled() : enabled);
82142

83143
async function fetchData() {
@@ -161,9 +221,22 @@ export function useCachedQuery<TResult>(
161221

162222
// Subscribe to cache invalidations
163223
$effect(() => {
164-
const unsubscribe = subscribeToCacheInvalidation((key) => {
224+
const unsubscribe = subscribeToCacheInvalidation((event) => {
165225
const currentKey = getCacheKey();
166-
if (key === currentKey || (key.endsWith('*') && currentKey.startsWith(key.slice(0, -1)))) {
226+
if (!matchesCacheKey(event.key, currentKey)) {
227+
return;
228+
}
229+
230+
if (event.type === 'update') {
231+
const cached = globalCache.get(currentKey);
232+
data = cached as TResult | undefined;
233+
error = undefined;
234+
isLoading = false;
235+
isStale = false;
236+
return;
237+
}
238+
239+
if (event.type === 'invalidate') {
167240
fetchData();
168241
}
169242
});
@@ -304,11 +377,62 @@ export const api = {
304377
} as const;
305378

306379
export function invalidateQuery(query: QueryConfig, queryArgs?: unknown): void {
307-
const key = `${query.url}:${JSON.stringify(queryArgs || {})}`;
380+
const key = buildQueryKey(query, (queryArgs as QueryArgs) ?? undefined);
308381
globalCache.delete(key);
309382
notifyInvalidation(key);
310383
}
311384

385+
export function setCachedQueryData<TResult>(
386+
query: QueryConfig,
387+
queryArgs: QueryArgs,
388+
updater: TResult | ((current: TResult | undefined) => TResult | undefined),
389+
options: { ttl?: number } = {}
390+
): void {
391+
const key = buildQueryKey(query, queryArgs);
392+
const current = globalCache.get(key) as TResult | undefined;
393+
const next =
394+
typeof updater === 'function'
395+
? (updater as (current: TResult | undefined) => TResult | undefined)(current)
396+
: updater;
397+
398+
if (next === undefined) {
399+
globalCache.delete(key);
400+
} else {
401+
globalCache.set(key, next, options.ttl);
402+
}
403+
404+
notifyUpdate(key);
405+
}
406+
407+
export function setCachedQueryDataMatching<TResult>(
408+
matcher: (entry: { key: string; url: string; args: QueryArgs }) => boolean,
409+
updater: (current: TResult | undefined, entry: { key: string; url: string; args: QueryArgs }) => TResult | undefined,
410+
options: { ttl?: number } = {}
411+
): void {
412+
for (const key of globalCache.keys()) {
413+
const parsed = parseCacheKey(key);
414+
if (!parsed) {
415+
continue;
416+
}
417+
418+
const entry = { key, url: parsed.url, args: parsed.args };
419+
if (!matcher(entry)) {
420+
continue;
421+
}
422+
423+
const current = globalCache.get(key) as TResult | undefined;
424+
const next = updater(current, entry);
425+
426+
if (next === undefined) {
427+
globalCache.delete(key);
428+
} else {
429+
globalCache.set(key, next, options.ttl);
430+
}
431+
432+
notifyUpdate(key);
433+
}
434+
}
435+
312436
export function invalidateQueryPattern(urlPrefix: string): void {
313437
const keys = Array.from(globalCache.keys());
314438
for (const key of keys) {

src/lib/components/app-sidebar.svelte

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -140,40 +140,18 @@
140140
const projectsQuery = useCachedQuery<Project[]>(api.projects.list, {
141141
cache_scope: session.current?.user.id ?? 'anonymous',
142142
});
143+
const safeProjects = $derived(Array.isArray(projectsQuery.data) ? projectsQuery.data : []);
144+
const safeConversations = $derived(
145+
Array.isArray(conversationsQuery.data) ? conversationsQuery.data : []
146+
);
143147
144148
let expandedProjects = $state<Record<string, boolean>>({});
145149
146150
function toggleProject(projectId: string) {
147151
expandedProjects[projectId] = !expandedProjects[projectId];
148152
}
149153
150-
// Track previous generating state to detect when generation completes
151-
let wasGenerating = $state(false);
152-
const hasGeneratingConversation = $derived(
153-
conversationsQuery.data?.some((c) => c.generating) ?? false
154-
);
155-
156-
// Poll for updates while a conversation is generating (to catch title updates)
157-
// and do a final refresh when generation completes
158-
$effect(() => {
159-
if (hasGeneratingConversation) {
160-
wasGenerating = true;
161-
// Poll every 3 seconds while generating to catch title updates
162-
const interval = setInterval(() => {
163-
invalidateQueryPattern(api.conversations.get.url);
164-
}, 3000);
165-
return () => clearInterval(interval);
166-
} else if (wasGenerating) {
167-
// Generation just completed, do a final refresh to catch the title
168-
wasGenerating = false;
169-
// Small delay to ensure title generation has completed
170-
setTimeout(() => {
171-
invalidateQueryPattern(api.conversations.get.url);
172-
}, 1000);
173-
}
174-
});
175-
176-
function groupConversationsByTime(conversations: Doc<'conversations'>[]) {
154+
function groupConversationsByTime(conversations: Doc<'conversations'>[]) {
177155
const now = Date.now();
178156
const oneDay = 24 * 60 * 60 * 1000;
179157
const sevenDays = 7 * oneDay;
@@ -225,7 +203,7 @@
225203
return groups;
226204
}
227205
228-
const groupedConversations = $derived(groupConversationsByTime(conversationsQuery.data ?? []));
206+
const groupedConversations = $derived(groupConversationsByTime(safeConversations));
229207
230208
const templateConversations = $derived([
231209
{ key: 'pinned', label: 'Pinned', conversations: groupedConversations.pinned, icon: PinIcon },
@@ -235,6 +213,18 @@
235213
{ key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth },
236214
{ key: 'older', label: 'Older', conversations: groupedConversations.older },
237215
]);
216+
const userInitials = $derived.by(() => {
217+
const userName = page.data.session?.user?.name;
218+
if (typeof userName !== 'string' || userName.trim().length === 0) {
219+
return '';
220+
}
221+
222+
return userName
223+
.split(' ')
224+
.filter(Boolean)
225+
.map((name) => name[0]?.toUpperCase() ?? '')
226+
.join('');
227+
});
238228
</script>
239229

240230
<Sidebar.Sidebar class="flex flex-col overflow-clip p-2">
@@ -323,15 +313,14 @@
323313
<div class="flex flex-col gap-0.5">
324314
{#if projectsQuery.isLoading}
325315
<div class="text-muted-foreground px-2 py-1 text-xs">Loading...</div>
326-
{:else if !projectsQuery.data || projectsQuery.data.length === 0}
316+
{:else if safeProjects.length === 0}
327317
<div class="text-muted-foreground px-2 py-1 text-xs italic">
328318
No projects. Create one above.
329319
</div>
330320
{:else}
331-
{#each projectsQuery.data as project (project.id)}
321+
{#each safeProjects as project (project.id)}
332322
{@const isExpanded = expandedProjects[project.id]}
333-
{@const projectConversations =
334-
conversationsQuery.data?.filter((c) => c.projectId === project.id) ?? []}
323+
{@const projectConversations = safeConversations.filter((c) => c.projectId === project.id)}
335324

336325
<div class="flex flex-col">
337326
<div class="group/project flex w-full items-center gap-1">
@@ -408,8 +397,8 @@
408397
>
409398
Remove from project
410399
</DropdownMenu.Item>
411-
{#if projectsQuery.data && projectsQuery.data.length > 0}
412-
{#each projectsQuery.data as project (project.id)}
400+
{#if safeProjects.length > 0}
401+
{#each safeProjects as project (project.id)}
413402
<DropdownMenu.Item
414403
disabled={conversation.projectId === project.id}
415404
onclick={() => setConversationProject(conversation.id, project.id)}
@@ -623,8 +612,8 @@
623612
>
624613
Remove from project
625614
</DropdownMenu.Item>
626-
{#if projectsQuery.data && projectsQuery.data.length > 0}
627-
{#each projectsQuery.data as project (project.id)}
615+
{#if safeProjects.length > 0}
616+
{#each safeProjects as project (project.id)}
628617
<DropdownMenu.Item
629618
disabled={conversation.projectId === project.id}
630619
onclick={() => setConversationProject(conversation.id, project.id)}
@@ -696,10 +685,7 @@
696685
}
697686
)}
698687
>
699-
{page.data.session?.user.name
700-
.split(' ')
701-
.map((name: string) => name[0]?.toUpperCase())
702-
.join('')}
688+
{userInitials}
703689
</span>
704690
{/snippet}
705691
</Avatar>

0 commit comments

Comments
 (0)