diff --git a/CHANGELOG.md b/CHANGELOG.md index 140c736..a5107cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ + [优化] Canvas Agent Debug 日志改为简洁的纯文本单行格式。 + [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。 + [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。 ++ [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。 + [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。 + [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。 + [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。 diff --git a/canvas-agent/README.md b/canvas-agent/README.md index 975b8be..b22989b 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -134,7 +134,7 @@ default_tools_approval_mode = "approve" 本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。 -侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。 +侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会合并短时间内的文本增量并转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。 侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。 diff --git a/canvas-agent/src/agent/codex-client.ts b/canvas-agent/src/agent/codex-client.ts index 5e744b5..12de4cd 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -11,9 +11,11 @@ import type { AgentEmit } from "./types.js"; type AgentEvent = JsonRecord & { type: string; usage?: unknown }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; +type PendingDelta = { delta: string; params: CodexNotificationParams<"item/agentMessage/delta">; timer: ReturnType }; const canvasAgentMcp = canvasAgentMcpCommand(); const require = createRequire(import.meta.url); +const STREAM_UPDATE_INTERVAL_MS = 40; /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */ export class CodexAppClient { @@ -26,6 +28,7 @@ export class CodexAppClient { private pending = new Map(); private activeTurns = new Map(); private completedTurns = new Map(); + private pendingDeltas = new Map(); /** 保存 app-server 子进程和事件出口。 */ private constructor(private child: ChildProcess, private emit: AgentEmit) {} @@ -180,6 +183,7 @@ export class CodexAppClient { if (event.type === "item.completed") { const item = field(event, "item") as JsonRecord | undefined; const id = String(field(item, "id") || ""); + this.flushDelta(id); const streamedText = this.textByItem.get(id); if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText; if (id) this.textByItem.delete(id); @@ -208,9 +212,27 @@ export class CodexAppClient { /** 合并并广播 Agent 文本增量。 */ private emitDelta(params: CodexNotificationParams<"item/agentMessage/delta">) { const id = params.itemId; - const text = `${this.textByItem.get(id) || ""}${params.delta}`; - this.textByItem.set(id, text); - this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) }); + this.textByItem.set(id, `${this.textByItem.get(id) || ""}${params.delta}`); + const pending = this.pendingDeltas.get(id); + if (pending) { + pending.delta += params.delta; + pending.params = params; + return; + } + this.pendingDeltas.set(id, { + delta: params.delta, + params, + timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS), + }); + } + + /** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */ + private flushDelta(id: string) { + const pending = this.pendingDeltas.get(id); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingDeltas.delete(id); + if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", delta: pending.delta }, ...codexEventScope(pending.params) }); } /** 自动回复 app-server 发起的授权或交互请求。 */ @@ -236,8 +258,11 @@ export class CodexAppClient { /** 拒绝进程退出时仍未完成的请求与 turn。 */ private failAll(message: string) { [...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message))); + this.pendingDeltas.forEach((item) => clearTimeout(item.timer)); this.pending.clear(); this.activeTurns.clear(); + this.pendingDeltas.clear(); + this.textByItem.clear(); this.currentThreadId = ""; this.currentTurnId = ""; } diff --git a/canvas-agent/src/canvas/session.ts b/canvas-agent/src/canvas/session.ts index aecbf39..8beb349 100644 --- a/canvas-agent/src/canvas/session.ts +++ b/canvas-agent/src/canvas/session.ts @@ -59,7 +59,9 @@ export class CanvasSession { /** 更新并广播 Codex 运行状态。 */ setCodexState(patch: Partial) { - this.codexState = { ...this.codexState, ...patch }; + const next = { ...this.codexState, ...patch }; + if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return; + this.codexState = next; logger.debug("Codex state changed", this.codexState); this.emitAll("codex_state", this.codexState); } diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 88e804b..f52d9df 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -10,6 +10,7 @@ description: 当前版本已实现但仍需人工验证的变更项 - Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。 - Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。 - Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和 `working...`,任务运行期间不应闪退;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。 +- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。 - Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。 - Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。 - 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。 diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 7a707cd..a8eea19 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { App, Button, Checkbox, Input, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; @@ -12,7 +12,7 @@ import { readImageMeta } from "@/lib/image-utils"; import { randomId } from "@/lib/utils"; import { uploadImage } from "@/services/image-storage"; import { useThemeStore } from "@/stores/use-theme-store"; -import { useUserStore } from "@/stores/use-user-store"; +import { useUserStore, type LocalUser } from "@/stores/use-user-store"; import { useShallow } from "zustand/react/shallow"; import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary, type AgentTokenUsage } from "@/stores/use-agent-store"; import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; @@ -42,7 +42,7 @@ type AgentEventPayload = { usage?: Record; duration_ms?: number; }; -type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } }; +type AgentEventItem = { id?: string; type?: string; text?: unknown; delta?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } }; type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string }; type AgentWorkspace = { workspacePath: string; activeThreadId?: string }; @@ -51,13 +51,11 @@ type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean }; type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string }; type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState }; -type AgentHealth = { codexBusy?: boolean }; type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean }; type AgentChatEvent = { threadId?: string; sourceClientId?: string; message?: AgentChatItem }; export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const user = useUserStore((state) => state.user); const { message, modal } = App.useApp(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -65,7 +63,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb // 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入, // 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅, // 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。 - const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore( + const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore( useShallow((state) => ({ width: state.width, url: state.url, @@ -76,7 +74,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb attachments: state.attachments, sending: state.sending, waiting: state.waiting, - messages: state.messages, tokenUsage: state.tokenUsage, eventLogs: state.eventLogs, threads: state.threads, @@ -94,9 +91,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const pushMessage = useAgentStore((state) => state.addMessage); const pushEventLog = useAgentStore((state) => state.addEventLog); const clearEventLogs = useAgentStore((state) => state.clearEventLogs); - const listRef = useRef(null); - const followMessagesRef = useRef(true); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const messageCount = useAgentStore((state) => state.messages.length); const canvasContextRef = useRef(useAgentStore.getState().canvasContext); const confirmToolsRef = useRef(confirmTools); const pendingToolRef = useRef(null); @@ -106,7 +101,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const attachmentUrlsRef = useRef(new Set()); const clientIdRef = useRef(randomId()); const loadThreadsSequenceRef = useRef(0); - const turnSyncSequenceRef = useRef(0); const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken"); const loadThreads = useCallback(async (skipHistory = false) => { @@ -114,37 +108,24 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const sequence = ++loadThreadsSequenceRef.current; setAgentState({ loadingThreads: true }); try { + const currentThreadId = useAgentStore.getState().activeThreadId; + const currentThreadRequest = currentThreadId && !skipHistory ? fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`).catch(() => null) : null; const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads`); const nextThreadId = data.workspace?.activeThreadId || ""; let nextMessages: AgentChatItem[] = []; if (nextThreadId && !skipHistory) { - const thread = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`); + let thread = nextThreadId === currentThreadId ? await currentThreadRequest : null; + thread ||= await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`); nextMessages = normalizeHistoryMessages(thread.messages || []); } if (sequence !== loadThreadsSequenceRef.current) return; - setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", activeThreadId: nextThreadId, messages: nextMessages }); + setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", activeThreadId: nextThreadId, ...(skipHistory ? {} : { messages: nextMessages }) }); } catch (error) { addEventLog("读取历史失败", error); } finally { if (sequence === loadThreadsSequenceRef.current) setAgentState({ loadingThreads: false }); } }, [endpoint, setAgentState, token]); - const syncTurnMessages = async (sequence: number) => { - while (sequence === turnSyncSequenceRef.current && useAgentStore.getState().connected) { - try { - const health = await fetchAgentJson(endpoint, token, "/health"); - if (!health.codexBusy) { - await wait(300); - if (sequence !== turnSyncSequenceRef.current) return; - await loadThreads(); - if (sequence === turnSyncSequenceRef.current) setAgentState({ waiting: false, sending: false, activity: "完成" }); - return; - } - } catch {} - await wait(500); - } - }; - // canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。 useEffect(() => { let timer: ReturnType | null = null; @@ -166,30 +147,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb useEffect(() => { pendingToolRef.current = pendingTool; }, [pendingTool]); - const updateScrollState = useCallback(() => { - const list = listRef.current; - if (!list) return; - const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD; - followMessagesRef.current = atBottom; - setShowScrollToBottom(!atBottom); - }, []); - const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { - const list = listRef.current; - if (!list) return; - followMessagesRef.current = true; - list.scrollTo({ top: list.scrollHeight, behavior }); - setShowScrollToBottom(false); - }, []); - useEffect(() => { - if (activeTab !== "chat") return; - const frame = requestAnimationFrame(() => scrollToBottom("auto")); - return () => cancelAnimationFrame(frame); - }, [activeTab, activeThreadId, scrollToBottom]); - useEffect(() => { - if (activeTab !== "chat") return; - const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState())); - return () => cancelAnimationFrame(frame); - }, [activeTab, messages, pendingTool, scrollToBottom, updateScrollState, waiting]); useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []); useEffect(() => { @@ -233,11 +190,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb source.addEventListener("workspace_changed", (event) => { const data = parseEventData(event); if (!data) return; - enqueueEvent(async () => { + enqueueEvent(() => { const nextThreadId = data.activeThreadId ?? data.threadId ?? ""; pendingToolRef.current = null; setAgentState({ activeThreadId: nextThreadId, messages: [], tokenUsage: null, pendingTool: null }); - await loadThreads(data.emptyThread); + void loadThreads(Boolean(data.emptyThread)); }); }); source.addEventListener("chat_message", (event) => { @@ -333,8 +290,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb attachmentUrlsRef.current.delete(item.url); }); setAgentState({ prompt: "", attachments: [], sending: false, waiting: true, activity: "Codex 正在运行" }); - const sequence = ++turnSyncSequenceRef.current; - void syncTurnMessages(sequence); } catch (error) { const text = error instanceof Error ? error.message : "发送失败"; const busy = text.includes("Codex 正在运行"); @@ -533,7 +488,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb function clearAgentSession(patch: Parameters[0] = {}) { loadThreadsSequenceRef.current += 1; - turnSyncSequenceRef.current += 1; setAgentState({ messages: [], tokenUsage: null, @@ -645,6 +599,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb const log = formatAgentEventLog(event); if (log) addEventLog(log.title, log.text); if (event.type === "thread.started" && event.thread_id) setAgentState({ activeThreadId: event.thread_id }); + if (event.type === "item.updated" && event.item?.type === "agent_message" && event.item.id) { + appendStreamDelta(event.item.id, stringText(event.item.delta)); + return; + } if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) { const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.streamId === event.item?.id); @@ -659,7 +617,17 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb if (item) addMessage(item); }; - const streaming = messages.some((message) => message.streamId); + const appendStreamDelta = (streamId: string, delta: string) => { + if (!delta) return; + const currentMessages = useAgentStore.getState().messages; + const index = currentMessages.findIndex((message) => message.streamId === streamId); + if (index < 0) { + pushMessage({ id: `stream-${streamId}`, role: "assistant", title: "Codex", text: delta, streamId }); + return; + } + setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text: `${message.text}${delta}` } : message)) }); + }; + const content = ( <> message.success(text)} onCopyBlocked={(text) => message.warning(text)} /> ) : ( <> -
-
- {messages.map((item) => ( - - ))} - {pendingTool ? ( - - ) : null} - {(sending || waiting) && !streaming && !pendingTool ? : null} -
- {showScrollToBottom ? ( - -
+ {tokenUsage ? : null} void; + onApproveTool: () => void; +}) { + const messages = useAgentStore((state) => state.messages); + const user = useUserStore((state) => state.user); + const listRef = useRef(null); + const followMessagesRef = useRef(true); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const streaming = messages.some((message) => message.streamId); + const updateScrollState = useCallback(() => { + const list = listRef.current; + if (!list) return; + const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD; + followMessagesRef.current = atBottom; + setShowScrollToBottom(!atBottom); + }, []); + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + const list = listRef.current; + if (!list) return; + followMessagesRef.current = true; + list.scrollTo({ top: list.scrollHeight, behavior }); + setShowScrollToBottom(false); + }, []); + useEffect(() => { + const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState())); + return () => cancelAnimationFrame(frame); + }, [messages, pendingTool, scrollToBottom, updateScrollState, waiting]); + return ( +
+
+ {messages.map((item) => ( + + ))} + {pendingTool ? ( + + ) : null} + {(sending || waiting) && !streaming && !pendingTool ? : null} +
+ {showScrollToBottom ? ( + +
+ ); +} + +const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme, user }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null }) { + return ( +
+ +
+ ); +}); + function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { return (
@@ -1170,7 +1190,7 @@ function agentAttachmentToChatAttachment(item: AgentAttachment): CanvasAgentChat function formatAgentEvent(event: AgentEventPayload): Omit | null { const item = event.item; if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item }; - if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), streamId: event.type === "item.updated" ? item.id : undefined }; + if (event.type === "item.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) }; if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) }; return null; } @@ -1439,10 +1459,6 @@ function createId() { return randomId(); } -function wait(ms: number) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); }