mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
fix(agent): synchronize conversation preparation state
This commit is contained in:
@@ -39,7 +39,8 @@ export function AgentChatTimeline({
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
const working = bootstrapStatus || workingActivity(messages.at(-1));
|
||||
const showBootstrap = Boolean(bootstrapStatus && !messages.some((message) => message.role === "user" || message.role === "assistant"));
|
||||
const working = showBootstrap ? bootstrapStatus! : workingActivity(messages.at(-1));
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
@@ -89,7 +90,7 @@ export function AgentChatTimeline({
|
||||
/>
|
||||
) : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working && typeof working.detail === "string" ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
|
||||
{(sending || waiting || showBootstrap) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working && typeof working.detail === "string" ? working.detail : undefined} status={showBootstrap ? bootstrapStatus?.status : undefined} mcpStatuses={showBootstrap ? Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item })) : []} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
|
||||
@@ -14,10 +14,10 @@ import { bindPendingAgentUserMessage, deleteAgentThreadMessages, deletePendingAg
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { useAgentStore, type AgentBootstrapStatus, type AgentCanvasContext, 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, discoverAgentConfig, fetchAgentJson, interruptCodexTurn, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
|
||||
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";
|
||||
@@ -72,22 +72,51 @@ const AGENT_REASONING_EFFORTS = new Set<AgentReasoningEffort>(["minimal", "low",
|
||||
const AGENT_REASONING_LABELS: Record<AgentReasoningEffort, string> = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" };
|
||||
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
|
||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[]; settledTurnIds?: string[]; historyReady?: boolean };
|
||||
type AgentWorkspaceResponse = { ok?: boolean; workspace?: AgentWorkspace };
|
||||
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 }; codex?: AgentCodexState; pendingApprovals?: AgentPendingApproval[] };
|
||||
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; sourceClientId?: string; emptyThread?: boolean; draftThread?: boolean };
|
||||
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"; threadId?: string; name?: string; status?: "starting" | "ready" | "failed" | "cancelled"; error?: string | null; failureReason?: string | null };
|
||||
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<string> };
|
||||
|
||||
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<string, AgentBootstrapStatus> = Object.fromEntries(Object.entries(conversation.mcpStatuses).map(([name, item]) => {
|
||||
const view: AgentBootstrapStatus = item.status === "starting"
|
||||
? { key: `mcp:${name}:starting`, text: `正在启动 MCP:${name}`, detail: "正在建立工具连接并读取可用工具列表", status: "running" }
|
||||
: item.status === "ready"
|
||||
? { key: `mcp:${name}:ready`, text: `MCP 已就绪:${name}`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: { key: `mcp:${name}:${item.status}`, text: item.status === "failed" ? `MCP 启动失败:${name}` : `MCP 启动已取消:${name}`, detail: item.error || "工具服务未能完成初始化", 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: "正在启动 MCP 服务", detail: pending ? `还有 ${pending} 个工具服务正在初始化` : "正在确认工具服务状态", status: "running" }
|
||||
: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }
|
||||
: conversation.status === "warning"
|
||||
? { key: "mcp:warning", text: "部分 MCP 服务初始化失败", detail: "其余工具已就绪,可以开始对话", status: "error" }
|
||||
: conversation.status === "failed"
|
||||
? { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: conversation.error || "无法创建 Codex 会话", status: "error" }
|
||||
: conversation.status === "ready"
|
||||
? { key: "mcp:ready", text: `${services.length} 个 MCP 服务已完成初始化`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: null;
|
||||
return { bootstrapStatus, mcpStartupStatuses };
|
||||
}
|
||||
|
||||
export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message, modal } = App.useApp();
|
||||
@@ -97,7 +126,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
|
||||
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,
|
||||
@@ -121,13 +150,15 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
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 agentInitializing = useAgentStore((state) => state.bootstrapStatus?.status === "running");
|
||||
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);
|
||||
@@ -238,6 +269,21 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
});
|
||||
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;
|
||||
@@ -245,9 +291,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(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 = data.workspace?.activeThreadId ?? current.activeThreadId;
|
||||
if (currentThreadId !== current.activeThreadId) sequence = applyWorkspaceChange({ activeThreadId: currentThreadId });
|
||||
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) {
|
||||
@@ -261,7 +311,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
} finally {
|
||||
if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false });
|
||||
}
|
||||
}, [applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]);
|
||||
}, [applyConversationState, applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]);
|
||||
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -318,8 +368,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
}
|
||||
const codex = hello?.codex;
|
||||
const busy = Boolean(codex?.busy);
|
||||
const nextThreadId = hello?.workspace?.activeThreadId ?? useAgentStore.getState().activeThreadId;
|
||||
applyWorkspaceChange({ activeThreadId: nextThreadId });
|
||||
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}`);
|
||||
@@ -345,12 +396,14 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (!headless) message.success("本地 Agent 已连接");
|
||||
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
|
||||
if (!busy && !nextThreadId) {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
void fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId, permissionMode }) }).catch((error) => {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: error instanceof Error ? error.message : "无法创建 Codex 会话", status: "error" } });
|
||||
addEventLog("Codex 对话初始化失败", error);
|
||||
});
|
||||
if (!busy && !nextThreadId && (!hello?.conversation || hello.conversation.status === "idle")) {
|
||||
void fetchAgentJson<AgentWorkspaceResponse>(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("Codex 对话初始化失败", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
source.addEventListener("codex_state", (event) => {
|
||||
@@ -411,15 +464,17 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const data = parseEventData<AgentBootstrapEvent>(event);
|
||||
if (!data?.type) return;
|
||||
if (data.type === "codex.preparing") {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
addEventLog("正在初始化 Codex 对话", "正在创建会话并启动画布工具服务", data);
|
||||
return;
|
||||
}
|
||||
if (data.type === "codex.prepare_failed") {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: data.error || "无法创建 Codex 会话", status: "error" } });
|
||||
addEventLog("Codex 对话初始化失败", data.error, data);
|
||||
return;
|
||||
}
|
||||
if (data.type === "mcp.complete") {
|
||||
addEventLog("MCP 状态确认完成", "已读取 Codex 返回的完整工具服务清单", data);
|
||||
return;
|
||||
}
|
||||
if (!data.name || !data.status) return;
|
||||
const label = data.name;
|
||||
const status = data.status === "starting"
|
||||
@@ -429,20 +484,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
: data.status === "failed"
|
||||
? { text: `MCP 启动失败:${label}`, detail: data.error || "工具服务未能完成初始化", status: "error" as const }
|
||||
: { text: `MCP 启动已取消:${label}`, detail: "工具服务初始化已取消", status: "error" as const };
|
||||
const mcpStartupStatuses = { ...useAgentStore.getState().mcpStartupStatuses, [label]: { key: `mcp:${label}:${data.status}`, ...status } };
|
||||
const services = Object.values(mcpStartupStatuses);
|
||||
const failed = services.some((item) => item.status === "error");
|
||||
const ready = services.length > 0 && services.every((item) => item.status === "ready");
|
||||
setAgentState({
|
||||
mcpStartupStatuses,
|
||||
bootstrapStatus: failed
|
||||
? { key: "mcp:failed", text: "部分 MCP 服务初始化失败", detail: "可以查看下方服务状态和诊断日志", status: "error" }
|
||||
: ready
|
||||
? { key: "mcp:ready", text: `${services.length} 个 MCP 服务已就绪`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: { key: "mcp:starting", text: "正在启动 MCP 服务", detail: `正在初始化 ${services.length} 个工具服务`, status: "running" },
|
||||
});
|
||||
addEventLog(status.text, status.detail, data);
|
||||
});
|
||||
source.addEventListener("conversation_changed", (event) => {
|
||||
const data = parseEventData<AgentConversationState>(event);
|
||||
if (data) enqueueEvent(() => { applyConversationState(data); });
|
||||
});
|
||||
source.addEventListener("workspace_changed", (event) => {
|
||||
const data = parseEventData<AgentWorkspaceEvent>(event);
|
||||
if (!data) return;
|
||||
@@ -455,7 +502,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (keepPendingMessage && nextThreadId) {
|
||||
await moveAgentUserMessage(pendingThreadId, nextThreadId, pendingMessage!.clientMessageId || pendingMessage!.itemId || pendingMessage!.id).catch(() => undefined);
|
||||
}
|
||||
applyWorkspaceChange(data);
|
||||
if (data.conversation) applyConversationState(data.conversation);
|
||||
else applyWorkspaceChange(data);
|
||||
if (!data.draftThread) void loadThreads(Boolean(data.emptyThread));
|
||||
});
|
||||
});
|
||||
@@ -545,7 +593,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
useAgentSkillStore.getState().reset();
|
||||
};
|
||||
}, [applyWorkspaceChange, clientReady, enabled, endpoint, loadSkills, loadThreads, message, setAgentState, token]);
|
||||
}, [applyConversationState, applyWorkspaceChange, clientReady, enabled, endpoint, loadSkills, loadThreads, message, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) void loadThreads();
|
||||
@@ -600,7 +648,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const selectedSkillRevision = skillState.selectionRevision;
|
||||
const requestPrompt = promptWithAttachments(text, files);
|
||||
const currentState = useAgentStore.getState();
|
||||
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads) return;
|
||||
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return;
|
||||
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
|
||||
return;
|
||||
@@ -627,6 +675,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
messageId,
|
||||
clientId: clientIdRef.current,
|
||||
threadId,
|
||||
conversationId: currentBeforeSend.conversation.conversationId,
|
||||
expectedRevision: currentBeforeSend.conversation.revision,
|
||||
permissionMode,
|
||||
model,
|
||||
effort: reasoningEffort,
|
||||
@@ -649,7 +699,10 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
} catch (error) {
|
||||
if (files.length) await deletePendingAgentUserMessage(messageId).catch(() => undefined);
|
||||
const text = error instanceof Error ? error.message : "发送失败";
|
||||
const busy = text.includes("Codex 正在运行");
|
||||
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) => {
|
||||
@@ -657,16 +710,17 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (next.length !== messages.length) threadMessagesRef.current.set(cachedThreadId, next);
|
||||
});
|
||||
const ownsCurrentThread = state.activeThreadId === (threadId || requestThreadId);
|
||||
const restoreDraft = state.prompt || state.attachments.length ? {} : { prompt, attachments: files };
|
||||
if (ownsCurrentThread) {
|
||||
setAgentState({
|
||||
activity: busy ? "Codex 正在运行" : "发送失败",
|
||||
activity: stale ? "会话已同步" : busy ? "Codex 正在运行" : "发送失败",
|
||||
sending: false,
|
||||
messages: removeFailedPending(state.messages),
|
||||
...(state.prompt || state.attachments.length ? {} : { prompt, attachments: files }),
|
||||
...restoreDraft,
|
||||
});
|
||||
addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: busy ? "任务仍在运行" : "发送失败", text });
|
||||
addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: stale ? "会话已同步" : busy ? "任务仍在运行" : "发送失败", text });
|
||||
} else {
|
||||
setAgentState({ sending: false, messages: removeFailedPending(state.messages) });
|
||||
setAgentState({ sending: false, messages: removeFailedPending(state.messages), ...restoreDraft });
|
||||
}
|
||||
addEventLog("发送失败", error);
|
||||
}
|
||||
@@ -905,6 +959,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
sending: false,
|
||||
pendingTool: null,
|
||||
pendingApprovals: [],
|
||||
conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} },
|
||||
bootstrapStatus: null,
|
||||
mcpStartupStatuses: {},
|
||||
...patch,
|
||||
@@ -928,19 +983,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
|
||||
const startNewThread = async () => {
|
||||
const current = useAgentStore.getState();
|
||||
if (!current.connected || current.sending || current.waiting || current.loadingThreads) return;
|
||||
if (!current.connected || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return;
|
||||
const operation = beginThreadOperation();
|
||||
applyWorkspaceChange({ activeThreadId: "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current });
|
||||
clearSkillSelection();
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话", bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话" });
|
||||
try {
|
||||
const result = await fetchAgentJson<AgentWorkspaceResponse>(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;
|
||||
const latest = useAgentStore.getState();
|
||||
if (latest.activeThreadId || latest.messages.length) applyWorkspaceChange({ activeThreadId: result.workspace?.activeThreadId || "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current });
|
||||
if (result.conversation) applyConversationState(result.conversation);
|
||||
setAgentState({ activeTab: "chat", activity: "新对话" });
|
||||
} catch (error) {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: error instanceof Error ? error.message : "无法创建 Codex 会话", status: "error" } });
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("新建对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "新建对话失败");
|
||||
await loadThreads();
|
||||
@@ -951,13 +1005,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
|
||||
const resumeThread = async (threadId: string) => {
|
||||
const current = useAgentStore.getState();
|
||||
if (!current.connected || !threadId || current.sending || current.waiting || current.loadingThreads) return;
|
||||
if (!current.connected || !threadId || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return;
|
||||
const operation = beginThreadOperation();
|
||||
try {
|
||||
await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) });
|
||||
const result = await fetchAgentJson<AgentThreadResponse>(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: "已恢复会话" });
|
||||
} catch (error) {
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("恢复对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "恢复对话失败");
|
||||
await loadThreads();
|
||||
@@ -1257,7 +1314,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
right={
|
||||
<>
|
||||
<Tooltip title="新对话" placement="bottom">
|
||||
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label="新对话" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label="新对话" disabled={!connected || loadingThreads || sending || waiting || conversationBusy} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
<span className="hidden @min-[560px]:inline">新对话</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -1290,7 +1347,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
activeThreadId={activeThreadId}
|
||||
workspacePath={workspacePath}
|
||||
loading={loadingThreads}
|
||||
busy={sending || waiting}
|
||||
busy={sending || waiting || conversationBusy}
|
||||
connected={connected}
|
||||
onRefresh={() => void loadThreads()}
|
||||
onNewThread={() => void startNewThread()}
|
||||
@@ -1314,9 +1371,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
attachments={attachments.map(agentAttachmentToChatAttachment)}
|
||||
disabled={!connected || agentInitializing}
|
||||
disabled={!connected || !conversationReady || loadingThreads}
|
||||
sending={sending || waiting}
|
||||
placeholder={agentInitializing ? "MCP 初始化中,完成后即可发送" : "询问 Codex,或让它操作网站/画布"}
|
||||
placeholder={conversation.status === "idle" || conversation.status === "preparing"
|
||||
? "MCP 初始化中,完成后即可发送"
|
||||
: conversation.status === "failed"
|
||||
? "Codex 对话初始化失败,请新建或恢复对话"
|
||||
: "询问 Codex,或让它操作网站/画布"}
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { AgentReasoningEffort } from "@/stores/use-agent-store";
|
||||
|
||||
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
|
||||
|
||||
export class AgentApiError<T = unknown> extends Error {
|
||||
constructor(readonly status: number, readonly response: T & { code?: string; error?: string; msg?: string }) {
|
||||
super(response.error || response.msg || "本地 Agent 请求失败");
|
||||
this.name = "AgentApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentSkillScope = "user" | "repo" | "system" | "admin";
|
||||
export type AgentSkillInterface = { displayName?: string | null; shortDescription?: string | null; defaultPrompt?: string | null };
|
||||
export type AgentSkillSummary = {
|
||||
@@ -103,7 +110,7 @@ export async function fetchAgentJson<T>(endpoint: string, token: string, path: s
|
||||
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string; msg?: string };
|
||||
if (!res.ok) throw new Error(data.error || data.msg || "本地 Agent 请求失败");
|
||||
if (!res.ok) throw new AgentApiError(res.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops
|
||||
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
|
||||
export type AgentTokenUsage = { input: number; cached: number; output: number };
|
||||
export type AgentBootstrapStatus = { key: string; text: string; detail: string; status: "running" | "ready" | "error" };
|
||||
export type AgentConversationState = {
|
||||
revision: number;
|
||||
conversationId: string;
|
||||
threadId: string;
|
||||
status: "idle" | "preparing" | "ready" | "warning" | "running" | "failed";
|
||||
mcpStatuses: Record<string, { status: "starting" | "ready" | "failed" | "cancelled"; error?: string | null; failureReason?: string | null }>;
|
||||
sourceClientId?: string;
|
||||
error?: string;
|
||||
};
|
||||
export type AgentPanelTab = "chat" | "setup" | "history" | "skills" | "log";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 6000;
|
||||
@@ -59,6 +68,7 @@ type AgentStore = {
|
||||
model: string;
|
||||
reasoningEffort: AgentReasoningEffort | "";
|
||||
activity: string;
|
||||
conversation: AgentConversationState;
|
||||
bootstrapStatus: AgentBootstrapStatus | null;
|
||||
mcpStartupStatuses: Record<string, AgentBootstrapStatus>;
|
||||
connectError: string;
|
||||
@@ -108,6 +118,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
model: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-model") || "",
|
||||
reasoningEffort: typeof window === "undefined" ? "" : (localStorage.getItem("canvas-agent-reasoning-effort") as AgentReasoningEffort) || "",
|
||||
activity: "就绪",
|
||||
conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} },
|
||||
bootstrapStatus: null,
|
||||
mcpStartupStatuses: {},
|
||||
connectError: "",
|
||||
@@ -145,7 +156,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
agentSource = null;
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", bootstrapStatus: null, mcpStartupStatuses: {}, ...patch });
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} }, bootstrapStatus: null, mcpStartupStatuses: {}, ...patch });
|
||||
},
|
||||
addMessage: (item) => set((state) => ({ messages: [...state.messages, item] })),
|
||||
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
|
||||
|
||||
Reference in New Issue
Block a user