feat(webui): improve beta turn completion and streaming UX

This commit is contained in:
ramonpaolo
2026-05-03 22:28:40 +08:00
committed by Xubin Ren
parent 5853d5dfda
commit 76e3f74df7
18 changed files with 850 additions and 28 deletions
+72 -20
View File
@@ -37,6 +37,7 @@ export interface SendImage {
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
): {
messages: UIMessage[];
isStreaming: boolean;
@@ -51,9 +52,23 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [isStreaming, setIsStreaming] = useState(false);
/** If the last loaded message is a trace row (e.g. "Using 2 tools"),
* the model was still processing when the page loaded — keep the
* loading spinner alive so the user sees the model is active. */
const initialStreaming = initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
* When the model finishes a text segment and calls a tool, the server
* sends ``stream_end`` but the agent is still "thinking" while the tool
* executes. By deferring the flag reset by a short window (1 s) we keep
* the loading spinner alive across tool-call boundaries without needing
* backend changes. */
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return client.onError((err) => setStreamError(err));
@@ -62,21 +77,43 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
// Reset local state when switching chats. ``streamError`` is scoped to the
// send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat).
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(false);
setStreamError(null);
buffer.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId]);
// send that triggered it, so a chat swap should wipe it out: a stale
// "Message too large" banner on a freshly-opened chat-B would confuse the
// user about which send actually failed (and in which chat).
useEffect(() => {
setMessages(initialMessages);
// Check if the new chat's last message is a trace row — if so, the
// model may still be processing.
setIsStreaming(
initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false,
);
// Also consider hasPendingToolCalls from session history.
if (hasPendingToolCalls) {
setIsStreaming(true);
}
setStreamError(null);
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, initialMessages, hasPendingToolCalls]);
useEffect(() => {
if (!chatId) return;
const handle = (ev: InboundEvent) => {
// Any incoming event while the debounce timer is alive means the model
// is still working (e.g. tool result arrived, more text to stream).
// Cancel the pending "stream ended" timer so we don't hide the spinner.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
if (ev.event === "delta") {
const id = buffer.current?.messageId ?? crypto.randomUUID();
if (!buffer.current) {
@@ -103,17 +140,24 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
if (!buffer.current) {
setIsStreaming(false);
return;
}
const finalId = buffer.current.messageId;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
// definitive "turn is complete" signal is ``turn_end``.
if (!buffer.current) return;
buffer.current = null;
return;
}
if (ev.event === "turn_end") {
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) =>
m.id === finalId ? { ...m, isStreaming: false } : m,
),
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
return;
}
@@ -157,7 +201,8 @@ export function useNanobotStream(
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
setIsStreaming(false);
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
@@ -183,6 +228,10 @@ export function useNanobotStream(
return () => {
unsub();
buffer.current = null;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
};
}, [chatId, client]);
@@ -205,6 +254,9 @@ export function useNanobotStream(
...(previews ? { images: previews } : {}),
},
]);
// Mark streaming immediately so the UI shows the loading indicator
// right away, before the first delta arrives from the server.
setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
client.sendMessage(chatId, content, wireMedia);
},
+20 -2
View File
@@ -84,6 +84,9 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
/** ``true`` when the last persisted message has ``tool_calls`` but no
* final text yet — the model was still processing when the page loaded. */
hasPendingToolCalls: boolean;
} {
const { token } = useClient();
const [state, setState] = useState<{
@@ -91,11 +94,13 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[];
loading: boolean;
error: string | null;
hasPendingToolCalls: boolean;
}>({
key: null,
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
useEffect(() => {
@@ -105,6 +110,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
return;
}
@@ -116,6 +122,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: true,
error: null,
hasPendingToolCalls: false,
});
(async () => {
try {
@@ -146,11 +153,19 @@ export function useSessionHistory(key: string | null): {
},
];
});
// Check if the last persisted message has tool_calls but no final
// text yet — the model was still processing when the page loaded.
const lastRaw = body.messages[body.messages.length - 1];
const hasPending =
lastRaw?.role === "assistant" &&
Array.isArray(lastRaw.tool_calls) &&
lastRaw.tool_calls.length > 0;
setState({
key,
messages: ui,
loading: false,
error: null,
hasPendingToolCalls: hasPending,
});
} catch (e) {
if (cancelled) return;
@@ -162,6 +177,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
});
} else {
setState({
@@ -169,6 +185,7 @@ export function useSessionHistory(key: string | null): {
messages: [],
loading: false,
error: (e as Error).message,
hasPendingToolCalls: false,
});
}
}
@@ -179,19 +196,20 @@ export function useSessionHistory(key: string | null): {
}, [key, token]);
if (!key) {
return { messages: EMPTY_MESSAGES, loading: false, error: null };
return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false };
}
// Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key.
if (state.key !== key) {
return { messages: EMPTY_MESSAGES, loading: true, error: null };
return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false };
}
return {
messages: state.messages,
loading: state.loading,
error: state.error,
hasPendingToolCalls: state.hasPendingToolCalls,
};
}