mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-30 13:04:38 +08:00
feat(agent): enhance conversation flow with incremental updates and user-friendly summaries for reasoning, plans, commands, searches, and file changes
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
+ [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。
|
||||
+ [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。
|
||||
+ [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。
|
||||
+ [优化] Agent 对话新增思考摘要、计划、命令、搜索和文件变更时间线,工具卡片改为中文摘要与用户可读详情,不再展示原始 JSON。
|
||||
+ [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。
|
||||
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
|
||||
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
|
||||
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
|
||||
|
||||
@@ -134,7 +134,7 @@ default_tools_approval_mode = "approve"
|
||||
|
||||
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
|
||||
|
||||
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会合并短时间内的文本增量并转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
|
||||
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把计划、搜索、文件修改与工具操作整理为中文过程时间线。
|
||||
|
||||
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import type { AgentEmit } from "./types.js";
|
||||
|
||||
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type PendingDelta = { delta: string; params: CodexNotificationParams<"item/agentMessage/delta">; timer: ReturnType<typeof setTimeout> };
|
||||
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
|
||||
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -162,7 +163,8 @@ export class CodexAppClient {
|
||||
const id = Number(message.id);
|
||||
if (message.error && this.pending.has(id)) {
|
||||
const error = String(field(message.error, "message") || "Codex request failed");
|
||||
logger.warn("Codex request failed", { id, error });
|
||||
if (/not materialized yet.*includeTurns/i.test(error)) logger.debug("Codex thread has no messages yet", { id });
|
||||
else logger.warn("Codex request failed", { id, error });
|
||||
return this.reject(id, error);
|
||||
}
|
||||
if (this.pending.has(id)) return this.resolve(id, message.result);
|
||||
@@ -172,7 +174,14 @@ export class CodexAppClient {
|
||||
|
||||
/** 转换并广播 app-server 通知。 */
|
||||
private handleNotification(method: string, params: JsonRecord) {
|
||||
if (method === "item/agentMessage/delta") return this.emitDelta(params as unknown as CodexNotificationParams<"item/agentMessage/delta">);
|
||||
if (method === "item/agentMessage/delta") {
|
||||
const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">;
|
||||
this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
|
||||
return this.emitDelta("agent_message", value);
|
||||
}
|
||||
if (method === "item/plan/delta") return this.emitDelta("plan", params as unknown as CodexNotificationParams<"item/plan/delta">);
|
||||
if (method === "item/reasoning/summaryTextDelta") return this.emitDelta("reasoning", params as unknown as CodexNotificationParams<"item/reasoning/summaryTextDelta">);
|
||||
if (method === "item/commandExecution/outputDelta") return this.emitDelta("command_execution", params as unknown as CodexNotificationParams<"item/commandExecution/outputDelta">);
|
||||
if (method === "thread/tokenUsage/updated") {
|
||||
this.lastUsage = normalizeUsage(params as unknown as CodexNotificationParams<"thread/tokenUsage/updated">);
|
||||
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
|
||||
@@ -209,18 +218,19 @@ export class CodexAppClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** 合并并广播 Agent 文本增量。 */
|
||||
private emitDelta(params: CodexNotificationParams<"item/agentMessage/delta">) {
|
||||
/** 合并并广播 Agent 文本或执行输出增量。 */
|
||||
private emitDelta(itemType: string, params: ItemDeltaParams) {
|
||||
const id = params.itemId;
|
||||
this.textByItem.set(id, `${this.textByItem.get(id) || ""}${params.delta}`);
|
||||
const pending = this.pendingDeltas.get(id);
|
||||
if (pending) {
|
||||
pending.delta += params.delta;
|
||||
pending.itemType = itemType;
|
||||
pending.params = params;
|
||||
return;
|
||||
}
|
||||
this.pendingDeltas.set(id, {
|
||||
delta: params.delta,
|
||||
itemType,
|
||||
params,
|
||||
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
|
||||
});
|
||||
@@ -232,7 +242,7 @@ export class CodexAppClient {
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingDeltas.delete(id);
|
||||
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", delta: pending.delta }, ...codexEventScope(pending.params) });
|
||||
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
|
||||
}
|
||||
|
||||
/** 自动回复 app-server 发起的授权或交互请求。 */
|
||||
@@ -310,6 +320,13 @@ function normalizeItem(item: unknown) {
|
||||
const value = item && typeof item === "object" ? { ...(item as JsonRecord) } : {};
|
||||
if (value.type === "agentMessage") value.type = "agent_message";
|
||||
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
||||
if (value.type === "commandExecution") value.type = "command_execution";
|
||||
if (value.type === "fileChange") value.type = "file_change";
|
||||
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
|
||||
if (value.type === "collabToolCall") value.type = "collab_tool_call";
|
||||
if (value.type === "webSearch") value.type = "web_search";
|
||||
if (value.type === "imageView") value.type = "image_view";
|
||||
if (value.type === "contextCompaction") value.type = "context_compaction";
|
||||
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
||||
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
||||
return value;
|
||||
|
||||
@@ -36,14 +36,31 @@ export function threadMessages(thread: unknown): AgentHistoryMessage[] {
|
||||
}
|
||||
if (type === "mcpToolCall") {
|
||||
const tool = String(field(item, "tool") || "工具调用");
|
||||
const error = field(field(item, "error"), "message");
|
||||
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
|
||||
const error = String(field(field(item, "error"), "message") || "");
|
||||
const input = toolArguments(field(item, "arguments"));
|
||||
messages.push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, input), detail: toolHistoryDetail(tool, item, input, error) });
|
||||
}
|
||||
if (type === "commandExecution") {
|
||||
const command = String(field(item, "command") || "").trim();
|
||||
if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
|
||||
if (command) messages.push({ id, role: "tool", title: "执行命令", text: command, detail: commandDetail(item) });
|
||||
}
|
||||
if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
|
||||
if (type === "fileChange") {
|
||||
const changes = arrayValue(field(item, "changes"));
|
||||
messages.push({ id, role: "tool", title: "修改文件", text: fileChangeSummary(changes), detail: { kind: "file", status: field(item, "status"), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })) } });
|
||||
}
|
||||
if (type === "reasoning") {
|
||||
const text = readableText(field(item, "summary"));
|
||||
if (text) messages.push({ id, role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status: "completed" } });
|
||||
}
|
||||
if (type === "plan") {
|
||||
const text = String(field(item, "text") || "").trim();
|
||||
if (text) messages.push({ id, role: "tool", title: "执行计划", text, detail: { kind: "plan", status: "completed" } });
|
||||
}
|
||||
if (type === "webSearch") messages.push({ id, role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status: "completed", rows: webSearchRows(item) } });
|
||||
if (type === "imageView") messages.push({ id, role: "tool", title: "查看图片", text: String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: "completed" } });
|
||||
if (type === "contextCompaction") messages.push({ id, role: "tool", title: "整理上下文", text: "已整理当前对话,继续处理任务", detail: { kind: "context", status: "completed" } });
|
||||
if (type === "dynamicToolCall") messages.push({ id, role: "tool", title: "使用工具", text: "已完成工具操作", detail: { kind: "tool", status: field(item, "status") } });
|
||||
if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } });
|
||||
});
|
||||
});
|
||||
return messages.filter((item) => item.text).slice(-120);
|
||||
@@ -81,20 +98,148 @@ function stringOrNull(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
/** 生成命令执行的用户可读详情。 */
|
||||
function commandDetail(item: unknown) {
|
||||
const rows = [
|
||||
textRow("工作目录", field(item, "cwd")),
|
||||
textRow("退出状态", field(item, "exitCode")),
|
||||
durationRow(field(item, "durationMs")),
|
||||
].filter(Boolean);
|
||||
return { kind: "command", status: field(item, "status"), rows, output: String(field(item, "aggregatedOutput") || "").trim() };
|
||||
}
|
||||
|
||||
/** 生成 MCP 工具的用户可读详情。 */
|
||||
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
|
||||
return { kind: "tool", status: error ? "failed" : field(item, "status"), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
|
||||
}
|
||||
|
||||
/** 生成 MCP 工具在对话中的结果摘要。 */
|
||||
function toolHistorySummary(tool: string, input: unknown) {
|
||||
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
|
||||
if (tool === "canvas_list_projects") return "已读取画布列表";
|
||||
if (tool === "canvas_get_state") return "已读取当前画布内容";
|
||||
if (tool === "canvas_get_selection") return "已读取当前选中内容";
|
||||
if (tool === "prompts_search") return `已搜索提示词“${String(field(input, "query") || "") || "全部"}”`;
|
||||
if (tool === "assets_list") return "已读取我的素材";
|
||||
if (tool === "generation_get_status") return "已检查生成任务状态";
|
||||
return `${toolName(tool)}已完成`;
|
||||
}
|
||||
|
||||
/** 提取工具参数中适合普通用户查看的信息。 */
|
||||
function toolInputRows(tool: string, input: unknown) {
|
||||
if (tool === "site_navigate") return [textRow("目标页面", routeName(String(field(input, "path") || "/")))].filter(Boolean);
|
||||
if (tool === "prompts_search") return [textRow("搜索内容", field(input, "query"))].filter(Boolean);
|
||||
if (tool === "canvas_create_text_node") return [textRow("文本内容", field(input, "text"))].filter(Boolean);
|
||||
if (tool === "canvas_apply_ops") return [textRow("操作数量", arrayValue(field(input, "ops")).length)].filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 生成人类可读的文件变更摘要。 */
|
||||
function fileChangeSummary(changes: unknown[]) {
|
||||
if (!changes.length) return "已完成文件修改";
|
||||
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
|
||||
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))}${names[0]}`;
|
||||
return `涉及 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
|
||||
}
|
||||
|
||||
/** 生成网页搜索摘要。 */
|
||||
function webSearchSummary(item: unknown) {
|
||||
const action = field(item, "action");
|
||||
const type = String(field(action, "type") || "");
|
||||
if (type === "openPage") return `打开网页:${String(field(action, "url") || "")}`;
|
||||
if (type === "findInPage") return `在网页中查找“${String(field(action, "pattern") || "内容")}”`;
|
||||
return `搜索:${String(field(item, "query") || field(action, "query") || "相关资料")}`;
|
||||
}
|
||||
|
||||
/** 生成网页搜索详情行。 */
|
||||
function webSearchRows(item: unknown) {
|
||||
const action = field(item, "action");
|
||||
return [textRow("关键词", field(item, "query") || field(action, "query")), textRow("网页", field(action, "url"))].filter(Boolean);
|
||||
}
|
||||
|
||||
/** 从 reasoning 结构中提取可读文本。 */
|
||||
function readableText(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||
return readableText(field(value, "text"));
|
||||
}
|
||||
|
||||
/** 将历史工具参数解析为对象。 */
|
||||
function toolArguments(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建非空详情行。 */
|
||||
function textRow(label: string, value: unknown) {
|
||||
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
|
||||
}
|
||||
|
||||
/** 创建命令耗时详情行。 */
|
||||
function durationRow(value: unknown) {
|
||||
const duration = Number(value || 0);
|
||||
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||
}
|
||||
|
||||
/** 将文件变更类型转换为中文。 */
|
||||
function changeKind(value: unknown) {
|
||||
if (value === "add") return "新增";
|
||||
if (value === "delete") return "删除";
|
||||
return "修改";
|
||||
}
|
||||
|
||||
/** 将站点路由转换为中文页面名称。 */
|
||||
function routeName(path: string) {
|
||||
if (path === "/") return "首页";
|
||||
if (path === "/canvas") return "画布页面";
|
||||
if (path.startsWith("/canvas/")) return "指定画布";
|
||||
if (path.startsWith("/image")) return "生图工作台";
|
||||
if (path.startsWith("/video")) return "视频工作台";
|
||||
if (path.startsWith("/prompts")) return "提示词中心";
|
||||
if (path.startsWith("/assets")) return "我的素材";
|
||||
if (path.startsWith("/config")) return "配置页面";
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 将 MCP 工具名称转换为聊天记录中的中文标题。 */
|
||||
function toolName(name: string) {
|
||||
if (name === "site_navigate") return "打开页面";
|
||||
if (name === "canvas_list_projects") return "查看画布列表";
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_node") return "创建节点";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||
if (name === "canvas_create_config_node") return "创建生成配置";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_update_node") return "更新节点";
|
||||
if (name === "canvas_update_node_text") return "更新文本";
|
||||
if (name === "canvas_move_nodes") return "移动节点";
|
||||
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||
if (name === "canvas_delete_nodes") return "删除节点";
|
||||
if (name === "canvas_connect_nodes") return "连接节点";
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
return name;
|
||||
if (name === "workbench_image_get_config") return "读取生图设置";
|
||||
if (name === "workbench_image_generate") return "在生图工作台生成";
|
||||
if (name === "workbench_video_get_config") return "读取视频设置";
|
||||
if (name === "workbench_video_generate") return "在视频工作台生成";
|
||||
if (name === "prompts_search") return "搜索提示词";
|
||||
if (name === "assets_list") return "查看我的素材";
|
||||
if (name === "assets_add") return "添加到我的素材";
|
||||
if (name === "generation_get_status") return "查看生成状态";
|
||||
return "工具操作";
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ type CodexNotificationSpec = {
|
||||
"item/started": { threadId: string; turnId: string; item: CodexItem };
|
||||
"item/completed": { threadId: string; turnId: string; item: CodexItem };
|
||||
"item/agentMessage/delta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"item/plan/delta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"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 } };
|
||||
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||
let codexThreadId = "";
|
||||
const unmaterializedThreadIds = new Set<string>();
|
||||
|
||||
export { summarizeCodexThread } from "./codex-history.js";
|
||||
|
||||
@@ -35,6 +36,7 @@ export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
||||
return thread;
|
||||
}
|
||||
|
||||
@@ -65,7 +67,14 @@ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string;
|
||||
|
||||
/** 读取指定 Codex 线程及其聊天历史。 */
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
||||
let thread: unknown;
|
||||
try {
|
||||
thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId));
|
||||
} catch (error) {
|
||||
if (!/not materialized yet.*includeTurns/i.test(errorMessage(error))) throw error;
|
||||
unmaterializedThreadIds.add(threadId);
|
||||
thread = await loadCodexThread(emit, threadId, cwd, false);
|
||||
}
|
||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
@@ -79,6 +88,7 @@ export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await app.archiveThread(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
}
|
||||
|
||||
/** 判断线程异常是否允许自动新建线程后重试。 */
|
||||
@@ -95,6 +105,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
|
||||
const app = await getCodexApp(options.appEmit || emit);
|
||||
let threadId = await ensureCodexThread(app, options, emit);
|
||||
options.onThread?.(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
try {
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
} catch (error) {
|
||||
@@ -103,6 +114,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
unmaterializedThreadIds.delete(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -133,6 +145,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
|
||||
if (!codexThreadId) {
|
||||
const thread = await app.startThread(options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
||||
}
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
|
||||
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
|
||||
- Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
|
||||
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和 `working...`,任务运行期间不应闪退;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。
|
||||
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
|
||||
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
|
||||
- Agent 过程时间线:Codex 运行时应在对话中依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动,同一操作应从「进行中」原位更新为「已完成」而不是生成重复卡片;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。
|
||||
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
|
||||
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { ArrowUp, Brain, CheckCircle2, ChevronDown, CircleAlert, FilePenLine, FileText, ImagePlus, ListChecks, LoaderCircle, Search, Square, TerminalSquare, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
@@ -20,8 +20,6 @@ export type CanvasAgentChatMessage = {
|
||||
streamId?: string;
|
||||
};
|
||||
|
||||
const WORKING_TEXT = "working...";
|
||||
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
@@ -66,38 +64,30 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
|
||||
}
|
||||
|
||||
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
|
||||
const view = userDetail(detail);
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar theme={theme} />
|
||||
<div className="min-w-0 flex-1 rounded-xl border p-4" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<details>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
|
||||
<CircleAlert className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span>确认工具调用</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: "rgba(217,119,6,.22)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
|
||||
等待确认
|
||||
</span>
|
||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
|
||||
{summary}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}>
|
||||
<details className="group">
|
||||
<summary className={`flex list-none items-start gap-2.5 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<CircleAlert className="mt-0.5 size-4 shrink-0 text-amber-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium leading-5">
|
||||
<span>等待确认</span>
|
||||
{view ? <ChevronDown className="ml-auto size-3.5 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div>
|
||||
</div>
|
||||
</summary>
|
||||
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
|
||||
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
|
||||
</details>
|
||||
{onReject || onApprove ? (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
|
||||
拒绝执行
|
||||
</Button>
|
||||
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
|
||||
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
|
||||
批准执行
|
||||
</Button>
|
||||
</div>
|
||||
@@ -109,50 +99,59 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
|
||||
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const state = toolCardState(title, text, detail);
|
||||
const view = userDetail(detail);
|
||||
const kind = String(objectField(detail, "kind") || "");
|
||||
return (
|
||||
<details className="min-w-0 flex-1 rounded-xl border px-4 py-3.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
|
||||
{state.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>详情</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
<details className="group min-w-0 flex-1 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
||||
<summary className={`flex list-none items-start gap-2.5 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
||||
<span className="mt-0.5 shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-normal" style={{ color: state.color }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
||||
</div>
|
||||
<div className={`mt-1 whitespace-pre-wrap break-words text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
|
||||
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [length, setLength] = useState(1);
|
||||
export function AgentWorkingMessage({ text, activityKey, theme }: { text: string; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
|
||||
const startedAt = Date.now();
|
||||
setElapsed(0);
|
||||
const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [setLength]);
|
||||
}, [activityKey]);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar theme={theme} />
|
||||
<div className="min-w-0 max-w-[82%]">
|
||||
<div className="font-mono text-sm" style={{ color: theme.node.muted }} aria-label={WORKING_TEXT}>
|
||||
<span className="inline-block w-[76px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
|
||||
<div className="min-w-0 flex-1 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" />
|
||||
<span className="min-w-0">{text}</span>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function waitingTime(seconds: number) {
|
||||
if (seconds < 60) return `已等待 ${seconds} 秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `已等待 ${minutes} 分 ${seconds % 60} 秒`;
|
||||
}
|
||||
|
||||
export function AgentChatComposer({
|
||||
prompt,
|
||||
attachments = [],
|
||||
@@ -264,11 +263,40 @@ export function AgentPanelTabs<T extends string>({ value, items, theme, right, o
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
|
||||
|
||||
function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
return (
|
||||
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-lg border p-3 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
{JSON.stringify(detail, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-3 space-y-2.5 border-t pt-3 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
|
||||
{detail.rows?.length ? (
|
||||
<dl className="space-y-1.5">
|
||||
{detail.rows.map((row) => (
|
||||
<div key={`${row.label}-${row.value}`} className="grid grid-cols-[64px_minmax(0,1fr)] gap-2">
|
||||
<dt className="opacity-60">{row.label}</dt>
|
||||
<dd className="min-w-0 break-words" style={{ color: theme.node.text }}>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{detail.files?.length ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="opacity-60">涉及文件</div>
|
||||
{detail.files.map((file) => (
|
||||
<div key={`${file.action}-${file.path}`} className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 break-all" style={{ color: theme.node.text }}>{file.path}</span>
|
||||
{file.action ? <span className="shrink-0 opacity-60">{file.action}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{detail.output ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="opacity-60">{detail.status === "failed" || detail.status === "error" ? "错误信息" : "运行输出"}</div>
|
||||
<pre className="thin-scrollbar max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg px-3 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{detail.output}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,19 +330,53 @@ function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChat
|
||||
function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
|
||||
const lower = raw.toLowerCase();
|
||||
const tool = String(objectField(detail, "name") || objectField(detail, "tool") || "");
|
||||
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
|
||||
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "工具完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
|
||||
const status = String(objectField(detail, "status") || "").toLowerCase();
|
||||
if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", icon: <CircleAlert className="size-4" />, isError: false };
|
||||
if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: "已取消", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-4 animate-spin" />, isError: false };
|
||||
if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
return { label: "已记录", color: "#2563eb", icon: <Wrench className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
function toolIcon(kind: string | undefined, fallback: ReactNode) {
|
||||
if (kind === "reasoning") return <Brain className="size-4" />;
|
||||
if (kind === "command") return <TerminalSquare className="size-4" />;
|
||||
if (kind === "search") return <Search className="size-4" />;
|
||||
if (kind === "file") return <FilePenLine className="size-4" />;
|
||||
if (kind === "plan") return <ListChecks className="size-4" />;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function userDetail(value: unknown): UserDetail | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const detail = value as Record<string, unknown>;
|
||||
const rows = Array.isArray(detail.rows)
|
||||
? detail.rows.flatMap((row) => {
|
||||
if (!row || typeof row !== "object") return [];
|
||||
const label = String((row as Record<string, unknown>).label || "");
|
||||
const value = String((row as Record<string, unknown>).value || "");
|
||||
return label && value ? [{ label, value }] : [];
|
||||
})
|
||||
: [];
|
||||
const files = Array.isArray(detail.files)
|
||||
? detail.files.flatMap((file) => {
|
||||
if (!file || typeof file !== "object") return [];
|
||||
const path = String((file as Record<string, unknown>).path || "");
|
||||
return path ? [{ path, action: String((file as Record<string, unknown>).action || "") || undefined }] : [];
|
||||
})
|
||||
: [];
|
||||
const error = objectField(detail.error, "message");
|
||||
const output = typeof detail.output === "string" ? detail.output.trim() : typeof error === "string" ? error : "";
|
||||
if (!rows.length && !files.length && !output) return null;
|
||||
return { kind: typeof detail.kind === "string" ? detail.kind : undefined, status: typeof detail.status === "string" ? detail.status : undefined, rows, files, output };
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (value instanceof Error) return value.message;
|
||||
if (value == null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
return String(objectField(value, "message") || "");
|
||||
}
|
||||
|
||||
function objectField(value: unknown, key: string) {
|
||||
|
||||
@@ -42,7 +42,30 @@ type AgentEventPayload = {
|
||||
usage?: Record<string, unknown>;
|
||||
duration_ms?: number;
|
||||
};
|
||||
type AgentEventItem = { id?: string; type?: string; text?: unknown; delta?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
|
||||
type AgentEventItem = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
text?: unknown;
|
||||
delta?: unknown;
|
||||
message?: unknown;
|
||||
server?: string;
|
||||
tool?: string;
|
||||
status?: string;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
aggregatedOutput?: unknown;
|
||||
exitCode?: unknown;
|
||||
durationMs?: unknown;
|
||||
changes?: unknown;
|
||||
summary?: unknown;
|
||||
query?: unknown;
|
||||
action?: unknown;
|
||||
path?: unknown;
|
||||
};
|
||||
type AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
|
||||
|
||||
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
@@ -190,11 +213,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
source.addEventListener("workspace_changed", (event) => {
|
||||
const data = parseEventData<AgentWorkspaceEvent>(event);
|
||||
if (!data) return;
|
||||
enqueueEvent(() => {
|
||||
enqueueEvent(async () => {
|
||||
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ activeThreadId: nextThreadId, messages: [], tokenUsage: null, pendingTool: null });
|
||||
void loadThreads(Boolean(data.emptyThread));
|
||||
await loadThreads(Boolean(data.emptyThread));
|
||||
});
|
||||
});
|
||||
source.addEventListener("chat_message", (event) => {
|
||||
@@ -371,10 +394,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
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(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: siteToolSummary(payload.name, result), detail: { requestId: payload.requestId, name: payload.name, input: payload.input, result } });
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: siteToolSummary(payload.name, result), detail: toolCallDetail(payload.name, payload.input, "completed") });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "工具执行失败";
|
||||
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
|
||||
}
|
||||
return;
|
||||
@@ -408,13 +431,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({
|
||||
role: "tool",
|
||||
title: `${toolName(payload.name)}完成`,
|
||||
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成",
|
||||
detail: { requestId: payload.requestId, name: payload.name, input, result },
|
||||
title: toolName(payload.name),
|
||||
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "已完成画布操作" : payload.name === "site_navigate" ? `已打开${routeName(input.path || "/")}` : "已完成",
|
||||
detail: toolCallDetail(payload.name, input, "completed"),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "画布操作失败";
|
||||
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
|
||||
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
|
||||
}
|
||||
};
|
||||
@@ -422,7 +445,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
const rejectPendingTool = async () => {
|
||||
if (!pendingTool) return;
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" });
|
||||
addMessage({ role: "tool", title: "拒绝执行", text: toolName(pendingTool.name), detail: { requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input } });
|
||||
addMessage({ role: "tool", title: toolName(pendingTool.name), text: "用户已取消本次操作", detail: toolCallDetail(pendingTool.name, pendingTool.input, "declined") });
|
||||
pendingToolRef.current = null;
|
||||
setAgentState({ pendingTool: null });
|
||||
};
|
||||
@@ -594,6 +617,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: new Date().toLocaleTimeString(), title, text: value, raw });
|
||||
};
|
||||
|
||||
const upsertActivityMessage = (item: AgentChatItem) => {
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
pushMessage(item);
|
||||
return;
|
||||
}
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...item } : message)) });
|
||||
};
|
||||
|
||||
const appendActivityDelta = (item: AgentEventItem) => {
|
||||
if (!item.id) return;
|
||||
const delta = stringText(item.delta);
|
||||
if (!delta) return;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.id === item.id);
|
||||
if (index < 0) {
|
||||
if (!delta.trim()) return;
|
||||
upsertActivityMessage(activityDeltaFallback(item, delta));
|
||||
return;
|
||||
}
|
||||
const current = currentMessages[index];
|
||||
if (item.type === "command_execution") {
|
||||
const detail = activityDetail(current.detail, "command", "inProgress");
|
||||
detail.output = `${detail.output || ""}${delta}`;
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
|
||||
return;
|
||||
}
|
||||
const placeholder = activityPlaceholder(item.type);
|
||||
if (!delta.trim() && current.text === placeholder) return;
|
||||
const text = current.text === placeholder ? delta : `${current.text}${delta}`;
|
||||
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) });
|
||||
};
|
||||
|
||||
const handleAgentEvent = (event: AgentEventPayload) => {
|
||||
if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) });
|
||||
const log = formatAgentEventLog(event);
|
||||
@@ -603,6 +660,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
appendStreamDelta(event.item.id, stringText(event.item.delta));
|
||||
return;
|
||||
}
|
||||
if (event.type === "item.updated" && event.item) {
|
||||
appendActivityDelta(event.item);
|
||||
return;
|
||||
}
|
||||
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) {
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
const index = currentMessages.findIndex((message) => message.streamId === event.item?.id);
|
||||
@@ -612,6 +673,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
|
||||
return;
|
||||
}
|
||||
}
|
||||
const activity = formatAgentActivity(event);
|
||||
if (activity && event.item?.id) {
|
||||
upsertActivityMessage({ ...activity, id: event.item.id });
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn.completed") setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
|
||||
const item = formatAgentEvent(event);
|
||||
if (item) addMessage(item);
|
||||
@@ -742,6 +808,7 @@ function AgentChatTimeline({
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
const working = workingActivity(messages.at(-1));
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
@@ -769,13 +836,13 @@ function AgentChatTimeline({
|
||||
{pendingTool ? (
|
||||
<AgentPendingToolCard
|
||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
|
||||
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
||||
theme={theme}
|
||||
onReject={onRejectTool}
|
||||
onApprove={onApproveTool}
|
||||
/>
|
||||
) : null}
|
||||
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
|
||||
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Tooltip title="滚动到底部" placement="left">
|
||||
@@ -1191,10 +1258,119 @@ function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> |
|
||||
const item = event.item;
|
||||
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
|
||||
if (event.type === "item.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) };
|
||||
if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||
const item = event.item;
|
||||
if (!item || (event.type !== "item.started" && event.type !== "item.completed")) return null;
|
||||
const completed = event.type === "item.completed";
|
||||
const status = String(item.status || (completed ? "completed" : "inProgress"));
|
||||
if (item.type === "reasoning") {
|
||||
const text = readableText(item.summary) || (completed ? "已完成分析" : activityPlaceholder(item.type));
|
||||
return { role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status } };
|
||||
}
|
||||
if (item.type === "plan") {
|
||||
const text = stringText(item.text) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行计划", text, detail: { kind: "plan", status } };
|
||||
}
|
||||
if (item.type === "command_execution") {
|
||||
const command = stringText(item.command) || activityPlaceholder(item.type);
|
||||
return { role: "tool", title: "执行命令", text: command, detail: commandActivityDetail(item, status) };
|
||||
}
|
||||
if (item.type === "file_change") {
|
||||
const files = activityFiles(item.changes);
|
||||
return { role: "tool", title: "修改文件", text: fileActivitySummary(files, completed), detail: { kind: "file", status, files } };
|
||||
}
|
||||
if (item.type === "web_search") {
|
||||
return { role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status, rows: webSearchDetailRows(item) } };
|
||||
}
|
||||
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: stringText(item.path) || "正在查看图片", detail: { kind: "image", status } };
|
||||
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…", detail: { kind: "context", status } };
|
||||
if (isMcpToolItem(item) && isReadTool(String(item.tool || ""))) {
|
||||
const name = String(item.tool || "");
|
||||
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}…`, detail: toolDetail(item, item.error ? "failed" : status) };
|
||||
}
|
||||
if (item.type === "dynamic_tool_call") return { role: "tool", title: "使用工具", text: completed ? "已完成工具操作" : "正在执行工具操作…", detail: { kind: "tool", status } };
|
||||
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: completed ? "已完成协作任务" : "正在协作处理任务…", detail: { kind: "tool", status } };
|
||||
return null;
|
||||
}
|
||||
|
||||
function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem {
|
||||
if (item.type === "command_execution") return { id: item.id || createId(), role: "tool", title: "执行命令", text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } };
|
||||
return { id: item.id || createId(), role: "tool", title: item.type === "plan" ? "执行计划" : "思考摘要", text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
|
||||
}
|
||||
|
||||
function activityPlaceholder(type?: string) {
|
||||
if (type === "plan") return "正在整理执行步骤…";
|
||||
if (type === "command_execution") return "正在执行命令…";
|
||||
return "正在分析任务…";
|
||||
}
|
||||
|
||||
function activityKind(type?: string) {
|
||||
if (type === "command_execution") return "command";
|
||||
if (type === "plan") return "plan";
|
||||
return "reasoning";
|
||||
}
|
||||
|
||||
function activityDetail(value: unknown, kind: string, status: string): AgentUserDetail {
|
||||
const current = value && typeof value === "object" ? (value as Partial<AgentUserDetail>) : {};
|
||||
return { kind, status, rows: current.rows, output: current.output, files: current.files };
|
||||
}
|
||||
|
||||
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
|
||||
const rows = [detailRow("工作目录", item.cwd), detailRow("退出状态", item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : []));
|
||||
return { kind: "command", status, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
|
||||
}
|
||||
|
||||
function activityFiles(value: unknown) {
|
||||
return (Array.isArray(value) ? value : []).flatMap((change) => {
|
||||
const path = stringText(objectField(change, "path"));
|
||||
return path ? [{ path, action: changeAction(objectField(change, "kind")) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function fileActivitySummary(files: Array<{ path: string; action?: string }>, completed: boolean) {
|
||||
if (!files.length) return completed ? "已完成文件修改" : "正在准备文件修改…";
|
||||
if (files.length === 1) return `${completed ? "已" : "正在"}${files[0].action || "修改"} ${files[0].path}`;
|
||||
const names = files.slice(0, 3).map((file) => file.path).join("、");
|
||||
return `${completed ? "已修改" : "正在修改"} ${files.length} 个文件:${names}${files.length > 3 ? " 等" : ""}`;
|
||||
}
|
||||
|
||||
function webSearchSummary(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
const type = stringText(objectField(action, "type"));
|
||||
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
|
||||
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "相关内容"}”`;
|
||||
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
||||
}
|
||||
|
||||
function webSearchDetailRows(item: AgentEventItem) {
|
||||
const action = item.action;
|
||||
return [detailRow("关键词", item.query || objectField(action, "query")), detailRow("网页", objectField(action, "url"))].flatMap((row) => (row ? [row] : []));
|
||||
}
|
||||
|
||||
function readableText(value: unknown): string {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||
return readableText(objectField(value, "text"));
|
||||
}
|
||||
|
||||
function detailRow(label: string, value: unknown) {
|
||||
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
|
||||
}
|
||||
|
||||
function durationDetailRow(value: unknown) {
|
||||
const duration = Number(value || 0);
|
||||
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||
}
|
||||
|
||||
function changeAction(value: unknown) {
|
||||
if (value === "add") return "新增";
|
||||
if (value === "delete") return "删除";
|
||||
return "修改";
|
||||
}
|
||||
|
||||
function parseEventData<T>(event: Event) {
|
||||
try {
|
||||
return JSON.parse((event as MessageEvent).data) as T;
|
||||
@@ -1285,9 +1461,9 @@ function toolName(name: string) {
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
if (name === "site_navigate") return "网站跳转";
|
||||
if (name === "site_navigate") return "打开页面";
|
||||
if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
|
||||
return name;
|
||||
return "工具操作";
|
||||
}
|
||||
|
||||
function siteToolSummary(name: string, result: unknown) {
|
||||
@@ -1313,8 +1489,22 @@ function isMcpToolItem(item?: AgentEventItem) {
|
||||
return item?.type === "mcp_tool_call";
|
||||
}
|
||||
|
||||
function toolDetail(item?: AgentEventItem) {
|
||||
return { server: item?.server, tool: item?.tool, status: item?.status, arguments: item?.arguments, result: parseToolResult(item?.result), error: item?.error };
|
||||
function toolDetail(item: AgentEventItem | undefined, status: string): AgentUserDetail {
|
||||
const name = String(item?.tool || "");
|
||||
return { kind: "tool", status, rows: toolInputRows(name, item?.arguments), ...(item?.error?.message ? { output: item.error.message } : {}) };
|
||||
}
|
||||
|
||||
function toolCallDetail(name: string, input: unknown, status: string, error = ""): AgentUserDetail {
|
||||
return { kind: "tool", status, rows: toolInputRows(name, input), ...(error ? { output: error } : {}) };
|
||||
}
|
||||
|
||||
function toolInputRows(name: string, input: unknown) {
|
||||
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "prompts_search") return [detailRow("搜索内容", objectField(input, "query"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_create_text_node") return [detailRow("文本内容", objectField(input, "text"))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_apply_ops") return [detailRow("操作内容", summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : []));
|
||||
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "nodes")) ? (objectField(input, "nodes") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
|
||||
return [];
|
||||
}
|
||||
|
||||
function toolSummary(item?: AgentEventItem) {
|
||||
@@ -1327,6 +1517,33 @@ function toolSummary(item?: AgentEventItem) {
|
||||
return "工具调用完成";
|
||||
}
|
||||
|
||||
function toolAction(name: string) {
|
||||
const label = toolName(name);
|
||||
if (label.startsWith("读取") || label.startsWith("查看") || label.startsWith("搜索") || label.startsWith("打开")) return label;
|
||||
return `执行${label}`;
|
||||
}
|
||||
|
||||
function routeName(path: string) {
|
||||
if (path === "/") return "首页";
|
||||
if (path === "/canvas") return "画布页面";
|
||||
if (path.startsWith("/canvas/")) return "指定画布";
|
||||
if (path.startsWith("/image")) return "生图工作台";
|
||||
if (path.startsWith("/video")) return "视频工作台";
|
||||
if (path.startsWith("/prompts")) return "提示词中心";
|
||||
if (path.startsWith("/assets")) return "我的素材";
|
||||
if (path.startsWith("/config")) return "配置页面";
|
||||
return path;
|
||||
}
|
||||
|
||||
function workingActivity(item?: AgentChatItem) {
|
||||
const status = String(objectField(item?.detail, "status") || "");
|
||||
const key = `${item?.id || "waiting"}-${status}`;
|
||||
if (item?.role !== "tool") return { key, text: "正在思考..." };
|
||||
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` };
|
||||
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." };
|
||||
return { key, text: `${item.title || "工具操作"}已完成,Codex 正在继续处理...` };
|
||||
}
|
||||
|
||||
function parseToolResult(result: unknown) {
|
||||
const content = objectField(result, "content");
|
||||
const text = Array.isArray(content)
|
||||
|
||||
Reference in New Issue
Block a user