mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-03 15:41:16 +08:00
feat(agent): enhance Codex initialization with MCP preheating and structured status updates
This commit is contained in:
+5
-3
@@ -2,11 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [优化] Agent 排查日志改为筛选栏固定的结构化滚动列表,支持筛选、展开详情并折叠连续重复事件。
|
||||
+ [优化] Agent 对话与排查日志统一使用居中的回到底部入口,向上浏览时暂停跟随并可快速返回最新内容。
|
||||
+ [优化] Agent 进入空白对话或点击新对话后后台预热 Codex 与 MCP。
|
||||
+ [优化] Agent 排查日志改为筛选栏结构化滚动列表,支持筛选、展开详情并折叠连续重复事件。
|
||||
+ [优化] Agent 日志统一使用居中的回到底部入口,向上浏览时暂停跟随并可快速返回最新内容。
|
||||
+ [优化] Agent 对话将同一轮连续命令合并为按数量折叠的命令组,默认隐藏冗长命令预览。
|
||||
+ [修复] MCP 初始化期间禁用 Agent 按钮与回车发送,避免首条消息提前清除服务加载状态。
|
||||
+ [修复] Agent 对话代码块关闭行号后多行内容被拼接为一行的问题。
|
||||
+ [修复] 统一 Agent 实时事件与线程历史的消息归属,避免多窗口或刷新后出现过程重复、命令记录丢失和画布操作串页。
|
||||
+ [修复] 统一 Agent 实时事件与线程历史的消息归属,避免多窗口或刷新后出现过程重复等问题。
|
||||
|
||||
## v0.12.1 - 2026-07-31
|
||||
|
||||
|
||||
@@ -258,6 +258,18 @@ export class CodexAppClient {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (method === "mcpServer/startupStatus/updated") {
|
||||
const value = params as unknown as CodexNotificationParams<"mcpServer/startupStatus/updated">;
|
||||
this.emit("agent_bootstrap", {
|
||||
type: "mcp.startup",
|
||||
threadId: value.threadId || this.currentThreadId,
|
||||
name: value.name,
|
||||
status: value.status,
|
||||
error: value.error,
|
||||
failureReason: value.failureReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const turnEvent = method.startsWith("turn/") || method.startsWith("item/") || method === "thread/tokenUsage/updated" || method === "error";
|
||||
if (turnEvent) {
|
||||
const threadId = String(field(params, "threadId") || this.currentThreadId);
|
||||
|
||||
@@ -6,6 +6,7 @@ export type CodexTurnError = JsonRecord & { message: string };
|
||||
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
|
||||
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
|
||||
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
|
||||
export type CodexMcpStartupStatus = { threadId: string | null; name: string; status: "starting" | "ready" | "failed" | "cancelled"; error: string | null; failureReason: "reauthenticationRequired" | null };
|
||||
export type CodexReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
||||
export type CodexModel = JsonRecord & {
|
||||
id: string;
|
||||
@@ -99,6 +100,7 @@ type CodexNotificationSpec = {
|
||||
"item/reasoning/summaryTextDelta": { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex: number };
|
||||
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
|
||||
"mcpServer/startupStatus/updated": CodexMcpStartupStatus;
|
||||
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,24 @@ export function startHttpServer() {
|
||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||
return workspace;
|
||||
};
|
||||
let draftThreadStart: ReturnType<typeof startCodexThread> | null = null;
|
||||
const prepareDraftThread = (clientId: string, permission: AgentPermissionMode) => {
|
||||
if (draftThreadStart) return draftThreadStart;
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
|
||||
const start = startCodexThread(emit, workspace.workspacePath, permission);
|
||||
draftThreadStart = start;
|
||||
void start.then((thread) => {
|
||||
if (draftThreadStart !== start) return;
|
||||
draftThreadStart = null;
|
||||
const threadId = String((thread as Record<string, unknown>).id || "");
|
||||
if (threadId && !ensureSiteWorkspace(config).activeThreadId) setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId });
|
||||
}).catch((error) => {
|
||||
if (draftThreadStart === start) draftThreadStart = null;
|
||||
emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
return start;
|
||||
};
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "30mb" }));
|
||||
@@ -124,7 +142,10 @@ export function startHttpServer() {
|
||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.post("/agent/codex/threads/reset", codexMutation((req, res) => {
|
||||
res.json({ ok: true, workspace: setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: String(req.body?.clientId || "") }) });
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
const workspace = setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId });
|
||||
void prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
|
||||
res.json({ ok: true, workspace });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
@@ -172,7 +193,7 @@ export function startHttpServer() {
|
||||
try {
|
||||
let turnId = "";
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||
const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- Agent MCP 初始化状态:首次进入空白对话或点击「新对话」后,无需先发送消息,对话区应立即显示 Codex 会话准备,并同时列出 `codex_apps`、`infinite-canvas`、`node_repl` 等全部 MCP 服务各自的启动、就绪或失败状态;初始化期间可输入并保留草稿,但发送按钮、上传和回车提交应禁用,全部完成后显示服务数量、恢复发送并复用同一个预热线程;右侧日志应记录同样的真实状态,`starting` 与 `ready` 归入信息且就绪显示绿色成功图标,只有 `failed` 归入错误、`cancelled` 归入警告,模型开始处理后不应残留初始化文案。
|
||||
- Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度。
|
||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;此时不应生成空历史记录,第一次发送消息时才创建线程;多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话。
|
||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;后台应立即创建线程并预热 MCP,多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话或重复创建线程。
|
||||
- Agent 读取画布卡片:读取当前画布完成后,卡片应按非零类型显示文本、图片、配置、视频、音频、分组、其他节点及连线数量,例如「3 个文本、5 张图片、2 个配置、4 条连线」;空画布应显示「当前画布为空」,执行失败时仍应显示错误信息,刷新恢复历史后统计保持一致。
|
||||
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
|
||||
- Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。
|
||||
@@ -27,7 +28,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,多行代码块和文本流程图应保留原始换行并允许横向滚动,完成事件不应再用「已完成分析」覆盖已经收到的摘要;同一轮连续命令应合并为显示数量的无边框折叠行,折叠时不显示命令预览,单条展开后直接显示详情,多条展开后可逐条查看工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。
|
||||
- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;提交决定后卡片应保持禁用等待,只有 Codex 确认处理后才移除;等待审批时刷新页面,未处理的审批卡应自动恢复且不能让任务永久卡住;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。
|
||||
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话,并在后台创建新线程、预热 MCP;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
|
||||
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧显示为约 40px 的紧凑缩略图,不再撑大消息区域,单击缩略图应打开大图预览,打开或关闭预览不应改变消息间距或产生额外空行;任务运行中、完成同步历史、切换页面及重新进入历史对话后都应显示浏览器本地保存的图片缩略图,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中;删除历史会话后应同步清理对应缩略图。
|
||||
- 画布文本复制:在 Agent 对话或节点信息详情中用鼠标选中文字后,按 `Ctrl/Cmd + C` 应复制所选文本,不应触发画布节点复制;未选中文字且焦点位于画布时,原有节点复制快捷键应保持可用。
|
||||
|
||||
@@ -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