feat(chat): 优化分析过程与结果展示
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s

This commit is contained in:
2026-08-26 18:05:03 +08:00
parent fee4fc6ce1
commit 0dad61ff1f
13 changed files with 1049 additions and 292 deletions
+63 -161
View File
@@ -31,6 +31,7 @@ import {
import {
applyQuestionResponse,
cancelRunningTodos,
completeRunningActivities,
completeRunningProgress,
createAssistantMessage,
createTodoUpdateFromEvent,
@@ -40,6 +41,7 @@ import {
normalizeSessionTodos,
toPermissionStatus,
upsertPermission,
upsertActivity,
upsertProgress,
upsertQuestionAcrossMessages,
} from "./agentChatSessionState";
@@ -48,68 +50,21 @@ import type {
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 completeTodos = (todoUpdate: Message["todos"]) =>
todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "completed" as const,
updatedAt: Date.now(),
}
: todo,
),
}
}
}
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));
};
: undefined;
export const useAgentChatSession = ({
projectId,
@@ -136,11 +91,6 @@ 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);
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
useEffect(() => {
@@ -168,83 +118,6 @@ export const useAgentChatSession = ({
});
}, []);
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;
}, [isSessionTitleManuallyEdited]);
@@ -391,10 +264,6 @@ export const useAgentChatSession = ({
assistantMessageId?: string;
},
) => {
if (event.type !== "token") {
flushPendingTokens();
}
if (
event.type !== "session_title" &&
"sessionId" in event &&
@@ -447,7 +316,36 @@ export const useAgentChatSession = ({
}
if (event.type === "token") {
queueTokenContent(assistantMessageId, event.content);
applyTokenContent(assistantMessageId, event.content);
} else if (event.type === "final_answer") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, content: event.content, isError: false }
: message,
);
messagesRef.current = next;
return next;
});
} else if (event.type === "activity_update") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, activities: upsertActivity(message.activities, event) }
: message,
);
return event.todos
? normalizeSessionTodos(
next,
{
sessionId: event.sessionId,
todos: event.todos,
createdAt: event.todosCreatedAt ?? Date.now(),
},
assistantMessageId,
)
: next;
});
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
@@ -583,6 +481,7 @@ export const useAgentChatSession = ({
prev.map((message) => {
if (message.id !== assistantMessageId) return message;
const completedProgress = completeRunningProgress(message.progress);
const completedActivities = completeRunningActivities(message.activities);
if (
message.content.trim().length === 0 &&
!(message.artifacts?.length)
@@ -592,9 +491,16 @@ export const useAgentChatSession = ({
content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}
return { ...message, progress: completedProgress };
return {
...message,
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}),
);
setIsStreaming(false);
@@ -607,6 +513,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
@@ -623,6 +530,7 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
@@ -632,12 +540,11 @@ export const useAgentChatSession = ({
}
},
[
applyTokenContent,
appendArtifact,
flushPendingTokens,
getLastAssistantMessageId,
handleCredentialRefresh,
onToolCall,
queueTokenContent,
],
);
@@ -654,20 +561,18 @@ 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, flushPendingTokens],
[applyStreamEvent],
);
resumeStreamingSessionRef.current = resumeStreamingSession;
@@ -716,7 +621,6 @@ export const useAgentChatSession = ({
}),
});
} catch (error) {
flushPendingTokens();
if (controller.signal.aborted) {
setMessages((prev) =>
prev
@@ -733,6 +637,7 @@ export const useAgentChatSession = ({
message.content.trim().length === 0 &&
!(message.artifacts?.length) &&
!(message.progress?.length) &&
!(message.activities?.length) &&
!message.todos
),
),
@@ -747,20 +652,19 @@ export const useAgentChatSession = ({
content: `⚠️ **错误:** ${String(error)}`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
}
: message,
),
);
setIsStreaming(false);
} finally {
flushPendingTokens();
abortRef.current = null;
setIsStreaming(false);
}
},
[
applyStreamEvent,
flushPendingTokens,
getApprovalMode,
getModel,
isHydrating,
@@ -773,7 +677,6 @@ export const useAgentChatSession = ({
const abort = useCallback(() => {
const controller = abortRef.current;
controller?.abort();
flushPendingTokens();
setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId();
@@ -796,7 +699,7 @@ export const useAgentChatSession = ({
}
});
cancelPromiseRef.current = trackedCancelPromise;
}, [flushPendingTokens, getLastAssistantMessageId]);
}, [getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionDecision) => {
@@ -1009,7 +912,6 @@ export const useAgentChatSession = ({
const createSession = useCallback(() => {
if (isHydrating || isStreaming) return;
flushPendingTokens();
const controller = abortRef.current;
controller?.abort();
hydrationNonceRef.current += 1;
@@ -1020,7 +922,7 @@ export const useAgentChatSession = ({
setIsSessionTitleManuallyEdited(false);
setSessionId(undefined);
setIsStreaming(false);
}, [flushPendingTokens, isHydrating, isStreaming]);
}, [isHydrating, isStreaming]);
const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => {