diff --git a/next.config.ts b/next.config.ts index bc3cbd8..21310d7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,7 +3,6 @@ import type { NextConfig } from "next"; const parseAllowedDevOrigins = () => { const raw = process.env.NEXT_ALLOWED_DEV_ORIGINS?.trim(); if (!raw) { - // 默认放行本地局域网调试来源,避免 Next 未来版本升级后被跨域策略拦截。 return ["192.168.96.167"]; } diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 08278d9..b17162e 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -5,7 +5,6 @@ import { } from "@/lib/model/chatGateway"; import { recordServerEvent } from "@/lib/observability/serverEvents"; -// Allow streaming responses up to 30 seconds export const maxDuration = 30; export async function POST(req: Request) { diff --git a/src/app/chat/[chat_id]/page.tsx b/src/app/chat/[chat_id]/page.tsx index abacd06..09d1e06 100644 --- a/src/app/chat/[chat_id]/page.tsx +++ b/src/app/chat/[chat_id]/page.tsx @@ -17,6 +17,7 @@ import { runAgentPipelineForChat } from "@/lib/agent/chatPipeline"; import { createLocalChat, getChatScope, updateLocalChat } from "@/lib/chatStore"; import { getFirstUserMessageText, getMessageText } from "@/lib/chatMessages"; import { readStoredMessages, writeStoredMessages } from "@/lib/chatMessageStorage"; +import type { AgentErrorCode, AgentRunStatus } from "@/lib/agent/types"; import { setGlobalChatModel, useGlobalChatModel } from "@/lib/model/globalModel"; import { recordClientEvent } from "@/lib/observability/clientEvents"; import { useHydrated } from "@/lib/useHydrated"; @@ -27,9 +28,16 @@ type StreamMetric = { firstTokenAt?: number; }; -/** - * 根据 assistant 消息定位其前置 user 输入,用于“消息级重新生成”。 - */ +type AgentRunSummary = { + status: AgentRunStatus; + attempts: number; + durationMs: number; + degraded: boolean; + summary?: string; + adapterMode?: "mock" | "http"; + errorCode?: AgentErrorCode; +}; + const getPromptForRegenerate = (messages: UIMessage[], assistantMessageId: string) => { const assistantIndex = messages.findIndex((message) => message.id === assistantMessageId); if (assistantIndex < 0) return ""; @@ -68,11 +76,16 @@ function ChatSession({ const [localActionError, setLocalActionError] = useState(""); const [showAgentPanel, setShowAgentPanel] = useState(false); const [agentHint, setAgentHint] = useState("Agent: idle"); + const [agentRunSummary, setAgentRunSummary] = useState(null); const [lastTtftMs, setLastTtftMs] = useState(null); const inputRef = useRef(null); const hasAutoSentRef = useRef(false); const persistTimerRef = useRef | null>(null); const streamMetricRef = useRef(null); + const activePipelineRef = useRef<{ + requestId: string; + controller: AbortController; + } | null>(null); const initialMessages = useMemo(() => { if (isDraftSession) return []; @@ -122,7 +135,6 @@ function ChatSession({ persistTimerRef.current = null; } - // 流式生成期间以较低频率落盘,降低长会话 localStorage 写入抖动。 const delayMs = isLoading ? 260 : 80; persistTimerRef.current = setTimeout(() => { writeStoredMessages(sessionId, safeMessages); @@ -169,9 +181,12 @@ function ChatSession({ } }, [latestAssistantTextLength, sessionId, status]); - /** - * 新建草稿页提交时先创建会话,再跳转到正式会话并自动发送首条消息。 - */ + useEffect(() => { + return () => { + activePipelineRef.current?.controller.abort(); + }; + }, []); + const createChatAndRedirect = useCallback( async (text: string) => { const normalizedTitle = text.slice(0, 40) || "新对话"; @@ -187,9 +202,6 @@ function ChatSession({ [chatScope, globalModel, queryClient, router] ); - /** - * 主链路发送:先执行 Agent,再将结果作为上下文注入模型调用。 - */ const sendWithPipeline = useCallback( async ( text: string, @@ -199,6 +211,18 @@ function ChatSession({ } ) => { const requestId = `req_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const previousPipeline = activePipelineRef.current; + if (previousPipeline) { + previousPipeline.controller.abort(); + recordClientEvent("agent.pipeline.cancel_requested", { + sessionId, + requestId: previousPipeline.requestId, + reason: "superseded_by_new_request", + }); + } + + const controller = new AbortController(); + activePipelineRef.current = { requestId, controller }; streamMetricRef.current = { requestId, startedAt: Date.now() }; recordClientEvent("chat.send.started", { sessionId, @@ -208,11 +232,44 @@ function ChatSession({ }); setAgentHint("Agent: 执行中..."); + setAgentRunSummary({ + status: "running", + attempts: 0, + durationMs: 0, + degraded: false, + }); const agentResult = await runAgentPipelineForChat({ sessionId, input: text, + signal: controller.signal, }); + if (activePipelineRef.current?.requestId !== requestId) { + return; + } + activePipelineRef.current = null; + + const firstStep = agentResult.state?.steps[0]; + const nextSummary: AgentRunSummary = { + status: agentResult.state?.status ?? "failed", + attempts: firstStep?.attempt ?? 0, + durationMs: agentResult.context?.durationMs ?? 0, + degraded: agentResult.degraded, + summary: agentResult.context?.summary, + adapterMode: agentResult.context?.adapterMode, + errorCode: agentResult.state?.lastError?.code, + }; + setAgentRunSummary(nextSummary); + + if (agentResult.state?.status === "cancelled") { + setAgentHint("Agent: 已取消"); + recordClientEvent("agent.pipeline.cancelled", { + sessionId, + requestId, + }); + return; + } + if (agentResult.context) { setAgentHint(`Agent: ${truncate(agentResult.context.summary)}`); } else if (agentResult.reason) { @@ -227,6 +284,8 @@ function ChatSession({ degraded: agentResult.degraded, runStatus: agentResult.state?.status ?? "unknown", errorCode: agentResult.state?.lastError?.code, + attempts: nextSummary.attempts, + durationMs: nextSummary.durationMs, }); const requestBody = { @@ -289,7 +348,6 @@ function ChatSession({ const chatId = Number(routeChatId); if (!Number.isFinite(chatId)) return; - // 在会话产生实际内容后同步标题与更新时间,保持侧栏排序正确。 void updateLocalChat(chatScope, chatId, { title: firstUserTitle, model: globalModel, @@ -339,6 +397,12 @@ function ChatSession({ }); }; + const handleStop = () => { + activePipelineRef.current?.controller.abort(); + stop(); + setAgentHint("Agent: 已取消"); + }; + return (
@@ -354,6 +418,17 @@ function ChatSession({ {lastTtftMs === null ? "" : `TTFT ${lastTtftMs}ms`}

+ {agentRunSummary ? ( +
+ + Agent {agentRunSummary.status} + + attempts {agentRunSummary.attempts} + {agentRunSummary.durationMs}ms + {agentRunSummary.errorCode ? {agentRunSummary.errorCode} : null} + {agentRunSummary.adapterMode ? {agentRunSummary.adapterMode} : null} +
+ ) : null}
- {showAgentPanel ? : null} + {showAgentPanel ? : null} {error ? : null} {localActionError ? : null} @@ -437,7 +512,7 @@ function ChatSession({ onInputChange={setInput} onSubmit={handleSubmit} isLoading={isLoading} - onStop={stop} + onStop={handleStop} textareaRef={inputRef} />
diff --git a/src/app/components/AgentMvpPanel.tsx b/src/app/components/AgentMvpPanel.tsx index 5a74a7a..2802b18 100644 --- a/src/app/components/AgentMvpPanel.tsx +++ b/src/app/components/AgentMvpPanel.tsx @@ -1,10 +1,10 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { createAgentAdapter } from "@/lib/agent/createAdapter"; import { getAgentAdapterMode, getAgentApiBaseUrl, getAgentToolName } from "@/lib/agent/config"; import { runAgent } from "@/lib/agent/runner"; -import type { AgentRunState } from "@/lib/agent/types"; +import type { AgentErrorCode, AgentRunState, AgentRunStatus } from "@/lib/agent/types"; type AgentScenario = "success" | "timeout" | "retry_exhausted" | "remote_call"; @@ -19,9 +19,16 @@ type ScenarioConfig = { type ScenarioMap = Partial>; -/** - * 将状态值映射为统一徽标样式,避免页面里散落大量条件类名判断。 - */ +type LinkedRunSummary = { + status: AgentRunStatus; + attempts: number; + durationMs: number; + degraded: boolean; + summary?: string; + adapterMode?: "mock" | "http"; + errorCode?: AgentErrorCode; +}; + const getStatusBadgeClass = (status: string) => { if (status === "succeeded") { return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/60 dark:bg-emerald-900/20 dark:text-emerald-300"; @@ -35,10 +42,11 @@ const getStatusBadgeClass = (status: string) => { return "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"; }; -/** - * Agent MVP 演示面板:展示本地状态机、mock adapter 和三条核心测试路径。 - */ -export default function AgentMvpPanel() { +export default function AgentMvpPanel({ + linkedRunSummary, +}: { + linkedRunSummary?: LinkedRunSummary | null; +}) { const adapterMode = useMemo(() => getAgentAdapterMode(), []); const configuredApiBaseUrl = useMemo(() => getAgentApiBaseUrl(), []); const configuredToolName = useMemo(() => getAgentToolName(), []); @@ -54,6 +62,7 @@ export default function AgentMvpPanel() { baseUrl?: string; toolName?: string; } | null>(null); + const activeControllerRef = useRef(null); const scenarioConfig = useMemo( () => @@ -105,6 +114,8 @@ export default function AgentMvpPanel() { setRunDurationMs(null); setIsRunning(true); const startedAt = performance.now(); + const controller = new AbortController(); + activeControllerRef.current = controller; try { const resolvedAdapter = adapterMode === "http" @@ -131,6 +142,7 @@ export default function AgentMvpPanel() { maxRetries: selectedScenario.maxRetries, timeoutMs: selectedScenario.timeoutMs, retryDelayMs: selectedScenario.retryDelayMs, + signal: controller.signal, }); setRunState(result); setRunDurationMs(Math.round(performance.now() - startedAt)); @@ -139,6 +151,9 @@ export default function AgentMvpPanel() { setRunState(null); setRunDurationMs(Math.round(performance.now() - startedAt)); } finally { + if (activeControllerRef.current === controller) { + activeControllerRef.current = null; + } setIsRunning(false); } }; @@ -169,9 +184,32 @@ export default function AgentMvpPanel() {
MVP:单 run / 单 step / 单工具调用
+ {isRunning ? ( + + ) : null}
+ {linkedRunSummary ? ( +
+

主聊天最近一次 Agent:{linkedRunSummary.status}

+

+ attempts:{linkedRunSummary.attempts} / duration:{linkedRunSummary.durationMs}ms + {linkedRunSummary.errorCode ? ` / ${linkedRunSummary.errorCode}` : ""} +

+

+ {linkedRunSummary.adapterMode ? `${linkedRunSummary.adapterMode} / ` : ""} + {linkedRunSummary.summary ?? (linkedRunSummary.degraded ? "已降级" : "暂无摘要")} +

+
+ ) : null} + {isHttpMode ? (

diff --git a/src/app/components/MessageList.tsx b/src/app/components/MessageList.tsx index 3b1d575..e19b20b 100644 --- a/src/app/components/MessageList.tsx +++ b/src/app/components/MessageList.tsx @@ -16,9 +16,6 @@ type MessageListProps = { scrollerRef?: (element: HTMLElement | Window | null) => void; }; -/** - * 提取消息中的纯文本片段,统一作为渲染与复制的数据来源。 - */ const getMessageContent = (message: UIMessage) => { return message.parts .map((part) => (part.type === "text" ? part.text : "")) @@ -226,9 +223,6 @@ const MessageCard = memo(function MessageCard({ ); }); -/** - * 使用虚拟列表承载长会话,降低 DOM 数量并稳定滚动帧率。 - */ export default function MessageList({ messages, onRegenerate, diff --git a/src/app/components/ModelSelector.tsx b/src/app/components/ModelSelector.tsx index d4caf75..4b89d00 100644 --- a/src/app/components/ModelSelector.tsx +++ b/src/app/components/ModelSelector.tsx @@ -16,9 +16,6 @@ const MODEL_LABELS: Record = { "deepseek-r1": "DeepSeek R1", }; -/** - * 全局模型选择器:用于统一切换聊天模型,并保持跨会话一致。 - */ export default function ModelSelector({ value, onChange, diff --git a/src/app/perf/page.tsx b/src/app/perf/page.tsx index 7b7edf4..c39ab8f 100644 --- a/src/app/perf/page.tsx +++ b/src/app/perf/page.tsx @@ -29,9 +29,6 @@ const getUsedHeapSize = () => { : null; }; -/** - * 用两帧作为“渲染稳定”采样点,减少单帧偶然抖动影响。 - */ const waitForStableFrame = async () => { await new Promise((resolve) => { requestAnimationFrame(() => { @@ -42,9 +39,6 @@ const waitForStableFrame = async () => { }); }; -/** - * 通过固定时长自动滚动来估算滚动帧率。 - */ const measureScrollFps = async (element: HTMLElement) => { const durationMs = 1_500; const start = performance.now(); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 0c4f9c5..df7a673 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -28,9 +28,6 @@ type NavbarProps = { onToggleCollapse: () => void; }; -/** - * 将时间戳格式化为“x 分钟前”,保持会话列表信息密度与可读性平衡。 - */ const formatRelativeTime = (updatedAt: number) => { const diffMs = Date.now() - updatedAt; const minute = 60_000; @@ -67,9 +64,6 @@ export default function Navbar({ collapsed, onToggleCollapse }: NavbarProps) { const chatList = useMemo(() => chats, [chats]); - /** - * 统一包装侧边栏会话操作,保证错误处理和刷新逻辑一致。 - */ const runChatAction = async (chatId: number, task: () => Promise) => { setActionError(""); setBusyChatId(chatId); diff --git a/src/components/QueryClientProvider.tsx b/src/components/QueryClientProvider.tsx index f12ac25..03a5e7f 100644 --- a/src/components/QueryClientProvider.tsx +++ b/src/components/QueryClientProvider.tsx @@ -2,14 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -// Create a client const queryClient = new QueryClient(); function App({ children }: { children: React.ReactNode }) { - return ( - // Provide the client to your App - {children} - ); + return {children}; } export default App; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index cb62872..269d69d 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -26,11 +26,9 @@ import { export type IconProps = Omit; type ToggleIconProps = IconProps & { - // When true, use filled style to indicate selected state. filled?: boolean; }; -// Factory for standard line icons to keep size and stroke consistent. const createIcon = (Icon: LucideIcon, name: string) => { const Component = ({ size = 18, strokeWidth = 1.8, ...props }: IconProps) => ( @@ -39,7 +37,6 @@ const createIcon = (Icon: LucideIcon, name: string) => { return Component; }; -// Factory for toggled icons that can switch between outlined and filled. const createToggleIcon = (Icon: LucideIcon, name: string) => { const Component = ({ filled = false, diff --git a/src/lib/agent/adapter.ts b/src/lib/agent/adapter.ts index d8d8c16..01e157d 100644 --- a/src/lib/agent/adapter.ts +++ b/src/lib/agent/adapter.ts @@ -1,8 +1,17 @@ +import type { AgentErrorCode } from "./types"; + export type AdapterResult = { summary: string; output?: unknown; }; +export type AgentAdapterErrorOptions = { + code?: AgentErrorCode; + message: string; + retryable?: boolean; + details?: unknown; +}; + export type AdapterContext = { run_id: string; session_id: string; @@ -10,8 +19,27 @@ export type AdapterContext = { input: string; attempt: number; timeout_ms: number; + signal?: AbortSignal; }; export interface AgentAdapter { invokeTool(ctx: AdapterContext): Promise; } + +export class AgentAdapterError extends Error { + code?: AgentErrorCode; + retryable?: boolean; + details?: unknown; + + constructor(options: AgentAdapterErrorOptions) { + super(options.message); + this.name = "AgentAdapterError"; + this.code = options.code; + this.retryable = options.retryable; + this.details = options.details; + } +} + +export const isAgentAdapterError = (error: unknown): error is AgentAdapterError => { + return error instanceof AgentAdapterError; +}; diff --git a/src/lib/agent/chatPipeline.ts b/src/lib/agent/chatPipeline.ts index 8abc7c9..4ba2dde 100644 --- a/src/lib/agent/chatPipeline.ts +++ b/src/lib/agent/chatPipeline.ts @@ -6,6 +6,7 @@ import type { AgentRunState } from "./types"; type RunAgentPipelineParams = { sessionId: string; input: string; + signal?: AbortSignal; }; export type AgentPipelineResult = { @@ -19,9 +20,6 @@ const DEFAULT_TIMEOUT_MS = 8_000; const DEFAULT_MAX_RETRIES = 1; const DEFAULT_RETRY_DELAY_MS = 20; -/** - * 运行聊天前置 Agent 流程,失败时降级为直接对话,避免阻断主链路。 - */ export const runAgentPipelineForChat = async ( params: RunAgentPipelineParams ): Promise => { @@ -37,6 +35,7 @@ export const runAgentPipelineForChat = async ( maxRetries: DEFAULT_MAX_RETRIES, timeoutMs: DEFAULT_TIMEOUT_MS, retryDelayMs: DEFAULT_RETRY_DELAY_MS, + signal: params.signal, }); const firstStep = runState.steps[0]; @@ -57,7 +56,9 @@ export const runAgentPipelineForChat = async ( reason: runState.status === "succeeded" ? undefined - : runState.lastError?.message ?? "agent run failed", + : runState.status === "cancelled" + ? "agent run cancelled" + : runState.lastError?.message ?? "agent run failed", }; } catch (error) { return { diff --git a/src/lib/agent/config.ts b/src/lib/agent/config.ts index ce7eb31..2bca5e6 100644 --- a/src/lib/agent/config.ts +++ b/src/lib/agent/config.ts @@ -6,26 +6,15 @@ export const DEFAULT_AGENT_ADAPTER_MODE: AgentAdapterMode = "mock"; export const DEFAULT_AGENT_TOOL_NAME = "deepscan.search"; export const DEFAULT_MOCK_MODE: MockAdapterMode = "success"; -/** - * 读取前端 adapter 运行模式。 - * - mock:本地演示与开发 - * - http:对接后端接口联调 - */ export const getAgentAdapterMode = (): AgentAdapterMode => { const raw = process.env.NEXT_PUBLIC_AGENT_ADAPTER?.trim().toLowerCase(); return raw === "http" ? "http" : DEFAULT_AGENT_ADAPTER_MODE; }; -/** - * 返回 Agent HTTP 基础地址(可为空,调用方负责校验)。 - */ export const getAgentApiBaseUrl = () => { return process.env.NEXT_PUBLIC_AGENT_API_BASE_URL?.trim() ?? ""; }; -/** - * 返回工具名,未配置时使用默认工具占位名。 - */ export const getAgentToolName = () => { const configured = process.env.NEXT_PUBLIC_AGENT_TOOL_NAME?.trim(); return configured || DEFAULT_AGENT_TOOL_NAME; diff --git a/src/lib/agent/createAdapter.ts b/src/lib/agent/createAdapter.ts index 9f4a306..d2a1892 100644 --- a/src/lib/agent/createAdapter.ts +++ b/src/lib/agent/createAdapter.ts @@ -25,9 +25,6 @@ export type ResolvedAgentAdapter = { }; }; -/** - * 统一创建 adapter,确保运行层不直接依赖具体实现。 - */ export const createAgentAdapter = ( options: CreateAgentAdapterOptions = {} ): ResolvedAgentAdapter => { diff --git a/src/lib/agent/httpAdapter.test.ts b/src/lib/agent/httpAdapter.test.ts index 4ac14c1..1a586cb 100644 --- a/src/lib/agent/httpAdapter.test.ts +++ b/src/lib/agent/httpAdapter.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { AgentAdapterError } from "./adapter"; import { HttpAgentAdapter } from "./httpAdapter"; const buildContext = () => ({ @@ -64,6 +65,35 @@ describe("httpAgentAdapter", () => { await expect(adapter.invokeTool(buildContext())).rejects.toThrow("upstream down"); }); + it("preserves remote error code and retryable flag", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: { + code: "UPSTREAM_ERROR", + message: "bad request", + retryable: false, + details: { field: "input" }, + }, + }), + }); + + const adapter = new HttpAgentAdapter({ + baseUrl: "https://api.example.com", + toolName: "deepscan.search", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + await expect(adapter.invokeTool(buildContext())).rejects.toMatchObject({ + name: "AgentAdapterError", + code: "UPSTREAM_ERROR", + message: "bad request", + retryable: false, + details: { field: "input" }, + } satisfies Partial); + }); + it("falls back to default summary when response body is empty", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/lib/agent/httpAdapter.ts b/src/lib/agent/httpAdapter.ts index 6dd6edc..6477ecd 100644 --- a/src/lib/agent/httpAdapter.ts +++ b/src/lib/agent/httpAdapter.ts @@ -1,4 +1,10 @@ -import type { AdapterContext, AdapterResult, AgentAdapter } from "./adapter"; +import { + AgentAdapterError, + type AdapterContext, + type AdapterResult, + type AgentAdapter, +} from "./adapter"; +import type { AgentErrorCode } from "./types"; type HttpAgentAdapterOptions = { baseUrl: string; @@ -9,7 +15,10 @@ type HttpAgentAdapterOptions = { type ErrorPayload = { error?: { + code?: AgentErrorCode; message?: string; + retryable?: boolean; + details?: unknown; }; }; @@ -24,9 +33,6 @@ type SuccessPayload = { const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ""); -/** - * 将远端响应统一映射为 adapter 标准输出,避免上层感知后端返回细节。 - */ const normalizeSuccessPayload = (payload: unknown): AdapterResult => { if (!payload || typeof payload !== "object") { return { summary: "remote tool call completed" }; @@ -39,19 +45,34 @@ const normalizeSuccessPayload = (payload: unknown): AdapterResult => { return { summary, output }; }; -const normalizeErrorMessage = (payload: unknown, status: number) => { +const normalizeErrorPayload = ( + payload: unknown, + status: number +): { + code: AgentErrorCode; + message: string; + retryable: boolean; + details?: unknown; +} => { if (payload && typeof payload === "object") { const parsed = payload as ErrorPayload; if (parsed.error?.message) { - return parsed.error.message; + return { + code: parsed.error.code ?? "UPSTREAM_ERROR", + message: parsed.error.message, + retryable: parsed.error.retryable ?? status >= 500, + details: parsed.error.details, + }; } } - return `remote tool call failed with status ${status}`; + return { + code: "UPSTREAM_ERROR", + message: `remote tool call failed with status ${status}`, + retryable: status >= 500, + details: payload, + }; }; -/** - * HTTP 适配器:对接 `/v1/mcp/tools/{tool_name}/invoke`。 - */ export class HttpAgentAdapter implements AgentAdapter { private readonly baseUrl: string; private readonly toolName: string; @@ -85,6 +106,7 @@ export class HttpAgentAdapter implements AgentAdapter { ...this.headers, }, body: JSON.stringify(requestBody), + signal: ctx.signal, }); let payload: unknown = null; @@ -95,7 +117,8 @@ export class HttpAgentAdapter implements AgentAdapter { } if (!response.ok) { - throw new Error(normalizeErrorMessage(payload, response.status)); + const normalizedError = normalizeErrorPayload(payload, response.status); + throw new AgentAdapterError(normalizedError); } return normalizeSuccessPayload(payload); diff --git a/src/lib/agent/mockAdapter.ts b/src/lib/agent/mockAdapter.ts index cb53786..e6338e9 100644 --- a/src/lib/agent/mockAdapter.ts +++ b/src/lib/agent/mockAdapter.ts @@ -8,11 +8,26 @@ export type MockAdapterOptions = { failMessage?: string; }; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", handleAbort); + resolve(); + }, ms); + + const handleAbort = () => { + clearTimeout(timer); + reject(new DOMException("The operation was aborted.", "AbortError")); + }; + + if (signal?.aborted) { + handleAbort(); + return; + } + + signal?.addEventListener("abort", handleAbort, { once: true }); + }); -/** - * 本地 mock adapter,用于在无后端时模拟工具调用行为。 - */ export class MockAgentAdapter implements AgentAdapter { private readonly options: MockAdapterOptions; private calls = 0; @@ -30,17 +45,16 @@ export class MockAgentAdapter implements AgentAdapter { const delayMs = this.options.delayMs ?? 0; if (this.options.mode === "timeout") { - // 故意延迟超过 runner 的 timeout,触发超时分支。 - await sleep(Math.max(ctx.timeout_ms + 30, delayMs)); + await sleep(Math.max(ctx.timeout_ms + 30, delayMs), ctx.signal); return { summary: "timeout-path" }; } if (this.options.mode === "fail") { - await sleep(delayMs); + await sleep(delayMs, ctx.signal); throw new Error(this.options.failMessage ?? "mock upstream error"); } - await sleep(delayMs); + await sleep(delayMs, ctx.signal); return { summary: `mock success attempt ${ctx.attempt}`, output: { diff --git a/src/lib/agent/runner.test.ts b/src/lib/agent/runner.test.ts index 936cb73..128ffed 100644 --- a/src/lib/agent/runner.test.ts +++ b/src/lib/agent/runner.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { AgentAdapterError } from "./adapter"; import { MockAgentAdapter } from "./mockAdapter"; import { runAgent } from "./runner"; @@ -59,4 +60,54 @@ describe("agent runner", () => { expect(state.steps[0].attempt).toBe(3); expect(adapter.callCount).toBe(3); }); + + it("cancels run when abort signal is triggered", async () => { + const adapter = new MockAgentAdapter({ mode: "timeout" }); + const controller = new AbortController(); + + const pending = runAgent({ + runId: "run_cancelled", + sessionId: "chat_1", + input: "取消执行", + adapter, + maxRetries: 1, + timeoutMs: 100, + retryDelayMs: 0, + signal: controller.signal, + }); + + controller.abort(); + const state = await pending; + + expect(state.status).toBe("cancelled"); + expect(state.steps[0].status).toBe("skipped"); + expect(state.events.some((event) => event.type === "run.cancelled")).toBe(true); + }); + + it("does not retry non-retryable adapter errors", async () => { + const adapter = { + invokeTool: async () => { + throw new AgentAdapterError({ + code: "UPSTREAM_ERROR", + message: "bad request", + retryable: false, + }); + }, + }; + + const state = await runAgent({ + runId: "run_non_retryable", + sessionId: "chat_1", + input: "bad request", + adapter, + maxRetries: 3, + timeoutMs: 50, + retryDelayMs: 0, + }); + + expect(state.status).toBe("failed"); + expect(state.lastError?.code).toBe("UPSTREAM_ERROR"); + expect(state.steps[0].attempt).toBe(1); + expect(state.events.some((event) => event.type === "retry.scheduled")).toBe(false); + }); }); diff --git a/src/lib/agent/runner.ts b/src/lib/agent/runner.ts index 9903afd..67497ce 100644 --- a/src/lib/agent/runner.ts +++ b/src/lib/agent/runner.ts @@ -1,4 +1,7 @@ -import type { AgentAdapter } from "./adapter"; +import { + isAgentAdapterError, + type AgentAdapter, +} from "./adapter"; import { createInitialRunState, transitionRunState } from "./stateMachine"; import type { AgentError, AgentRunState } from "./types"; @@ -10,30 +13,83 @@ type RunParams = { maxRetries?: number; timeoutMs?: number; retryDelayMs?: number; + signal?: AbortSignal; }; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", handleAbort); + resolve(); + }, ms); -const withTimeout = async (task: Promise, timeoutMs: number): Promise => { + const handleAbort = () => { + clearTimeout(timer); + reject(buildCancelledError()); + }; + + if (signal?.aborted) { + handleAbort(); + return; + } + + signal?.addEventListener("abort", handleAbort, { once: true }); + }); +const TOOL_TIMEOUT_MESSAGE = "TOOL_TIMEOUT"; +const RUN_CANCELLED_MESSAGE = "AGENT_RUN_CANCELLED"; + +const buildTimeoutError = () => new Error(TOOL_TIMEOUT_MESSAGE); +const buildCancelledError = () => new Error(RUN_CANCELLED_MESSAGE); + +const isCancelledError = (error: unknown) => + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && + (error.message === RUN_CANCELLED_MESSAGE || error.name === "AbortError")); + +const getRetryDelay = (baseDelayMs: number, attempt: number) => { + if (baseDelayMs <= 0) return 0; + return Math.min(baseDelayMs * 2 ** Math.max(0, attempt - 1), 2_000); +}; + +const withTimeout = async ( + task: Promise, + timeoutMs: number, + signal?: AbortSignal +): Promise => { return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("TOOL_TIMEOUT")), timeoutMs); + const timer = setTimeout(() => reject(buildTimeoutError()), timeoutMs); + + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", handleAbort); + }; + + const handleAbort = () => { + cleanup(); + reject(buildCancelledError()); + }; + + if (signal?.aborted) { + handleAbort(); + return; + } + + signal?.addEventListener("abort", handleAbort, { once: true }); + task .then((value) => { - clearTimeout(timer); + cleanup(); resolve(value); }) .catch((error) => { - clearTimeout(timer); + cleanup(); reject(error); }); }); }; -/** - * 将任意异常收敛为统一错误模型,简化状态机消费。 - */ const normalizeError = (error: unknown): AgentError => { - if (error instanceof Error && error.message === "TOOL_TIMEOUT") { + if (error instanceof Error && error.message === TOOL_TIMEOUT_MESSAGE) { return { code: "TOOL_TIMEOUT", message: "tool call timeout", @@ -41,6 +97,15 @@ const normalizeError = (error: unknown): AgentError => { }; } + if (isAgentAdapterError(error)) { + return { + code: error.code ?? "UPSTREAM_ERROR", + message: error.message, + retryable: error.retryable ?? true, + details: error.details, + }; + } + return { code: "UPSTREAM_ERROR", message: error instanceof Error ? error.message : "upstream call failed", @@ -49,9 +114,6 @@ const normalizeError = (error: unknown): AgentError => { }; }; -/** - * 运行单步 Agent 流程(MVP):只执行一个 tool_call 步骤,并处理重试。 - */ export const runAgent = async (params: RunParams): Promise => { const maxRetries = params.maxRetries ?? 0; const timeoutMs = params.timeoutMs ?? 10_000; @@ -70,10 +132,17 @@ export const runAgent = async (params: RunParams): Promise => { }; dispatch({ type: "QUEUE" }); + dispatch({ type: "APPEND_EVENT", event: { type: "run.queued", at: Date.now() } }); dispatch({ type: "START_RUN" }); dispatch({ type: "APPEND_EVENT", event: { type: "run.started", at: Date.now() } }); for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) { + if (params.signal?.aborted) { + dispatch({ type: "CANCEL_RUN" }); + dispatch({ type: "APPEND_EVENT", event: { type: "run.cancelled", at: Date.now() } }); + return state; + } + dispatch({ type: "START_STEP", stepId, attempt }); dispatch({ type: "APPEND_EVENT", @@ -83,6 +152,14 @@ export const runAgent = async (params: RunParams): Promise => { payload: { step_id: stepId, attempt }, }, }); + dispatch({ + type: "APPEND_EVENT", + event: { + type: "tool.call.requested", + at: Date.now(), + payload: { step_id: stepId, attempt }, + }, + }); try { const result = await withTimeout( @@ -93,8 +170,10 @@ export const runAgent = async (params: RunParams): Promise => { input: params.input, attempt, timeout_ms: timeoutMs, + signal: params.signal, }), - timeoutMs + timeoutMs, + params.signal ); dispatch({ type: "COMPLETE_STEP", stepId, summary: result.summary }); @@ -106,23 +185,61 @@ export const runAgent = async (params: RunParams): Promise => { payload: { step_id: stepId, attempt }, }, }); + dispatch({ + type: "APPEND_EVENT", + event: { + type: "step.completed", + at: Date.now(), + payload: { step_id: stepId, attempt }, + }, + }); dispatch({ type: "COMPLETE_RUN" }); dispatch({ type: "APPEND_EVENT", event: { type: "run.succeeded", at: Date.now() } }); return state; } catch (error) { + if (isCancelledError(error)) { + dispatch({ type: "CANCEL_RUN" }); + dispatch({ + type: "APPEND_EVENT", + event: { + type: "run.cancelled", + at: Date.now(), + payload: { step_id: stepId, attempt }, + }, + }); + return state; + } + const normalized = normalizeError(error); dispatch({ type: "FAIL_STEP", stepId, error: normalized }); + dispatch({ + type: "APPEND_EVENT", + event: { + type: "step.failed", + at: Date.now(), + payload: { step_id: stepId, attempt, code: normalized.code }, + }, + }); const isLastAttempt = attempt > maxRetries; const shouldRetry = normalized.retryable && !isLastAttempt; if (shouldRetry) { - await sleep(retryDelayMs); + const delayMs = getRetryDelay(retryDelayMs, attempt); + dispatch({ + type: "APPEND_EVENT", + event: { + type: "retry.scheduled", + at: Date.now(), + payload: { step_id: stepId, attempt, delay_ms: delayMs }, + }, + }); + await sleep(delayMs, params.signal); continue; } const finalError: AgentError = - normalized.code === "TOOL_TIMEOUT" + normalized.code === "TOOL_TIMEOUT" || normalized.retryable === false ? normalized : { code: "TOOL_RETRY_EXHAUSTED", diff --git a/src/lib/agent/stateMachine.ts b/src/lib/agent/stateMachine.ts index 1c7a969..1c59741 100644 --- a/src/lib/agent/stateMachine.ts +++ b/src/lib/agent/stateMachine.ts @@ -20,9 +20,6 @@ const RUN_TRANSITIONS: Record (step.id === stepId ? updater(step) : step)); }; -/** - * 纯函数 reducer:只负责状态迁移,不执行副作用。 - */ export const transitionRunState = ( state: AgentRunState, action: AgentAction @@ -148,7 +142,22 @@ export const transitionRunState = ( } case "CANCEL_RUN": { assertRunTransition(state.status, "cancelled"); - return { ...state, status: "cancelled", endedAt: now, updatedAt: now }; + return { + ...state, + status: "cancelled", + steps: state.steps.map((step) => { + if (step.status === "succeeded" || step.status === "failed") { + return step; + } + return { + ...step, + status: "skipped", + endedAt: step.endedAt ?? now, + }; + }), + endedAt: now, + updatedAt: now, + }; } case "APPEND_EVENT": { return { diff --git a/src/lib/chatMessageStorage.ts b/src/lib/chatMessageStorage.ts index 12c12cf..31ad8f7 100644 --- a/src/lib/chatMessageStorage.ts +++ b/src/lib/chatMessageStorage.ts @@ -11,7 +11,6 @@ type MessageCacheEntry = { const messageCache = new Map(); -// Centralize message persistence so export/import and draft migration stay consistent. export const readStoredMessages = (sessionId: string): UIMessage[] => { if (typeof window === "undefined") return []; const storageKey = getChatMessagesStorageKey(sessionId); @@ -33,7 +32,6 @@ export const readStoredMessages = (sessionId: string): UIMessage[] => { } }; -// Persist full message arrays for a given session id. export const writeStoredMessages = (sessionId: string, messages: UIMessage[]) => { if (typeof window === "undefined") return; const storageKey = getChatMessagesStorageKey(sessionId); @@ -42,7 +40,6 @@ export const writeStoredMessages = (sessionId: string, messages: UIMessage[]) => localStorage.setItem(storageKey, serialized); }; -// Remove all messages for a session, used by replace-import cleanup. export const removeStoredMessages = (sessionId: string) => { if (typeof window === "undefined") return; const storageKey = getChatMessagesStorageKey(sessionId); diff --git a/src/lib/chatStore.test.ts b/src/lib/chatStore.test.ts index 72e20d5..8328afb 100644 --- a/src/lib/chatStore.test.ts +++ b/src/lib/chatStore.test.ts @@ -18,9 +18,6 @@ type StorageMock = { setItem: (key: string, value: string) => void; }; -/** - * 为依赖 localStorage 的 store 单测提供内存版存储实现。 - */ const createStorageMock = (): StorageMock => { const store: StorageMap = new Map(); diff --git a/src/lib/chatStore.ts b/src/lib/chatStore.ts index a02a0ce..f813f01 100644 --- a/src/lib/chatStore.ts +++ b/src/lib/chatStore.ts @@ -86,7 +86,6 @@ const normalizeChatsByScope = (sourceScopes: unknown) => { }; const migrateStore = (raw: unknown): ChatStoreState => { - // Always normalize unknown/legacy payloads into the latest schema. if (!raw || typeof raw !== "object") return getDefaultState(); const candidate = raw as { @@ -103,7 +102,6 @@ const migrateStore = (raw: unknown): ChatStoreState => { typeof candidate.nextChatId === "number" && candidate.nextChatId > 0 ? candidate.nextChatId : 1; - // Guard against stale nextChatId by recomputing from the max existing chat id. const nextChatId = Math.max(storedNextChatId, maxChatId + 1); if (storedVersion > CHAT_STORE_VERSION) { @@ -122,7 +120,6 @@ const migrateStore = (raw: unknown): ChatStoreState => { const readStore = (): ChatStoreState => { if (typeof window === "undefined") return getDefaultState(); - // Prefer the new key, but still support one-time read from legacy key. const raw = localStorage.getItem(CHAT_STORE_KEY); const legacyRaw = raw ? null : localStorage.getItem(LEGACY_CHAT_STORE_KEY); const source = raw ?? legacyRaw; @@ -135,7 +132,6 @@ const readStore = (): ChatStoreState => { const storedVersion = coerceVersion( (parsed as { version?: unknown })?.version ); - // Rewrite normalized data so future reads always hit the latest schema. if (legacyRaw || storedVersion !== CHAT_STORE_VERSION) { writeStore(migrated); if (legacyRaw) { @@ -240,7 +236,6 @@ export const exportLocalChats = async ( scope: string ): Promise => { const chats = await listChats(scope); - // Export messages alongside chat metadata for complete backup/restore. const messagesByChatId: Record = {}; chats.forEach((chat) => { const messages = readStoredMessages(String(chat.id)); diff --git a/src/lib/model/chatGateway.ts b/src/lib/model/chatGateway.ts index c06a4fa..39b56e3 100644 --- a/src/lib/model/chatGateway.ts +++ b/src/lib/model/chatGateway.ts @@ -21,9 +21,6 @@ export type NormalizedChatPayload = { const BASE_SYSTEM_PROMPT = "You are a helpful assistant."; -/** - * 从环境变量读取网关配置,统一收敛错误出口。 - */ export const resolveChatGatewayConfig = ( env?: ChatGatewayEnv ): ChatGatewayConfig => { @@ -61,9 +58,6 @@ const parseAgentContext = (raw: unknown): AgentPipelineContext | null => { }; }; -/** - * 将请求体规范化为统一结构,避免 route 中散落校验逻辑。 - */ export const normalizeChatPayload = (body: unknown): NormalizedChatPayload => { const payload = body && typeof body === "object" @@ -79,9 +73,6 @@ export const normalizeChatPayload = (body: unknown): NormalizedChatPayload => { }; }; -/** - * 在不破坏原始系统提示的前提下,附加 Agent 运行摘要以保持上下文连续。 - */ export const buildSystemPrompt = ( agentContext: AgentPipelineContext | null ): string => { @@ -105,9 +96,6 @@ export const buildSystemPrompt = ( return `${BASE_SYSTEM_PROMPT}\n\n${pipelineSummary}`; }; -/** - * 统一执行 OpenAI-compatible 流式调用,供 API route 复用。 - */ export const streamChatWithGateway = async ( payload: NormalizedChatPayload, config?: ChatGatewayConfig diff --git a/src/lib/model/globalModel.ts b/src/lib/model/globalModel.ts index ec60df8..22b53c8 100644 --- a/src/lib/model/globalModel.ts +++ b/src/lib/model/globalModel.ts @@ -52,9 +52,6 @@ const getSnapshot = (): SupportedChatModel => { const getServerSnapshot = (): SupportedChatModel => DEFAULT_CHAT_MODEL; -/** - * 更新全局模型选择,并同步到 localStorage 供跨页面复用。 - */ export const setGlobalChatModel = (model: SupportedChatModel) => { cachedModel = model; initialized = true; diff --git a/src/lib/model/models.ts b/src/lib/model/models.ts index 5c45ceb..00b2b1f 100644 --- a/src/lib/model/models.ts +++ b/src/lib/model/models.ts @@ -4,18 +4,12 @@ export type SupportedChatModel = (typeof SUPPORTED_CHAT_MODELS)[number]; export const DEFAULT_CHAT_MODEL: SupportedChatModel = "deepseek-v3"; -/** - * 统一模型白名单判断,避免页面和 API 各自维护分散的字符串常量。 - */ export const isSupportedChatModel = ( value: unknown ): value is SupportedChatModel => typeof value === "string" && (SUPPORTED_CHAT_MODELS as readonly string[]).includes(value); -/** - * 将外部输入收敛为支持的模型名,非法值自动回退默认模型。 - */ export const normalizeChatModel = (value: unknown): SupportedChatModel => { return isSupportedChatModel(value) ? value : DEFAULT_CHAT_MODEL; }; diff --git a/src/lib/observability/clientEvents.ts b/src/lib/observability/clientEvents.ts index 852bbf1..116dba5 100644 --- a/src/lib/observability/clientEvents.ts +++ b/src/lib/observability/clientEvents.ts @@ -25,9 +25,6 @@ const readEvents = (): ClientEventRecord[] => { } }; -/** - * 记录浏览器侧关键链路事件,便于排查实时交互与 Agent 链路问题。 - */ export const recordClientEvent = ( name: string, payload: ClientEventPayload = {} diff --git a/src/lib/observability/serverEvents.ts b/src/lib/observability/serverEvents.ts index 5bd1b23..0e08de0 100644 --- a/src/lib/observability/serverEvents.ts +++ b/src/lib/observability/serverEvents.ts @@ -1,8 +1,5 @@ type ServerEventPayload = Record; -/** - * 记录服务端关键事件,先使用结构化日志沉淀链路,后续可平滑接入日志平台。 - */ export const recordServerEvent = ( name: string, payload: ServerEventPayload = {} diff --git a/src/lib/perf/chatPerfDataset.ts b/src/lib/perf/chatPerfDataset.ts index 9d04f44..5b3036d 100644 --- a/src/lib/perf/chatPerfDataset.ts +++ b/src/lib/perf/chatPerfDataset.ts @@ -6,9 +6,6 @@ const SAMPLE_CODE = [ "```", ].join("\n"); -/** - * 生成可复现的长会话数据集,便于在不同版本间做稳定对比。 - */ export const buildSyntheticMessages = (turnCount: number): UIMessage[] => { const normalizedTurns = Math.max(1, Math.floor(turnCount)); const messages: UIMessage[] = [];