mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 16:14:22 +08:00
feat(agent): enhance Codex initialization with MCP preheating and structured status updates
This commit is contained in:
@@ -85,6 +85,7 @@ export function AgentChatComposer({
|
||||
onKeyDown={(event) => {
|
||||
if (!isPlainEnterKey(event)) return;
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
void onSubmit();
|
||||
}}
|
||||
className="thin-scrollbar max-h-32 min-h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
@@ -100,7 +101,7 @@ export function AgentChatComposer({
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Tooltip title="上传图片">
|
||||
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||
<Button type="text" shape="circle" className="!h-9 !w-9 !min-w-9" disabled={disabled || sending} style={{ color: theme.node.muted }} icon={<ImagePlus className="size-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -349,7 +349,7 @@ function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkingMessage({ text, activityKey, theme }: { text: string; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
export function AgentWorkingMessage({ text, detail, status = "running", mcpStatuses = [], activityKey, theme }: { text: string; detail?: string; status?: "running" | "ready" | "error"; mcpStatuses?: Array<{ name: string; status: "running" | "ready" | "error"; detail: string }>; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
@@ -360,11 +360,25 @@ export function AgentWorkingMessage({ text, activityKey, theme }: { text: string
|
||||
return (
|
||||
<div className="min-w-0 py-1" aria-live="polite">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm" style={{ color: theme.node.muted }}>
|
||||
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
|
||||
{status === "running" ? <LoaderCircle className="size-3.5 shrink-0 animate-spin" /> : status === "ready" ? <CheckCircle2 className="size-3.5 shrink-0 text-emerald-600" /> : <XCircle className="size-3.5 shrink-0 text-red-600" />}
|
||||
<span className="min-w-0">{text}</span>
|
||||
{elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
|
||||
{status === "running" && elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
|
||||
</div>
|
||||
{elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}>响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。</div> : null}
|
||||
{detail ? <div className="ml-5.5 mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}>{detail}</div> : null}
|
||||
{mcpStatuses.length ? (
|
||||
<div className="ml-5.5 mt-3 space-y-2">
|
||||
{mcpStatuses.map((item) => (
|
||||
<div key={item.name} className="flex min-w-0 items-start gap-2 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{item.status === "running" ? <LoaderCircle className="mt-0.5 size-3.5 shrink-0 animate-spin" /> : item.status === "ready" ? <CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-emerald-600" /> : <XCircle className="mt-0.5 size-3.5 shrink-0 text-red-600" />}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium" style={{ color: theme.node.text }}>{item.name}</div>
|
||||
<div className="opacity-65">{item.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{status === "running" && elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}>响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,13 +31,15 @@ export function AgentChatTimeline({
|
||||
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
|
||||
}) {
|
||||
const messages = useAgentStore((state) => state.messages);
|
||||
const bootstrapStatus = useAgentStore((state) => state.bootstrapStatus);
|
||||
const mcpStartupStatuses = useAgentStore((state) => state.mcpStartupStatuses);
|
||||
const timeline = useMemo(() => groupTimelineMessages(messages), [messages]);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
const working = workingActivity(messages.at(-1));
|
||||
const working = bootstrapStatus || workingActivity(messages.at(-1));
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
@@ -87,7 +89,7 @@ export function AgentChatTimeline({
|
||||
/>
|
||||
) : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
{(sending || waiting || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
|
||||
@@ -290,13 +290,19 @@ function expandLog(item: AgentEventLog): DisplayLog[] {
|
||||
displayText,
|
||||
level,
|
||||
signature: `${level}\n${title}\n${logSignature(displayText)}`,
|
||||
success: level === "info" && /完成|成功|已连接|收到回复/.test(`${title}\n${displayText}`),
|
||||
success: level === "info" && /完成|成功|已连接|已就绪|收到回复/.test(`${title}\n${displayText}`),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function logLevel(item: AgentEventLog, entry?: unknown): DisplayLog["level"] {
|
||||
const entries = entry === undefined ? parseJsonEntries(item.raw ?? item.text) : [entry];
|
||||
const structured = entries.find((value) => value && typeof value === "object" && !Array.isArray(value)) as Record<string, unknown> | undefined;
|
||||
if (structured?.type === "mcp.startup") {
|
||||
if (structured.status === "failed") return "error";
|
||||
if (structured.status === "cancelled") return "warning";
|
||||
return "info";
|
||||
}
|
||||
const declared = entries.map(declaredLogLevel).filter(Boolean);
|
||||
if (declared.includes("error")) return "error";
|
||||
if (declared.includes("warning")) return "warning";
|
||||
|
||||
@@ -79,6 +79,7 @@ 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 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 AgentClientGlobal = typeof globalThis & { __infiniteCanvasAgentClientIdPromise?: Promise<string> };
|
||||
|
||||
function authoritativeHistoryTurnKeys(threadId: string, settledTurnIds: string[]) {
|
||||
@@ -124,6 +125,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
})),
|
||||
);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const agentInitializing = useAgentStore((state) => state.bootstrapStatus?.status === "running");
|
||||
const closePanel = useAgentStore((state) => state.closePanel);
|
||||
const pushMessage = useAgentStore((state) => state.addMessage);
|
||||
const pushEventLog = useAgentStore((state) => state.addEventLog);
|
||||
@@ -337,6 +339,13 @@ 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
source.addEventListener("codex_state", (event) => {
|
||||
const data = parseEventData<AgentCodexState>(event);
|
||||
@@ -392,6 +401,42 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
return handleAgentEvent(data);
|
||||
});
|
||||
});
|
||||
source.addEventListener("agent_bootstrap", (event) => {
|
||||
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.name || !data.status) return;
|
||||
const label = data.name;
|
||||
const status = data.status === "starting"
|
||||
? { text: `正在启动 MCP:${label}`, detail: "正在建立工具连接并读取可用工具列表", status: "running" as const }
|
||||
: data.status === "ready"
|
||||
? { text: `MCP 已就绪:${label}`, detail: "工具列表加载完成,可以开始对话", status: "ready" as const }
|
||||
: 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("workspace_changed", (event) => {
|
||||
const data = parseEventData<AgentWorkspaceEvent>(event);
|
||||
if (!data) return;
|
||||
@@ -837,6 +882,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
sending: false,
|
||||
pendingTool: null,
|
||||
pendingApprovals: [],
|
||||
bootstrapStatus: null,
|
||||
mcpStartupStatuses: {},
|
||||
...patch,
|
||||
});
|
||||
pendingToolRef.current = null;
|
||||
@@ -859,14 +906,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const current = useAgentStore.getState();
|
||||
if (!current.connected || current.sending || current.waiting || current.loadingThreads) return;
|
||||
const operation = beginThreadOperation();
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话" });
|
||||
applyWorkspaceChange({ activeThreadId: "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current });
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话", bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
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 }) });
|
||||
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 });
|
||||
setAgentState({ activeTab: "chat", activity: "新对话" });
|
||||
} catch (error) {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: error instanceof Error ? error.message : "无法创建 Codex 会话", status: "error" } });
|
||||
addEventLog("新建对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "新建对话失败");
|
||||
await loadThreads();
|
||||
@@ -1050,7 +1099,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const current = useAgentStore.getState();
|
||||
if (!scope.threadId || !scope.turnId) return;
|
||||
liveTurnKeysRef.current.add(`${scope.threadId}\0${scope.turnId}`);
|
||||
setAgentState({ activeTurnId: scope.turnId, messages: bindPendingTurnMessages(current.messages, scope.threadId, 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);
|
||||
@@ -1229,9 +1278,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
attachments={attachments.map(agentAttachmentToChatAttachment)}
|
||||
disabled={!connected}
|
||||
disabled={!connected || agentInitializing}
|
||||
sending={sending || waiting}
|
||||
placeholder="询问 Codex,或让它操作网站/画布"
|
||||
placeholder={agentInitializing ? "MCP 初始化中,完成后即可发送" : "询问 Codex,或让它操作网站/画布"}
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type AgentPendingApproval = { requestId: string; method: string; threadId
|
||||
export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean };
|
||||
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 AgentPanelTab = "chat" | "setup" | "history" | "log";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 6000;
|
||||
@@ -58,6 +59,8 @@ type AgentStore = {
|
||||
model: string;
|
||||
reasoningEffort: AgentReasoningEffort | "";
|
||||
activity: string;
|
||||
bootstrapStatus: AgentBootstrapStatus | null;
|
||||
mcpStartupStatuses: Record<string, AgentBootstrapStatus>;
|
||||
connectError: string;
|
||||
pendingTool: AgentPendingToolCall | null;
|
||||
pendingApprovals: AgentPendingApproval[];
|
||||
@@ -105,6 +108,8 @@ 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: "就绪",
|
||||
bootstrapStatus: null,
|
||||
mcpStartupStatuses: {},
|
||||
connectError: "",
|
||||
pendingTool: null,
|
||||
pendingApprovals: [],
|
||||
@@ -140,7 +145,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
agentSource = null;
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", ...patch });
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", 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