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") : [],