Skip to content

Commit 8537934

Browse files
committed
allow for multiple chats in parallel. requires backend changes (do not merge until backend changes are merged)
1 parent b3ab270 commit 8537934

7 files changed

Lines changed: 327 additions & 197 deletions

File tree

src/components/chat/ChatMessages.tsx

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ type ChatMessagesProps = {
1010
streamingContent: string;
1111
loadingPhase?: string;
1212
loadingProgress?: number;
13-
layersOnGpu?: number;
13+
layersOnRpc?: number;
1414
};
1515

1616
function phaseLabel(phase: string, progress: number): string {
@@ -115,7 +115,7 @@ export function ChatMessages({
115115
streamingContent,
116116
loadingPhase,
117117
loadingProgress = 0,
118-
layersOnGpu = 0,
118+
layersOnRpc = 0,
119119
}: ChatMessagesProps) {
120120
const bottomRef = useRef<HTMLDivElement>(null);
121121

@@ -132,6 +132,8 @@ export function ChatMessages({
132132
return (
133133
<div className="chat-messages flex-1 overflow-y-auto px-6 pt-6 pb-4 flex flex-col gap-5">
134134
{messages.map((msg, idx) => {
135+
if (msg.role === 'system') return null;
136+
135137
// The empty assistant placeholder added at the start of streaming is
136138
// visually covered by the typing indicator — skip it to avoid a double row.
137139
if (msg.role === 'assistant' && msg.content === '') return null;
@@ -183,35 +185,6 @@ export function ChatMessages({
183185
</div>
184186
);
185187
})}
186-
187-
{/* Live streaming message */}
188-
{streaming &&
189-
streamingContent !== '' &&
190-
(() => {
191-
const { thinking, response, thinkingComplete } =
192-
parseThinking(streamingContent);
193-
return (
194-
<div className="flex gap-3 max-w-195 w-full self-start">
195-
<div className={assistantAvatar}>
196-
<ClusterIcon />
197-
</div>
198-
<div className={assistantBubble}>
199-
{thinking && (
200-
<ThinkingBlock
201-
thinking={thinking}
202-
thinkingComplete={thinkingComplete}
203-
/>
204-
)}
205-
{response && (
206-
<ReactMarkdown remarkPlugins={[remarkGfm]}>
207-
{response}
208-
</ReactMarkdown>
209-
)}
210-
</div>
211-
</div>
212-
);
213-
})()}
214-
215188
{/* Status indicator while waiting for first token */}
216189
{streaming && streamingContent === '' && (
217190
<div className="flex gap-3 max-w-195 w-full self-start">
@@ -225,7 +198,7 @@ export function ChatMessages({
225198
<span className="typing-dot" />
226199
<span className="typing-status-text">
227200
{phaseLabel(loadingPhase, loadingProgress)}
228-
{layersOnGpu > 0 && ` · ${layersOnGpu} layers on GPU`}
201+
{layersOnRpc > 0 && ` · ${layersOnRpc} layers on RPC nodes`}
229202
</span>
230203
</div>
231204
) : (

src/context/ChatStreamingContext.tsx

Lines changed: 142 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
22
import type { ReactNode } from 'react';
3-
import { streamChat, appendChatEvent, getLoadingStatus } from '../lib/api';
3+
import {
4+
getCurrentChatRun,
5+
getLoadingStatus,
6+
startChatRun,
7+
stopCurrentChatRun,
8+
subscribeToChatRun,
9+
} from '../lib/api';
410
import { useConversations } from './ConversationContext';
5-
import type { ChatMessage } from '../types/ui';
11+
import type { ChatMessage, ChatRunSnapshot, ChatRunStreamEvent } from '../types/ui';
612

713
export type StreamEntry = {
814
streaming: boolean;
915
streamingContent: string;
1016
loadingPhase: string;
1117
loadingProgress: number;
12-
layersOnGpu: number;
18+
layersOnRpc: number;
1319
};
1420

1521
export type StreamToast = {
@@ -24,7 +30,7 @@ const emptyEntry: StreamEntry = {
2430
streamingContent: '',
2531
loadingPhase: '',
2632
loadingProgress: 0,
27-
layersOnGpu: 0,
33+
layersOnRpc: 0,
2834
};
2935

3036
export type StartStreamParams = {
@@ -47,22 +53,29 @@ type ChatStreamingContextValue = {
4753

4854
const ChatStreamingContext = createContext<ChatStreamingContextValue | null>(null);
4955

56+
function isRunActive(snapshot: ChatRunSnapshot): boolean {
57+
return snapshot.status === 'starting' || snapshot.status === 'streaming';
58+
}
59+
5060
export function ChatStreamingProvider({ children }: { children: ReactNode }) {
51-
const { appendMessage, updateLastMessage, activeId } = useConversations();
61+
const {
62+
conversations,
63+
appendMessages,
64+
upsertAssistantMessage,
65+
activeId,
66+
} = useConversations();
5267
const [streams, setStreams] = useState<Record<string, StreamEntry>>({});
5368
const [nodeCount, setNodeCount] = useState(0);
5469
const [toasts, setToasts] = useState<StreamToast[]>([]);
55-
const abortRefs = useRef<Record<string, AbortController>>({});
56-
// Ref so async stream callbacks always see the current activeId without stale closure
70+
const sourcesRef = useRef<Record<string, EventSource>>({});
71+
const hydratedRef = useRef<Set<string>>(new Set());
72+
const hydrationPromiseRef = useRef<Record<string, Promise<ChatRunSnapshot | null>>>({});
5773
const activeIdRef = useRef<string | null>(activeId);
74+
5875
useEffect(() => {
5976
activeIdRef.current = activeId;
60-
if (activeId) {
61-
setToasts((prev) => prev.filter((t) => t.convId !== activeId));
62-
}
6377
}, [activeId]);
6478

65-
// Always-on slow poll for node count
6679
useEffect(() => {
6780
const fetch = () => {
6881
void getLoadingStatus().then((s) => setNodeCount(s.node_count)).catch(() => undefined);
@@ -79,122 +92,143 @@ export function ChatStreamingProvider({ children }: { children: ReactNode }) {
7992
}));
8093
}, []);
8194

82-
const stopStream = useCallback((convId: string) => {
83-
abortRefs.current[convId]?.abort();
95+
const applySnapshot = useCallback((convId: string, snapshot: ChatRunSnapshot) => {
96+
upsertAssistantMessage(convId, snapshot.assistant_content);
97+
patch(convId, {
98+
streaming: isRunActive(snapshot),
99+
streamingContent: snapshot.assistant_content,
100+
loadingPhase: snapshot.loading_phase,
101+
loadingProgress: snapshot.loading_progress,
102+
layersOnRpc: snapshot.layers_on_gpu,
103+
});
104+
}, [patch, upsertAssistantMessage]);
105+
106+
const closeSource = useCallback((convId: string) => {
107+
sourcesRef.current[convId]?.close();
108+
delete sourcesRef.current[convId];
109+
}, []);
110+
111+
const attachSource = useCallback((convId: string, convTitle: string, model: string) => {
112+
closeSource(convId);
113+
const source = subscribeToChatRun(
114+
convId,
115+
(event: ChatRunStreamEvent) => {
116+
applySnapshot(convId, event.snapshot);
117+
if (!isRunActive(event.snapshot)) {
118+
closeSource(convId);
119+
if (event.snapshot.status === 'completed' && activeIdRef.current !== convId) {
120+
setToasts((prev) => [
121+
...prev,
122+
{ id: crypto.randomUUID(), convId, convTitle, model },
123+
]);
124+
}
125+
}
126+
},
127+
() => {
128+
closeSource(convId);
129+
}
130+
);
131+
sourcesRef.current[convId] = source;
132+
}, [applySnapshot, closeSource]);
133+
134+
const hydrateRun = useCallback((convId: string, convTitle: string, model: string): Promise<ChatRunSnapshot | null> => {
135+
const existing = hydrationPromiseRef.current[convId];
136+
if (existing) return existing;
137+
138+
const pending = getCurrentChatRun(convId)
139+
.then((snapshot) => {
140+
applySnapshot(convId, snapshot);
141+
if (isRunActive(snapshot)) {
142+
attachSource(convId, convTitle, model);
143+
}
144+
return snapshot;
145+
})
146+
.catch(() => null)
147+
.finally(() => {
148+
delete hydrationPromiseRef.current[convId];
149+
});
150+
151+
hydrationPromiseRef.current[convId] = pending;
152+
return pending;
153+
}, [applySnapshot, attachSource]);
154+
155+
useEffect(() => {
156+
conversations.forEach((conv) => {
157+
if (hydratedRef.current.has(conv.id)) return;
158+
hydratedRef.current.add(conv.id);
159+
void hydrateRun(conv.id, conv.title, conv.model);
160+
});
161+
}, [conversations, hydrateRun]);
162+
163+
useEffect(() => () => {
164+
Object.values(sourcesRef.current).forEach((source) => source.close());
165+
sourcesRef.current = {};
84166
}, []);
85167

168+
const stopStream = useCallback((convId: string) => {
169+
void stopCurrentChatRun(convId)
170+
.then(async () => {
171+
const snapshot = await getCurrentChatRun(convId);
172+
applySnapshot(convId, snapshot);
173+
})
174+
.catch(() => undefined)
175+
.finally(() => {
176+
closeSource(convId);
177+
});
178+
}, [applySnapshot, closeSource]);
179+
86180
const dismissToast = useCallback((id: string) => {
87181
setToasts((prev) => prev.filter((t) => t.id !== id));
88182
}, []);
89183

90184
const startStream = useCallback(({
91185
convId, model, content, prevMessages, thinkingEnabled, convTitle,
92186
}: StartStreamParams) => {
93-
abortRefs.current[convId]?.abort();
94-
const controller = new AbortController();
95-
abortRefs.current[convId] = controller;
96-
97-
appendMessage(convId, { role: 'user', content });
98-
appendMessage(convId, { role: 'assistant', content: '' });
99-
100-
void appendChatEvent(convId, {
101-
event_type: 'message_sent',
102-
role: 'user',
103-
content,
104-
timestamp: new Date().toISOString(),
105-
}).catch(() => undefined);
106-
107-
patch(convId, { streaming: true, streamingContent: '', loadingPhase: '', loadingProgress: 0 });
108-
109-
const history: ChatMessage[] = [
110-
...(!thinkingEnabled ? [{ role: 'system' as const, content: '/no_think' }] : []),
111-
...prevMessages,
112-
{ role: 'user' as const, content },
113-
];
187+
closeSource(convId);
114188

115189
void (async () => {
116-
let assistantContent = '';
117-
let firstTokenTime: number | null = null;
118-
let tokenCount = 0;
119-
120-
// Poll loading phase until first token arrives
121-
const phaseInterval = setInterval(() => {
122-
if (assistantContent !== '') { clearInterval(phaseInterval); return; }
123-
void getLoadingStatus().then((s) => {
124-
if (assistantContent === '') {
125-
patch(convId, { loadingPhase: s.phase, loadingProgress: s.progress, layersOnGpu: s.layers_on_gpu });
126-
}
127-
}).catch(() => undefined);
128-
}, 1200);
190+
const existingRun = await hydrateRun(convId, convTitle, model);
191+
if (existingRun && isRunActive(existingRun)) {
192+
return;
193+
}
129194

130-
const attemptStream = async (isRetry: boolean): Promise<boolean> => {
131-
if (isRetry) {
132-
await new Promise((r) => setTimeout(r, 2000));
133-
if (controller.signal.aborted) return false;
134-
}
135-
for await (const token of streamChat(history, model, controller.signal)) {
136-
if (firstTokenTime === null) firstTokenTime = Date.now();
137-
tokenCount += 1;
138-
assistantContent += token;
139-
patch(convId, { streamingContent: assistantContent, loadingPhase: '' });
140-
}
141-
return true;
142-
};
143-
144-
const finalize = (tps?: number) => {
145-
updateLastMessage(convId, () => assistantContent, tps);
146-
void appendChatEvent(convId, {
147-
event_type: 'message_completed',
148-
role: 'assistant',
149-
content: assistantContent,
150-
timestamp: new Date().toISOString(),
151-
}).catch(() => undefined);
152-
if (activeIdRef.current !== convId) {
153-
setToasts((prev) => [...prev, {
154-
id: crypto.randomUUID(), convId, convTitle, model,
155-
}]);
156-
}
157-
};
195+
appendMessages(convId, [
196+
{ role: 'user', content },
197+
{ role: 'assistant', content: '' },
198+
]);
199+
patch(convId, { streaming: true, streamingContent: '', loadingPhase: '', loadingProgress: 0, layersOnRpc: 0 });
158200

159-
const calcTps = () =>
160-
firstTokenTime !== null && tokenCount > 0
161-
? tokenCount / ((Date.now() - firstTokenTime) / 1000)
162-
: undefined;
201+
const messages: Array<Pick<ChatMessage, 'role' | 'content'>> = [
202+
...prevMessages.map((message) => ({ role: message.role, content: message.content })),
203+
{ role: 'user' as const, content },
204+
];
163205

164206
try {
165-
let ok = await attemptStream(false);
166-
if (!ok) throw new DOMException('Aborted', 'AbortError');
167-
if (assistantContent === '') {
168-
ok = await attemptStream(true);
169-
if (!ok) throw new DOMException('Aborted', 'AbortError');
170-
}
171-
finalize(calcTps());
172-
} catch (err) {
173-
if ((err as Error).name !== 'AbortError') {
174-
try {
175-
await attemptStream(true);
176-
finalize(calcTps());
177-
} catch (retryErr) {
178-
if ((retryErr as Error).name !== 'AbortError') {
179-
updateLastMessage(convId, (prev) => prev || '_(Error: could not reach model server)_');
180-
void appendChatEvent(convId, {
181-
event_type: 'stream_error',
182-
error: (retryErr as Error).message,
183-
timestamp: new Date().toISOString(),
184-
}).catch(() => undefined);
185-
}
186-
}
187-
}
188-
} finally {
189-
clearInterval(phaseInterval);
190-
patch(convId, { streaming: false, streamingContent: '', loadingPhase: '' });
191-
delete abortRefs.current[convId];
207+
const snapshot = await startChatRun(convId, {
208+
model,
209+
messages,
210+
thinking_enabled: thinkingEnabled,
211+
});
212+
applySnapshot(convId, snapshot);
213+
attachSource(convId, convTitle, model);
214+
} catch {
215+
upsertAssistantMessage(convId, '_(Error: could not reach model server)_');
216+
patch(convId, { streaming: false, loadingPhase: '', loadingProgress: 0 });
192217
}
193218
})();
194-
}, [appendMessage, updateLastMessage, patch]);
219+
}, [appendMessages, attachSource, applySnapshot, closeSource, hydrateRun, patch, upsertAssistantMessage]);
195220

196221
return (
197-
<ChatStreamingContext.Provider value={{ streams, nodeCount, toasts, startStream, stopStream, dismissToast }}>
222+
<ChatStreamingContext.Provider
223+
value={{
224+
streams,
225+
nodeCount,
226+
toasts: activeId ? toasts.filter((toast) => toast.convId !== activeId) : toasts,
227+
startStream,
228+
stopStream,
229+
dismissToast,
230+
}}
231+
>
198232
{children}
199233
</ChatStreamingContext.Provider>
200234
);

0 commit comments

Comments
 (0)