diff --git a/src/components/chat/AgentActivityTimeline.tsx b/src/components/chat/AgentActivityTimeline.tsx
new file mode 100644
index 0000000..de91ba3
--- /dev/null
+++ b/src/components/chat/AgentActivityTimeline.tsx
@@ -0,0 +1,384 @@
+"use client";
+
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ Box,
+ Collapse,
+ IconButton,
+ LinearProgress,
+ Stack,
+ Typography,
+ alpha,
+ useMediaQuery,
+ useTheme,
+} from "@mui/material";
+import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
+import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
+import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
+import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
+import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
+import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
+import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
+
+import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
+
+const activityAccent = "#0097a7";
+
+type TimedActivityItem = {
+ status: "running" | "completed" | "error" | "cancelled";
+ startedAt: number;
+ endedAt?: number;
+ elapsedMs?: number;
+ elapsedSnapshotAt?: number;
+ durationMs?: number;
+};
+
+const formatDuration = (durationMs: number | undefined) => {
+ if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
+ if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
+ const seconds = Math.round(durationMs / 1000);
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
+};
+
+const getElapsedMs = (
+ item: TimedActivityItem,
+ now: number,
+) => {
+ if (item.durationMs !== undefined) return item.durationMs;
+ if (item.status === "running") {
+ if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
+ return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
+ }
+ return Math.max(0, now - item.startedAt);
+ }
+ return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
+};
+
+const StatusIcon = ({
+ status,
+ size = 18,
+}: {
+ status: AgentActivity["status"];
+ size?: number;
+}) => {
+ if (status === "completed") {
+ return ;
+ }
+ if (status === "error") {
+ return ;
+ }
+ if (status === "cancelled") {
+ return ;
+ }
+ return ;
+};
+
+const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
+ if (status === "error") {
+ return ;
+ }
+ if (status === "completed") {
+ return ;
+ }
+ return (
+
+ );
+};
+
+const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
+ const elapsed = getElapsedMs(action, now);
+ return (
+
+
+
+
+
+ {action.title}
+
+
+ {formatDuration(elapsed)}
+
+
+ {action.target ? (
+
+ {action.target}
+
+ ) : null}
+ {action.error ? (
+
+ {action.error}
+
+ ) : null}
+
+
+ );
+};
+
+export const AgentActivityTimeline = ({
+ activities,
+}: {
+ activities: AgentActivity[];
+}) => {
+ const theme = useTheme();
+ const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
+ const hasRunning = activities.some((activity) => activity.status === "running");
+ const hasError = activities.some((activity) => activity.status === "error");
+ const hasCancelled = activities.some((activity) => activity.status === "cancelled");
+ const [expanded, setExpanded] = useState(false);
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ if (!hasRunning) return;
+ const timer = window.setInterval(() => setNow(Date.now()), 500);
+ return () => window.clearInterval(timer);
+ }, [hasRunning]);
+
+ const current = [...activities]
+ .reverse()
+ .find((activity) => activity.status === "running") ?? activities.at(-1);
+ const totalDuration = useMemo(() => {
+ if (!activities.length) return undefined;
+ const start = Math.min(...activities.map((activity) => activity.startedAt));
+ const end = hasRunning
+ ? now
+ : Math.max(
+ ...activities.map((activity) => activity.endedAt ?? activity.startedAt),
+ );
+ return formatDuration(Math.max(0, end - start));
+ }, [activities, hasRunning, now]);
+ const overallStatus: AgentActivity["status"] = hasRunning
+ ? "running"
+ : hasError
+ ? "error"
+ : hasCancelled
+ ? "cancelled"
+ : "completed";
+ const statusLabel = {
+ running: "进行中",
+ completed: "已完成",
+ error: "失败",
+ cancelled: "已停止",
+ }[overallStatus];
+ const statusColor = {
+ running: activityAccent,
+ completed: theme.palette.success.main,
+ error: theme.palette.error.main,
+ cancelled: theme.palette.text.secondary,
+ }[overallStatus];
+ const summary = hasRunning
+ ? (current?.title ?? "正在分析")
+ : hasError
+ ? "分析未完成"
+ : hasCancelled
+ ? "分析已停止"
+ : `已完成 ${activities.length} 个阶段`;
+
+ return (
+
+
+
+
+
+
+
+
+ 分析过程
+
+
+
+
+ {statusLabel}
+
+
+
+
+ {summary}
+ {totalDuration ? ` · ${totalDuration}` : ""}
+
+
+ setExpanded((current) => !current)}
+ sx={{
+ width: 28,
+ height: 28,
+ flex: "0 0 auto",
+ color: "text.secondary",
+ bgcolor: alpha("#000", 0.035),
+ "&:hover": { bgcolor: alpha("#000", 0.07) },
+ }}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {hasRunning ? (
+
+ ) : null}
+
+
+ {activities.map((activity, index) => {
+ const elapsed = formatDuration(getElapsedMs(activity, now));
+ return (
+
+
+ {index < activities.length - 1 ? (
+
+ ) : null}
+
+
+
+
+
+ {activity.title}
+
+
+ {elapsed}
+
+
+
+ {activity.reason}
+
+ {activity.actions.length ? (
+
+ {activity.actions.map((action) => (
+
+ ))}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/src/components/chat/AgentPermissionRequests.tsx b/src/components/chat/AgentPermissionRequests.tsx
index 1e36cf5..44ea68c 100644
--- a/src/components/chat/AgentPermissionRequests.tsx
+++ b/src/components/chat/AgentPermissionRequests.tsx
@@ -118,19 +118,10 @@ const PermissionRequestCard = ({
sx={{
borderRadius: 3,
overflow: "hidden",
- border: `1px solid ${alpha("#fff", 0.72)}`,
- bgcolor: alpha("#fff", 0.5),
- boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
- backdropFilter: "blur(20px)",
+ border: `1px solid ${alpha(accentColor, 0.18)}`,
+ bgcolor: alpha(accentColor, 0.035),
+ boxShadow: `0 6px 18px ${alpha("#000", 0.04)}`,
position: "relative",
- "&::before": {
- content: '""',
- position: "absolute",
- inset: "10px auto 10px 0",
- width: 3,
- borderRadius: "0 999px 999px 0",
- bgcolor: accentColor,
- },
}}
>
+
+
+ 执行目的
+
+
+ {permission.reason?.trim() || "Agent 未提供执行目的"}
+
+
todo.status === "completed").length;
- const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
+ const runningCount = todoUpdate.todos.filter(
+ (todo) => todo.status === "in_progress",
+ ).length;
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -77,7 +79,7 @@ export const TodoPlanCard = ({
? `${completed} 完成 / ${cancelled} 中止`
: [
completed ? `${completed} 完成` : null,
- running ? "1 进行中" : null,
+ runningCount ? `${runningCount} 进行中` : null,
pending ? `${pending} 待办` : null,
cancelled ? `${cancelled} 中止` : null,
].filter(Boolean).join(" / ") || "等待任务";
@@ -221,14 +223,14 @@ export const TodoPlanCard = ({
@@ -305,4 +307,3 @@ export const TodoPlanCard = ({
);
};
-
diff --git a/src/components/chat/AgentTurn.test.tsx b/src/components/chat/AgentTurn.test.tsx
index 613103e..7684885 100644
--- a/src/components/chat/AgentTurn.test.tsx
+++ b/src/components/chat/AgentTurn.test.tsx
@@ -14,6 +14,7 @@ jest.mock("next/image", () => ({
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ useReducedMotion: () => false,
motion: {
div: ({
children,
@@ -44,6 +45,47 @@ jest.mock("./AgentMarkdownBlock", () => ({
}));
describe("AgentTurn speech selection", () => {
+ it("mounts the answer only after the complete response is available", () => {
+ const sharedProps = {
+ messageSpeechState: "idle" as const,
+ onSpeak: jest.fn(),
+ onPause: jest.fn(),
+ onResume: jest.fn(),
+ onStopSpeech: jest.fn(),
+ isTtsSupported: true,
+ onCreateBranch: jest.fn(),
+ onReplyPermission: jest.fn(),
+ onReplyQuestion: jest.fn(),
+ onRejectQuestion: jest.fn(),
+ };
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.queryByTestId("agent-answer-content")).not.toBeInTheDocument();
+ expect(screen.getByText("正在生成")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByTestId("agent-answer-content")).toHaveTextContent(
+ "完整分析结果已生成。",
+ );
+ });
+
it("shows a floating action and reads from the selected text", async () => {
const content = "第一段内容。\n\n第二段内容。";
const speechText = "第一段内容。\n第二段内容。";
@@ -136,6 +178,7 @@ describe("AgentTurn speech selection", () => {
permission: "bash",
patterns: ["npm test"],
target: "npm test",
+ reason: "需要运行测试确认本次改动没有引入回归。",
always: ["npm test"],
createdAt: 1,
status: "pending",
@@ -158,10 +201,151 @@ describe("AgentTurn speech selection", () => {
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
+ expect(screen.getByText("执行目的")).toBeInTheDocument();
+ expect(screen.getByText("需要运行测试确认本次改动没有引入回归。")).toBeInTheDocument();
expect(screen.getAllByText("npm test")).toHaveLength(2);
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
});
+
+ it("groups concrete actions under a business activity", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("分析过程")).toBeInTheDocument();
+ expect(screen.getByText("进行中")).toBeInTheDocument();
+ expect(screen.getAllByText("准备供水分区数据").length).toBeGreaterThan(0);
+ expect(screen.getByTestId("KeyboardArrowDownRoundedIcon")).toBeInTheDocument();
+ expect(
+ screen.queryByText("需要确认拓扑与水库属性完整,才能计算服务范围。"),
+ ).not.toBeVisible();
+ expect(screen.queryByText("查询后端数据")).not.toBeVisible();
+
+ fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
+ expect(screen.getByTestId("KeyboardArrowUpRoundedIcon")).toBeInTheDocument();
+ expect(screen.getByText("需要确认拓扑与水库属性完整,才能计算服务范围。")).toBeVisible();
+ expect(screen.getByText("查询后端数据")).toBeVisible();
+ });
+
+ it("shows the actual number of in-progress session tasks", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/2 完成 \/ 2 进行中/u)).toBeInTheDocument();
+ });
+
+ it("keeps a failed activity compact until the user expands it", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("失败")).toBeInTheDocument();
+ expect(screen.getByText("分析未完成 · 4.1s")).toBeInTheDocument();
+ expect(
+ screen.queryByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
+ ).not.toBeVisible();
+
+ fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
+ expect(
+ screen.getByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx
index 829cc95..7e7decc 100644
--- a/src/components/chat/AgentTurn.tsx
+++ b/src/components/chat/AgentTurn.tsx
@@ -2,7 +2,7 @@
import Image from "next/image";
import React, { useMemo } from "react";
-import { motion } from "framer-motion";
+import { motion, useReducedMotion } from "framer-motion";
import {
Avatar,
Box,
@@ -33,6 +33,7 @@ import type {
import { stripMarkdown } from "./globalChatboxUtils";
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
+import { AgentActivityTimeline } from "./AgentActivityTimeline";
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
@@ -149,61 +150,6 @@ const StreamingStatus = () => {
);
};
-const StreamingMarkdownBlock = ({
- text,
- isStreaming,
- segmentKey,
-}: {
- text: string;
- isStreaming: boolean;
- segmentKey: string;
-}) => {
- const [streamTextState, setStreamTextState] = React.useState<{
- displayText: string;
- animatedTailLength: number;
- }>({
- displayText: text,
- animatedTailLength: 0,
- });
-
- React.useLayoutEffect(() => {
- setStreamTextState((current) => {
- if (current.displayText === text) {
- return current;
- }
-
- if (!isStreaming) {
- return {
- displayText: text,
- animatedTailLength: 0,
- };
- }
-
- if (current.displayText === text) {
- return current;
- }
-
- return {
- displayText: text,
- animatedTailLength:
- text.length > current.displayText.length &&
- text.startsWith(current.displayText)
- ? Math.min(48, text.length - current.displayText.length)
- : 0,
- };
- });
- }, [isStreaming, text]);
-
- return (
-
- {streamTextState.displayText}
-
- );
-};
-
export const AgentTurn = React.memo(
({
message,
@@ -220,9 +166,11 @@ export const AgentTurn = React.memo(
onRejectQuestion,
}: AgentTurnProps) => {
const theme = useTheme();
+ const reduceMotion = useReducedMotion();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
+ const hasFinalAnswer = message.content.trim().length > 0;
const [isHovered, setIsHovered] = React.useState(false);
const answerContentRef = React.useRef(null);
const [speechSelection, setSpeechSelection] = React.useState(null);
@@ -230,7 +178,8 @@ export const AgentTurn = React.memo(
(item) => item.phase === "complete" && item.status === "completed",
) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && (
- message.progress?.some((item) => item.status === "running") ?? false
+ (message.activities?.some((item) => item.status === "running") ?? false) ||
+ (message.progress?.some((item) => item.status === "running") ?? false)
);
const parsedAssistantSections = useMemo(
@@ -456,7 +405,9 @@ export const AgentTurn = React.memo(
}}
>
- {message.progress?.length ? (
+ {message.activities?.length ? (
+
+ ) : message.progress?.length ? (
) : null}
@@ -493,63 +444,98 @@ export const AgentTurn = React.memo(
}}
>
-
-
+
+
分析结果
{isStreamingAssistant ? : null}
- {contentSegments.map((segment, segIdx) => {
- if (segment.type === "text") {
- const text = segment.content.trim();
- if (!text && contentSegments.length > 1) return null;
- return (
-
- );
- }
- if (segment.type === "tool_call") {
- if (
- segment.toolCall.tool === "chart" ||
- segment.toolCall.tool === "show_chart"
- ) {
- const p = segment.toolCall.params;
- return (
-
- );
+ {hasFinalAnswer || !isStreamingAssistant ? (
+
- );
- }
- if (segment.type === "tool_call_pending") {
- return (
- }
- />
- );
- }
- return null;
- })}
+ animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
+ transition={{
+ duration: reduceMotion ? 0 : 0.24,
+ ease: [0.16, 1, 0.3, 1],
+ }}
+ >
+
+ {contentSegments.map((segment, segIdx) => {
+ if (segment.type === "text") {
+ const text = segment.content.trim();
+ if (!text && contentSegments.length > 1) return null;
+ return (
+
+ {text || "..."}
+
+ );
+ }
+ if (segment.type === "tool_call") {
+ if (
+ segment.toolCall.tool === "chart" ||
+ segment.toolCall.tool === "show_chart"
+ ) {
+ const p = segment.toolCall.params;
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+ if (segment.type === "tool_call_pending") {
+ return (
+ }
+ />
+ );
+ }
+ return null;
+ })}
+
+
+ ) : null}
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index ef308fc..eb1dac1 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -229,18 +229,23 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => {
useEffect(() => {
if (isStreaming) {
+ const latestAssistant = [...messages]
+ .reverse()
+ .find((message) => message.role === "assistant");
+ if (latestAssistant?.content.trim()) {
+ cancelStreamingScroll();
+ return;
+ }
if (!isNearBottomRef.current) return;
scheduleStreamingScrollToBottom();
return;
}
cancelStreamingScroll();
- scrollToBottom("smooth");
}, [
cancelStreamingScroll,
isStreaming,
messages,
scheduleStreamingScrollToBottom,
- scrollToBottom,
]);
useEffect(
diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts
index 9bb97fb..f08fd37 100644
--- a/src/components/chat/GlobalChatbox.types.ts
+++ b/src/components/chat/GlobalChatbox.types.ts
@@ -1,4 +1,5 @@
import type {
+ AgentActivity,
AgentQuestionRequest,
AgentTodoUpdate,
} from "@/lib/chatStream";
@@ -42,6 +43,8 @@ export type AgentPermissionRequest = {
permission: string;
patterns: string[];
target?: string;
+ activityId?: string;
+ reason?: string;
always: string[];
tool?: {
messageID: string;
@@ -59,6 +62,7 @@ export type Message = {
content: string;
isError?: boolean;
progress?: ChatProgress[];
+ activities?: AgentActivity[];
artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts
index d1edf76..649e975 100644
--- a/src/components/chat/hooks/agentChatSessionState.ts
+++ b/src/components/chat/hooks/agentChatSessionState.ts
@@ -1,4 +1,5 @@
import type {
+ AgentActivity,
AgentQuestionRequest,
AgentTodoUpdate,
PermissionReply,
@@ -79,6 +80,48 @@ export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
};
});
+export const upsertActivity = (
+ activities: AgentActivity[] | undefined,
+ event: StreamEvent & { type: "activity_update" },
+) => {
+ const next = [...(activities ?? [])];
+ const index = next.findIndex((activity) => activity.id === event.activity.id);
+ if (index >= 0) next[index] = event.activity;
+ else next.push(event.activity);
+ return next;
+};
+
+export const completeRunningActivities = (
+ activities: AgentActivity[] | undefined,
+ status: "completed" | "error" | "cancelled" = "completed",
+) => activities?.map((activity) => {
+ if (activity.status !== "running") return activity;
+ const endedAt = Date.now();
+ return {
+ ...activity,
+ status,
+ actions: activity.actions.map((action) =>
+ action.status === "running"
+ ? {
+ ...action,
+ status: status === "error" ? "error" as const : "completed" as const,
+ endedAt,
+ elapsedMs: undefined,
+ elapsedSnapshotAt: undefined,
+ durationMs: Math.max(0, endedAt - action.startedAt),
+ ...(status === "error"
+ ? { error: action.error ?? "活动执行失败" }
+ : {}),
+ }
+ : action,
+ ),
+ endedAt,
+ elapsedMs: undefined,
+ elapsedSnapshotAt: undefined,
+ durationMs: Math.max(0, endedAt - activity.startedAt),
+ };
+});
+
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate
? {
@@ -107,6 +150,8 @@ export const upsertPermission = (
permission: event.permission,
patterns: event.patterns,
target: event.target,
+ activityId: event.activityId,
+ reason: event.reason,
always: event.always,
tool: event.tool,
createdAt: event.createdAt,
@@ -405,6 +450,7 @@ export const rejectOpenQuestionsAfterAbort = (
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress);
+ const cancelledActivities = completeRunningActivities(message.activities, "cancelled");
const cancelledTodos = cancelRunningTodos(message.todos);
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
@@ -414,6 +460,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
Boolean(abortedPermissions?.length) ||
Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) ||
+ Boolean(cancelledActivities?.length) ||
Boolean(cancelledTodos);
if (!hasVisibleOutput) {
@@ -425,6 +472,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
content: message.content || "⚠️ **请求已中断**",
isError: true,
progress: completedProgress,
+ activities: cancelledActivities,
permissions: abortedPermissions,
questions: rejectedQuestions,
todos: cancelledTodos,
diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx
index 77a14e7..bd18be1 100644
--- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx
+++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx
@@ -132,6 +132,80 @@ describe("useAgentChatSession actions", () => {
);
});
+ it("applies an activity phase and todo snapshot atomically before revealing the final answer", async () => {
+ listChatSessions.mockResolvedValue([]);
+ jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
+ onEvent({
+ type: "activity_update",
+ sessionId: "session-1",
+ activity: {
+ id: "activity-analyze",
+ title: "分析管网数据",
+ reason: "需要识别影响供水能力的关键管段。",
+ status: "running",
+ actions: [],
+ startedAt: 1000,
+ },
+ todos: [
+ {
+ id: "todo-data",
+ content: "准备管网数据",
+ status: "completed",
+ priority: "high",
+ },
+ {
+ id: "todo-analysis",
+ content: "识别瓶颈管段",
+ status: "in_progress",
+ priority: "high",
+ },
+ ],
+ todosCreatedAt: 1001,
+ });
+ onEvent({
+ type: "final_answer",
+ sessionId: "session-1",
+ content: "已识别关键瓶颈管段。",
+ });
+ onEvent({ type: "done", sessionId: "session-1" });
+ });
+
+ const { result } = renderHook(() =>
+ useAgentChatSession({
+ projectId: "project-1",
+ onToolCall: jest.fn(),
+ }),
+ );
+
+ await waitFor(() => expect(result.current.isHydrating).toBe(false));
+
+ await act(async () => {
+ await result.current.sendPrompt("分析管网瓶颈");
+ });
+
+ const assistantMessage = result.current.messages.at(-1);
+ expect(assistantMessage).toMatchObject({
+ role: "assistant",
+ content: "已识别关键瓶颈管段。",
+ todos: {
+ sessionId: "session-1",
+ createdAt: 1001,
+ todos: [
+ expect.objectContaining({ id: "todo-data", status: "completed" }),
+ expect.objectContaining({ id: "todo-analysis", status: "completed" }),
+ ],
+ },
+ });
+ expect(
+ assistantMessage?.activities?.find(
+ (activity) => activity.id === "activity-analyze",
+ ),
+ ).toMatchObject({
+ title: "分析管网数据",
+ status: "completed",
+ });
+ });
+
it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(
diff --git a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx
index 5c8fabc..62b33e1 100644
--- a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx
+++ b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx
@@ -340,7 +340,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
}),
expect.objectContaining({
id: "todo-2",
- status: "in_progress",
+ status: "completed",
}),
],
}),
diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts
index 01a987b..f0d9598 100644
--- a/src/components/chat/hooks/useAgentChatSession.ts
+++ b/src/components/chat/hooks/useAgentChatSession.ts
@@ -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 | null>(null);
const titleUpdateNonceRef = useRef(0);
- const pendingTokenRef = useRef<{
- assistantMessageId: string;
- content: string;
- } | null>(null);
- const tokenPlaybackIntervalRef = useRef(null);
const credentialRefreshRequestIdsRef = useRef(new Set());
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) => {
diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts
index a588084..b0a94b8 100644
--- a/src/lib/chatStream.test.ts
+++ b/src/lib/chatStream.test.ts
@@ -100,6 +100,27 @@ describe("streamAgentChat", () => {
]);
});
+ it("parses one complete final answer event", async () => {
+ mockNewSessionStream({
+ ok: true,
+ body: makeStream([
+ 'event: final_answer\ndata: {"session_id":"s1","content":"完整分析结果"}\n\n',
+ 'event: done\ndata: {"session_id":"s1"}\n\n',
+ ]),
+ });
+ const events: StreamEvent[] = [];
+
+ await streamAgentChat({
+ message: "分析",
+ onEvent: (event) => events.push(event),
+ });
+
+ expect(events).toEqual([
+ { type: "final_answer", sessionId: "s1", content: "完整分析结果" },
+ { type: "done", sessionId: "s1" },
+ ]);
+ });
+
it("parses state events from a resumed stream", async () => {
apiFetch.mockResolvedValue({
ok: true,
@@ -172,6 +193,41 @@ describe("streamAgentChat", () => {
});
});
+ it("parses grouped activities and inherited permission reasons", async () => {
+ mockNewSessionStream({
+ ok: true,
+ body: makeStream([
+ 'event: activity_update\ndata: {"session_id":"s1","activity":{"id":"a1","title":"准备分析数据","reason":"需要先确认输入数据完整。","status":"running","started_at":100,"elapsed_ms":25,"actions":[{"id":"x1","tool":"tjwater_cli","title":"查询后端数据","status":"running","target":"data list","started_at":110,"elapsed_ms":15}]},"todos":[{"id":"t1","content":"准备分析数据","status":"completed","priority":"high"},{"id":"t2","content":"生成分析结果","status":"in_progress","priority":"medium"}],"todos_created_at":125}\n\n',
+ 'event: permission_request\ndata: {"session_id":"s1","request_id":"p1","permission":"bash","patterns":["python3 analysis.py"],"target":"python3 analysis.py","always":[],"activity_id":"a1","reason":"需要先确认输入数据完整。","created_at":123}\n\n',
+ ]),
+ });
+ const events: StreamEvent[] = [];
+
+ await streamAgentChat({ message: "分析", onEvent: (event) => events.push(event) });
+
+ expect(events[0]).toMatchObject({
+ type: "activity_update",
+ sessionId: "s1",
+ activity: {
+ id: "a1",
+ title: "准备分析数据",
+ reason: "需要先确认输入数据完整。",
+ status: "running",
+ actions: [expect.objectContaining({ id: "x1", target: "data list" })],
+ },
+ todos: [
+ expect.objectContaining({ id: "t1", status: "completed" }),
+ expect.objectContaining({ id: "t2", status: "in_progress" }),
+ ],
+ todosCreatedAt: 125,
+ });
+ expect(events[1]).toMatchObject({
+ type: "permission_request",
+ activityId: "a1",
+ reason: "需要先确认输入数据完整。",
+ });
+ });
+
it("parses credential refresh lifecycle events", async () => {
mockNewSessionStream({
ok: true,
@@ -246,6 +302,8 @@ describe("streamAgentChat", () => {
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
+ activityId: undefined,
+ reason: undefined,
always: ["rm *"],
tool: undefined,
createdAt: 123,
diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts
index efe6213..da7ca39 100644
--- a/src/lib/chatStream.ts
+++ b/src/lib/chatStream.ts
@@ -59,6 +59,35 @@ export type AgentTodoUpdate = {
createdAt: number;
};
+export type AgentActivityStatus = "running" | "completed" | "error" | "cancelled";
+
+export type AgentActivityAction = {
+ id: string;
+ tool: string;
+ title: string;
+ status: "running" | "completed" | "error";
+ target?: string;
+ error?: string;
+ startedAt: number;
+ endedAt?: number;
+ elapsedMs?: number;
+ elapsedSnapshotAt?: number;
+ durationMs?: number;
+};
+
+export type AgentActivity = {
+ id: string;
+ title: string;
+ reason: string;
+ status: AgentActivityStatus;
+ actions: AgentActivityAction[];
+ startedAt: number;
+ endedAt?: number;
+ elapsedMs?: number;
+ elapsedSnapshotAt?: number;
+ durationMs?: number;
+};
+
export type StreamEvent =
| {
type: "state";
@@ -68,8 +97,16 @@ export type StreamEvent =
runStatus?: string;
}
| { type: "token"; sessionId: string; content: string }
+ | { type: "final_answer"; sessionId: string; content: string }
| { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string }
+ | {
+ type: "activity_update";
+ sessionId: string;
+ activity: AgentActivity;
+ todos?: AgentTodoItem[];
+ todosCreatedAt?: number;
+ }
| {
type: "progress";
sessionId: string;
@@ -127,6 +164,8 @@ export type StreamEvent =
permission: string;
patterns: string[];
target?: string;
+ activityId?: string;
+ reason?: string;
always: string[];
tool?: {
messageID: string;
@@ -295,6 +334,47 @@ const normalizeTodos = (value: unknown): AgentTodoItem[] => {
}));
};
+const normalizeActivityStatus = (value: unknown): AgentActivityStatus => {
+ if (value === "completed" || value === "error" || value === "cancelled") {
+ return value;
+ }
+ return "running";
+};
+
+const normalizeActivity = (value: unknown): AgentActivity | undefined => {
+ if (!isObjectRecord(value) || typeof value.id !== "string") return undefined;
+ const now = Date.now();
+ const actions: AgentActivityAction[] = Array.isArray(value.actions)
+ ? value.actions.filter(isObjectRecord).map((action, index) => ({
+ id: typeof action.id === "string" ? action.id : `${value.id}-action-${index}`,
+ tool: typeof action.tool === "string" ? action.tool : "tool",
+ title: typeof action.title === "string" ? action.title : "执行操作",
+ status: action.status === "completed" || action.status === "error"
+ ? action.status
+ : "running",
+ target: typeof action.target === "string" ? action.target : undefined,
+ error: typeof action.error === "string" ? action.error : undefined,
+ startedAt: typeof action.started_at === "number" ? action.started_at : now,
+ endedAt: typeof action.ended_at === "number" ? action.ended_at : undefined,
+ elapsedMs: typeof action.elapsed_ms === "number" ? action.elapsed_ms : undefined,
+ elapsedSnapshotAt: typeof action.elapsed_ms === "number" ? now : undefined,
+ durationMs: typeof action.duration_ms === "number" ? action.duration_ms : undefined,
+ }))
+ : [];
+ return {
+ id: value.id,
+ title: typeof value.title === "string" ? value.title : "正在处理",
+ reason: typeof value.reason === "string" ? value.reason : "",
+ status: normalizeActivityStatus(value.status),
+ actions,
+ startedAt: typeof value.started_at === "number" ? value.started_at : now,
+ endedAt: typeof value.ended_at === "number" ? value.ended_at : undefined,
+ elapsedMs: typeof value.elapsed_ms === "number" ? value.elapsed_ms : undefined,
+ elapsedSnapshotAt: typeof value.elapsed_ms === "number" ? now : undefined,
+ durationMs: typeof value.duration_ms === "number" ? value.duration_ms : undefined,
+ };
+};
+
const emitParsedStreamEvent = (
event: string,
data: string,
@@ -327,6 +407,7 @@ const emitParsedStreamEvent = (
target?: string;
always?: unknown;
created_at?: number;
+ todos_created_at?: number;
reply?: PermissionReply;
questions?: unknown;
answers?: unknown;
@@ -335,6 +416,8 @@ const emitParsedStreamEvent = (
todos?: unknown;
reason?: string;
timeout_ms?: number;
+ activity?: unknown;
+ activity_id?: string;
};
if (event === "state") {
onEvent({
@@ -350,6 +433,12 @@ const emitParsedStreamEvent = (
sessionId: parsed.session_id ?? "",
content: parsed.content ?? "",
});
+ } else if (event === "final_answer") {
+ onEvent({
+ type: "final_answer",
+ sessionId: parsed.session_id ?? "",
+ content: parsed.content ?? "",
+ });
} else if (event === "progress") {
onEvent({
type: "progress",
@@ -364,6 +453,19 @@ const emitParsedStreamEvent = (
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
+ } else if (event === "activity_update") {
+ const activity = normalizeActivity(parsed.activity);
+ if (activity) {
+ onEvent({
+ type: "activity_update",
+ sessionId: parsed.session_id ?? "",
+ activity,
+ todos: Array.isArray(parsed.todos)
+ ? normalizeTodos(parsed.todos)
+ : undefined,
+ todosCreatedAt: parsed.todos_created_at,
+ });
+ }
} else if (event === "done") {
onEvent({
type: "done",
@@ -429,6 +531,8 @@ const emitParsedStreamEvent = (
? parsed.patterns.filter((item): item is string => typeof item === "string")
: [],
target: typeof parsed.target === "string" ? parsed.target : undefined,
+ activityId: typeof parsed.activity_id === "string" ? parsed.activity_id : undefined,
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
always: Array.isArray(parsed.always)
? parsed.always.filter((item): item is string => typeof item === "string")
: [],