feat(chat): smooth streaming output

This commit is contained in:
2026-06-10 21:12:53 +08:00
parent 7d2ae87e39
commit 224d53a04d
9 changed files with 915 additions and 85 deletions
+183 -15
View File
@@ -10,6 +10,69 @@ import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSess
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
const TOKEN_PLAYBACK_BASE_CHARS = 28;
const TOKEN_PLAYBACK_MAX_CHARS = 160;
const sliceCodePoints = (value: string, count: number) =>
Array.from(value).slice(0, count).join("");
let cachedSegmenter: Intl.Segmenter | null | undefined;
const getSegmenter = () => {
if (cachedSegmenter !== undefined) return cachedSegmenter;
cachedSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter("zh", { granularity: "word" })
: null;
return cachedSegmenter;
};
const getPlaybackChunkSize = (bufferLength: number) => {
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
if (bufferLength >= 300) return 112;
if (bufferLength >= 140) return 72;
if (bufferLength >= 64) return 44;
return TOKEN_PLAYBACK_BASE_CHARS;
};
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
if (content.length <= maxChars) return content;
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
const segmenter = getSegmenter();
if (segmenter) {
let chunk = "";
for (const segment of segmenter.segment(content)) {
chunk += segment.segment;
if (
chunk.length >= maxChars ||
(chunk.length >= targetChars &&
/[\s,.!?;:]/u.test(segment.segment))
) {
return chunk;
}
}
}
const phrase = content.match(/^.{1,12}?[\s,.!?;:]+/u)?.[0];
if (phrase) return phrase;
const cjkChunk = content.match(
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
)?.[0];
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk.length <= maxChars
? wordChunk
: sliceCodePoints(wordChunk, maxChars);
}
return sliceCodePoints(content, Math.min(maxChars, 12));
};
export const useAgentChatSession = ({
projectId,
onToolCall,
@@ -34,6 +97,11 @@ export const useAgentChatSession = ({
const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0);
const pendingTokenRef = useRef<{
assistantMessageId: string;
content: string;
} | null>(null);
const tokenPlaybackIntervalRef = useRef<number | null>(null);
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -43,6 +111,99 @@ export const useAgentChatSession = ({
messagesRef.current = messages;
}, [messages]);
const applyTokenContent = useCallback((assistantMessageId: string, content: string) => {
if (!content) return;
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content + content,
isError: false,
}
: message,
);
messagesRef.current = next;
return next;
});
}, []);
const cancelTokenPlayback = useCallback(() => {
const intervalId = tokenPlaybackIntervalRef.current;
if (intervalId === null) return;
window.clearInterval(intervalId);
tokenPlaybackIntervalRef.current = null;
}, []);
const flushPendingTokens = useCallback(() => {
const pending = pendingTokenRef.current;
pendingTokenRef.current = null;
cancelTokenPlayback();
if (!pending) return;
applyTokenContent(pending.assistantMessageId, pending.content);
}, [applyTokenContent, cancelTokenPlayback]);
const scheduleTokenPlayback = useCallback(() => {
if (tokenPlaybackIntervalRef.current !== null) return;
const id = window.setInterval(() => {
const pending = pendingTokenRef.current;
if (!pending) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
return;
}
const chunk = takeNextTokenPlaybackChunk(
pending.content,
getPlaybackChunkSize(pending.content.length),
);
if (!chunk) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
pendingTokenRef.current = null;
return;
}
const remaining = pending.content.slice(chunk.length);
pendingTokenRef.current = remaining
? { assistantMessageId: pending.assistantMessageId, content: remaining }
: null;
applyTokenContent(pending.assistantMessageId, chunk);
if (!remaining) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
}
}, TOKEN_PLAYBACK_INTERVAL_MS);
tokenPlaybackIntervalRef.current = id;
}, [applyTokenContent]);
const queueTokenContent = useCallback(
(assistantMessageId: string, content: string) => {
const pending = pendingTokenRef.current;
if (pending && pending.assistantMessageId !== assistantMessageId) {
flushPendingTokens();
}
pendingTokenRef.current = {
assistantMessageId,
content:
pending?.assistantMessageId === assistantMessageId
? pending.content + content
: content,
};
scheduleTokenPlayback();
},
[flushPendingTokens, scheduleTokenPlayback],
);
useEffect(
() => () => {
pendingTokenRef.current = null;
cancelTokenPlayback();
},
[cancelTokenPlayback],
);
useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
@@ -135,6 +296,10 @@ export const useAgentChatSession = ({
assistantMessageId?: string;
},
) => {
if (event.type !== "token") {
flushPendingTokens();
}
if (
event.type !== "session_title" &&
"sessionId" in event &&
@@ -187,17 +352,7 @@ export const useAgentChatSession = ({
}
if (event.type === "token") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content + event.content,
isError: false,
}
: message,
),
);
queueTokenContent(assistantMessageId, event.content);
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
@@ -303,7 +458,13 @@ export const useAgentChatSession = ({
setIsStreaming(false);
}
},
[appendArtifact, getLastAssistantMessageId, onToolCall],
[
appendArtifact,
flushPendingTokens,
getLastAssistantMessageId,
onToolCall,
queueTokenContent,
],
);
const resumeStreamingSession = useCallback(
@@ -319,18 +480,20 @@ export const useAgentChatSession = ({
onEvent: (event) => applyStreamEvent(event),
})
.catch((error) => {
flushPendingTokens();
if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false);
}
})
.finally(() => {
flushPendingTokens();
if (abortRef.current === controller) {
abortRef.current = null;
}
});
},
[applyStreamEvent],
[applyStreamEvent, flushPendingTokens],
);
resumeStreamingSessionRef.current = resumeStreamingSession;
@@ -379,6 +542,7 @@ export const useAgentChatSession = ({
}),
});
} catch (error) {
flushPendingTokens();
if (controller.signal.aborted) {
setMessages((prev) =>
prev
@@ -415,12 +579,14 @@ export const useAgentChatSession = ({
);
setIsStreaming(false);
} finally {
flushPendingTokens();
abortRef.current = null;
setIsStreaming(false);
}
},
[
applyStreamEvent,
flushPendingTokens,
getApprovalMode,
getModel,
isHydrating,
@@ -433,6 +599,7 @@ export const useAgentChatSession = ({
const abort = useCallback(() => {
const controller = abortRef.current;
controller?.abort();
flushPendingTokens();
setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId();
@@ -455,7 +622,7 @@ export const useAgentChatSession = ({
}
});
cancelPromiseRef.current = trackedCancelPromise;
}, [getLastAssistantMessageId]);
}, [flushPendingTokens, getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => {
@@ -668,6 +835,7 @@ export const useAgentChatSession = ({
const createSession = useCallback(() => {
if (isHydrating || isStreaming) return;
flushPendingTokens();
const controller = abortRef.current;
controller?.abort();
hydrationNonceRef.current += 1;
@@ -678,7 +846,7 @@ export const useAgentChatSession = ({
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setIsStreaming(false);
}, [isHydrating, isStreaming]);
}, [flushPendingTokens, isHydrating, isStreaming]);
const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => {