import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { App, Button, Tooltip } from "antd"; import dayjs from "dayjs"; import { Bot, History, MessageSquare, PanelRightClose, PlugZap, Plus, Sparkles, Terminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import i18n from "@/i18n"; import { canvasThemes } from "@/lib/canvas-theme"; import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data"; import { imageMetadata } from "@/lib/canvas/canvas-node-factory"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { resolveCanvasReferenceImages } from "@/lib/canvas/canvas-resource-references"; 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 { useAgentSkillStore } from "@/stores/use-agent-skill-store"; import { useShallow } from "zustand/react/shallow"; import { useAgentStore, type AgentAttachment, type AgentBootstrapStatus, type AgentCanvasContext, type AgentCanvasReference, type AgentChatItem, type AgentConversationState, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store"; import { type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools"; import { acknowledgeCodexHistory, activateAgentClient, AgentApiError, discoverAgentConfig, fetchAgentJson, interruptCodexTurn, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent"; import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat"; import { AgentChatComposer } from "./agent-chat-composer"; import { AgentConnectView } from "./agent-connect-view"; import { activityDeltaFallback, activityDetail, activityKind, activityPlaceholder, agentAttachmentToChatAttachment, agentErrorView, attachmentPayloadBytes, compactText, eventUsage, formatAgentActivity, formatAgentEvent, formatAgentEventLog, formatAgentPlan, formatBytes, bindPendingTurnMessages, isCanvasWriteTool, isConnectionErrorMessage, isCurrentThreadEvent, isReasoningSummary, mergeAgentMessages, mergeStreamText, normalizeHistoryMessages, normalizeText, parseEventData, promptWithAttachments, promptWithCanvasReferences, reasoningActivityText, registerLiveAgentTurn, scopeChatItem, stringText, toolName, turnPlanStatus, upsertAgentMessage, type AgentEventItem, type AgentEventPayload, } from "./agent-event-formatters"; import { AgentHistoryView } from "./agent-history-view"; import { AgentLogView } from "./agent-log-view"; import { AgentPanelTabs } from "./agent-panel-tabs"; import { AgentSkillsView } from "./agent-skills-view"; const MAX_ATTACHMENTS = 6; const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024; const MESSAGE_PREVIEW_LONG_EDGE = 192; const MESSAGE_PREVIEW_MAX_LENGTH = 500_000; const DEFAULT_AGENT_URL = "http://127.0.0.1:17371"; const AGENT_PROTOCOL_VERSION = 5; const HISTORY_RETRY_DELAYS_MS = [0, 150, 350, 700, 1200]; const AGENT_REASONING_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); const rt = (key: string, options?: Record) => i18n.t(`agent.runtime.${key}`, options); type AgentWorkspace = { workspacePath: string; activeThreadId?: string }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; data?: AgentThreadSummary[] }; type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; thread?: AgentThreadSummary; messages?: AgentChatItem[]; settledTurnIds?: string[]; historyReady?: boolean }; type AgentWorkspaceResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState }; type AgentTurnResponse = { ok?: boolean; threadId?: string }; type AgentModelsResponse = { ok?: boolean; data?: AgentModel[] }; type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string }; type AgentHelloEvent = { ok?: boolean; protocolVersion?: number; clientId?: string; workspace?: { activeThreadId?: string }; conversation?: AgentConversationState; codex?: AgentCodexState; pendingApprovals?: AgentPendingApproval[] }; type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; sourceClientId?: string; emptyThread?: boolean; draftThread?: boolean; conversation?: AgentConversationState }; type AgentChatEvent = { threadId?: string; turnId?: string; sourceClientId?: string; replayed?: boolean; message?: AgentChatItem }; type AgentBootstrapEvent = { type?: "codex.preparing" | "codex.prepare_failed" | "mcp.startup" | "mcp.complete"; phase?: "preheat" | "runtime"; threadId?: string; name?: string; status?: "starting" | "ready" | "failed" | "cancelled"; error?: string | null; failureReason?: string | null }; type AgentClientGlobal = typeof globalThis & { __infiniteCanvasAgentClientIdPromise?: Promise }; function authoritativeHistoryTurnKeys(threadId: string, settledTurnIds: string[]) { return new Set(settledTurnIds.map((turnId) => `${threadId}\0${turnId}`)); } function agentErrorState(error: unknown) { return error instanceof AgentApiError ? (error.response as { state?: AgentConversationState }).state : undefined; } function conversationBootstrapView(conversation: AgentConversationState) { const mcpStartupStatuses: Record = Object.fromEntries(Object.entries(conversation.mcpStatuses).map(([name, item]) => { const view: AgentBootstrapStatus = item.status === "starting" ? { key: `mcp:${name}:starting`, text: rt("mcpStarting", { name }), detail: rt("mcpConnecting"), status: "running" } : item.status === "ready" ? { key: `mcp:${name}:ready`, text: rt("mcpReadyNamed", { name }), detail: rt("toolsReady"), status: "ready" } : { key: `mcp:${name}:${item.status}`, text: rt(item.status === "failed" ? "mcpFailedNamed" : "mcpCanceledNamed", { name }), detail: item.error || rt("toolInitFailed"), status: "error" }; return [name, view]; })); const services = Object.values(mcpStartupStatuses); const pending = services.filter((item) => item.status === "running").length; const bootstrapStatus: AgentBootstrapStatus | null = conversation.status === "idle" || conversation.status === "preparing" ? services.length ? { key: "mcp:starting", text: rt("mcpServicesStarting"), detail: pending ? rt("toolServicesPending", { count: pending }) : rt("checkingToolServices"), status: "running" } : { key: "codex:preparing", text: rt("conversationInitializing"), detail: rt("conversationCreating"), status: "running" } : conversation.status === "warning" ? { key: "mcp:warning", text: rt("someMcpFailed"), detail: rt("remainingToolsReady"), status: "error" } : conversation.status === "failed" ? { key: "codex:prepare_failed", text: rt("conversationInitFailed"), detail: conversation.error || rt("conversationCreateFailed"), status: "error" } : conversation.status === "ready" ? { key: "mcp:ready", text: rt("mcpServicesReady", { count: services.length }), detail: rt("toolsReady"), status: "ready" } : null; return { bootstrapStatus, mcpStartupStatuses }; } export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) { const { t } = useTranslation(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const { message, modal } = App.useApp(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); // Field-level selectors with useShallow rerender only when these fields change. // canvasContext is intentionally excluded because project updates it every frame during dragging and resizing. // The panel uses it only for ref synchronization and debounced postState calls, never during rendering. // Subscribing here would rerender the panel every frame and amplify the #185 crash, so it is observed imperatively below. const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, conversation, connectError, pendingTool, pendingApprovals } = useAgentStore( useShallow((state) => ({ width: state.width, url: state.url, token: state.token, connected: state.connected, enabled: state.enabled, prompt: state.prompt, attachments: state.attachments, sending: state.sending, waiting: state.waiting, tokenUsage: state.tokenUsage, eventLogs: state.eventLogs, threads: state.threads, activeThreadId: state.activeThreadId, workspacePath: state.workspacePath, loadingThreads: state.loadingThreads, activeTab: state.activeTab, confirmTools: state.confirmTools, permissionMode: state.permissionMode, models: state.models, model: state.model, reasoningEffort: state.reasoningEffort, activity: state.activity, conversation: state.conversation, connectError: state.connectError, pendingTool: state.pendingTool, pendingApprovals: state.pendingApprovals, })), ); const setAgentState = useAgentStore((state) => state.setAgentState); const conversationReady = conversation.status === "ready" || conversation.status === "warning"; const conversationBusy = conversation.status === "preparing" || conversation.status === "running"; const closePanel = useAgentStore((state) => state.closePanel); const pushMessage = useAgentStore((state) => state.addMessage); const pushEventLog = useAgentStore((state) => state.addEventLog); const clearEventLogs = useAgentStore((state) => state.clearEventLogs); const loadSkills = useAgentSkillStore((state) => state.loadSkills); const clearSkillSelection = useAgentSkillStore((state) => state.clearSelection); const skillCount = useAgentSkillStore((state) => state.skills.length); const messageCount = useAgentStore((state) => state.messages.length); const canvasContextRef = useRef(useAgentStore.getState().canvasContext); const confirmToolsRef = useRef(confirmTools); const pendingToolRef = useRef(null); const autoConnectRef = useRef(false); const connectedRef = useRef(false); const errorLoggedRef = useRef(false); const attachmentUrlsRef = useRef(new Set()); const clientIdRef = useRef(""); const [clientReady, setClientReady] = useState(false); const loadThreadsSequenceRef = useRef(0); const threadMessagesRef = useRef(new Map()); const authoritativeHistoryTurnsRef = useRef(new Set()); const liveTurnKeysRef = useRef(new Set()); const threadOperationRef = useRef(0); const threadOperationSequenceRef = useRef(0); const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]); const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken"); useEffect(() => { let disposed = false; void acquireAgentClientId().then((clientId) => { if (!disposed) { clientIdRef.current = clientId; setClientReady(true); } }); return () => { disposed = true; }; }, []); const loadThreadSnapshot = useCallback(async (threadId: string, sequence: number, response?: AgentThreadResponse, expectedTurnId = "") => { let thread = response; let lastError: unknown; for (const delayMs of HISTORY_RETRY_DELAYS_MS) { if (delayMs) await delay(delayMs); if (sequence !== loadThreadsSequenceRef.current || useAgentStore.getState().activeThreadId !== threadId) return false; try { thread ||= await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}`); lastError = undefined; } catch (error) { lastError = error; thread = undefined; continue; } const history = normalizeHistoryMessages(thread.messages || []); const latest = useAgentStore.getState(); if (sequence !== loadThreadsSequenceRef.current || latest.activeThreadId !== threadId) return false; const historyTurns = authoritativeHistoryTurnKeys(threadId, thread.settledTurnIds || []); const hasExpectedTurn = !expectedTurnId || historyTurns.has(`${threadId}\0${expectedTurnId}`); historyTurns.forEach((key) => liveTurnKeysRef.current.delete(key)); if (latest.activeTurnId) liveTurnKeysRef.current.add(`${threadId}\0${latest.activeTurnId}`); authoritativeHistoryTurnsRef.current = historyTurns; const messages = mergeAgentMessages(history, latest.messages, threadId, liveTurnKeysRef.current); threadMessagesRef.current.set(threadId, messages); setAgentState({ messages, connectError: "" }); const coveredTurnIds = [...historyTurns].map((key) => key.slice(threadId.length + 1)); if (coveredTurnIds.length) void acknowledgeCodexHistory(endpoint, token, threadId, coveredTurnIds).catch(() => undefined); if (hasExpectedTurn && (thread.historyReady !== false || Boolean(expectedTurnId))) return true; thread = undefined; } if (lastError) throw lastError; return false; }, [endpoint, setAgentState, token]); const applyWorkspaceChange = useCallback((data: AgentWorkspaceEvent) => { const nextThreadId = data.activeThreadId ?? data.threadId ?? ""; const current = useAgentStore.getState(); const threadChanged = current.activeThreadId !== nextThreadId; const emptyThread = Boolean(data.emptyThread || data.draftThread); const pendingMessage = [...current.messages].reverse().find((item) => item.role === "user" && !item.turnId); const keepPendingMessage = Boolean( data.emptyThread && pendingMessage && (current.sending || current.waiting) && (!data.sourceClientId || data.sourceClientId === clientIdRef.current), ); if (threadChanged && current.activeThreadId) { const messages = keepPendingMessage ? current.messages.filter((item) => item.id !== pendingMessage!.id) : current.messages; threadMessagesRef.current.set(current.activeThreadId, messages); } if (emptyThread && nextThreadId) threadMessagesRef.current.delete(nextThreadId); if (threadChanged || emptyThread) { loadThreadsSequenceRef.current += 1; authoritativeHistoryTurnsRef.current.clear(); liveTurnKeysRef.current.clear(); } const messages = keepPendingMessage ? [scopeChatItem(pendingMessage!, nextThreadId, "")] : emptyThread ? [] : threadChanged ? threadMessagesRef.current.get(nextThreadId) || [] : current.messages; pendingToolRef.current = null; setAgentState({ activeThreadId: nextThreadId, activeTurnId: threadChanged || emptyThread ? "" : current.activeTurnId, messages, tokenUsage: threadChanged || emptyThread ? null : current.tokenUsage, pendingTool: null, pendingApprovals: threadChanged || emptyThread ? [] : current.pendingApprovals, }); return loadThreadsSequenceRef.current; }, [setAgentState]); const applyConversationState = useCallback((next: AgentConversationState, force = false) => { const current = useAgentStore.getState(); if (!next?.revision || !force && next.revision <= current.conversation.revision) return false; const conversationChanged = next.conversationId !== current.conversation.conversationId; if (conversationChanged || next.threadId !== current.activeThreadId) { applyWorkspaceChange({ activeThreadId: next.threadId, emptyThread: conversationChanged || !current.activeThreadId, draftThread: next.status === "preparing", sourceClientId: next.sourceClientId, }); } setAgentState({ conversation: next, ...conversationBootstrapView(next) }); return true; }, [applyWorkspaceChange, setAgentState]); const loadThreads = useCallback(async (skipHistory = false, expectedTurnId = "") => { if (!connectedRef.current && !useAgentStore.getState().connected) return; let sequence = ++loadThreadsSequenceRef.current; setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson(endpoint, token, `/agent/codex/threads`); if (sequence !== loadThreadsSequenceRef.current) return; if (data.conversation) { applyConversationState(data.conversation); sequence = loadThreadsSequenceRef.current; } const current = useAgentStore.getState(); const currentThreadId = current.activeThreadId || data.workspace?.activeThreadId || ""; if (!data.conversation && currentThreadId !== current.activeThreadId) sequence = applyWorkspaceChange({ activeThreadId: currentThreadId }); if (sequence !== loadThreadsSequenceRef.current || useAgentStore.getState().activeThreadId !== currentThreadId) return; setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "" }); if (currentThreadId && !skipHistory) { await loadThreadSnapshot(currentThreadId, sequence, undefined, expectedTurnId); } else { authoritativeHistoryTurnsRef.current.clear(); liveTurnKeysRef.current.clear(); } } catch (error) { addEventLog(rt("historyReadFailed"), error); } finally { if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false }); } }, [applyConversationState, applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]); // Imperatively subscribe to canvasContext to keep the ref current and debounce snapshot reports without rerendering the panel. useEffect(() => { let timer: ReturnType | null = null; const unsubscribe = useAgentStore.subscribe((state) => { if (state.canvasContext === canvasContextRef.current) return; canvasContextRef.current = state.canvasContext; if (!useAgentStore.getState().connected) return; if (timer) clearTimeout(timer); timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContextRef.current?.snapshot || null), 300); }); return () => { unsubscribe(); if (timer) clearTimeout(timer); }; }, [endpoint, token]); useEffect(() => { confirmToolsRef.current = confirmTools; }, [confirmTools]); useEffect(() => { pendingToolRef.current = pendingTool; }, [pendingTool]); useEffect(() => () => attachmentUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)), []); useEffect(() => { if (!clientReady || !enabled || !token.trim()) return; localStorage.setItem("canvas-agent-url", endpoint); localStorage.setItem("canvas-agent-token", token); const clientId = clientIdRef.current; let disposed = false; let protocolRejected = false; let eventQueue = Promise.resolve(); const isCurrentConnection = () => !disposed && clientIdRef.current === clientId; const enqueueEvent = (task: () => void | Promise) => { eventQueue = eventQueue.then(async () => { if (isCurrentConnection()) await task(); }).catch((error) => { if (isCurrentConnection()) addEventLog(rt("conversationSyncFailed"), error); }); }; const source = new EventSource(`${endpoint}/events?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`); source.addEventListener("hello", (event) => { if (!isCurrentConnection()) return; const hello = parseEventData(event); if (hello?.protocolVersion !== AGENT_PROTOCOL_VERSION) { const text = rt("agentOutdated"); protocolRejected = true; source.close(); connectedRef.current = false; setAgentState({ enabled: false, connected: false, waiting: false, sending: false, activity: rt("restartRequired"), connectError: text, silentConnect: false, pendingTool: null, pendingApprovals: [] }); useAgentSkillStore.getState().reset(); addEventLog(rt("versionMismatch"), text, hello); if (!headless) message.error(text); return; } const codex = hello?.codex; const busy = Boolean(codex?.busy); const nextThreadId = hello?.conversation?.threadId ?? hello?.workspace?.activeThreadId ?? useAgentStore.getState().activeThreadId; if (hello?.conversation) applyConversationState(hello.conversation, true); else applyWorkspaceChange({ activeThreadId: nextThreadId }); const current = useAgentStore.getState(); const nextTurnId = codex?.threadId === nextThreadId ? codex.turnId ?? "" : ""; if (nextTurnId) liveTurnKeysRef.current.add(`${nextThreadId}\0${nextTurnId}`); const activeTurnId = busy ? nextTurnId : ""; const pendingApprovals = busy ? (hello?.pendingApprovals || []).filter((item) => !item.threadId || item.threadId === nextThreadId) : []; const messages = activeTurnId ? bindPendingTurnMessages(current.messages.filter((item) => !isConnectionErrorMessage(item)), nextThreadId, activeTurnId) : current.messages.filter((item) => !isConnectionErrorMessage(item)); errorLoggedRef.current = false; connectedRef.current = true; setAgentState({ connected: true, activity: pendingApprovals.length ? rt("awaitingApproval") : busy ? rt("codexRunning") : rt("connected"), waiting: busy, sending: false, connectError: "", silentConnect: false, activeThreadId: nextThreadId, activeTurnId, messages, pendingApprovals, }); if (!headless) message.success(rt("localAgentConnected")); void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null); if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId); if (!busy && !nextThreadId && (!hello?.conversation || hello.conversation.status === "idle")) { void fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId, permissionMode }) }) .then((result) => result.conversation && applyConversationState(result.conversation)) .catch((error) => { const state = agentErrorState(error); if (state) applyConversationState(state); addEventLog(rt("conversationInitFailed"), error); }); } }); source.addEventListener("codex_state", (event) => { const data = parseEventData(event); if (!data) return; enqueueEvent(async () => { const busy = Boolean(data.busy); const current = useAgentStore.getState(); const appliesToCurrentThread = !data.threadId || data.threadId === current.activeThreadId; if (!appliesToCurrentThread) return; const turnId = data.turnId || current.activeTurnId; if (turnId) liveTurnKeysRef.current.add(`${current.activeThreadId}\0${turnId}`); const activeTurnId = busy ? turnId : ""; const messages = activeTurnId ? bindPendingTurnMessages(current.messages, current.activeThreadId, activeTurnId) : current.messages; setAgentState({ activity: busy ? rt("codexRunning") : current.activity === rt("processingFailed") ? rt("processingFailed") : rt("completed"), waiting: busy, sending: false, activeTurnId, messages, }); if (!busy && current.waiting) void loadThreads(false, turnId); }); }); source.addEventListener("tool_call", (event) => { if (!isCurrentConnection()) return; const data = parseEventData(event); if (data) void handleToolCall(endpoint, token, data); }); source.addEventListener("codex_approval", (event) => { if (!isCurrentConnection()) return; const data = parseEventData(event); if (!data || !isCurrentThreadEvent(data)) return; setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: rt("awaitingApproval") }); addEventLog(rt("awaitingApproval"), data.reason || data.method, data); }); source.addEventListener("codex_approval_resolved", (event) => { if (!isCurrentConnection()) return; const data = parseEventData<{ requestId?: string; decision?: "accept" | "acceptForSession" | "decline" | "cancel" }>(event); if (!data?.requestId) return; const current = useAgentStore.getState(); const approval = current.pendingApprovals.find((item) => item.requestId === data.requestId); const pendingApprovals = current.pendingApprovals.filter((item) => item.requestId !== data.requestId); setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, current.waiting, current.activity) }); const decision = data.decision || approval?.deciding; if (approval && decision) addEventLog(rt(decision === "accept" || decision === "acceptForSession" ? "approvalGranted" : "approvalCanceled"), approval.reason || approval.method, approval); }); source.addEventListener("agent_event", (event) => { const data = parseEventData(event); if (data) enqueueEvent(() => { if (!isCurrentThreadEvent(data)) return; const shouldProcess = registerLiveAgentTurn(data, authoritativeHistoryTurnsRef.current, liveTurnKeysRef.current); if (data.type !== "usage.updated" && !shouldProcess) return; return handleAgentEvent(data); }); }); source.addEventListener("agent_bootstrap", (event) => { const data = parseEventData(event); if (!data?.type) return; if (data.type === "codex.preparing") { addEventLog(rt("conversationInitializing"), rt("conversationCreating"), data); return; } if (data.type === "codex.prepare_failed") { addEventLog(rt("conversationInitFailed"), data.error, data); return; } if (data.type === "mcp.complete") { addEventLog(rt("mcpStatusComplete"), rt("mcpListRead"), data); return; } if (!data.name || !data.status) return; const label = data.name; const status = data.status === "starting" ? { text: rt("mcpStarting", { name: label }), detail: rt("mcpConnecting"), status: "running" as const } : data.status === "ready" ? { text: rt("mcpReadyNamed", { name: label }), detail: rt("toolsReady"), status: "ready" as const } : data.status === "failed" ? { text: rt("mcpFailedNamed", { name: label }), detail: data.error || rt("toolInitFailed"), status: "error" as const } : { text: rt("mcpCanceledNamed", { name: label }), detail: rt("toolInitCanceled"), status: "error" as const }; addEventLog(status.text, status.detail, data); }); source.addEventListener("conversation_changed", (event) => { const data = parseEventData(event); if (data) enqueueEvent(() => { applyConversationState(data); }); }); source.addEventListener("workspace_changed", (event) => { const data = parseEventData(event); if (!data) return; enqueueEvent(() => { if (data.conversation) applyConversationState(data.conversation); else applyWorkspaceChange(data); if (!data.draftThread) void loadThreads(Boolean(data.emptyThread)); }); }); source.addEventListener("chat_message", (event) => { const data = parseEventData(event); if (!data?.message) return; enqueueEvent(() => { if (!isCurrentThreadEvent(data)) return; if (!registerLiveAgentTurn(data, authoritativeHistoryTurnsRef.current, liveTurnKeysRef.current)) return; const current = useAgentStore.getState(); const threadId = data.threadId || data.message!.threadId || current.activeThreadId; const turnId = data.turnId ?? data.message!.turnId ?? ""; const clientMessageId = data.message!.clientMessageId || data.message!.itemId || data.message!.id; if (current.activeThreadId !== threadId) return; const next = scopeChatItem(data.message!, threadId, turnId); const currentMessages = data.message!.role === "user" && clientMessageId ? current.messages.filter((item) => item.role !== "user" || item.clientMessageId !== clientMessageId || item.id === next.id) : current.messages; const messages = upsertAgentMessage(currentMessages, next); setAgentState({ messages }); }); }); source.addEventListener("agent_log", (event) => { if (!isCurrentConnection()) return; const text = parseEventData<{ text?: unknown }>(event)?.text; addEventLog(rt("log"), text, text); }); source.addEventListener("skills_changed", (event) => { if (!isCurrentConnection()) return; const data = parseEventData<{ forceReload?: boolean }>(event); void loadSkills(endpoint, token, Boolean(data?.forceReload)); }); source.addEventListener("agent_error", (event) => { const data = parseEventData(event); if (!data) return; enqueueEvent(() => { if (!isCurrentThreadEvent(data)) return; if (!registerLiveAgentTurn(data, authoritativeHistoryTurnsRef.current, liveTurnKeysRef.current)) return; showAgentError(data.message, data, !data.replayed); }); }); source.onerror = () => { if (disposed || protocolRejected) return; const wasConnected = connectedRef.current; const silent = useAgentStore.getState().silentConnect && !wasConnected; const text = rt(wasConnected ? "connectionLostDescription" : "connectionFailedDescription"); if (!errorLoggedRef.current || wasConnected) { addEventLog(rt(wasConnected ? "connectionLost" : "connectionFailed"), text); if (!headless && !silent) message.error(text); } errorLoggedRef.current = true; connectedRef.current = false; pendingToolRef.current = null; setAgentState({ activity: rt(wasConnected ? "connectionLost" : "connectionFailed"), connected: false, waiting: false, sending: false, connectError: silent ? "" : text, silentConnect: false, pendingTool: null, pendingApprovals: [], }); useAgentSkillStore.getState().reset(); if (!wasConnected) { source.close(); setAgentState({ enabled: false }); } }; return () => { disposed = true; source.close(); connectedRef.current = false; loadThreadsSequenceRef.current += 1; useAgentSkillStore.getState().reset(); }; }, [applyConversationState, applyWorkspaceChange, clientReady, enabled, endpoint, loadSkills, loadThreads, message, setAgentState, token]); useEffect(() => { if (connected) void loadThreads(); }, [connected, loadThreads]); useEffect(() => { if (connected) void loadSkills(endpoint, token); }, [connected, endpoint, loadSkills, token]); useEffect(() => { if (!connected) return; void fetchAgentJson(endpoint, token, "/agent/codex/models").then(({ data = [] }) => { const names = new Set(); const models = data.flatMap((item) => { const name = item.displayName || item.model; const efforts = item.supportedReasoningEfforts.filter(({ reasoningEffort }) => AGENT_REASONING_EFFORTS.has(reasoningEffort)); if (item.model === "codex-auto-review" || names.has(name) || !efforts.length) return []; names.add(name); const defaultReasoningEffort = efforts.some((effort) => effort.reasoningEffort === item.defaultReasoningEffort) ? item.defaultReasoningEffort : efforts[0].reasoningEffort; return [{ ...item, supportedReasoningEfforts: efforts, defaultReasoningEffort }]; }); if (!models.length) return; const savedModel = useAgentStore.getState().model; const current = models.find((item) => item.model === savedModel) || models.find((item) => item.isDefault) || models[0]; const savedEffort = useAgentStore.getState().reasoningEffort; const efforts = current.supportedReasoningEfforts.map((item) => item.reasoningEffort); const nextEffort = efforts.includes(savedEffort as AgentReasoningEffort) ? savedEffort as AgentReasoningEffort : current.defaultReasoningEffort || efforts[0]; localStorage.setItem("canvas-agent-model", current.model); localStorage.setItem("canvas-agent-reasoning-effort", nextEffort); setAgentState({ models, model: current.model, reasoningEffort: nextEffort }); }).catch((error) => addEventLog(rt("modelListFailed"), error)); }, [connected, endpoint, setAgentState, token]); useEffect(() => { if (!connected) return; const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current); const activateVisible = () => { if (document.visibilityState === "visible") activate(); }; window.addEventListener("focus", activate); document.addEventListener("visibilitychange", activateVisible); return () => { window.removeEventListener("focus", activate); document.removeEventListener("visibilitychange", activateVisible); }; }, [connected, endpoint, token]); const sendPrompt = async () => { const text = prompt.trim(); const files = attachments; const skillState = useAgentSkillStore.getState(); const selectedSkill = skillState.selectedSkill; const selectedSkillRevision = skillState.selectionRevision; const currentState = useAgentStore.getState(); const canvasNodeIds = new Set(currentState.canvasContext?.snapshot.nodes.map((node) => node.id) || []); const canvasReferences = currentState.canvasReferences.filter((item) => canvasNodeIds.has(item.nodeId)); if (canvasReferences.length !== currentState.canvasReferences.length) { setAgentState({ canvasReferences }); message.warning(rt(canvasReferences.length ? "someCanvasReferencesMissing" : "canvasReferencesMissing")); } const requestPrompt = promptWithCanvasReferences(promptWithAttachments(text, files), canvasReferences); if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return; let referenceImages: AgentAttachment[] = []; if (canvasReferences.some((item) => item.kind === "image")) { setAgentState({ sending: true, activity: rt("readingCanvasImages") }); try { referenceImages = await resolveCanvasReferenceImages(canvasReferences, currentState.canvasContext?.snapshot.nodes || []); } catch (error) { setAgentState({ sending: false, activity: rt("canvasImageReadFailed") }); addMessage({ role: "error", title: rt("canvasImageReadFailed"), text: error instanceof Error ? error.message : rt("canvasImageReadFailed") }); return; } } const requestFiles = [...files, ...referenceImages.filter((reference) => !files.some((file) => file.dataUrl === reference.dataUrl))]; if (requestFiles.length > MAX_ATTACHMENTS) { setAgentState({ sending: false, activity: rt("tooManyImages") }); addMessage({ role: "error", title: rt("tooManyImages"), text: rt("imageCountLimit", { count: MAX_ATTACHMENTS }) }); return; } if (attachmentPayloadBytes(requestFiles) > MAX_ATTACHMENT_PAYLOAD_BYTES) { setAgentState({ sending: false, activity: rt("imageTooLarge") }); addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imagePayloadTooLarge") }); return; } const messageId = createId(); const userText = text || rt(files.length ? "imagesSent" : "canvasReferencesSent", { count: files.length || canvasReferences.length }); const messageReferences: AgentCanvasReference[] = await Promise.all(canvasReferences.map(async ({ nodeId, label, title, kind, previewUrl, text }) => { const image = referenceImages.find((item) => item.id === `canvas:${nodeId}`); return { nodeId, label, title, kind, previewUrl: image ? (await createMessageAttachmentMetadata(image)).url : previewUrl, text }; })); const messageSkill = selectedSkill ? { name: selectedSkill.name, path: selectedSkill.path, displayName: selectedSkill.interface?.displayName || undefined } : undefined; loadThreadsSequenceRef.current += 1; const currentBeforeSend = useAgentStore.getState(); const requestThreadId = currentBeforeSend.activeThreadId; setAgentState({ prompt: "", attachments: [], canvasReferences: [], activity: rt("sending"), sending: true, loadingThreads: false, activeTurnId: "", messages: currentBeforeSend.messages }); addMessage({ id: messageId, itemId: "synthetic:user", clientMessageId: messageId, threadId: requestThreadId, turnId: "", role: "user", text: userText, attachments: files, canvasReferences: messageReferences, skill: messageSkill }); let threadId = requestThreadId; try { const messageAttachments = await Promise.all(files.map(createMessageAttachmentMetadata)); const messageMetadata = { ...(messageAttachments.length ? { attachments: messageAttachments } : {}), ...(messageReferences.length ? { canvasReferences: messageReferences } : {}), ...(messageSkill ? { skill: messageSkill } : {}), }; const modelName = models.find((item) => item.model === model)?.displayName || model || rt("defaultModel"); const effortName = reasoningEffort ? i18n.t(`agent.composer.effort.${reasoningEffort}`) : rt("defaultEffort"); addEventLog(rt("sendTask"), `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · ${rt("attachmentCount", { count: files.length })}` : ""}${canvasReferences.length ? ` · ${rt("canvasReferenceCount", { count: canvasReferences.length })}` : ""} · ${compactText(text) || rt(canvasReferences.length ? "canvasReferencesOnly" : "attachmentsOnly")}`); const accepted = await fetchAgentJson(endpoint, token, "/agent/codex/turn", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, messageText: userText, messageId, clientId: clientIdRef.current, threadId, conversationId: currentBeforeSend.conversation.conversationId, expectedRevision: currentBeforeSend.conversation.revision, permissionMode, model, effort: reasoningEffort, skill: selectedSkill ? { name: selectedSkill.name, path: selectedSkill.path } : undefined, attachments: requestFiles.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })), messageMetadata, }), }); threadId = accepted.threadId || threadId; if (!threadId) throw new Error(rt("startConversationFailed")); if (selectedSkill) clearSkillSelection(selectedSkillRevision); files.forEach((item) => { URL.revokeObjectURL(item.url); attachmentUrlsRef.current.delete(item.url); }); } catch (error) { const text = error instanceof Error ? error.message : rt("sendFailed"); const response = error instanceof AgentApiError ? error.response as { code?: string; state?: AgentConversationState } : undefined; if (response?.state) applyConversationState(response.state); const stale = response?.code === "CONVERSATION_STALE"; const busy = response?.code === "CONVERSATION_BUSY" || text.includes("Codex 正在运行"); const state = useAgentStore.getState(); const removeFailedPending = (messages: AgentChatItem[]) => messages.filter((item) => item.clientMessageId !== messageId || Boolean(item.turnId)); threadMessagesRef.current.forEach((messages, cachedThreadId) => { const next = removeFailedPending(messages); if (next.length !== messages.length) threadMessagesRef.current.set(cachedThreadId, next); }); const ownsCurrentThread = state.activeThreadId === (threadId || requestThreadId); const restoreDraft = state.prompt || state.attachments.length || state.canvasReferences.length ? {} : { prompt, attachments: files, canvasReferences }; if (ownsCurrentThread) { setAgentState({ activity: rt(stale ? "conversationSynced" : busy ? "codexRunning" : "sendFailed"), sending: false, messages: removeFailedPending(state.messages), ...restoreDraft, }); addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: rt(stale ? "conversationSynced" : busy ? "taskStillRunning" : "sendFailed"), text }); } else { setAgentState({ sending: false, messages: removeFailedPending(state.messages), ...restoreDraft }); } addEventLog(rt("sendFailed"), error); } }; const stopTurn = async () => { if (!connected || (!sending && !waiting)) return; setAgentState({ activity: rt("stopping") }); try { await interruptCodexTurn(endpoint, token, useAgentStore.getState().activeThreadId || undefined); addEventLog(rt("stopTask"), rt("taskStopped")); } catch (error) { setAgentState({ activity: rt("stopFailed") }); addEventLog(rt("stopFailed"), error); } }; const addAttachments = async (files: FileList | File[] | null) => { if (!files) return; const images = Array.from(files).filter((file) => file.type.startsWith("image/")); const prev = useAgentStore.getState().attachments; try { const next = await Promise.all( images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => { const dataUrl = await readDataUrl(file); const meta = await readImageMeta(dataUrl); const url = URL.createObjectURL(file); attachmentUrlsRef.current.add(url); return { id: createId(), name: file.name, type: file.type, size: file.size, width: meta.width, height: meta.height, url, dataUrl }; }), ); const merged = [...prev, ...next]; if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) { next.forEach((item) => { URL.revokeObjectURL(item.url); attachmentUrlsRef.current.delete(item.url); }); addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imageLimit") }); return; } if (next.length) setAgentState({ attachments: merged }); } catch (error) { addMessage({ role: "error", title: rt("imageReadFailed"), text: error instanceof Error ? error.message : rt("imageReadFailed") }); } }; const removeAttachment = (id: string) => { const removed = attachments.find((item) => item.id === id); if (removed) { URL.revokeObjectURL(removed.url); attachmentUrlsRef.current.delete(removed.url); } setAgentState({ attachments: attachments.filter((item) => item.id !== id) }); }; const handleToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { if (confirmToolsRef.current && isCanvasWriteTool(payload.name)) { if (pendingToolRef.current) { await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: rt("pendingCanvasTool") }); return; } pendingToolRef.current = payload; setAgentState({ pendingTool: payload }); addEventLog(rt("awaitingConfirmation"), payload, payload); return; } await runToolCall(endpoint, token, payload); }; const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => { if (isSiteTool(payload.name)) { try { addEventLog(toolName(payload.name), payload, payload); const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null }); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); addEventLog(rt("toolCompleted", { tool: toolName(payload.name) }), result, result); } catch (error) { const message = error instanceof Error ? error.message : rt("toolExecutionFailed"); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); } return; } try { const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {}; addEventLog(toolName(payload.name), payload, payload); let result: unknown; let appliedOps = input.ops || []; if (payload.name === "site_navigate") { const path = input.path || "/"; navigate(path); result = { ok: true, path }; } else if (payload.name === "canvas_apply_ops") { const context = canvasContextRef.current; if (!context) throw new Error(rt("openCanvasFirst")); result = context.applyOps(appliedOps); void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot); } else if (payload.name === "canvas_create_attachment_nodes") { const context = canvasContextRef.current; if (!context) throw new Error(rt("openCanvasFirst")); appliedOps = await attachmentNodeOps(endpoint, token, clientIdRef.current, payload.input?.nodes); result = context.applyOps(appliedOps); await postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot); } else { const snapshot = canvasContextRef.current?.snapshot; if (!snapshot) throw new Error(rt("openCanvasFirst")); result = snapshot; } await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result }); addEventLog(rt("toolCompleted", { tool: toolName(payload.name) }), result, result); } catch (error) { const message = error instanceof Error ? error.message : rt("canvasOperationFailed"); await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message }); } }; const rejectPendingTool = async () => { if (!pendingTool) return; await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: rt("canvasToolCanceled") }); pendingToolRef.current = null; setAgentState({ pendingTool: null }); }; const approvePendingTool = async () => { if (!pendingTool) return; const tool = pendingTool; pendingToolRef.current = null; setAgentState({ pendingTool: null }); await runToolCall(endpoint, token, tool); }; const decideApproval = async (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => { const current = useAgentStore.getState(); const pending = current.pendingApprovals.find((item) => item.requestId === approval.requestId); if (!pending || pending.deciding) return; setAgentState({ pendingApprovals: current.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: decision } : item), activity: rt("submittingApproval") }); try { await postCodexApproval(endpoint, token, approval.requestId, decision); const latest = useAgentStore.getState(); if (latest.pendingApprovals.some((item) => item.requestId === approval.requestId)) setAgentState({ activity: rt("waitingCodexApproval") }); } catch (error) { const latest = useAgentStore.getState(); const expired = error instanceof Error && error.message.includes("审批请求已失效"); const resolved = expired || !latest.pendingApprovals.some((item) => item.requestId === approval.requestId); const pendingApprovals = resolved ? latest.pendingApprovals.filter((item) => item.requestId !== approval.requestId) : latest.pendingApprovals.map((item) => item.requestId === approval.requestId ? { ...item, deciding: undefined } : item); setAgentState({ pendingApprovals, activity: approvalActivity(pendingApprovals, latest.waiting, latest.activity) }); if (resolved) return; addEventLog(rt("approvalFailed"), error); message.error(error instanceof Error ? error.message : rt("approvalFailed")); } }; const changePermissionMode = (nextMode: AgentPermissionMode) => { const apply = () => { localStorage.setItem("canvas-agent-permission-mode", nextMode); setAgentState({ permissionMode: nextMode }); }; if (nextMode !== "full") return apply(); modal.confirm({ title: rt("enableFullAccess"), content: rt("fullAccessDescription"), okText: rt("enableFullAccessAction"), okType: "danger", cancelText: t("common.cancel"), onOk: apply, }); }; const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => { if (enabled) { clearAgentSession({ enabled: false, connected: false, activity: rt("offline"), connectError: "" }); return; } const urlToken = searchParams.get("agentToken") || ""; const urlEndpoint = searchParams.get("agentUrl") || ""; const discovered = urlToken ? null : await discoverAgentConfig(endpoint || DEFAULT_AGENT_URL); const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, ""); const nextToken = (urlToken || token.trim() || discovered?.token || "").trim(); if (!nextEndpoint) { const text = rt("addressRequired"); if (!silent) { setAgentState({ connectError: text }); if (!headless) message.warning(text); } return; } if (!nextToken) { const text = rt("agentNotFound"); if (!silent) { setAgentState({ connectError: text }); if (!headless) message.warning(text); } return; } try { const parsed = new URL(nextEndpoint); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol"); } catch { const text = rt("invalidAddress"); if (!silent) { setAgentState({ connectError: text }); if (!headless) message.warning(text); } return; } errorLoggedRef.current = false; setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: rt("connecting"), connectError: "", activeTab: "setup" }); }; useEffect(() => { if (urlAgentAutoConnect && confirmTools) setAgentState({ confirmTools: false }); }, [confirmTools, setAgentState, urlAgentAutoConnect]); useEffect(() => { if ((!autoConnect && !urlAgentAutoConnect) || autoConnectRef.current || enabled || connected) return; autoConnectRef.current = true; void toggleAgentConnection({ silent: true }); }, [autoConnect, connected, enabled, urlAgentAutoConnect]); function clearAgentSession(patch: Parameters[0] = {}) { loadThreadsSequenceRef.current += 1; threadMessagesRef.current.clear(); authoritativeHistoryTurnsRef.current.clear(); liveTurnKeysRef.current.clear(); threadOperationRef.current = 0; setAgentState({ messages: [], tokenUsage: null, threads: [], activeThreadId: "", activeTurnId: "", workspacePath: "", loadingThreads: false, waiting: false, sending: false, pendingTool: null, pendingApprovals: [], conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} }, bootstrapStatus: null, mcpStartupStatuses: {}, ...patch, }); useAgentSkillStore.getState().reset(); pendingToolRef.current = null; } const beginThreadOperation = () => { const operation = ++threadOperationSequenceRef.current; threadOperationRef.current = operation; setAgentState({ loadingThreads: true }); return operation; }; const finishThreadOperation = (operation: number) => { if (threadOperationRef.current !== operation) return; threadOperationRef.current = 0; setAgentState({ loadingThreads: false }); }; const startNewThread = async () => { const current = useAgentStore.getState(); if (!current.connected || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return; const operation = beginThreadOperation(); clearSkillSelection(); setAgentState({ activeTab: "chat", activity: rt("creatingConversation") }); try { const result = await fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current, permissionMode }) }); if (threadOperationRef.current !== operation) return; if (result.conversation) applyConversationState(result.conversation); setAgentState({ activeTab: "chat", activity: rt("newConversation") }); } catch (error) { const state = agentErrorState(error); if (state) applyConversationState(state); addEventLog(rt("newConversationFailed"), error); message.error(error instanceof Error ? error.message : rt("newConversationFailed")); await loadThreads(); } finally { finishThreadOperation(operation); } }; const resumeThread = async (threadId: string) => { const current = useAgentStore.getState(); if (!current.connected || !threadId || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return; const operation = beginThreadOperation(); try { const result = await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) }); if (result.conversation) applyConversationState(result.conversation); await loadThreads(); if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: rt("conversationResumed") }); } catch (error) { const state = agentErrorState(error); if (state) applyConversationState(state); addEventLog(rt("resumeConversationFailed"), error); message.error(error instanceof Error ? error.message : rt("resumeConversationFailed")); await loadThreads(); } finally { finishThreadOperation(operation); } }; const deleteThreads = async (threadIds: string[]) => { if (!connected || !threadIds.length || sending || waiting || loadingThreads) return; const operation = beginThreadOperation(); let deletedCount = 0; try { for (const threadId of new Set(threadIds)) { await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current }) }); threadMessagesRef.current.delete(threadId); deletedCount += 1; } await loadThreads(); message.success(rt("recordsDeleted", { count: deletedCount })); } catch (error) { await loadThreads(); addEventLog(rt("deleteConversationFailed"), error); message.error(error instanceof Error ? error.message : rt("deleteConversationFailed")); } finally { finishThreadOperation(operation); } }; const confirmDeleteThreads = (threadIds: string[]) => { modal.confirm({ title: rt("deleteConversations", { count: threadIds.length }), content: rt("deleteConversationsDescription"), okText: t("common.delete"), okType: "danger", cancelText: t("common.cancel"), onOk: () => deleteThreads(threadIds), }); }; const addMessage = (item: Omit & { id?: string }) => { const text = normalizeText(item.text); if (!text && !item.attachments?.length) return; const current = useAgentStore.getState(); const itemId = item.itemId || item.id || createId(); const next = scopeChatItem({ ...item, id: item.id || itemId, itemId, text } as AgentChatItem, item.threadId ?? current.activeThreadId, item.turnId ?? current.activeTurnId); setAgentState({ messages: upsertAgentMessage(current.messages, next) }); }; const addEventLog = (title: string, text: unknown, raw?: unknown) => { const value = normalizeText(text) || title; const last = useAgentStore.getState().eventLogs.at(-1); if (last?.title === title && last.text === value) return; pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: dayjs().format("YYYY-MM-DD HH:mm:ss"), title, text: value, raw }); }; const upsertActivityMessage = (item: AgentChatItem) => { setAgentState({ messages: upsertAgentMessage(useAgentStore.getState().messages, item) }); }; const appendActivityDelta = (event: AgentEventPayload) => { const item = event.item; if (!item?.id) return; const text = stringText(item.text) || stringText(item.delta); const isDelta = Boolean(stringText(item.delta)); if (!text) return; if (item.type === "reasoning") { const scoped = scopeEventChatItem(event, activityDeltaFallback(item, text), "synthetic:reasoning"); const current = useAgentStore.getState().messages.find((message) => message.id === scoped.id); const activityItems = { ...(current?.activityItems || {}) }; const previous = activityItems[item.id] || ""; activityItems[item.id] = isDelta ? `${previous === activityPlaceholder("reasoning") ? "" : previous}${text}` : text; upsertActivityMessage({ ...scoped, title: i18n.t("agent.events.reasoning"), text: reasoningActivityText(activityItems), activityItems, detail: activityDetail(current?.detail || scoped.detail, "reasoning", "inProgress") }); return; } const scoped = scopeEventChatItem(event, activityDeltaFallback(item, text), item.id); const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.id === scoped.id); if (index < 0) { if (!text.trim()) return; upsertActivityMessage(scoped); return; } const current = currentMessages[index]; if (item.type === "command_execution") { const detail = activityDetail(current.detail, "command", "inProgress"); detail.output = isDelta ? `${stringText(detail.output)}${text}` : text; setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, detail } : message) }); return; } const placeholder = activityPlaceholder(item.type); if (!text.trim() && current.text === placeholder) return; const nextText = isDelta ? `${current.text === placeholder ? "" : current.text}${text}` : mergeStreamText(current.text, text); setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: nextText, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message) }); }; const upsertEventActivity = (event: AgentEventPayload, item: Omit) => { const itemId = event.item?.id; if (!itemId) return; if (event.item?.type === "reasoning") { const scoped = scopeEventChatItem(event, { ...item, id: "synthetic:reasoning" }, "synthetic:reasoning"); const current = useAgentStore.getState().messages.find((message) => message.id === scoped.id); const activityItems = { ...(current?.activityItems || {}) }; const previous = activityItems[itemId] || ""; const incoming = normalizeText(item.text); activityItems[itemId] = incoming === "已完成分析" && previous && previous !== activityPlaceholder("reasoning") ? previous : incoming; upsertActivityMessage({ ...scoped, title: i18n.t("agent.events.reasoning"), text: reasoningActivityText(activityItems, incoming), activityItems }); return; } upsertActivityMessage(scopeEventChatItem(event, { ...item, id: itemId }, itemId)); }; const finishEmptyReasoningActivity = (event: AgentEventPayload) => { const itemId = event.item?.id; if (!itemId) return; const scopedId = scopeEventChatItem(event, { id: "synthetic:reasoning", role: "tool", text: "" }, "synthetic:reasoning").id; const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.id === scopedId); if (index < 0) return; const current = currentMessages[index]; const activityItems = { ...(current.activityItems || {}) }; delete activityItems[itemId]; if (!Object.values(activityItems).some(isReasoningSummary)) { setAgentState({ messages: currentMessages.filter((_, itemIndex) => itemIndex !== index) }); return; } setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: reasoningActivityText(activityItems), activityItems, detail: activityDetail(message.detail, "reasoning", "completed") } : message) }); }; const finishPlanActivity = (event: AgentEventPayload) => { const id = scopeEventChatItem(event, { id: "synthetic:plan", role: "tool", text: "" }, "synthetic:plan").id; const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.id === id); if (index < 0) return; const current = currentMessages[index]; const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, event.status)); setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, detail } : message) }); }; const showAgentError = (value: unknown, event?: AgentEventPayload, log = true) => { const error = agentErrorView(value); const item = event ? scopeEventChatItem(event, { id: "synthetic:error", role: "error", title: error.title, text: error.text }, "synthetic:error") : scopeChatItem({ id: createId(), role: "error", title: error.title, text: error.text }, useAgentStore.getState().activeThreadId, useAgentStore.getState().activeTurnId); const state = useAgentStore.getState(); const current = state.messages.find((message) => message.id === item.id); if (current && !normalizeText(value)) return; upsertActivityMessage(item); setAgentState({ activity: rt("processingFailed"), pendingApprovals: [] }); if (log) addEventLog(rt("processingFailed"), error.text, value); }; const handleAgentEvent = async (event: AgentEventPayload) => { if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) }); const log = event.replayed ? null : formatAgentEventLog(event); const activity = formatAgentActivity(event); if (log) addEventLog(log.title, log.text); if (event.type === "turn.started" && (event.turnId || event.turn_id)) { const scope = eventScope(event); const current = useAgentStore.getState(); if (!scope.threadId || !scope.turnId) return; liveTurnKeysRef.current.add(`${scope.threadId}\0${scope.turnId}`); setAgentState({ activeTurnId: scope.turnId, bootstrapStatus: null, mcpStartupStatuses: {}, messages: bindPendingTurnMessages(current.messages, scope.threadId, scope.turnId) }); } if (event.type === "item.updated" && event.item?.type === "agent_message" && event.item.id) { const delta = stringText(event.item.delta); appendStreamText(event, delta || stringText(event.item.text), Boolean(delta)); return; } if (event.type === "item.updated" && event.item) { appendActivityDelta(event); return; } if (event.type === "plan.updated" && event.turn_id) { const plan = formatAgentPlan(event); if (plan) upsertActivityMessage(scopeEventChatItem(event, { ...plan, id: "synthetic:plan" }, "synthetic:plan")); return; } if (event.type === "item.completed" && event.item?.type === "error") { showAgentError(event.item.message, event, !event.replayed); return; } if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) { const scoped = scopeEventChatItem(event, { id: event.item.id, role: "assistant", title: "Codex", text: stringText(event.item.text) }, event.item.id); const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.id === scoped.id); if (index >= 0) { const text = stringText(event.item.text); setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: text || message.text, streamId: undefined } : message) }); return; } addMessage(scoped); return; } if (event.type === "item.completed" && event.item?.type === "reasoning" && !activity) { finishEmptyReasoningActivity(event); return; } if (event.type === "item.completed" && !activity && event.item?.id && event.item.type === "plan") { const id = scopeEventChatItem(event, { id: event.item.id, role: "tool", text: "" }, event.item.id).id; setAgentState({ messages: useAgentStore.getState().messages.filter((item) => item.id !== id) }); return; } if (!event.replayed && event.type === "item.completed" && event.item?.type === "image_generation" && event.item.id && event.sourceClientId === clientIdRef.current) { const generated = await importGeneratedImages(endpoint, token, event.item); if (generated.length) { const context = canvasContextRef.current; if (context) { const right = Math.max(0, ...context.snapshot.nodes.map((node) => node.position.x + node.width)) + 80; const ops = generated.map((image, index) => { const size = fitNodeSize(image.upload.width, image.upload.height); return { type: "add_node", id: `image-${createId()}`, nodeType: "image", title: image.name, position: { x: right + index * 40, y: index * 40 }, ...size, metadata: imageMetadata(image.upload), }; }); const result = context.applyOps(ops); void postState(endpoint, token, clientIdRef.current, result); } addEventLog(rt("importGeneratedImages"), rt(context ? "addedToSourceCanvas" : "imageGenerated")); } } if (activity && event.item?.id) { upsertEventActivity(event, activity); return; } if (event.type === "turn.completed") { const scope = eventScope(event); if (scope.turnId) { finishPlanActivity(event); liveTurnKeysRef.current.add(`${scope.threadId}\0${scope.turnId}`); } const current = useAgentStore.getState(); setAgentState({ activeTurnId: current.activeTurnId === scope.turnId ? "" : current.activeTurnId, messages: current.messages.map((message) => message.threadId === scope.threadId && message.turnId === scope.turnId && message.streamId ? { ...message, streamId: undefined } : message), }); if (event.status === "failed") showAgentError(event.error?.message, event, !event.replayed); } const item = formatAgentEvent(event); if (item) addMessage(scopeEventChatItem(event, { ...item, id: event.item?.id || createId() }, event.item?.id || createId())); }; const appendStreamText = (event: AgentEventPayload, text: string, isDelta = false) => { if (!text) return; const itemId = event.item?.id; if (!itemId) return; const scoped = scopeEventChatItem(event, { id: itemId, role: "assistant", title: "Codex", text, streamId: itemId }, itemId); const currentMessages = useAgentStore.getState().messages; const index = currentMessages.findIndex((message) => message.id === scoped.id); if (index < 0) { pushMessage(scoped); return; } setAgentState({ messages: currentMessages.map((message, itemIndex) => itemIndex === index ? { ...message, text: isDelta ? `${message.text}${text}` : mergeStreamText(message.text, text) } : message) }); }; const connectionStatus = t(connectError ? "agent.status.failed" : connected ? "agent.status.connected" : enabled ? "agent.status.connecting" : "agent.status.disconnected"); const connectionStatusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted; const content = ( <>
Agent
} items={[ { value: "chat", label: t("agent.panel.chat"), icon: }, { value: "history", label: t("agent.panel.history"), icon: , count: threads.length }, { value: "skills", label: t("agent.panel.skills"), icon: , count: skillCount }, { value: "log", label: t("agent.panel.logs"), icon: , count: eventLogs.length }, ]} onChange={(activeTab) => { setAgentState({ activeTab }); if (activeTab === "history") void loadThreads(); }} right={ <>