mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-03 15:41:16 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 335467ec0d | |||
| 1e92157a23 | |||
| 8efd714221 | |||
| ea0414e88c | |||
| ee5804e586 | |||
| 29c5dbbc8c | |||
| 119931e724 | |||
| 6f1b6633b7 | |||
| ed2b0dabad | |||
| 29e52fecd6 | |||
| 576f7485bf | |||
| 9330bdb318 | |||
| 3c6af91e8d |
@@ -1,59 +0,0 @@
|
|||||||
name: Publish Canvas Agent
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "canvas-agent/**"
|
|
||||||
- ".github/workflows/publish-canvas-agent.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: publish-canvas-agent
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: canvas-agent
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
registry-url: https://registry.npmjs.org
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install --no-audit --no-fund
|
|
||||||
|
|
||||||
- name: Check package version
|
|
||||||
id: package
|
|
||||||
run: |
|
|
||||||
name=$(node -p "require('./package.json').name")
|
|
||||||
version=$(node -p "require('./package.json').version")
|
|
||||||
published=$(npm view "$name@$version" version 2>/dev/null || true)
|
|
||||||
echo "name=$name" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
||||||
if [ -n "$published" ]; then
|
|
||||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish to npm
|
|
||||||
if: steps.package.outputs.should_publish == 'true'
|
|
||||||
run: npm publish --access public
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Skip existing version
|
|
||||||
if: steps.package.outputs.should_publish != 'true'
|
|
||||||
run: echo "${{ steps.package.outputs.name }}@${{ steps.package.outputs.version }} already exists on npm, skipping publish."
|
|
||||||
@@ -82,3 +82,4 @@
|
|||||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不要在文档中误写成已支持云同步。
|
- 当前画布项目和“我的素材”主要保存在浏览器本地,不要在文档中误写成已支持云同步。
|
||||||
- 当前 AI API Key 存在浏览器本地,并由前端直接请求 OpenAI 兼容接口;涉及安全说明时要写清楚。
|
- 当前 AI API Key 存在浏览器本地,并由前端直接请求 OpenAI 兼容接口;涉及安全说明时要写清楚。
|
||||||
- Docker 静态资源路径目前仍是待办项,文档中不要过度承诺生产部署已经完全验证。
|
- Docker 静态资源路径目前仍是待办项,文档中不要过度承诺生产部署已经完全验证。
|
||||||
|
- Agent 对话消息必须同时按 `threadId`、`turnId` 和 `itemId` 归属;实时事件只用于补充未物化的 turn,历史快照成为权威后不得重复合并同一条消息。
|
||||||
|
|||||||
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v0.13.0 - 2026-08-03
|
||||||
|
|
||||||
|
+ [优化] Agent 进入空白对话或点击新对话后后台预热 Codex 与 MCP。
|
||||||
|
+ [优化] Agent 排查日志改为筛选栏结构化滚动列表,支持筛选、展开详情并折叠连续重复事件。
|
||||||
|
+ [优化] Agent 日志统一使用居中的回到底部入口,向上浏览时暂停跟随并可快速返回最新内容。
|
||||||
|
+ [优化] Agent 对话将同一轮连续命令合并为按数量折叠的命令组,默认隐藏冗长命令预览。
|
||||||
|
+ [修复] MCP 初始化期间禁用 Agent 按钮与回车发送,避免首条消息提前清除服务加载状态。
|
||||||
|
+ [修复] Agent 对话代码块关闭行号后多行内容被拼接为一行的问题。
|
||||||
|
+ [修复] 统一 Agent 实时事件与线程历史的消息归属,避免多窗口或刷新后出现过程重复等问题。
|
||||||
|
+ [修复] 节点缩放期间暂停渲染跟随节点的浮层工具条高频更新导致页面崩溃。
|
||||||
|
|
||||||
|
## v0.12.1 - 2026-07-31
|
||||||
|
|
||||||
|
+ [新增] 画布右侧Agent支持按当前账号可用范围选择Codex 模型与推理强度。
|
||||||
|
+ [优化] Canvas Agent 升级至最新 Codex,启动时检查版本更新,
|
||||||
|
+ [优化] 统一通过公共日志输出 Info 以上信息,美化转发日志时间。
|
||||||
|
+ [优化] Agent高速流式回复取消逐词动画排队和当前消息离屏占位。
|
||||||
|
+ [修复] Agent首次发送消息时立即保留并展示用户内容,不再晚于思考状态。
|
||||||
|
|
||||||
## v0.12.0 - 2026-07-30
|
## v0.12.0 - 2026-07-30
|
||||||
|
|
||||||
+ [新增] 画布 Agent 支持三档 Codex 权限,并可在对话中审批操作。
|
+ [新增] 画布 Agent 支持三档 Codex 权限,并可在对话中审批操作。
|
||||||
|
|||||||
Generated
+1532
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@basketikun/canvas-agent",
|
"name": "@basketikun/canvas-agent",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -15,15 +15,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"debug": "tsx src/index.ts --debug",
|
"debug": "tsx src/index.ts --debug",
|
||||||
"test": "tsx --test src/canvas/session.test.ts",
|
"test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.test.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"@openai/codex": "0.145.0",
|
"@openai/codex": "0.146.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"strip-ansi": "7.2.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { CodexAppClient } from "./codex-client.js";
|
||||||
|
|
||||||
|
type TestClient = {
|
||||||
|
currentThreadId: string;
|
||||||
|
currentTurnId: string;
|
||||||
|
completedTurns: Map<string, Error | null>;
|
||||||
|
plansByTurn: Map<string, unknown>;
|
||||||
|
lastUsage: unknown;
|
||||||
|
answerServerRequest(message: Record<string, unknown>): void;
|
||||||
|
failAll(message: string): void;
|
||||||
|
handle(message: Record<string, unknown>): void;
|
||||||
|
handleNotification(method: string, params: Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyEventHistory = { record: () => Promise.resolve(), recordTurn: () => Promise.resolve() };
|
||||||
|
|
||||||
|
test("审批只在 app-server 确认 resolved 后清除", () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.answerServerRequest({ id: 17, method: "item/commandExecution/requestApproval", params: { threadId: "thread-1", turnId: "turn-1" } });
|
||||||
|
assert.equal(events.filter((item) => item.type === "codex_approval").length, 1);
|
||||||
|
|
||||||
|
assert.equal(client.resolveApproval("17", "accept"), true);
|
||||||
|
assert.equal(writes.length, 1);
|
||||||
|
assert.equal(events.some((item) => item.type === "codex_approval_resolved"), false);
|
||||||
|
assert.equal(client.resolveApproval("17", "accept"), true);
|
||||||
|
assert.equal(writes.length, 1);
|
||||||
|
|
||||||
|
testClient.handleNotification("serverRequest/resolved", { requestId: "17" });
|
||||||
|
const resolved = events.find((item) => item.type === "codex_approval_resolved");
|
||||||
|
assert.deepEqual(resolved?.payload, { threadId: "thread-1", turnId: "turn-1", requestId: "17", decision: "accept" });
|
||||||
|
assert.equal(client.resolveApproval("17", "accept"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("中断请求只作用于当前运行线程", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
testClient.currentThreadId = "thread-1";
|
||||||
|
testClient.currentTurnId = "turn-1";
|
||||||
|
|
||||||
|
assert.equal(await client.interruptCurrentTurn("thread-2"), false);
|
||||||
|
assert.equal(writes.length, 0);
|
||||||
|
|
||||||
|
const interrupt = client.interruptCurrentTurn("thread-1");
|
||||||
|
const request = writes.find((item) => item.method === "turn/interrupt");
|
||||||
|
assert.ok(request);
|
||||||
|
testClient.handle({ id: request.id, result: {} });
|
||||||
|
assert.equal(await interrupt, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn/started 早于 turn/start 响应时保持完整事件归属", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
const turnIds: string[] = [];
|
||||||
|
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
|
||||||
|
const request = writes.find((item) => item.method === "turn/start");
|
||||||
|
assert.ok(request);
|
||||||
|
|
||||||
|
testClient.handleNotification("turn/started", { turn: { id: "turn-1", status: "inProgress" } });
|
||||||
|
assert.deepEqual(turnIds, ["turn-1"]);
|
||||||
|
testClient.handleNotification("item/started", { item: { id: "reasoning-1", type: "reasoning" } });
|
||||||
|
testClient.handleNotification("turn/completed", { turn: { id: "turn-1", status: "completed" } });
|
||||||
|
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await running;
|
||||||
|
|
||||||
|
assert.deepEqual(turnIds, ["turn-1"]);
|
||||||
|
const scopedEvents = events.filter((item) => item.type === "agent_event");
|
||||||
|
assert.deepEqual(scopedEvents.map((item) => eventScope(item.payload)), [
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1" },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1" },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(eventScope(events.find((item) => item.type === "agent_done")?.payload), { threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn/start 响应早于通知时 onTurn 仍只调用一次", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
const turnIds: string[] = [];
|
||||||
|
|
||||||
|
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
|
||||||
|
const request = writes.find((item) => item.method === "turn/start");
|
||||||
|
assert.ok(request);
|
||||||
|
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.deepEqual(turnIds, ["turn-1"]);
|
||||||
|
|
||||||
|
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
|
||||||
|
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||||
|
await running;
|
||||||
|
|
||||||
|
assert.deepEqual(turnIds, ["turn-1"]);
|
||||||
|
assert.equal(events.filter((item) => item.type === "agent_event" && eventType(item.payload) === "turn.started").length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn/started 通知缺失时使用 turn/start 响应回调", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
const turnIds: string[] = [];
|
||||||
|
|
||||||
|
const running = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(turnId));
|
||||||
|
const request = writes.find((item) => item.method === "turn/start");
|
||||||
|
assert.ok(request);
|
||||||
|
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(turnIds, ["turn-1"]);
|
||||||
|
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||||
|
await running;
|
||||||
|
assert.deepEqual(turnIds, ["turn-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("onTurn 按 threadId 和 turnId 去重", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
const turnIds: string[] = [];
|
||||||
|
|
||||||
|
const first = client.startTurn("thread-1", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(`thread-1:${turnId}`));
|
||||||
|
const firstRequest = writes.find((item) => item.method === "turn/start");
|
||||||
|
assert.ok(firstRequest);
|
||||||
|
testClient.handle({ id: firstRequest.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
|
||||||
|
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||||
|
await first;
|
||||||
|
|
||||||
|
const second = client.startTurn("thread-2", "测试", [], "request", undefined, undefined, (turnId) => turnIds.push(`thread-2:${turnId}`));
|
||||||
|
const secondRequest = writes.filter((item) => item.method === "turn/start").at(-1);
|
||||||
|
assert.ok(secondRequest);
|
||||||
|
testClient.handle({ id: secondRequest.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
testClient.handleNotification("turn/started", { threadId: "thread-2", turn: { id: "turn-1", status: "inProgress" } });
|
||||||
|
testClient.handleNotification("turn/completed", { threadId: "thread-2", turn: { id: "turn-1", status: "completed" } });
|
||||||
|
await second;
|
||||||
|
|
||||||
|
assert.deepEqual(turnIds, ["thread-1:turn-1", "thread-2:turn-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("稀疏的命令完成通知会保留开始通知中的命令内容", () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const persisted: unknown[] = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()) };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "command-1", type: "commandExecution", command: "Get-Location", cwd: "D:\\infinite-canvas" } });
|
||||||
|
testClient.handleNotification("item/commandExecution/outputDelta", { threadId: "thread-1", turnId: "turn-1", itemId: "command-1", delta: "D:\\infinite-canvas" });
|
||||||
|
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "command-1", type: "commandExecution", status: "completed", exitCode: 0 } });
|
||||||
|
|
||||||
|
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
|
||||||
|
const item = (completed?.payload as { item?: Record<string, unknown> })?.item;
|
||||||
|
assert.equal(item?.command, "Get-Location");
|
||||||
|
assert.equal(item?.cwd, "D:\\infinite-canvas");
|
||||||
|
assert.equal(item?.status, "completed");
|
||||||
|
assert.equal(item?.aggregatedOutput, "D:\\infinite-canvas");
|
||||||
|
assert.deepEqual(persisted, [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "command-1",
|
||||||
|
sequence: 1,
|
||||||
|
item,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("稀疏的 plan 完成通知会保留流式正文", () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const persisted: unknown[] = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("item/plan/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "plan-1", delta: "第一步\n第二步" });
|
||||||
|
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "plan-1", type: "plan", status: "completed" } });
|
||||||
|
|
||||||
|
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
|
||||||
|
assert.equal((completed?.payload as { item?: { text?: string } })?.item?.text, "第一步\n第二步");
|
||||||
|
assert.equal(((persisted[0] as { item?: { text?: string } })?.item?.text), "第一步\n第二步");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reasoning 完成通知缺少 summary 时保留流式摘要", () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const persisted: unknown[] = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("item/reasoning/summaryTextDelta", { threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1", summaryIndex: 0, delta: "分析结果" });
|
||||||
|
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "reasoning-1", type: "reasoning", status: "completed" } });
|
||||||
|
|
||||||
|
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
|
||||||
|
assert.equal((completed?.payload as { item?: { summary?: string } })?.item?.summary, "分析结果");
|
||||||
|
assert.equal(((persisted[0] as { item?: { summary?: string } })?.item?.summary), "分析结果");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("流式更新只发送当前增量而不重复传输累计正文", async () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("item/agentMessage/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", delta: "第一段" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
testClient.handleNotification("item/agentMessage/delta", { threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", delta: "第二段" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const updates = events.filter((event) => event.type === "agent_event" && eventType(event.payload) === "item.updated");
|
||||||
|
assert.deepEqual(updates.map((event) => (event.payload as { item: { delta?: string; text?: string } }).item), [
|
||||||
|
{ id: "assistant-1", type: "agent_message", delta: "第一段" },
|
||||||
|
{ id: "assistant-1", type: "agent_message", delta: "第二段" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("新版协作工具完成通知会归一化并写入补充历史", () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const persisted: unknown[] = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()), recordTurn: () => Promise.resolve() };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "collab-1", type: "collabAgentToolCall", status: "completed" } });
|
||||||
|
|
||||||
|
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "item.completed");
|
||||||
|
assert.equal((completed?.payload as { item?: { type?: string } })?.item?.type, "collab_tool_call");
|
||||||
|
assert.equal(((persisted[0] as { item?: { type?: string } })?.item?.type), "collab_tool_call");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("并行条目按开始顺序保存而不是完成顺序", () => {
|
||||||
|
const persisted: unknown[] = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: (entry: unknown) => (persisted.push(entry), Promise.resolve()) };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "first", type: "commandExecution", command: "first" } });
|
||||||
|
testClient.handleNotification("item/started", { threadId: "thread-1", turnId: "turn-1", item: { id: "second", type: "commandExecution", command: "second" } });
|
||||||
|
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "second", type: "commandExecution", status: "completed" } });
|
||||||
|
testClient.handleNotification("item/completed", { threadId: "thread-1", turnId: "turn-1", item: { id: "first", type: "commandExecution", status: "completed" } });
|
||||||
|
|
||||||
|
assert.deepEqual(persisted.map((entry) => ({ itemId: (entry as { itemId: string }).itemId, sequence: (entry as { sequence: number }).sequence })), [
|
||||||
|
{ itemId: "second", sequence: 2 },
|
||||||
|
{ itemId: "first", sequence: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn 完成通知会保存本轮输入与终态 turn", async () => {
|
||||||
|
const persistedTurns: unknown[] = [];
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
const running = client.startTurn("thread-1", "执行 Get-Location", [], "request");
|
||||||
|
const request = writes.find((item) => item.method === "turn/start");
|
||||||
|
assert.ok(request);
|
||||||
|
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", durationMs: 120 } });
|
||||||
|
await running;
|
||||||
|
|
||||||
|
assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "执行 Get-Location" } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn 完成状态会等待补充历史落盘后再广播", async () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
let release!: () => void;
|
||||||
|
const persisted = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: () => Promise.resolve(), recordTurn: () => persisted };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), false);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_done"), false);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_done"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app-server 失效时清除不能跨进程复用的 turn 状态", () => {
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
testClient.completedTurns.set("thread-1\0turn-1", null);
|
||||||
|
testClient.plansByTurn.set("thread-1\0turn-1", { threadId: "thread-1" });
|
||||||
|
testClient.lastUsage = { inputTokens: 1 };
|
||||||
|
|
||||||
|
testClient.failAll("app-server stopped");
|
||||||
|
|
||||||
|
assert.equal(testClient.completedTurns.size, 0);
|
||||||
|
assert.equal(testClient.plansByTurn.size, 0);
|
||||||
|
assert.equal(testClient.lastUsage, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app-server 在 turn 完成通知前退出时保存失败终态", async () => {
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const persistedTurns: unknown[] = [];
|
||||||
|
const child = { stdin: { write: () => true } };
|
||||||
|
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
const running = client.startTurn("thread-1", "执行失败任务", [], "request");
|
||||||
|
testClient.handle({ id: 1, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
testClient.failAll("Codex app-server exited: 1");
|
||||||
|
await assert.rejects(running, /Codex app-server exited/);
|
||||||
|
|
||||||
|
assert.deepEqual(persistedTurns, [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "执行失败任务" },
|
||||||
|
}]);
|
||||||
|
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed");
|
||||||
|
assert.equal((completed?.payload as { status?: string })?.status, "failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn/started 已到达但 turn/start 尚未响应时退出仍保存失败终态", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const persistedTurns: unknown[] = [];
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const history = { record: () => Promise.resolve(), recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()) };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
const running = client.startTurn("thread-1", "响应前退出", [], "request");
|
||||||
|
assert.ok(writes.some((item) => item.method === "turn/start"));
|
||||||
|
testClient.handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1", status: "inProgress" } });
|
||||||
|
testClient.failAll("Codex app-server exited: 1");
|
||||||
|
await assert.rejects(running, /Codex app-server exited/);
|
||||||
|
|
||||||
|
assert.deepEqual(persistedTurns, [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "响应前退出" },
|
||||||
|
}]);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_done"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app-server 退出时等待失败历史落盘后再结束 turn", async () => {
|
||||||
|
const writes: Array<Record<string, unknown>> = [];
|
||||||
|
const events: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
let release!: () => void;
|
||||||
|
const persisted = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||||
|
const history = { record: () => Promise.resolve(), recordTurn: () => persisted };
|
||||||
|
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||||
|
const testClient = client as unknown as TestClient;
|
||||||
|
|
||||||
|
const running = client.startTurn("thread-1", "等待落盘", [], "request");
|
||||||
|
const request = writes.find((item) => item.method === "turn/start");
|
||||||
|
assert.ok(request);
|
||||||
|
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
let settled = false;
|
||||||
|
const outcome = running.then(() => { settled = true; }, () => { settled = true; });
|
||||||
|
|
||||||
|
testClient.failAll("Codex app-server exited: 1");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(settled, false);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), false);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_done"), false);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await outcome;
|
||||||
|
assert.equal(settled, true);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"), true);
|
||||||
|
assert.equal(events.some((event) => event.type === "agent_done"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function eventScope(payload: unknown) {
|
||||||
|
const value = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
|
||||||
|
return { threadId: value.thread_id, turnId: value.turn_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventType(payload: unknown) {
|
||||||
|
return payload && typeof payload === "object" ? (payload as Record<string, unknown>).type : undefined;
|
||||||
|
}
|
||||||
@@ -2,21 +2,32 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
|
|
||||||
import { VERSION } from "../config.js";
|
import { VERSION } from "../config.js";
|
||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
import { field, type JsonRecord } from "../utils/value.js";
|
import { field, type JsonRecord } from "../utils/value.js";
|
||||||
import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
import { codexEventHistory, type CodexEventHistory } from "./codex-event-history.js";
|
||||||
|
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
||||||
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
||||||
|
|
||||||
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
||||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||||
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string };
|
type ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string };
|
||||||
|
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex?: number };
|
||||||
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
|
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
|
||||||
|
type ApprovalRequest = { id: number; method: string; params: JsonRecord; decision?: string };
|
||||||
|
type PendingTurnStart = { threadId: string; prompt: string; turnId?: string; onTurn?: (turnId: string) => void };
|
||||||
|
|
||||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const STREAM_UPDATE_INTERVAL_MS = 40;
|
const STREAM_UPDATE_INTERVAL_MS = 40;
|
||||||
|
const supplementalItemTypes = new Set(["agent_message", "reasoning", "plan", "mcp_tool_call", "command_execution", "file_change", "dynamic_tool_call", "collab_tool_call", "web_search", "image_view", "image_generation", "context_compaction"]);
|
||||||
|
|
||||||
|
/** 表示错误已经通过 app-server 终态或进程事件通知过网页。 */
|
||||||
|
export class CodexReportedError extends Error {
|
||||||
|
override name = "CodexReportedError";
|
||||||
|
}
|
||||||
|
|
||||||
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
||||||
export class CodexAppClient {
|
export class CodexAppClient {
|
||||||
@@ -24,37 +35,53 @@ export class CodexAppClient {
|
|||||||
private buffer = "";
|
private buffer = "";
|
||||||
private currentThreadId = "";
|
private currentThreadId = "";
|
||||||
private currentTurnId = "";
|
private currentTurnId = "";
|
||||||
|
private pendingTurnStart?: PendingTurnStart;
|
||||||
|
private startedTurnKeys = new Set<string>();
|
||||||
private textByItem = new Map<string, string>();
|
private textByItem = new Map<string, string>();
|
||||||
|
private reasoningTextByItem = new Map<string, Map<number, string>>();
|
||||||
private lastUsage: unknown = null;
|
private lastUsage: unknown = null;
|
||||||
private pending = new Map<number, PendingRequest>();
|
private pending = new Map<number, PendingRequest>();
|
||||||
private activeTurns = new Map<string, PendingRequest>();
|
private activeTurns = new Map<string, ActiveTurn>();
|
||||||
private completedTurns = new Map<string, Error | null>();
|
private completedTurns = new Map<string, Error | null>();
|
||||||
private pendingDeltas = new Map<string, PendingDelta>();
|
private pendingDeltas = new Map<string, PendingDelta>();
|
||||||
|
private startedItems = new Map<string, JsonRecord>();
|
||||||
|
private itemSequences = new Map<string, number>();
|
||||||
|
private nextItemSequences = new Map<string, number>();
|
||||||
private plansByTurn = new Map<string, CodexPlanUpdate>();
|
private plansByTurn = new Map<string, CodexPlanUpdate>();
|
||||||
private approvalRequests = new Map<string, { id: number; method: string; params: JsonRecord }>();
|
private approvalRequests = new Map<string, ApprovalRequest>();
|
||||||
|
private finalizingTurns = new Map<string, Promise<void>>();
|
||||||
|
private failing = false;
|
||||||
|
|
||||||
/** 保存 app-server 子进程和事件出口。 */
|
/** 保存 app-server 子进程和事件出口。 */
|
||||||
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
private constructor(private child: ChildProcess, private emit: AgentEmit, private eventHistory: Pick<CodexEventHistory, "record" | "recordTurn"> = codexEventHistory) {}
|
||||||
|
|
||||||
/** 启动并初始化 Codex app-server。 */
|
/** 启动并初始化 Codex app-server。 */
|
||||||
static async start(emit: AgentEmit, onExit: () => void) {
|
static async start(emit: AgentEmit, onExit: () => void) {
|
||||||
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
|
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
|
||||||
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
||||||
const client = new CodexAppClient(child, emit);
|
const client = new CodexAppClient(child, emit);
|
||||||
|
let stopped = false;
|
||||||
|
const stop = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
onExit();
|
||||||
|
};
|
||||||
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
||||||
child.stderr?.on("data", (chunk) => {
|
child.stderr?.on("data", (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = stripAnsi(chunk.toString()).replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
|
||||||
logger.warn("Codex app-server stderr", { text });
|
logger.warn("Codex app-server stderr", { text });
|
||||||
emit("agent_log", { text });
|
emit("agent_log", { text });
|
||||||
});
|
});
|
||||||
child.on("error", (error) => {
|
child.on("error", (error) => {
|
||||||
logger.error("Codex app-server process error", error);
|
logger.error("Codex app-server process error", error);
|
||||||
emit("agent_error", { message: error.message });
|
emit("agent_error", { message: error.message });
|
||||||
|
client.failAll(error.message, true);
|
||||||
|
stop();
|
||||||
});
|
});
|
||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
logger.warn("Codex app-server exited", { code });
|
logger.warn("Codex app-server exited", { code });
|
||||||
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
||||||
onExit();
|
stop();
|
||||||
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
||||||
});
|
});
|
||||||
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
||||||
@@ -91,6 +118,11 @@ export class CodexAppClient {
|
|||||||
return this.request("thread/archive", { threadId });
|
return this.request("thread/archive", { threadId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查询当前账号可用的 Codex 模型。 */
|
||||||
|
listModels() {
|
||||||
|
return this.request("model/list", { limit: 100, includeHidden: false });
|
||||||
|
}
|
||||||
|
|
||||||
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
||||||
planUpdates(threadId: string) {
|
planUpdates(threadId: string) {
|
||||||
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
||||||
@@ -98,35 +130,49 @@ export class CodexAppClient {
|
|||||||
|
|
||||||
/** 清理已归档线程的任务计划缓存。 */
|
/** 清理已归档线程的任务计划缓存。 */
|
||||||
clearPlanUpdates(threadId: string) {
|
clearPlanUpdates(threadId: string) {
|
||||||
this.plansByTurn.forEach((item, turnId) => {
|
this.plansByTurn.forEach((item, key) => {
|
||||||
if (item.threadId === threadId) this.plansByTurn.delete(turnId);
|
if (item.threadId === threadId) this.plansByTurn.delete(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动一个 Codex turn 并等待完成通知。 */
|
/** 启动一个 Codex turn 并等待完成通知。 */
|
||||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) {
|
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) {
|
||||||
this.currentThreadId = threadId;
|
this.currentThreadId = threadId;
|
||||||
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
|
this.currentTurnId = "";
|
||||||
const turnId = turn.id;
|
this.lastUsage = null;
|
||||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
const pendingStart: PendingTurnStart = { threadId, prompt, onTurn };
|
||||||
this.currentTurnId = turnId;
|
this.pendingTurnStart = pendingStart;
|
||||||
onTurn?.(turnId);
|
try {
|
||||||
const completed = this.completedTurns.get(turnId);
|
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
|
||||||
if (this.completedTurns.has(turnId)) {
|
const turnId = turn.id;
|
||||||
this.completedTurns.delete(turnId);
|
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||||
this.currentThreadId = "";
|
pendingStart.turnId = turnId;
|
||||||
this.currentTurnId = "";
|
this.currentTurnId = turnId;
|
||||||
if (completed) throw completed;
|
this.notifyTurnStarted(threadId, turnId, pendingStart);
|
||||||
return;
|
const turnKey = turnCacheKey(threadId, turnId);
|
||||||
|
const completed = this.completedTurns.get(turnKey);
|
||||||
|
if (this.completedTurns.has(turnKey)) {
|
||||||
|
this.completedTurns.delete(turnKey);
|
||||||
|
this.currentThreadId = "";
|
||||||
|
this.currentTurnId = "";
|
||||||
|
if (completed) throw completed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt }));
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.currentTurnId) this.currentThreadId = "";
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (this.pendingTurnStart === pendingStart) this.pendingTurnStart = undefined;
|
||||||
|
if (pendingStart.turnId) this.startedTurnKeys.delete(turnCacheKey(threadId, pendingStart.turnId));
|
||||||
}
|
}
|
||||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 中断当前正在运行的 Codex turn。 */
|
/** 中断当前正在运行且属于指定线程的 Codex turn。 */
|
||||||
async interruptCurrentTurn() {
|
async interruptCurrentTurn(requestedThreadId?: string) {
|
||||||
const threadId = this.currentThreadId;
|
const threadId = this.currentThreadId;
|
||||||
const turnId = this.currentTurnId;
|
const turnId = this.currentTurnId;
|
||||||
if (!threadId || !turnId) return false;
|
if (!threadId || !turnId || (requestedThreadId && requestedThreadId !== threadId)) return false;
|
||||||
try {
|
try {
|
||||||
logger.warn("Interrupting active Codex turn", { threadId, turnId });
|
logger.warn("Interrupting active Codex turn", { threadId, turnId });
|
||||||
await this.request("turn/interrupt", { threadId, turnId });
|
await this.request("turn/interrupt", { threadId, turnId });
|
||||||
@@ -141,10 +187,12 @@ export class CodexAppClient {
|
|||||||
resolveApproval(requestId: string, decision: string) {
|
resolveApproval(requestId: string, decision: string) {
|
||||||
const request = this.approvalRequests.get(requestId);
|
const request = this.approvalRequests.get(requestId);
|
||||||
if (!request) return false;
|
if (!request) return false;
|
||||||
this.approvalRequests.delete(requestId);
|
if (request.decision) return true;
|
||||||
|
request.decision = decision;
|
||||||
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
|
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
|
||||||
|
const accepted = decision === "accept" || decision === "acceptForSession";
|
||||||
const result = request.method === "item/permissions/requestApproval"
|
const result = request.method === "item/permissions/requestApproval"
|
||||||
? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
|
? { permissions: accepted ? permissions || {} : {}, scope: decision === "acceptForSession" ? "session" : "turn" }
|
||||||
: { decision };
|
: { decision };
|
||||||
this.write({ id: request.id, result });
|
this.write({ id: request.id, result });
|
||||||
return true;
|
return true;
|
||||||
@@ -203,14 +251,38 @@ export class CodexAppClient {
|
|||||||
private handleNotification(method: string, params: JsonRecord) {
|
private handleNotification(method: string, params: JsonRecord) {
|
||||||
if (method === "serverRequest/resolved") {
|
if (method === "serverRequest/resolved") {
|
||||||
const requestId = String(field(params, "requestId") || "");
|
const requestId = String(field(params, "requestId") || "");
|
||||||
if (requestId) this.approvalRequests.delete(requestId);
|
const request = requestId ? this.approvalRequests.get(requestId) : undefined;
|
||||||
this.emit("codex_approval_resolved", { requestId, ...params });
|
if (request) {
|
||||||
|
this.approvalRequests.delete(requestId);
|
||||||
|
this.emit("codex_approval_resolved", { ...request.params, ...params, requestId, decision: request.decision });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId };
|
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);
|
||||||
|
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || this.currentTurnId);
|
||||||
|
if (method === "turn/started") {
|
||||||
|
this.currentThreadId = threadId;
|
||||||
|
this.currentTurnId = turnId;
|
||||||
|
this.notifyTurnStarted(threadId, turnId);
|
||||||
|
}
|
||||||
|
params = { ...params, ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) };
|
||||||
|
}
|
||||||
if (method === "item/agentMessage/delta") {
|
if (method === "item/agentMessage/delta") {
|
||||||
const value = params as unknown as CodexNotificationParams<"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);
|
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/plan/delta") return this.emitDelta("plan", params as unknown as CodexNotificationParams<"item/plan/delta">);
|
||||||
@@ -219,7 +291,7 @@ export class CodexAppClient {
|
|||||||
if (method === "turn/plan/updated") {
|
if (method === "turn/plan/updated") {
|
||||||
const value = params as unknown as CodexNotificationParams<"turn/plan/updated">;
|
const value = params as unknown as CodexNotificationParams<"turn/plan/updated">;
|
||||||
const update: CodexPlanUpdate = { ...value, threadId: value.threadId || "" };
|
const update: CodexPlanUpdate = { ...value, threadId: value.threadId || "" };
|
||||||
if (update.threadId && update.turnId) this.plansByTurn.set(update.turnId, update);
|
if (update.threadId && update.turnId) this.plansByTurn.set(turnCacheKey(update.threadId, update.turnId), update);
|
||||||
params = update as unknown as JsonRecord;
|
params = update as unknown as JsonRecord;
|
||||||
}
|
}
|
||||||
if (method === "thread/tokenUsage/updated") {
|
if (method === "thread/tokenUsage/updated") {
|
||||||
@@ -229,66 +301,162 @@ export class CodexAppClient {
|
|||||||
}
|
}
|
||||||
const event = normalizeCodexNotification(method, params);
|
const event = normalizeCodexNotification(method, params);
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
|
const eventScope = codexEventScope(params);
|
||||||
|
if (event.type === "item.started" || event.type === "item.completed") {
|
||||||
|
const item = field(event, "item") as JsonRecord | undefined;
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
const threadId = String(field(event, "thread_id") || this.currentThreadId);
|
||||||
|
const turnId = String(field(event, "turn_id") || this.currentTurnId);
|
||||||
|
const key = itemCacheKey({ threadId, turnId, itemId: id });
|
||||||
|
if (id && threadId && turnId && event.type === "item.started") {
|
||||||
|
this.assignItemSequence(threadId, turnId, id);
|
||||||
|
this.startedItems.set(key, item || {});
|
||||||
|
}
|
||||||
|
if (id && event.type === "item.completed") {
|
||||||
|
const started = this.startedItems.get(key);
|
||||||
|
if (started) event.item = mergeDefinedRecord(started, item || {});
|
||||||
|
this.startedItems.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (event.type === "item.completed") {
|
if (event.type === "item.completed") {
|
||||||
const item = field(event, "item") as JsonRecord | undefined;
|
const item = field(event, "item") as JsonRecord | undefined;
|
||||||
const id = String(field(item, "id") || "");
|
const id = String(field(item, "id") || "");
|
||||||
this.flushDelta(id);
|
const key = itemCacheKey({ threadId: String(field(event, "thread_id") || this.currentThreadId), turnId: String(field(event, "turn_id") || this.currentTurnId), itemId: id });
|
||||||
const streamedText = this.textByItem.get(id);
|
this.flushDelta(key);
|
||||||
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
|
const streamedText = this.textByItem.get(key);
|
||||||
if (id) this.textByItem.delete(id);
|
if ((item?.type === "agent_message" || item?.type === "plan") && streamedText && !String(item.text || "").trim()) item.text = streamedText;
|
||||||
|
if (item?.type === "reasoning" && streamedText && !readableCodexText(field(item, "summary"))) item.summary = streamedText;
|
||||||
|
if (item?.type === "command_execution" && streamedText && !String(item.aggregatedOutput || "").trim()) item.aggregatedOutput = streamedText;
|
||||||
|
const threadId = String(field(event, "thread_id") || this.currentThreadId);
|
||||||
|
const turnId = String(field(event, "turn_id") || this.currentTurnId);
|
||||||
|
if (id && threadId && turnId && item && supplementalItemTypes.has(String(item.type || ""))) {
|
||||||
|
const sequence = this.assignItemSequence(threadId, turnId, id);
|
||||||
|
void this.eventHistory.record({ threadId, turnId, itemId: id, sequence, item }).catch((error) => logger.warn("Failed to persist Codex event history", { threadId, turnId, itemId: id, error }));
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
this.textByItem.delete(key);
|
||||||
|
this.reasoningTextByItem.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
let turnPersistence: Promise<void> | undefined;
|
||||||
if (event.type === "turn.completed") {
|
if (event.type === "turn.completed") {
|
||||||
const turn = field(params, "turn");
|
const turn = field(params, "turn");
|
||||||
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
|
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
|
||||||
const plan = this.plansByTurn.get(turnId);
|
const threadId = String(field(params, "threadId") || field(event, "thread_id") || "");
|
||||||
if (plan) this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
const planKey = turnCacheKey(threadId, turnId);
|
||||||
|
const plan = this.plansByTurn.get(planKey);
|
||||||
|
if (plan) this.plansByTurn.set(planKey, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
||||||
|
if (threadId && turnId) {
|
||||||
|
const input = this.pendingTurnStart?.threadId === threadId ? this.pendingTurnStart.prompt : "";
|
||||||
|
const turnRecord = { ...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }), ...(input ? { input } : {}) };
|
||||||
|
turnPersistence = this.eventHistory.recordTurn({ threadId, turnId, turn: turnRecord }).catch((error) => logger.warn("Failed to persist Codex turn history", { threadId, turnId, error }));
|
||||||
|
this.finalizingTurns.set(planKey, turnPersistence);
|
||||||
|
}
|
||||||
|
this.finishTurnDeltas(threadId, turnId);
|
||||||
}
|
}
|
||||||
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
|
||||||
this.emit("agent_event", { agent: "codex", ...event });
|
|
||||||
if (event.type === "turn.completed") {
|
if (event.type === "turn.completed") {
|
||||||
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
|
event.usage = this.lastUsage;
|
||||||
const turnId = turn.id;
|
const complete = () => this.completeTurn(event, params, eventScope);
|
||||||
const pending = this.activeTurns.get(turnId);
|
if (turnPersistence) void turnPersistence.then(complete);
|
||||||
const error = turn.error;
|
else complete();
|
||||||
if (pending) {
|
return;
|
||||||
this.activeTurns.delete(turnId);
|
|
||||||
error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
|
|
||||||
} else if (turnId) {
|
|
||||||
this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
|
|
||||||
}
|
|
||||||
if (turnId === this.currentTurnId) {
|
|
||||||
this.currentThreadId = "";
|
|
||||||
this.currentTurnId = "";
|
|
||||||
}
|
|
||||||
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
|
||||||
}
|
}
|
||||||
|
this.emit("agent_event", { agent: "codex", ...event });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 补充历史落盘后再广播 turn 终态,确保界面完成状态可跨 Agent 重启恢复。 */
|
||||||
|
private completeTurn(event: AgentEvent, params: JsonRecord, eventScope: ReturnType<typeof codexEventScope>) {
|
||||||
|
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
|
||||||
|
const turnId = turn.id;
|
||||||
|
const turnKey = turnCacheKey(String(field(params, "threadId") || field(event, "thread_id") || ""), turnId);
|
||||||
|
this.finalizingTurns.delete(turnKey);
|
||||||
|
this.emit("agent_event", { agent: "codex", ...event });
|
||||||
|
const pending = this.activeTurns.get(turnKey);
|
||||||
|
const error = turn.error;
|
||||||
|
const failure = error ? new CodexReportedError(error.message || "Codex turn failed") : null;
|
||||||
|
if (pending) {
|
||||||
|
this.activeTurns.delete(turnKey);
|
||||||
|
failure ? pending.reject(failure) : pending.resolve(event);
|
||||||
|
} else if (turnId) {
|
||||||
|
this.completedTurns.set(turnKey, failure);
|
||||||
|
}
|
||||||
|
if (turnId === this.currentTurnId) {
|
||||||
|
this.currentThreadId = "";
|
||||||
|
this.currentTurnId = "";
|
||||||
|
}
|
||||||
|
this.emit("agent_done", { agent: "codex", usage: event.usage, ...eventScope });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 合并并广播 Agent 文本或执行输出增量。 */
|
/** 合并并广播 Agent 文本或执行输出增量。 */
|
||||||
private emitDelta(itemType: string, params: ItemDeltaParams) {
|
private emitDelta(itemType: string, params: ItemDeltaParams) {
|
||||||
const id = params.itemId;
|
params = { ...params, threadId: params.threadId || this.currentThreadId, turnId: params.turnId || this.currentTurnId };
|
||||||
const pending = this.pendingDeltas.get(id);
|
const key = itemCacheKey(params);
|
||||||
|
this.textByItem.set(key, itemType === "reasoning" ? this.appendReasoningText(key, params) : `${this.textByItem.get(key) || ""}${params.delta}`);
|
||||||
|
const pending = this.pendingDeltas.get(key);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending.delta += params.delta;
|
pending.delta += params.delta;
|
||||||
pending.itemType = itemType;
|
pending.itemType = itemType;
|
||||||
pending.params = params;
|
pending.params = params;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pendingDeltas.set(id, {
|
this.pendingDeltas.set(key, {
|
||||||
delta: params.delta,
|
delta: params.delta,
|
||||||
itemType,
|
itemType,
|
||||||
params,
|
params,
|
||||||
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
|
timer: setTimeout(() => this.flushDelta(key), STREAM_UPDATE_INTERVAL_MS),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
||||||
private flushDelta(id: string) {
|
private flushDelta(key: string) {
|
||||||
const pending = this.pendingDeltas.get(id);
|
const pending = this.pendingDeltas.get(key);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
this.pendingDeltas.delete(id);
|
this.pendingDeltas.delete(key);
|
||||||
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) });
|
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id: pending.params.itemId, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 Codex summaryIndex 保存 reasoning 分段,顺序与线程历史一致。 */
|
||||||
|
private appendReasoningText(key: string, params: ItemDeltaParams) {
|
||||||
|
const segments = this.reasoningTextByItem.get(key) || new Map<number, string>();
|
||||||
|
this.reasoningTextByItem.set(key, segments);
|
||||||
|
return appendReasoningDelta(segments, params.summaryIndex, params.delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** turn 结束时发送最后一批增量并清理未收到 item.completed 的缓存。 */
|
||||||
|
private finishTurnDeltas(threadId: string, turnId: string) {
|
||||||
|
const prefix = `${turnCacheKey(threadId, turnId)}\0`;
|
||||||
|
[...this.pendingDeltas.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.flushDelta(key));
|
||||||
|
[...this.textByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.textByItem.delete(key));
|
||||||
|
[...this.reasoningTextByItem.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.reasoningTextByItem.delete(key));
|
||||||
|
[...this.startedItems.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.startedItems.delete(key));
|
||||||
|
[...this.itemSequences.keys()].filter((key) => key.startsWith(prefix)).forEach((key) => this.itemSequences.delete(key));
|
||||||
|
this.nextItemSequences.delete(turnCacheKey(threadId, turnId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为一个 turn 内的 item 固定开始顺序,完成通知只更新内容。 */
|
||||||
|
private assignItemSequence(threadId: string, turnId: string, itemId: string) {
|
||||||
|
const key = itemCacheKey({ threadId, turnId, itemId });
|
||||||
|
const existing = this.itemSequences.get(key);
|
||||||
|
if (existing !== undefined) return existing;
|
||||||
|
const turnKey = turnCacheKey(threadId, turnId);
|
||||||
|
const sequence = (this.nextItemSequences.get(turnKey) || 0) + 1;
|
||||||
|
this.nextItemSequences.set(turnKey, sequence);
|
||||||
|
this.itemSequences.set(key, sequence);
|
||||||
|
return sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在通知或 turn/start 响应到达时回调一次 turn 启动状态。 */
|
||||||
|
private notifyTurnStarted(threadId: string, turnId: string, fallback?: PendingTurnStart) {
|
||||||
|
if (!threadId || !turnId) return;
|
||||||
|
const pending = this.pendingTurnStart;
|
||||||
|
const registration = pending?.threadId === threadId && (!pending.turnId || pending.turnId === turnId) ? pending : fallback;
|
||||||
|
if (registration && !registration.turnId) registration.turnId = turnId;
|
||||||
|
const key = turnCacheKey(threadId, turnId);
|
||||||
|
if (this.startedTurnKeys.has(key)) return;
|
||||||
|
if (!registration) return;
|
||||||
|
this.startedTurnKeys.add(key);
|
||||||
|
registration.onTurn?.(turnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自动回复 app-server 发起的授权或交互请求。 */
|
/** 自动回复 app-server 发起的授权或交互请求。 */
|
||||||
@@ -319,19 +487,79 @@ export class CodexAppClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
||||||
private failAll(message: string) {
|
private failAll(message: string, reported = false) {
|
||||||
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
if (this.failing) return;
|
||||||
|
this.failing = true;
|
||||||
|
this.approvalRequests.forEach((request, requestId) => this.emit("codex_approval_resolved", { ...request.params, requestId, decision: request.decision || "cancel" }));
|
||||||
|
const failedTurns = new Map<string, { threadId: string; turnId: string; prompt: string }>();
|
||||||
|
this.activeTurns.forEach(({ threadId, turnId, prompt }, key) => {
|
||||||
|
if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt });
|
||||||
|
});
|
||||||
|
const pendingStart = this.pendingTurnStart;
|
||||||
|
if (pendingStart?.turnId) {
|
||||||
|
const key = turnCacheKey(pendingStart.threadId, pendingStart.turnId);
|
||||||
|
if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt });
|
||||||
|
}
|
||||||
|
const finalizing = [...this.finalizingTurns.values()];
|
||||||
|
const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt }) => {
|
||||||
|
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
|
||||||
|
return this.eventHistory.recordTurn({ threadId, turnId, turn }).catch((historyError) => logger.warn("Failed to persist Codex turn failure", { threadId, turnId, error: historyError }));
|
||||||
|
});
|
||||||
|
const error = reported || failedTurns.size || finalizing.length ? new CodexReportedError(message) : new Error(message);
|
||||||
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
||||||
this.pending.clear();
|
|
||||||
this.activeTurns.clear();
|
|
||||||
this.pendingDeltas.clear();
|
this.pendingDeltas.clear();
|
||||||
this.textByItem.clear();
|
this.textByItem.clear();
|
||||||
|
this.reasoningTextByItem.clear();
|
||||||
|
this.startedItems.clear();
|
||||||
|
this.itemSequences.clear();
|
||||||
|
this.nextItemSequences.clear();
|
||||||
|
this.plansByTurn.clear();
|
||||||
|
this.completedTurns.clear();
|
||||||
this.approvalRequests.clear();
|
this.approvalRequests.clear();
|
||||||
|
this.pendingTurnStart = undefined;
|
||||||
|
this.startedTurnKeys.clear();
|
||||||
|
this.lastUsage = null;
|
||||||
this.currentThreadId = "";
|
this.currentThreadId = "";
|
||||||
this.currentTurnId = "";
|
this.currentTurnId = "";
|
||||||
|
void Promise.all([...finalizing, ...persistence]).then(() => {
|
||||||
|
failedTurns.forEach(({ threadId, turnId, prompt }) => {
|
||||||
|
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
|
||||||
|
this.emit("agent_event", { agent: "codex", type: "turn.completed", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId, turn });
|
||||||
|
this.emit("agent_done", { agent: "codex", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId });
|
||||||
|
});
|
||||||
|
this.pending.forEach((item) => item.reject(error));
|
||||||
|
this.activeTurns.forEach((item) => item.reject(error));
|
||||||
|
this.pending.clear();
|
||||||
|
this.activeTurns.clear();
|
||||||
|
this.finalizingTurns.clear();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将 Codex 的字符串、摘要数组或文本对象转换为展示文本。 */
|
||||||
|
function readableCodexText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (Array.isArray(value)) return value.map(readableCodexText).filter(Boolean).join("\n");
|
||||||
|
if (!value || typeof value !== "object") return "";
|
||||||
|
return readableCodexText(field(value, "text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并单个 reasoning 分段增量并按 summaryIndex 输出。 */
|
||||||
|
export function appendReasoningDelta(segments: Map<number, string>, summaryIndex: number | undefined, delta: string) {
|
||||||
|
const index = Number.isInteger(summaryIndex) ? Number(summaryIndex) : 0;
|
||||||
|
segments.set(index, `${segments.get(index) || ""}${delta}`);
|
||||||
|
return [...segments.entries()].sort(([left], [right]) => left - right).map(([, text]) => text.trim()).filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成仅供进程内缓存使用的完整 turn 与 item 作用域键。 */
|
||||||
|
function itemCacheKey(scope: { threadId: string; turnId: string; itemId: string }) {
|
||||||
|
return `${turnCacheKey(scope.threadId, scope.turnId)}\0${scope.itemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnCacheKey(threadId: string, turnId: string) {
|
||||||
|
return `${threadId}\0${turnId}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
|
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
|
||||||
function canvasAgentMcpCommand() {
|
function canvasAgentMcpCommand() {
|
||||||
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
||||||
@@ -389,7 +617,7 @@ function normalizeItem(item: unknown) {
|
|||||||
if (value.type === "commandExecution") value.type = "command_execution";
|
if (value.type === "commandExecution") value.type = "command_execution";
|
||||||
if (value.type === "fileChange") value.type = "file_change";
|
if (value.type === "fileChange") value.type = "file_change";
|
||||||
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
|
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
|
||||||
if (value.type === "collabToolCall") value.type = "collab_tool_call";
|
if (value.type === "collabToolCall" || value.type === "collabAgentToolCall") value.type = "collab_tool_call";
|
||||||
if (value.type === "webSearch") value.type = "web_search";
|
if (value.type === "webSearch") value.type = "web_search";
|
||||||
if (value.type === "imageView") value.type = "image_view";
|
if (value.type === "imageView") value.type = "image_view";
|
||||||
if (value.type === "imageGeneration") value.type = "image_generation";
|
if (value.type === "imageGeneration") value.type = "image_generation";
|
||||||
@@ -399,6 +627,14 @@ function normalizeItem(item: unknown) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeDefinedRecord(started: JsonRecord, completed: JsonRecord) {
|
||||||
|
const merged = { ...started };
|
||||||
|
Object.entries(completed).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) merged[key] = value;
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
/** 将 Codex token usage 转换为前端字段。 */
|
/** 将 Codex token usage 转换为前端字段。 */
|
||||||
function normalizeUsage(params: CodexNotificationParams<"thread/tokenUsage/updated">) {
|
function normalizeUsage(params: CodexNotificationParams<"thread/tokenUsage/updated">) {
|
||||||
const last = params.tokenUsage.last;
|
const last = params.tokenUsage.last;
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { CONFIG_DIR } from "../config.js";
|
||||||
|
import type { CodexSupplementalHistory, CodexSupplementalHistoryItem, CodexSupplementalHistoryTurn } from "./codex-history.js";
|
||||||
|
|
||||||
|
type CodexEventHistoryData = { version: 1; items: CodexSupplementalHistoryItem[]; turns: CodexSupplementalHistoryTurn[] };
|
||||||
|
|
||||||
|
const MAX_ITEMS = 5000;
|
||||||
|
const MAX_STRING_LENGTH = 100_000;
|
||||||
|
|
||||||
|
export const CODEX_EVENT_HISTORY_FILE = path.join(CONFIG_DIR, "codex-event-history.json");
|
||||||
|
|
||||||
|
/** 保存 Codex 持久线程投影可能省略的实时完成事件。 */
|
||||||
|
export class CodexEventHistory {
|
||||||
|
private data?: CodexEventHistoryData;
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(private file = CODEX_EVENT_HISTORY_FILE) {}
|
||||||
|
|
||||||
|
/** 按 threadId、turnId 和 itemId 新增或更新一条补充事件。 */
|
||||||
|
record(entry: CodexSupplementalHistoryItem) {
|
||||||
|
return this.run(async () => {
|
||||||
|
const data = await this.load();
|
||||||
|
const index = data.items.findIndex((item) => sameItem(item, entry));
|
||||||
|
const previous = index >= 0 ? data.items[index] : undefined;
|
||||||
|
const nextEntry = normalizeEntry({
|
||||||
|
...entry,
|
||||||
|
...(entry.sequence === undefined && previous?.sequence !== undefined ? { sequence: previous.sequence } : {}),
|
||||||
|
item: mergeRecord(previous?.item, entry.item),
|
||||||
|
});
|
||||||
|
const items = [...data.items];
|
||||||
|
if (index >= 0) items[index] = nextEntry;
|
||||||
|
else items.push(nextEntry);
|
||||||
|
const nextData = { ...data, items: items.slice(-MAX_ITEMS) };
|
||||||
|
await this.save(nextData);
|
||||||
|
this.data = nextData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存 turn 终态,使标准线程历史尚未物化时仍可恢复完整轮次。 */
|
||||||
|
recordTurn(entry: CodexSupplementalHistoryTurn) {
|
||||||
|
return this.run(async () => {
|
||||||
|
const data = await this.load();
|
||||||
|
const index = data.turns.findIndex((turn) => sameTurn(turn, entry));
|
||||||
|
const previous = index >= 0 ? data.turns[index] : undefined;
|
||||||
|
const nextEntry = normalizeTurn({ ...entry, turn: mergeRecord(previous?.turn, entry.turn) });
|
||||||
|
const turns = [...data.turns];
|
||||||
|
if (index >= 0) turns[index] = nextEntry;
|
||||||
|
else turns.push(nextEntry);
|
||||||
|
const nextData = { ...data, turns: turns.slice(-MAX_ITEMS) };
|
||||||
|
await this.save(nextData);
|
||||||
|
this.data = nextData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 item 开始顺序返回指定线程的补充事件。 */
|
||||||
|
readThread(threadId: string) {
|
||||||
|
return this.run(async (): Promise<CodexSupplementalHistory> => {
|
||||||
|
const data = await this.load();
|
||||||
|
return {
|
||||||
|
items: data.items.filter((item) => item.threadId === threadId).sort(compareEntries).map(cloneEntry),
|
||||||
|
turns: data.turns.filter((turn) => turn.threadId === threadId).map(cloneTurn),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档线程后删除其补充事件。 */
|
||||||
|
removeThread(threadId: string) {
|
||||||
|
return this.run(async () => {
|
||||||
|
const data = await this.load();
|
||||||
|
const items = data.items.filter((item) => item.threadId !== threadId);
|
||||||
|
const turns = data.turns.filter((turn) => turn.threadId !== threadId);
|
||||||
|
if (items.length === data.items.length && turns.length === data.turns.length) return;
|
||||||
|
const nextData = { version: 1 as const, items, turns };
|
||||||
|
await this.save(nextData);
|
||||||
|
this.data = nextData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private run<T>(task: () => Promise<T>) {
|
||||||
|
const result = this.queue.then(task, task);
|
||||||
|
this.queue = result.then(() => undefined, () => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load() {
|
||||||
|
if (this.data) return this.data;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(await fs.readFile(this.file, "utf8")) as Partial<CodexEventHistoryData>;
|
||||||
|
this.data = value.version === 1 && Array.isArray(value.items) && Array.isArray(value.turns) ? { version: 1, items: value.items.map(normalizeEntry), turns: value.turns.map(normalizeTurn) } : emptyHistory();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
||||||
|
this.data = emptyHistory();
|
||||||
|
}
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(data: CodexEventHistoryData) {
|
||||||
|
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
||||||
|
const temporaryFile = `${this.file}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2));
|
||||||
|
await fs.rename(temporaryFile, this.file);
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(temporaryFile).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const codexEventHistory = new CodexEventHistory();
|
||||||
|
|
||||||
|
function emptyHistory(): CodexEventHistoryData {
|
||||||
|
return { version: 1, items: [], turns: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameItem(left: CodexSupplementalHistoryItem, right: CodexSupplementalHistoryItem) {
|
||||||
|
return left.threadId === right.threadId && left.turnId === right.turnId && left.itemId === right.itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTurn(left: CodexSupplementalHistoryTurn, right: CodexSupplementalHistoryTurn) {
|
||||||
|
return left.threadId === right.threadId && left.turnId === right.turnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneEntry(entry: CodexSupplementalHistoryItem) {
|
||||||
|
return structuredClone(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneTurn(entry: CodexSupplementalHistoryTurn) {
|
||||||
|
return structuredClone(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareEntries(left: CodexSupplementalHistoryItem, right: CodexSupplementalHistoryItem) {
|
||||||
|
if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) return left.sequence - right.sequence;
|
||||||
|
if (left.sequence !== undefined) return -1;
|
||||||
|
if (right.sequence !== undefined) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(entry: CodexSupplementalHistoryItem): CodexSupplementalHistoryItem {
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
...(entry.sequence === undefined ? {} : { sequence: entry.sequence }),
|
||||||
|
item: truncateRecord(entry.item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTurn(entry: CodexSupplementalHistoryTurn): CodexSupplementalHistoryTurn {
|
||||||
|
return { ...entry, turn: truncateRecord({ ...entry.turn, id: entry.turnId }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRecord(previous: Record<string, unknown> | undefined, next: Record<string, unknown>) {
|
||||||
|
if (!previous) return next;
|
||||||
|
const merged = { ...previous };
|
||||||
|
Object.entries(next).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) merged[key] = value;
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateRecord(value: Record<string, unknown>) {
|
||||||
|
return truncateValue(value) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}\n[输出已截断]` : value;
|
||||||
|
if (Array.isArray(value)) return value.map(truncateValue);
|
||||||
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, truncateValue(item)]));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { appendReasoningDelta } from "./codex-client.js";
|
||||||
|
import { CodexEventHistory } from "./codex-event-history.js";
|
||||||
|
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||||||
|
|
||||||
|
test("线程摘要提取结构化状态类型", () => {
|
||||||
|
assert.equal(summarizeCodexThread({ id: "thread-1", status: { type: "notLoaded" } }).status, "notLoaded");
|
||||||
|
assert.equal(summarizeCodexThread({ id: "thread-1", status: "idle" }).status, "idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reasoning 增量按 summaryIndex 聚合", () => {
|
||||||
|
const segments = new Map<number, string>();
|
||||||
|
appendReasoningDelta(segments, 1, "第二");
|
||||||
|
appendReasoningDelta(segments, 0, "第一");
|
||||||
|
assert.equal(appendReasoningDelta(segments, 1, "段"), "第一\n第二段");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("只有终态 turn 会交给历史快照作为权威内容", () => {
|
||||||
|
assert.deepEqual(settledTurnIds({ turns: [
|
||||||
|
{ id: "completed", status: "completed" },
|
||||||
|
{ id: "failed", status: "failed" },
|
||||||
|
{ id: "interrupted", status: "interrupted" },
|
||||||
|
{ id: "running", status: "inProgress" },
|
||||||
|
] }), ["completed", "failed", "interrupted"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史的运行中状态不会覆盖本地已经完成的 turn", () => {
|
||||||
|
const supplemental = {
|
||||||
|
items: [{ threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", sequence: 1, item: { id: "assistant-1", type: "agent_message", text: "完整回答" } }],
|
||||||
|
turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "问题" } }],
|
||||||
|
};
|
||||||
|
const thread = { id: "thread-1", turns: [{ id: "turn-1", status: "inProgress", items: [] }] };
|
||||||
|
|
||||||
|
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
|
||||||
|
assert.deepEqual(threadMessages(thread, [], supplemental).map((item) => item.text), ["问题", "完整回答"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史进入终态后保持权威状态", () => {
|
||||||
|
const supplemental = {
|
||||||
|
items: [],
|
||||||
|
turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "问题" } }],
|
||||||
|
};
|
||||||
|
const thread = { id: "thread-1", turns: [{ id: "turn-1", status: "failed", error: { message: "标准错误" }, items: [] }] };
|
||||||
|
|
||||||
|
const messages = threadMessages(thread, [], supplemental);
|
||||||
|
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
|
||||||
|
assert.equal(messages.at(-1)?.role, "error");
|
||||||
|
assert.equal(messages.at(-1)?.text, "标准错误");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("线程历史将多个 reasoning 条目投影为一张稳定卡片", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{
|
||||||
|
id: "turn-1",
|
||||||
|
status: "completed",
|
||||||
|
items: [
|
||||||
|
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "问题" }] },
|
||||||
|
{ id: "reasoning-1", type: "reasoning", summary: ["第一段", "第二段"] },
|
||||||
|
{ id: "reasoning-2", type: "reasoning", summary: [{ text: "第三段" }] },
|
||||||
|
{ id: "assistant-1", type: "agentMessage", text: "回答" },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const reasoning = messages.filter((item) => item.itemId === "synthetic:reasoning");
|
||||||
|
assert.equal(reasoning.length, 1);
|
||||||
|
assert.equal(reasoning[0].id, "thread-1:turn-1:synthetic:reasoning");
|
||||||
|
assert.equal(reasoning[0].text, "第一段\n第二段\n\n第三段");
|
||||||
|
assert.deepEqual(reasoning[0].activityItems, { "reasoning-1": "第一段\n第二段", "reasoning-2": "第三段" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("空 reasoning 不进入完成后的线程历史", () => {
|
||||||
|
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ id: "reasoning-1", type: "reasoning", summary: [] }] }] });
|
||||||
|
assert.equal(messages.some((item) => item.itemId === "synthetic:reasoning"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("用户消息使用与实时消息一致的 turn 级稳定 ID", () => {
|
||||||
|
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ id: "codex-user-1", type: "userMessage", content: [{ type: "text", text: "问题" }] }] }] });
|
||||||
|
assert.equal(messages[0].id, "thread-1:turn-1:synthetic:user");
|
||||||
|
assert.equal(messages[0].itemId, "synthetic:user");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Codex 历史省略命令时使用补充事件恢复完整命令卡片", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{
|
||||||
|
id: "turn-1",
|
||||||
|
status: "completed",
|
||||||
|
items: [
|
||||||
|
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "执行命令" }] },
|
||||||
|
{ id: "assistant-1", type: "agentMessage", text: "完成" },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}, [], { items: [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "command-1",
|
||||||
|
item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed", exitCode: 0, aggregatedOutput: "D:\\infinite-canvas" },
|
||||||
|
}], turns: [] });
|
||||||
|
|
||||||
|
const command = messages.find((item) => item.itemId === "command-1");
|
||||||
|
assert.equal(command?.text, "Get-Location");
|
||||||
|
assert.deepEqual(command?.detail, {
|
||||||
|
kind: "command",
|
||||||
|
status: "completed",
|
||||||
|
rows: [{ label: "退出状态", value: "0" }],
|
||||||
|
output: "D:\\infinite-canvas",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Codex 标准历史与补充事件重复时以标准历史为准", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "标准命令", status: "completed" }] }],
|
||||||
|
}, [], { items: [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "command-1",
|
||||||
|
item: { id: "command-1", type: "command_execution", command: "补充命令", status: "completed" },
|
||||||
|
}], turns: [] });
|
||||||
|
|
||||||
|
assert.equal(messages.filter((item) => item.itemId === "command-1").length, 1);
|
||||||
|
assert.equal(messages.find((item) => item.itemId === "command-1")?.text, "标准命令");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史重写 item id 时仍与同一条实时事件合并", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [
|
||||||
|
{ id: "item-13", type: "userMessage", content: [{ type: "text", text: "执行命令" }] },
|
||||||
|
{ id: "item-14", type: "agentMessage", text: "我将仅查看当前路径。" },
|
||||||
|
{ id: "item-15", type: "agentMessage", text: "完成。" },
|
||||||
|
] }],
|
||||||
|
}, [], { items: [
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-commentary", sequence: 2, item: { id: "msg-commentary", type: "agent_message", text: "我将仅查看当前路径。" } },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 3, item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed" } },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-final", sequence: 4, item: { id: "msg-final", type: "agent_message", text: "完成。" } },
|
||||||
|
], turns: [] });
|
||||||
|
|
||||||
|
assert.deepEqual(messages.map((item) => item.itemId), ["synthetic:user", "msg-commentary", "command-1", "msg-final"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史正文损坏且重写 item id 时不会重复显示同一条回复", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [{ id: "item-38", type: "agentMessage", text: "连接��" }] }],
|
||||||
|
}, [], { items: [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "msg-007367",
|
||||||
|
sequence: 1,
|
||||||
|
item: { id: "msg-007367", type: "agent_message", text: "连接服务" },
|
||||||
|
}], turns: [] });
|
||||||
|
|
||||||
|
const replies = messages.filter((item) => item.role === "assistant");
|
||||||
|
assert.equal(replies.length, 1);
|
||||||
|
assert.equal(replies[0].itemId, "msg-007367");
|
||||||
|
assert.equal(replies[0].text, "连接服务");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史同时保留损坏临时条目和稳定条目时移除损坏副本", () => {
|
||||||
|
const cleanText = "目前能排除“网页没开”和“Canvas Agent 没连”:前端和 Agent 均正常。";
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [
|
||||||
|
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "检查连接" }] },
|
||||||
|
{ id: "item-38", type: "agentMessage", text: "目前能排除“网页没开”和“Canvas Agent 没连��:前端和 Agent 均正常。" },
|
||||||
|
{ id: "command-1", type: "commandExecution", command: "Get-NetTCPConnection", status: "completed" },
|
||||||
|
{ id: "msg-stable", type: "agentMessage", text: cleanText },
|
||||||
|
] }],
|
||||||
|
}, [], { items: [
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-NetTCPConnection", status: "completed" } },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "msg-stable", sequence: 2, item: { id: "msg-stable", type: "agent_message", text: cleanText } },
|
||||||
|
], turns: [] });
|
||||||
|
|
||||||
|
const replies = messages.filter((item) => item.role === "assistant");
|
||||||
|
assert.equal(replies.length, 1);
|
||||||
|
assert.equal(replies[0].itemId, "msg-stable");
|
||||||
|
assert.equal(replies[0].text, cleanText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史条目稀疏时按字段补全补充事件", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "标准命令", status: "completed" }] }],
|
||||||
|
}, [], { items: [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "command-1",
|
||||||
|
sequence: 1,
|
||||||
|
item: { id: "command-1", type: "command_execution", command: "补充命令", cwd: "D:\\infinite-canvas", aggregatedOutput: "输出", exitCode: 0 },
|
||||||
|
}], turns: [] });
|
||||||
|
|
||||||
|
const command = messages.find((item) => item.itemId === "command-1");
|
||||||
|
assert.equal(command?.text, "标准命令");
|
||||||
|
assert.deepEqual(command?.detail, {
|
||||||
|
kind: "command",
|
||||||
|
status: "completed",
|
||||||
|
rows: [{ label: "工作目录", value: "D:\\infinite-canvas" }, { label: "退出状态", value: "0" }],
|
||||||
|
output: "输出",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史的 falsy 字段仍优先于补充事件", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [{ id: "command-1", type: "commandExecution", command: "", cwd: null, status: "completed", exitCode: 0, success: false, aggregatedOutput: "" }] }],
|
||||||
|
}, [], { items: [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "command-1",
|
||||||
|
sequence: 1,
|
||||||
|
item: { id: "command-1", type: "command_execution", command: "补充命令", cwd: "补充目录", exitCode: 9, success: true, aggregatedOutput: "补充输出" },
|
||||||
|
}], turns: [] });
|
||||||
|
|
||||||
|
const command = messages.find((item) => item.itemId === "command-1");
|
||||||
|
assert.equal(command?.text, "命令执行失败");
|
||||||
|
assert.deepEqual(command?.detail, { kind: "command", status: "failed", rows: [{ label: "退出状态", value: "0" }], output: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("补充事件按 item 开始顺序插入标准历史锚点之间", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [
|
||||||
|
{ id: "user-1", type: "userMessage", content: [{ type: "text", text: "执行" }] },
|
||||||
|
{ id: "second", type: "commandExecution", command: "second", status: "completed" },
|
||||||
|
{ id: "assistant", type: "agentMessage", text: "完成" },
|
||||||
|
] }],
|
||||||
|
}, [], { items: [
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "first", sequence: 1, item: { id: "first", type: "command_execution", command: "first", status: "completed" } },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "second", sequence: 2, item: { id: "second", type: "command_execution", command: "补充 second", status: "completed" } },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "third", sequence: 3, item: { id: "third", type: "command_execution", command: "third", status: "completed" } },
|
||||||
|
{ threadId: "thread-1", turnId: "turn-1", itemId: "assistant", sequence: 4, item: { id: "assistant", type: "agent_message", text: "补充回答" } },
|
||||||
|
], turns: [] });
|
||||||
|
|
||||||
|
assert.deepEqual(messages.map((item) => item.itemId), ["synthetic:user", "first", "second", "third", "assistant"]);
|
||||||
|
assert.equal(messages.find((item) => item.itemId === "second")?.text, "second");
|
||||||
|
assert.equal(messages.find((item) => item.itemId === "assistant")?.text, "完成");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("补充事件写入本地 JSON 后可在 Agent 重启后恢复", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const file = path.join(directory, "codex-event-history.json");
|
||||||
|
const entry = {
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "command-1",
|
||||||
|
item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await new CodexEventHistory(file).record(entry);
|
||||||
|
|
||||||
|
assert.deepEqual(await new CodexEventHistory(file).readThread("thread-1"), { items: [entry], turns: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("补充历史 JSON 损坏后会从空历史恢复并允许重新写入", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const file = path.join(directory, "codex-event-history.json");
|
||||||
|
await fs.writeFile(file, "{\"version\":1,\"items\":[");
|
||||||
|
const history = new CodexEventHistory(file);
|
||||||
|
|
||||||
|
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
|
||||||
|
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "item-1", item: { id: "item-1", type: "agent_message", text: "恢复成功" } });
|
||||||
|
|
||||||
|
assert.deepEqual(await new CodexEventHistory(file).readThread("thread-1"), {
|
||||||
|
items: [{ threadId: "thread-1", turnId: "turn-1", itemId: "item-1", item: { id: "item-1", type: "agent_message", text: "恢复成功" } }],
|
||||||
|
turns: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("标准历史尚未物化 turn 时从本地终态事件恢复完整对话", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const file = path.join(directory, "codex-event-history.json");
|
||||||
|
const history = new CodexEventHistory(file);
|
||||||
|
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-Location", status: "completed", exitCode: 0, aggregatedOutput: "D:\\infinite-canvas" } });
|
||||||
|
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "assistant-1", sequence: 2, item: { id: "assistant-1", type: "agent_message", text: "完成" } });
|
||||||
|
await history.recordTurn({ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", input: "执行 Get-Location" } });
|
||||||
|
|
||||||
|
const supplemental = await new CodexEventHistory(file).readThread("thread-1");
|
||||||
|
const thread = { id: "thread-1", turns: [] };
|
||||||
|
const messages = threadMessages(thread, [], supplemental);
|
||||||
|
|
||||||
|
assert.deepEqual(messages.map((message) => message.itemId), ["synthetic:user", "command-1", "assistant-1"]);
|
||||||
|
assert.equal(messages[0].text, "执行 Get-Location");
|
||||||
|
assert.equal(messages[1].text, "Get-Location");
|
||||||
|
assert.equal(messages[2].text, "完成");
|
||||||
|
assert.deepEqual(settledTurnIds(thread, supplemental), ["turn-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("归档线程只清除该线程的补充事件", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const history = new CodexEventHistory(path.join(directory, "codex-event-history.json"));
|
||||||
|
const entry = (threadId: string) => ({ threadId, turnId: "turn-1", itemId: "command-1", item: { id: "command-1", type: "command_execution" } });
|
||||||
|
await history.record(entry("thread-1"));
|
||||||
|
await history.record(entry("thread-2"));
|
||||||
|
|
||||||
|
await history.removeThread("thread-1");
|
||||||
|
|
||||||
|
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
|
||||||
|
assert.deepEqual(await history.readThread("thread-2"), { items: [entry("thread-2")], turns: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("补充事件更新时保留已有字段并限制单项输出大小", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const history = new CodexEventHistory(path.join(directory, "codex-event-history.json"));
|
||||||
|
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", sequence: 1, item: { id: "command-1", type: "command_execution", command: "Get-Location", cwd: "D:\\infinite-canvas" } });
|
||||||
|
await history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", item: { id: "command-1", status: "completed", aggregatedOutput: "x".repeat(100_001) } });
|
||||||
|
|
||||||
|
const [entry] = (await history.readThread("thread-1")).items;
|
||||||
|
assert.equal(entry.sequence, 1);
|
||||||
|
assert.equal(entry.item.command, "Get-Location");
|
||||||
|
assert.equal(entry.item.cwd, "D:\\infinite-canvas");
|
||||||
|
assert.equal(String(entry.item.aggregatedOutput).endsWith("[输出已截断]"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("补充事件落盘失败时不污染内存读取", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-agent-history-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const file = path.join(directory, "history-target");
|
||||||
|
const history = new CodexEventHistory(file);
|
||||||
|
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
|
||||||
|
await fs.mkdir(file);
|
||||||
|
await assert.rejects(() => history.record({ threadId: "thread-1", turnId: "turn-1", itemId: "command-1", item: { id: "command-1", type: "command_execution" } }));
|
||||||
|
assert.deepEqual(await history.readThread("thread-1"), { items: [], turns: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn 错误优先于条目错误且只生成一张错误卡片", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "failed", error: { message: "turn error" }, items: [{ id: "error-1", type: "error", message: "item error" }] }],
|
||||||
|
});
|
||||||
|
const errors = messages.filter((item) => item.role === "error");
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
assert.equal(errors[0].id, "thread-1:turn-1:synthetic:error");
|
||||||
|
assert.equal(errors[0].text, "turn error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("失败条目的状态与错误详情在历史中保持完整", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{
|
||||||
|
id: "turn-1",
|
||||||
|
status: "completed",
|
||||||
|
items: [
|
||||||
|
{ id: "command-1", type: "commandExecution", command: "exit 1", status: "completed", exitCode: 1, aggregatedOutput: "failed" },
|
||||||
|
{ id: "image-1", type: "imageGeneration", status: "completed", success: false },
|
||||||
|
{ id: "context-1", type: "contextCompaction", status: "failed", error: { message: "compact failed" } },
|
||||||
|
{ id: "collab-1", type: "collabToolCall", status: "completed", success: false },
|
||||||
|
{ id: "reasoning-1", type: "reasoning", status: "failed", summary: ["分析失败"] },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const byId = new Map(messages.map((item) => [item.itemId, item]));
|
||||||
|
assert.equal((byId.get("command-1")?.detail as { status?: string }).status, "failed");
|
||||||
|
assert.equal((byId.get("image-1")?.detail as { status?: string }).status, "failed");
|
||||||
|
assert.equal(byId.get("context-1")?.text, "compact failed");
|
||||||
|
assert.equal((byId.get("context-1")?.detail as { output?: string }).output, "compact failed");
|
||||||
|
assert.equal((byId.get("collab-1")?.detail as { status?: string }).status, "failed");
|
||||||
|
assert.equal((byId.get("synthetic:reasoning")?.detail as { status?: string }).status, "failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("新版协作工具条目在标准历史和补充历史中保持同一张卡片", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [{ id: "collab-1", type: "collabAgentToolCall", status: "completed" }] }],
|
||||||
|
}, [], { items: [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
itemId: "collab-1",
|
||||||
|
sequence: 1,
|
||||||
|
item: { id: "collab-1", type: "collab_tool_call", status: "completed" },
|
||||||
|
}], turns: [] });
|
||||||
|
|
||||||
|
assert.equal(messages.filter((item) => item.itemId === "collab-1").length, 1);
|
||||||
|
assert.equal(messages[0].title, "协作处理");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("尚未完成的 turn 只由实时事件展示,不进入历史快照", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "inProgress", items: [{ id: "reasoning-1", type: "reasoning", status: "pending", summary: ["正在分析"] }] }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(messages, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少稳定 item id 的历史条目不会生成无法对齐的临时消息", () => {
|
||||||
|
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ type: "agentMessage", text: "回答" }] }] });
|
||||||
|
assert.equal(messages.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("通用工具只由标题和结构化状态表达完成结果", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{ id: "turn-1", status: "completed", items: [{ id: "tool-1", type: "mcpToolCall", tool: "list_mcp_resources", status: "completed" }] }],
|
||||||
|
});
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.equal(messages[0].title, "调用工具:list_mcp_resources");
|
||||||
|
assert.equal(messages[0].text, "");
|
||||||
|
assert.equal((messages[0].detail as { status?: string }).status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("命令完成条目缺少 command 时仍保留历史卡片", () => {
|
||||||
|
const messages = threadMessages({
|
||||||
|
id: "thread-1",
|
||||||
|
turns: [{
|
||||||
|
id: "turn-1",
|
||||||
|
status: "completed",
|
||||||
|
items: [{ id: "command-1", type: "commandExecution", status: "completed", exitCode: 0, aggregatedOutput: "done" }],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.equal(messages[0].itemId, "command-1");
|
||||||
|
assert.equal(messages[0].title, "执行命令");
|
||||||
|
assert.equal(messages[0].text, "命令已完成");
|
||||||
|
assert.equal((messages[0].detail as { status?: string }).status, "completed");
|
||||||
|
});
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import { field } from "../utils/value.js";
|
import { field } from "../utils/value.js";
|
||||||
import type { CodexPlanUpdate } from "./codex-protocol.js";
|
import type { CodexPlanUpdate } from "./codex-protocol.js";
|
||||||
|
|
||||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
type AgentHistoryMessage = { id: string; itemId: string; threadId: string; turnId: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; activityItems?: Record<string, string> };
|
||||||
|
export type CodexSupplementalHistoryItem = { threadId: string; turnId: string; itemId: string; sequence?: number; item: Record<string, unknown> };
|
||||||
|
export type CodexSupplementalHistoryTurn = { threadId: string; turnId: string; turn: Record<string, unknown> };
|
||||||
|
export type CodexSupplementalHistory = { items: CodexSupplementalHistoryItem[]; turns: CodexSupplementalHistoryTurn[] };
|
||||||
|
|
||||||
|
const emptySupplementalHistory: CodexSupplementalHistory = { items: [], turns: [] };
|
||||||
|
|
||||||
/** 将 Codex 线程转换为列表展示所需的摘要。 */
|
/** 将 Codex 线程转换为列表展示所需的摘要。 */
|
||||||
export function summarizeCodexThread(thread: unknown) {
|
export function summarizeCodexThread(thread: unknown) {
|
||||||
|
const status = field(thread, "status");
|
||||||
return {
|
return {
|
||||||
id: String(field(thread, "id") || ""),
|
id: String(field(thread, "id") || ""),
|
||||||
sessionId: String(field(thread, "sessionId") || ""),
|
sessionId: String(field(thread, "sessionId") || ""),
|
||||||
preview: displayUserText(String(field(thread, "preview") || "")),
|
preview: displayUserText(String(field(thread, "preview") || "")),
|
||||||
name: stringOrNull(field(thread, "name")),
|
name: stringOrNull(field(thread, "name")),
|
||||||
cwd: String(field(thread, "cwd") || ""),
|
cwd: String(field(thread, "cwd") || ""),
|
||||||
status: String(field(thread, "status") || ""),
|
status: typeof status === "string" ? status : String(field(status, "type") || ""),
|
||||||
source: field(thread, "source"),
|
source: field(thread, "source"),
|
||||||
threadSource: field(thread, "threadSource"),
|
threadSource: field(thread, "threadSource"),
|
||||||
createdAt: Number(field(thread, "createdAt") || 0),
|
createdAt: Number(field(thread, "createdAt") || 0),
|
||||||
@@ -19,22 +25,62 @@ export function summarizeCodexThread(thread: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回已经终结、可由持久化历史作为权威内容的 turn。 */
|
||||||
|
export function settledTurnIds(thread: unknown, supplementalHistory: CodexSupplementalHistory = emptySupplementalHistory) {
|
||||||
|
return mergeHistoryTurns(thread, supplementalHistory).flatMap((turn) => {
|
||||||
|
const id = String(field(turn, "id") || "");
|
||||||
|
return id && isSettledTurn(turn) ? [id] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 将 Codex turn items 转换为网页聊天历史。 */
|
/** 将 Codex turn items 转换为网页聊天历史。 */
|
||||||
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = []): AgentHistoryMessage[] {
|
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = [], supplementalHistory: CodexSupplementalHistory = emptySupplementalHistory): AgentHistoryMessage[] {
|
||||||
const turns = arrayValue(field(thread, "turns"));
|
const threadId = String(field(thread, "id") || "");
|
||||||
|
if (!threadId) return [];
|
||||||
|
const turns = mergeHistoryTurns(thread, supplementalHistory);
|
||||||
const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item]));
|
const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item]));
|
||||||
|
const supplementalByTurn = new Map<string, CodexSupplementalHistoryItem[]>();
|
||||||
|
supplementalHistory.items.filter((item) => item.threadId === threadId).forEach((item) => supplementalByTurn.set(item.turnId, [...(supplementalByTurn.get(item.turnId) || []), item]));
|
||||||
const messages: AgentHistoryMessage[] = [];
|
const messages: AgentHistoryMessage[] = [];
|
||||||
turns.forEach((turn, turnIndex) => {
|
turns.forEach((turn) => {
|
||||||
const turnId = String(field(turn, "id") || turnIndex);
|
const turnId = String(field(turn, "id") || "");
|
||||||
|
if (!turnId || !isSettledTurn(turn)) return;
|
||||||
const turnError = String(field(field(turn, "error"), "message") || "").trim();
|
const turnError = String(field(field(turn, "error"), "message") || "").trim();
|
||||||
const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId: "", turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") });
|
const items = mergeHistoryItems(arrayValue(field(turn, "items")), supplementalByTurn.get(turnId) || []);
|
||||||
|
const push = (message: Omit<AgentHistoryMessage, "itemId" | "threadId" | "turnId">) => messages.push({ ...message, id: historyMessageId(threadId, turnId, message.id), itemId: message.id, threadId, turnId });
|
||||||
|
const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId, turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") }, threadId);
|
||||||
|
const reasoningItems = items.flatMap((item) => {
|
||||||
|
if (field(item, "type") !== "reasoning") return [];
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
const text = readableText(field(item, "summary"));
|
||||||
|
return id && text ? [{ id, text, status: itemStatus(item, itemErrorMessage(item)) }] : [];
|
||||||
|
});
|
||||||
|
const reasoningText = reasoningItems.map((item) => item.text).join("\n\n");
|
||||||
|
const reasoningStatus = aggregateItemStatus(reasoningItems.map((item) => item.status));
|
||||||
|
const itemErrors = items.flatMap((item) => field(item, "type") === "error" ? [readableText(field(item, "message"))] : []).filter(Boolean);
|
||||||
|
const itemError = itemErrors[itemErrors.length - 1] || "";
|
||||||
|
let userAdded = false;
|
||||||
let planAdded = false;
|
let planAdded = false;
|
||||||
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
let reasoningAdded = false;
|
||||||
|
const fallbackUserText = displayUserText(String(field(turn, "input") || ""));
|
||||||
|
if (fallbackUserText && !items.some((item) => field(item, "type") === "userMessage")) {
|
||||||
|
push({ id: "synthetic:user", role: "user", text: fallbackUserText });
|
||||||
|
userAdded = true;
|
||||||
|
if (planMessage) {
|
||||||
|
messages.push(planMessage);
|
||||||
|
planAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.forEach((item) => {
|
||||||
const type = String(field(item, "type") || "");
|
const type = String(field(item, "type") || "");
|
||||||
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
const id = String(field(item, "id") || "");
|
||||||
|
if (!id) return;
|
||||||
if (type === "userMessage") {
|
if (type === "userMessage") {
|
||||||
const text = displayUserText(userInputText(field(item, "content")));
|
const text = displayUserText(userInputText(field(item, "content")));
|
||||||
if (text) messages.push({ id, role: "user", text });
|
if (text && !userAdded) {
|
||||||
|
push({ id: "synthetic:user", role: "user", text });
|
||||||
|
userAdded = true;
|
||||||
|
}
|
||||||
if (planMessage && !planAdded) {
|
if (planMessage && !planAdded) {
|
||||||
messages.push(planMessage);
|
messages.push(planMessage);
|
||||||
planAdded = true;
|
planAdded = true;
|
||||||
@@ -42,55 +88,337 @@ export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] =
|
|||||||
}
|
}
|
||||||
if (type === "agentMessage") {
|
if (type === "agentMessage") {
|
||||||
const text = String(field(item, "text") || "").trim();
|
const text = String(field(item, "text") || "").trim();
|
||||||
if (text) messages.push({ id, role: "assistant", title: "Codex", text });
|
if (text) push({ id, role: "assistant", title: "Codex", text });
|
||||||
}
|
}
|
||||||
if (type === "mcpToolCall") {
|
if (type === "mcpToolCall") {
|
||||||
const tool = String(field(item, "tool") || "工具调用");
|
const tool = String(field(item, "tool") || "工具调用");
|
||||||
const error = String(field(field(item, "error"), "message") || "");
|
const error = String(field(field(item, "error"), "message") || "");
|
||||||
const input = toolArguments(field(item, "arguments"));
|
const input = toolArguments(field(item, "arguments"));
|
||||||
messages.push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, item, input), detail: toolHistoryDetail(tool, item, input, error) });
|
push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, item, input), detail: toolHistoryDetail(tool, item, input, error) });
|
||||||
}
|
}
|
||||||
if (type === "commandExecution") {
|
if (type === "commandExecution") {
|
||||||
const command = String(field(item, "command") || "").trim();
|
const command = String(field(item, "command") || "").trim();
|
||||||
if (command) messages.push({ id, role: "tool", title: "执行命令", text: command, detail: commandDetail(item) });
|
const detail = commandDetail(item);
|
||||||
|
push({ id, role: "tool", title: "执行命令", text: command || (detail.status === "failed" ? "命令执行失败" : "命令已完成"), detail });
|
||||||
}
|
}
|
||||||
if (type === "fileChange") {
|
if (type === "fileChange") {
|
||||||
const changes = arrayValue(field(item, "changes"));
|
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")) })) } });
|
const error = itemErrorMessage(item);
|
||||||
|
push({ id, role: "tool", title: "修改文件", text: error || fileChangeSummary(changes), detail: { kind: "file", status: itemStatus(item, error), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })), ...(error ? { output: error } : {}) } });
|
||||||
}
|
}
|
||||||
if (type === "reasoning") {
|
if (type === "reasoning" && reasoningText && !reasoningAdded) {
|
||||||
const text = readableText(field(item, "summary"));
|
const sourceItemIds = reasoningItems.map((item) => item.id);
|
||||||
if (text) messages.push({ id, role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status: "completed" } });
|
push({ id: "synthetic:reasoning", role: "tool", title: "思考摘要", text: reasoningText, activityItems: Object.fromEntries(reasoningItems.map((item) => [item.id, item.text])), detail: { kind: "reasoning", status: reasoningStatus, sourceItemIds } });
|
||||||
|
reasoningAdded = true;
|
||||||
}
|
}
|
||||||
if (type === "plan") {
|
if (type === "plan") {
|
||||||
const text = String(field(item, "text") || "").trim();
|
const text = String(field(item, "text") || "").trim();
|
||||||
if (text) messages.push({ id, role: "tool", title: "执行计划", text, detail: { kind: "plan", status: "completed" } });
|
const error = itemErrorMessage(item);
|
||||||
|
if (text || error) push({ id, role: "tool", title: "执行计划", text: error || text, detail: { kind: "plan", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
|
||||||
|
}
|
||||||
|
if (type === "webSearch") {
|
||||||
|
const error = itemErrorMessage(item);
|
||||||
|
push({ id, role: "tool", title: "搜索资料", text: error || webSearchSummary(item), detail: { kind: "search", status: itemStatus(item, error), rows: webSearchRows(item), ...(error ? { output: error } : {}) } });
|
||||||
|
}
|
||||||
|
if (type === "imageView") {
|
||||||
|
const error = itemErrorMessage(item);
|
||||||
|
push({ id, role: "tool", title: "查看图片", text: error || String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
|
||||||
|
}
|
||||||
|
if (type === "imageGeneration") {
|
||||||
|
const error = String(field(field(item, "error"), "message") || "");
|
||||||
|
const normalizedStatus = itemStatus(item, error);
|
||||||
|
push({ id, role: "tool", title: "内置生图", text: error || (normalizedStatus === "failed" ? "图片生成失败" : "图片生成完成"), detail: { kind: "image", status: normalizedStatus, savedPath: field(item, "savedPath"), ...(error ? { output: error } : {}) } });
|
||||||
|
}
|
||||||
|
if (type === "contextCompaction") {
|
||||||
|
const error = itemErrorMessage(item);
|
||||||
|
push({ id, role: "tool", title: "整理上下文", text: error || "已整理当前对话,继续处理任务", detail: { kind: "context", status: itemStatus(item, error), ...(error ? { output: error } : {}) } });
|
||||||
}
|
}
|
||||||
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 === "imageGeneration") messages.push({ id, role: "tool", title: "内置生图", text: String(field(item, "savedPath") || "图片生成完成"), detail: { kind: "image", status: field(item, "status"), savedPath: field(item, "savedPath") } });
|
|
||||||
if (type === "contextCompaction") messages.push({ id, role: "tool", title: "整理上下文", text: "已整理当前对话,继续处理任务", detail: { kind: "context", status: "completed" } });
|
|
||||||
if (type === "dynamicToolCall") {
|
if (type === "dynamicToolCall") {
|
||||||
const tool = String(field(item, "tool") || "");
|
const tool = String(field(item, "tool") || "");
|
||||||
const title = toolName(tool);
|
const title = toolName(tool);
|
||||||
const error = String(field(field(item, "error"), "message") || "");
|
const error = itemErrorMessage(item);
|
||||||
const status = String(field(item, "status") || "");
|
push({ id, role: "tool", title, text: error || readableText(field(item, "contentItems")), detail: toolHistoryDetail(tool, item, toolArguments(field(item, "arguments")), error) });
|
||||||
const failed = Boolean(error) || field(item, "success") === false || status === "failed" || status === "error";
|
}
|
||||||
messages.push({ id, role: "tool", title, text: error || readableText(field(item, "contentItems")) || `${title}${failed ? "失败" : "完成"}`, detail: { kind: "tool", status: failed ? "failed" : status } });
|
if (type === "collabToolCall" || type === "collabAgentToolCall") {
|
||||||
|
const error = String(field(field(item, "error"), "message") || "");
|
||||||
|
const normalizedStatus = itemStatus(item, error);
|
||||||
|
push({ id, role: "tool", title: "协作处理", text: error || (normalizedStatus === "failed" ? "协作任务失败" : "已完成协作任务"), detail: { kind: "tool", status: normalizedStatus, ...(error ? { output: error } : {}) } });
|
||||||
}
|
}
|
||||||
if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } });
|
|
||||||
});
|
});
|
||||||
if (planMessage && !planAdded) messages.push(planMessage);
|
if (planMessage && !planAdded) messages.push(planMessage);
|
||||||
if (turnError) {
|
if (itemError || turnError) {
|
||||||
const error = userFacingCodexError(turnError);
|
const error = userFacingCodexError(turnError || itemError);
|
||||||
messages.push({ id: `error-${turnId}`, role: "error", title: error.title, text: error.text });
|
push({ id: "synthetic:error", role: "error", title: error.title, text: error.text });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return messages.filter((item) => item.text).slice(-120);
|
return latestCompleteTurns(messages.filter((item) => item.text || item.role === "tool"), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并标准 turn 与本地终态事件,标准历史已有字段保持优先。 */
|
||||||
|
function mergeHistoryTurns(thread: unknown, supplementalHistory: CodexSupplementalHistory) {
|
||||||
|
const threadId = String(field(thread, "id") || "");
|
||||||
|
const standardTurns = arrayValue(field(thread, "turns"));
|
||||||
|
const supplementalTurns = supplementalHistory.turns.filter((entry) => entry.threadId === threadId && entry.turnId);
|
||||||
|
const supplementalById = new Map(supplementalTurns.map((entry) => [entry.turnId, entry.turn]));
|
||||||
|
const standardIds = new Set(standardTurns.map((turn) => String(field(turn, "id") || "")).filter(Boolean));
|
||||||
|
return [
|
||||||
|
...standardTurns.map((turn) => {
|
||||||
|
const turnId = String(field(turn, "id") || "");
|
||||||
|
const supplemental = supplementalById.get(turnId);
|
||||||
|
return supplemental ? mergeHistoryTurn(turn, supplemental) : turn;
|
||||||
|
}),
|
||||||
|
...supplementalTurns.filter((entry) => !standardIds.has(entry.turnId)).map((entry) => entry.turn),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 终态不可被标准历史中尚未物化完成的临时状态降级。 */
|
||||||
|
function mergeHistoryTurn(standard: unknown, supplemental: Record<string, unknown>) {
|
||||||
|
const merged = mergeHistoryItem(standard, supplemental);
|
||||||
|
const standardStatus = String(field(standard, "status") || "");
|
||||||
|
const supplementalStatus = String(field(supplemental, "status") || "");
|
||||||
|
if (!isSettledStatus(standardStatus) && isSettledStatus(supplementalStatus)) merged.status = supplementalStatus;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以 Codex 标准历史为准,只补入同一 turn 中被持久线程投影省略的实时条目。 */
|
||||||
|
function mergeHistoryItems(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
|
||||||
|
const supplemental = supplementalItems
|
||||||
|
.filter((entry) => entry.itemId)
|
||||||
|
.map((entry, index) => ({ ...entry, item: normalizeSupplementalItem(entry.item), fallbackIndex: index }))
|
||||||
|
.sort(compareSupplementalEntries);
|
||||||
|
items = removeCorruptedHistoryDuplicates(items, supplemental);
|
||||||
|
items = reconcileHistoryItemIds(items, supplemental);
|
||||||
|
const standardIds = new Set(items.map((item) => String(field(item, "id") || "")).filter(Boolean));
|
||||||
|
const supplementalById = new Map(supplemental.map((entry) => [entry.itemId, entry]));
|
||||||
|
const mergedStandard = items.map((item) => {
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
const supplementalEntry = id ? supplementalById.get(id) : undefined;
|
||||||
|
return supplementalEntry ? mergeHistoryItem(item, supplementalEntry.item) : item;
|
||||||
|
});
|
||||||
|
const sequenced = supplemental.filter((entry) => entry.sequence !== undefined);
|
||||||
|
let merged = sequenced.length ? mergeSequencedItems(mergedStandard, sequenced) : mergedStandard;
|
||||||
|
supplemental.forEach((entry, index) => {
|
||||||
|
if (standardIds.has(entry.itemId) || entry.sequence !== undefined) return;
|
||||||
|
const nextStandardId = supplemental.slice(index + 1).find((candidate) => standardIds.has(candidate.itemId))?.itemId;
|
||||||
|
const insertAt = nextStandardId ? merged.findIndex((candidate) => String(field(candidate, "id") || "") === nextStandardId) : -1;
|
||||||
|
if (insertAt >= 0) merged.splice(insertAt, 0, entry.item);
|
||||||
|
else merged.push(entry.item);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** thread/read 会重写部分 item id;按类型、内容和出现顺序恢复实时事件的稳定身份。 */
|
||||||
|
function reconcileHistoryItemIds(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
|
||||||
|
const supplementalById = new Map(supplementalItems.map((entry) => [entry.itemId, entry]));
|
||||||
|
const reservedIds = new Set(items.map((item) => String(field(item, "id") || "")).filter((id) => supplementalById.has(id)));
|
||||||
|
const candidates = new Map<string, CodexSupplementalHistoryItem[]>();
|
||||||
|
supplementalItems.forEach((entry) => {
|
||||||
|
if (reservedIds.has(entry.itemId)) return;
|
||||||
|
const identity = historyItemIdentity(entry.item);
|
||||||
|
if (identity) candidates.set(identity, [...(candidates.get(identity) || []), entry]);
|
||||||
|
});
|
||||||
|
const replacements = new Map<number, string>();
|
||||||
|
const usedSupplementalIds = new Set(reservedIds);
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
if (!id || supplementalById.has(id)) return;
|
||||||
|
const identity = historyItemIdentity(item);
|
||||||
|
const candidate = identity ? candidates.get(identity)?.shift() : undefined;
|
||||||
|
if (!candidate || usedSupplementalIds.has(candidate.itemId)) return;
|
||||||
|
replacements.set(index, candidate.itemId);
|
||||||
|
usedSupplementalIds.add(candidate.itemId);
|
||||||
|
});
|
||||||
|
const remainingStandard = new Map<string, number[]>();
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
if (!id || supplementalById.has(id) || replacements.has(index)) return;
|
||||||
|
const type = historyItemType(item);
|
||||||
|
if (type) remainingStandard.set(type, [...(remainingStandard.get(type) || []), index]);
|
||||||
|
});
|
||||||
|
const remainingSupplemental = new Map<string, CodexSupplementalHistoryItem[]>();
|
||||||
|
supplementalItems.forEach((entry) => {
|
||||||
|
if (usedSupplementalIds.has(entry.itemId)) return;
|
||||||
|
const type = historyItemType(entry.item);
|
||||||
|
if (type) remainingSupplemental.set(type, [...(remainingSupplemental.get(type) || []), entry]);
|
||||||
|
});
|
||||||
|
remainingStandard.forEach((standardIndexes, type) => {
|
||||||
|
const supplemental = remainingSupplemental.get(type) || [];
|
||||||
|
if (standardIndexes.length === supplemental.length) {
|
||||||
|
standardIndexes.forEach((index, position) => {
|
||||||
|
const entry = supplemental[position];
|
||||||
|
replacements.set(index, entry.itemId);
|
||||||
|
usedSupplementalIds.add(entry.itemId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (supplemental.length !== 1) return;
|
||||||
|
const corrupted = standardIndexes.filter((index) => hasReplacementCharacter(items[index]));
|
||||||
|
if (corrupted.length === 1) {
|
||||||
|
replacements.set(corrupted[0], supplemental[0].itemId);
|
||||||
|
usedSupplementalIds.add(supplemental[0].itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return items.map((item, index) => {
|
||||||
|
const itemId = replacements.get(index);
|
||||||
|
if (!itemId || !item || typeof item !== "object" || Array.isArray(item)) return item;
|
||||||
|
return { ...(item as Record<string, unknown>), id: itemId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyItemType(item: unknown) {
|
||||||
|
const type = String(field(normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {}), "type") || "");
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReplacementCharacter(item: unknown) {
|
||||||
|
const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
|
||||||
|
return [value.text, value.summary, value.message, value.aggregatedOutput].some((fieldValue) => readableText(fieldValue).includes("\uFFFD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** thread/read 偶尔同时返回损坏临时条目和已物化条目,只保留补充历史已确认的稳定副本。 */
|
||||||
|
function removeCorruptedHistoryDuplicates(items: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
|
||||||
|
const standardIds = new Set(items.map((item) => String(field(item, "id") || "")).filter(Boolean));
|
||||||
|
const stableItems = supplementalItems.filter((entry) => standardIds.has(entry.itemId) && !hasReplacementCharacter(entry.item));
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (!hasReplacementCharacter(item)) return true;
|
||||||
|
const identity = comparableHistoryText(item);
|
||||||
|
if (identity.length < 8) return true;
|
||||||
|
return !stableItems.some((entry) => historyItemType(entry.item) === historyItemType(item) && comparableHistoryText(entry.item) === identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparableHistoryText(item: unknown) {
|
||||||
|
const value = item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {};
|
||||||
|
const type = historyItemType(value);
|
||||||
|
const text = type === "agentMessage" ? value.text : type === "reasoning" ? value.summary : type === "plan" ? value.text : "";
|
||||||
|
return readableText(text).normalize("NFKC").toLocaleLowerCase().replace(/[\uFFFD\p{P}\p{S}\p{Z}]+/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只使用消息本身的稳定语义字段,不使用状态、结果或临时投影 id。 */
|
||||||
|
function historyItemIdentity(item: unknown) {
|
||||||
|
const normalized = normalizeSupplementalItem(item && typeof item === "object" && !Array.isArray(item) ? item as Record<string, unknown> : {});
|
||||||
|
const type = String(field(normalized, "type") || "");
|
||||||
|
let value: unknown;
|
||||||
|
if (type === "agentMessage") value = String(field(normalized, "text") || "").trim();
|
||||||
|
else if (type === "reasoning") value = readableText(field(normalized, "summary"));
|
||||||
|
else if (type === "plan") value = String(field(normalized, "text") || "").trim();
|
||||||
|
else if (type === "mcpToolCall" || type === "dynamicToolCall") value = [field(normalized, "tool"), toolArguments(field(normalized, "arguments"))];
|
||||||
|
else if (type === "commandExecution") value = String(field(normalized, "command") || "").trim();
|
||||||
|
else if (type === "fileChange") value = arrayValue(field(normalized, "changes")).map((change) => [field(change, "path"), field(change, "kind")]);
|
||||||
|
else if (type === "collabToolCall" || type === "collabAgentToolCall") value = [field(normalized, "tool"), field(normalized, "prompt"), field(normalized, "receiverThreadId"), field(normalized, "receiverAgentId")];
|
||||||
|
else if (type === "webSearch") value = [field(normalized, "query"), field(field(normalized, "action"), "type"), field(field(normalized, "action"), "query"), field(field(normalized, "action"), "url"), field(field(normalized, "action"), "pattern")];
|
||||||
|
else if (type === "imageView") value = field(normalized, "path");
|
||||||
|
else if (type === "imageGeneration") value = field(normalized, "prompt") || field(normalized, "savedPath");
|
||||||
|
else if (type === "contextCompaction") value = true;
|
||||||
|
else return "";
|
||||||
|
return `${type}\0${JSON.stringify(stableHistoryValue(value))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableHistoryValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(stableHistoryValue);
|
||||||
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableHistoryValue(item)]));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSequencedItems(standardItems: unknown[], supplementalItems: CodexSupplementalHistoryItem[]) {
|
||||||
|
const byId = new Map(supplementalItems.map((entry) => [entry.itemId, entry]));
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
const merged: unknown[] = [];
|
||||||
|
standardItems.forEach((item) => {
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
const sequence = id ? byId.get(id)?.sequence : undefined;
|
||||||
|
if (sequence !== undefined) {
|
||||||
|
supplementalItems.forEach((entry) => {
|
||||||
|
if (entry.sequence !== undefined && entry.sequence < sequence && !emitted.has(entry.itemId)) {
|
||||||
|
merged.push(findStandardItem(standardItems, entry.itemId) || entry.item);
|
||||||
|
emitted.add(entry.itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (emitted.has(id)) return;
|
||||||
|
if (id) emitted.add(id);
|
||||||
|
merged.push(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
merged.push(item);
|
||||||
|
});
|
||||||
|
supplementalItems.forEach((entry) => {
|
||||||
|
if (!emitted.has(entry.itemId)) merged.push(entry.item);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStandardItem(items: unknown[], itemId: string) {
|
||||||
|
return items.find((item) => String(field(item, "id") || "") === itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSupplementalEntries(left: CodexSupplementalHistoryItem & { fallbackIndex: number }, right: CodexSupplementalHistoryItem & { fallbackIndex: number }) {
|
||||||
|
if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) return left.sequence - right.sequence;
|
||||||
|
if (left.sequence !== undefined) return -1;
|
||||||
|
if (right.sequence !== undefined) return 1;
|
||||||
|
return left.fallbackIndex - right.fallbackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标准历史字段优先,补充事件只填充标准历史没有返回的字段。 */
|
||||||
|
function mergeHistoryItem(standard: unknown, supplemental: Record<string, unknown>) {
|
||||||
|
if (!standard || typeof standard !== "object" || Array.isArray(standard)) return supplemental;
|
||||||
|
const merged = { ...supplemental };
|
||||||
|
Object.entries(standard as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
|
const supplementalValue = merged[key];
|
||||||
|
if (typeof value === "string" && typeof supplementalValue === "string" && value.includes("\uFFFD") && !supplementalValue.includes("\uFFFD")) return;
|
||||||
|
if (value !== undefined) merged[key] = value;
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将实时事件的 snake_case 类型还原为 thread/read 使用的 camelCase 类型。 */
|
||||||
|
function normalizeSupplementalItem(item: Record<string, unknown>) {
|
||||||
|
const type = String(field(item, "type") || "");
|
||||||
|
const normalizedType = supplementalItemTypes[type] || type;
|
||||||
|
return normalizedType === type ? item : { ...item, type: normalizedType };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplementalItemTypes: Record<string, string> = {
|
||||||
|
agent_message: "agentMessage",
|
||||||
|
mcp_tool_call: "mcpToolCall",
|
||||||
|
command_execution: "commandExecution",
|
||||||
|
file_change: "fileChange",
|
||||||
|
dynamic_tool_call: "dynamicToolCall",
|
||||||
|
collab_tool_call: "collabToolCall",
|
||||||
|
web_search: "webSearch",
|
||||||
|
image_view: "imageView",
|
||||||
|
image_generation: "imageGeneration",
|
||||||
|
context_compaction: "contextCompaction",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 只按完整 turn 截取最近历史,避免列表从某轮中间开始。 */
|
||||||
|
function latestCompleteTurns(messages: AgentHistoryMessage[], limit: number) {
|
||||||
|
const turns: AgentHistoryMessage[][] = [];
|
||||||
|
messages.forEach((message) => {
|
||||||
|
const current = turns.at(-1);
|
||||||
|
if (!current || current[0].turnId !== message.turnId) turns.push([message]);
|
||||||
|
else current.push(message);
|
||||||
|
});
|
||||||
|
const selected: AgentHistoryMessage[][] = [];
|
||||||
|
let count = 0;
|
||||||
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
||||||
|
const turn = turns[index];
|
||||||
|
if (selected.length && count + turn.length > limit) break;
|
||||||
|
selected.unshift(turn);
|
||||||
|
count += turn.length;
|
||||||
|
}
|
||||||
|
return selected.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettledTurn(turn: unknown) {
|
||||||
|
return isSettledStatus(String(field(turn, "status") || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettledStatus(status: string) {
|
||||||
|
return ["completed", "failed", "interrupted", "cancelled", "canceled"].includes(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将结构化任务计划转换为聊天进度卡片。 */
|
/** 将结构化任务计划转换为聊天进度卡片。 */
|
||||||
function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | null {
|
function structuredPlanMessage(update: CodexPlanUpdate, threadId: string): AgentHistoryMessage | null {
|
||||||
const tasks = arrayValue(update.plan).flatMap((item) => {
|
const tasks = arrayValue(update.plan).flatMap((item) => {
|
||||||
const step = String(field(item, "step") || "").trim();
|
const step = String(field(item, "step") || "").trim();
|
||||||
return step ? [{ step, status: String(field(item, "status") || "pending") }] : [];
|
return step ? [{ step, status: String(field(item, "status") || "pending") }] : [];
|
||||||
@@ -98,7 +426,10 @@ function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | n
|
|||||||
if (!tasks.length) return null;
|
if (!tasks.length) return null;
|
||||||
const completed = tasks.filter((item) => item.status === "completed").length;
|
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||||
return {
|
return {
|
||||||
id: `plan-${update.turnId}`,
|
id: historyMessageId(threadId, update.turnId, "synthetic:plan"),
|
||||||
|
itemId: "synthetic:plan",
|
||||||
|
threadId,
|
||||||
|
turnId: update.turnId,
|
||||||
role: "tool",
|
role: "tool",
|
||||||
title: "任务进度",
|
title: "任务进度",
|
||||||
text: `已完成 ${completed}/${tasks.length} 项`,
|
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||||
@@ -106,6 +437,11 @@ function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | n
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成跨线程和 turn 唯一的聊天消息 ID。 */
|
||||||
|
function historyMessageId(threadId: string, turnId: string, itemId: string) {
|
||||||
|
return `${threadId}:${turnId}:${itemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 根据步骤和 turn 状态生成任务卡片状态。 */
|
/** 根据步骤和 turn 状态生成任务卡片状态。 */
|
||||||
function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) {
|
function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) {
|
||||||
if (turnStatus === "failed") return "failed";
|
if (turnStatus === "failed") return "failed";
|
||||||
@@ -155,34 +491,60 @@ function stringOrNull(value: unknown) {
|
|||||||
|
|
||||||
/** 生成命令执行的用户可读详情。 */
|
/** 生成命令执行的用户可读详情。 */
|
||||||
function commandDetail(item: unknown) {
|
function commandDetail(item: unknown) {
|
||||||
|
const error = String(field(field(item, "error"), "message") || "");
|
||||||
|
const status = String(field(item, "status") || "completed");
|
||||||
|
const exitCode = field(item, "exitCode");
|
||||||
|
const failed = Boolean(error) || field(item, "success") === false || status === "failed" || status === "error" || (typeof exitCode === "number" && exitCode !== 0);
|
||||||
const rows = [
|
const rows = [
|
||||||
textRow("工作目录", field(item, "cwd")),
|
textRow("工作目录", field(item, "cwd")),
|
||||||
textRow("退出状态", field(item, "exitCode")),
|
textRow("退出状态", exitCode),
|
||||||
durationRow(field(item, "durationMs")),
|
durationRow(field(item, "durationMs")),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return { kind: "command", status: field(item, "status"), rows, output: String(field(item, "aggregatedOutput") || "").trim() };
|
return { kind: "command", status: failed ? "failed" : status, rows, output: error || String(field(item, "aggregatedOutput") || "").trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成 MCP 工具的用户可读详情。 */
|
/** 生成 MCP 工具的用户可读详情。 */
|
||||||
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
|
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 } : {}) };
|
return { kind: "tool", status: itemStatus(item, error), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemErrorMessage(item: unknown) {
|
||||||
|
return String(field(field(item, "error"), "message") || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemStatus(item: unknown, error = "") {
|
||||||
|
const status = String(field(item, "status") || "completed");
|
||||||
|
return error || field(item, "success") === false || status === "failed" || status === "error" ? "failed" : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateItemStatus(statuses: string[]) {
|
||||||
|
if (statuses.includes("failed")) return "failed";
|
||||||
|
if (statuses.includes("interrupted")) return "interrupted";
|
||||||
|
if (statuses.some((status) => ["inProgress", "in_progress", "running", "started", "pending"].includes(status))) return "inProgress";
|
||||||
|
return "completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成 MCP 工具在对话中的结果摘要。 */
|
/** 生成 MCP 工具在对话中的结果摘要。 */
|
||||||
function toolHistorySummary(tool: string, item: unknown, input: unknown) {
|
function toolHistorySummary(tool: string, item: unknown, input: unknown) {
|
||||||
|
const result = parseToolResult(field(item, "result"));
|
||||||
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
|
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
|
||||||
if (tool === "canvas_list_projects") return "已读取画布列表";
|
if (tool === "canvas_list_projects") return `共 ${numberValue(field(result, "total"))} 个画布`;
|
||||||
if (tool === "canvas_get_state") {
|
if (tool === "canvas_get_state") {
|
||||||
const result = parseToolResult(field(item, "result"));
|
|
||||||
const nodes = arrayValue(field(result, "nodes"));
|
const nodes = arrayValue(field(result, "nodes"));
|
||||||
const connections = arrayValue(field(result, "connections"));
|
const connections = arrayValue(field(result, "connections"));
|
||||||
return nodes.length || connections.length || result ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
|
return Array.isArray(field(result, "nodes")) || Array.isArray(field(result, "connections")) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
|
||||||
}
|
}
|
||||||
if (tool === "canvas_get_selection") return "已读取当前选中内容";
|
if (tool === "canvas_get_selection") return "已读取当前选中内容";
|
||||||
if (tool === "prompts_search") return `已搜索提示词“${String(field(input, "query") || "") || "全部"}”`;
|
if (tool === "prompts_search") return `找到 ${numberValue(field(result, "total"))} 条提示词`;
|
||||||
if (tool === "assets_list") return "已读取我的素材";
|
if (tool === "assets_list") return `共 ${numberValue(field(result, "total"))} 个资产`;
|
||||||
if (tool === "generation_get_status") return "已检查生成任务状态";
|
if (tool === "assets_add") return "已加入我的素材";
|
||||||
return `${toolName(tool)}已完成`;
|
if (tool === "generation_get_status") {
|
||||||
|
const summary = field(result, "summary");
|
||||||
|
return `共 ${numberValue(field(result, "total"))} 个任务,排队 ${numberValue(field(summary, "queued"))},运行中 ${numberValue(field(summary, "running"))},成功 ${numberValue(field(summary, "succeeded"))},失败 ${numberValue(field(summary, "failed"))}`;
|
||||||
|
}
|
||||||
|
if (tool === "workbench_image_generate" || tool === "workbench_video_generate") return String(field(result, "note") || "已在工作台执行");
|
||||||
|
if (tool === "workbench_image_get_config" || tool === "workbench_video_get_config") return "已读取工作台配置";
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按节点类型生成人类可读的画布内容概览。 */
|
/** 按节点类型生成人类可读的画布内容概览。 */
|
||||||
@@ -226,16 +588,38 @@ function toolInputRows(tool: string, input: unknown) {
|
|||||||
if (tool === "site_navigate") return [textRow("目标页面", routeName(String(field(input, "path") || "/")))].filter(Boolean);
|
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 === "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_create_text_node") return [textRow("文本内容", field(input, "text"))].filter(Boolean);
|
||||||
if (tool === "canvas_apply_ops") return [textRow("操作数量", arrayValue(field(input, "ops")).length)].filter(Boolean);
|
if (tool === "canvas_apply_ops") return [textRow("操作内容", summarizeCanvasOps(arrayValue(field(input, "ops"))))].filter(Boolean);
|
||||||
|
if (tool === "canvas_create_attachment_nodes") return [textRow("图片数量", arrayValue(field(input, "attachmentIds")).length)].filter(Boolean);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeCanvasOps(ops: unknown[]) {
|
||||||
|
const counts = ops.reduce<Record<string, number>>((result, op) => {
|
||||||
|
const type = String(field(op, "type") || "");
|
||||||
|
if (type) result[type] = (result[type] || 0) + 1;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
return Object.entries(counts).map(([type, count]) => `${canvasOpLabel(type)} ${count}`).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasOpLabel(type: string) {
|
||||||
|
if (type === "add_node") return "新增节点";
|
||||||
|
if (type === "update_node") return "更新节点";
|
||||||
|
if (type === "delete_node") return "删除节点";
|
||||||
|
if (type === "delete_connections") return "删除连线";
|
||||||
|
if (type === "connect_nodes") return "连接";
|
||||||
|
if (type === "set_viewport") return "调整视图";
|
||||||
|
if (type === "select_nodes") return "选择节点";
|
||||||
|
if (type === "run_generation") return "触发生成";
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
/** 生成人类可读的文件变更摘要。 */
|
/** 生成人类可读的文件变更摘要。 */
|
||||||
function fileChangeSummary(changes: unknown[]) {
|
function fileChangeSummary(changes: unknown[]) {
|
||||||
if (!changes.length) return "已完成文件修改";
|
if (!changes.length) return "已完成文件修改";
|
||||||
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
|
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
|
||||||
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))}${names[0]}`;
|
if (changes.length === 1) return `已${changeKind(field(changes[0], "kind"))} ${names[0]}`;
|
||||||
return `涉及 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
|
return `已修改 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成网页搜索摘要。 */
|
/** 生成网页搜索摘要。 */
|
||||||
@@ -257,6 +641,7 @@ function webSearchRows(item: unknown) {
|
|||||||
function readableText(value: unknown): string {
|
function readableText(value: unknown): string {
|
||||||
if (typeof value === "string") return value.trim();
|
if (typeof value === "string") return value.trim();
|
||||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||||
|
if (!value || typeof value !== "object") return "";
|
||||||
return readableText(field(value, "text"));
|
return readableText(field(value, "text"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +666,12 @@ function durationRow(value: unknown) {
|
|||||||
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将未知数值转换为有限数字。 */
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** 将文件变更类型转换为中文。 */
|
/** 将文件变更类型转换为中文。 */
|
||||||
function changeKind(value: unknown) {
|
function changeKind(value: unknown) {
|
||||||
if (value === "add") return "新增";
|
if (value === "add") return "新增";
|
||||||
@@ -309,7 +700,7 @@ function toolName(name: string) {
|
|||||||
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件";
|
if (name === "apply_patch" || name.endsWith("__apply_patch")) return "修改文件";
|
||||||
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料";
|
if (name === "web__run" || name.endsWith("__web__run")) return "搜索资料";
|
||||||
if (name === "site_navigate") return "打开页面";
|
if (name === "site_navigate") return "打开页面";
|
||||||
if (name === "canvas_list_projects") return "查看画布列表";
|
if (name === "canvas_list_projects") return "画布列表";
|
||||||
if (name === "canvas_apply_ops") return "画布操作";
|
if (name === "canvas_apply_ops") return "画布操作";
|
||||||
if (name === "canvas_get_state") return "读取画布";
|
if (name === "canvas_get_state") return "读取画布";
|
||||||
if (name === "canvas_get_selection") return "读取选区";
|
if (name === "canvas_get_selection") return "读取选区";
|
||||||
@@ -334,13 +725,13 @@ function toolName(name: string) {
|
|||||||
if (name === "canvas_select_nodes") return "选择节点";
|
if (name === "canvas_select_nodes") return "选择节点";
|
||||||
if (name === "canvas_set_viewport") return "调整视口";
|
if (name === "canvas_set_viewport") return "调整视口";
|
||||||
if (name === "canvas_run_generation") return "触发生成";
|
if (name === "canvas_run_generation") return "触发生成";
|
||||||
if (name === "workbench_image_get_config") return "读取生图设置";
|
if (name === "workbench_image_get_config") return "生图配置";
|
||||||
if (name === "workbench_image_generate") return "在生图工作台生成";
|
if (name === "workbench_image_generate") return "生图工作台生成";
|
||||||
if (name === "workbench_video_get_config") return "读取视频设置";
|
if (name === "workbench_video_get_config") return "视频配置";
|
||||||
if (name === "workbench_video_generate") return "在视频工作台生成";
|
if (name === "workbench_video_generate") return "视频创作台生成";
|
||||||
if (name === "prompts_search") return "搜索提示词";
|
if (name === "prompts_search") return "搜索提示词";
|
||||||
if (name === "assets_list") return "查看我的素材";
|
if (name === "assets_list") return "资产列表";
|
||||||
if (name === "assets_add") return "添加到我的素材";
|
if (name === "assets_add") return "添加资产";
|
||||||
if (name === "generation_get_status") return "查看生成状态";
|
if (name === "generation_get_status") return "生成任务状态";
|
||||||
return name ? `调用工具:${name}` : "工具操作";
|
return name ? `调用工具:${name}` : "工具操作";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ export type CodexTurnError = JsonRecord & { message: string };
|
|||||||
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
|
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
|
||||||
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
|
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 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;
|
||||||
|
model: string;
|
||||||
|
displayName: string;
|
||||||
|
defaultReasoningEffort: CodexReasoningEffort;
|
||||||
|
supportedReasoningEfforts: Array<{ reasoningEffort: CodexReasoningEffort; description?: string }>;
|
||||||
|
isDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CodexTurnInput =
|
export type CodexTurnInput =
|
||||||
| { type: "text"; text: string; text_elements: [] }
|
| { type: "text"; text: string; text_elements: [] }
|
||||||
@@ -53,8 +63,12 @@ type CodexRequestSpec = {
|
|||||||
params: { threadId: string };
|
params: { threadId: string };
|
||||||
result: Record<string, never>;
|
result: Record<string, never>;
|
||||||
};
|
};
|
||||||
|
"model/list": {
|
||||||
|
params: { limit: number; includeHidden: boolean };
|
||||||
|
result: { data: CodexModel[]; nextCursor: string | null };
|
||||||
|
};
|
||||||
"turn/start": {
|
"turn/start": {
|
||||||
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } };
|
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" }; model?: string; effort?: CodexReasoningEffort };
|
||||||
result: { turn: CodexTurn };
|
result: { turn: CodexTurn };
|
||||||
};
|
};
|
||||||
"turn/interrupt": {
|
"turn/interrupt": {
|
||||||
@@ -86,6 +100,7 @@ type CodexNotificationSpec = {
|
|||||||
"item/reasoning/summaryTextDelta": { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex: number };
|
"item/reasoning/summaryTextDelta": { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex: number };
|
||||||
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
|
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||||
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
|
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
|
||||||
|
"mcpServer/startupStatus/updated": CodexMcpStartupStatus;
|
||||||
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,31 +4,33 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
import { errorMessage, field } from "../utils/value.js";
|
import { errorMessage, field } from "../utils/value.js";
|
||||||
import { CodexAppClient } from "./codex-client.js";
|
import { CodexAppClient, CodexReportedError } from "./codex-client.js";
|
||||||
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
|
import { codexEventHistory } from "./codex-event-history.js";
|
||||||
|
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||||||
|
import type { CodexReasoningEffort } from "./codex-protocol.js";
|
||||||
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
||||||
|
|
||||||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||||
|
|
||||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||||
let codexApp: CodexAppClient | null = null;
|
let codexApp: CodexAppClient | null = null;
|
||||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||||
let codexThreadId = "";
|
/** 仅表示最近主动加载/选择的线程;运行中的 turn 身份由 CodexAppClient 自己维护。 */
|
||||||
const unmaterializedThreadIds = new Set<string>();
|
let loadedThreadId = "";
|
||||||
|
|
||||||
export { summarizeCodexThread } from "./codex-history.js";
|
export { summarizeCodexThread } from "./codex-history.js";
|
||||||
|
|
||||||
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
||||||
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
export async function runCodexTurn(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
||||||
if (!prompt.trim()) return;
|
if (!prompt.trim()) return;
|
||||||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, lifecycleEmit, attachments, options));
|
||||||
await codexQueue;
|
await codexQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 中断当前线程正在执行的 Codex turn。 */
|
/** 中断当前线程正在执行的 Codex turn。 */
|
||||||
export async function interruptCodexTurn(threadId?: string) {
|
export async function interruptCodexTurn(threadId?: string) {
|
||||||
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
if (!codexApp) return false;
|
||||||
return await codexApp.interruptCurrentTurn();
|
return await codexApp.interruptCurrentTurn(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回复当前 app-server 的待处理权限请求。 */
|
/** 回复当前 app-server 的待处理权限请求。 */
|
||||||
@@ -40,20 +42,17 @@ export async function resolveCodexApproval(requestId: string, decision: string)
|
|||||||
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||||
const app = await getCodexApp(emit);
|
const app = await getCodexApp(emit);
|
||||||
const thread = await app.startThread(cwd, permissionMode);
|
const thread = await app.startThread(cwd, permissionMode);
|
||||||
codexThreadId = String(field(thread, "id") || "");
|
loadedThreadId = String(field(thread, "id") || "");
|
||||||
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
|
||||||
return thread;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
||||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||||
const app = await getCodexApp(emit);
|
const app = await getCodexApp(emit);
|
||||||
await loadCodexThread(emit, threadId, cwd, false);
|
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true);
|
||||||
const thread = await app.resumeThread(threadId, cwd, permissionMode);
|
const history = await loadCodexHistory(emit, threadId, cwd);
|
||||||
assertThreadWorkspace(thread, cwd);
|
const supplementalItems = await codexEventHistory.readThread(threadId);
|
||||||
codexThreadId = String(field(thread, "id") || threadId);
|
return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
||||||
const historyThread = await loadCodexThread(emit, codexThreadId, cwd, true);
|
|
||||||
return { thread, messages: threadMessages(historyThread, app.planUpdates(threadId)) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询当前工作空间中的 Codex 线程。 */
|
/** 查询当前工作空间中的 Codex 线程。 */
|
||||||
@@ -71,32 +70,32 @@ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string;
|
|||||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查询当前账号可用于新任务的 Codex 模型。 */
|
||||||
|
export async function listCodexModels(emit: AgentEmit) {
|
||||||
|
return await (await getCodexApp(emit)).listModels();
|
||||||
|
}
|
||||||
|
|
||||||
/** 读取指定 Codex 线程及其聊天历史。 */
|
/** 读取指定 Codex 线程及其聊天历史。 */
|
||||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
const app = await getCodexApp(emit);
|
const app = await getCodexApp(emit);
|
||||||
let thread: unknown;
|
const history = await loadCodexHistory(emit, threadId, cwd);
|
||||||
try {
|
const supplementalItems = await codexEventHistory.readThread(threadId);
|
||||||
thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId));
|
return { thread: summarizeCodexThread(history.thread), messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
||||||
} 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, app.planUpdates(threadId)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确认指定 Codex 线程属于当前工作空间。 */
|
|
||||||
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
|
||||||
await loadCodexThread(emit, threadId, cwd, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归档指定 Codex 线程。 */
|
/** 归档指定 Codex 线程。 */
|
||||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
const app = await getCodexApp(emit);
|
const app = await getCodexApp(emit);
|
||||||
await loadCodexThread(emit, threadId, cwd, false);
|
try {
|
||||||
|
await loadCodexThread(emit, threadId, cwd, false);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRecoverableThreadError(error)) throw error;
|
||||||
|
await resumeLoadedThread(app, threadId, cwd, "request", false);
|
||||||
|
}
|
||||||
await app.archiveThread(threadId);
|
await app.archiveThread(threadId);
|
||||||
app.clearPlanUpdates(threadId);
|
app.clearPlanUpdates(threadId);
|
||||||
unmaterializedThreadIds.delete(threadId);
|
await codexEventHistory.removeThread(threadId);
|
||||||
|
if (loadedThreadId === threadId) loadedThreadId = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 判断线程异常是否允许自动新建线程后重试。 */
|
/** 判断线程异常是否允许自动新建线程后重试。 */
|
||||||
@@ -105,29 +104,27 @@ export function isRecoverableThreadError(error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
|
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
|
||||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||||
let files: string[] = [];
|
let files: string[] = [];
|
||||||
try {
|
try {
|
||||||
options.onStart?.();
|
options.onStart?.();
|
||||||
files = await writeAttachmentFiles(attachments);
|
files = await writeAttachmentFiles(attachments);
|
||||||
const app = await getCodexApp(options.appEmit || emit);
|
const app = await getCodexApp(options.appEmit || lifecycleEmit);
|
||||||
let threadId = await ensureCodexThread(app, options, emit);
|
let threadId = await ensureCodexThread(app, options, lifecycleEmit);
|
||||||
options.onThread?.(threadId);
|
options.onThread?.(threadId);
|
||||||
unmaterializedThreadIds.delete(threadId);
|
|
||||||
try {
|
try {
|
||||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isRecoverableThreadError(error)) throw error;
|
if (!isRecoverableThreadError(error)) throw error;
|
||||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||||
codexThreadId = "";
|
loadedThreadId = "";
|
||||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit);
|
||||||
options.onThread?.(threadId);
|
options.onThread?.(threadId);
|
||||||
unmaterializedThreadIds.delete(threadId);
|
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
|
||||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Codex turn failed", error);
|
logger.error("Codex turn failed", error);
|
||||||
emit("agent_error", { message: errorMessage(error) });
|
if (!(error instanceof CodexReportedError)) lifecycleEmit("agent_error", { message: errorMessage(error) });
|
||||||
} finally {
|
} finally {
|
||||||
options.onFinish?.();
|
options.onFinish?.();
|
||||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||||
@@ -137,25 +134,21 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
|
|||||||
/** 恢复请求线程或创建新的 Codex 线程。 */
|
/** 恢复请求线程或创建新的 Codex 线程。 */
|
||||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
||||||
if (options.threadId) {
|
if (options.threadId) {
|
||||||
if (options.threadId === codexThreadId) return codexThreadId;
|
if (options.threadId === loadedThreadId) return loadedThreadId;
|
||||||
try {
|
try {
|
||||||
const result = await app.readThread(options.threadId, false);
|
await resumeLoadedThread(app, options.threadId, options.cwd, options.permissionMode || "request", true);
|
||||||
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
return loadedThreadId;
|
||||||
const thread = await app.resumeThread(options.threadId, options.cwd, options.permissionMode || "request");
|
|
||||||
assertThreadWorkspace(thread, options.cwd);
|
|
||||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
|
||||||
return codexThreadId;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isRecoverableThreadError(error)) throw error;
|
if (!isRecoverableThreadError(error)) throw error;
|
||||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||||
|
loadedThreadId = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!codexThreadId) {
|
if (!loadedThreadId) {
|
||||||
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
|
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
|
||||||
codexThreadId = String(field(thread, "id") || "");
|
loadedThreadId = String(field(thread, "id") || "");
|
||||||
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
|
||||||
}
|
}
|
||||||
return codexThreadId;
|
return loadedThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 app-server 读取线程并校验工作空间。 */
|
/** 从 app-server 读取线程并校验工作空间。 */
|
||||||
@@ -167,12 +160,38 @@ async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string |
|
|||||||
return thread;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取线程历史,并显式标记 Codex 是否已经物化 turns。 */
|
||||||
|
async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
|
try {
|
||||||
|
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (/not materialized yet.*includeTurns/i.test(errorMessage(error))) return { thread: await loadCodexThread(emit, threadId, cwd, false), historyReady: false };
|
||||||
|
if (!isRecoverableThreadError(error)) throw error;
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
const thread = await resumeLoadedThread(app, threadId, cwd, "request", false);
|
||||||
|
try {
|
||||||
|
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
|
||||||
|
} catch (historyError) {
|
||||||
|
if (/not materialized yet.*includeTurns/i.test(errorMessage(historyError))) return { thread, historyReady: false };
|
||||||
|
throw historyError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
|
||||||
|
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true) {
|
||||||
|
const thread = await app.resumeThread(threadId, cwd, permissionMode);
|
||||||
|
assertThreadWorkspace(thread, cwd);
|
||||||
|
if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取已启动的 Codex app-server 客户端。 */
|
/** 获取已启动的 Codex app-server 客户端。 */
|
||||||
async function getCodexApp(emit: AgentEmit) {
|
async function getCodexApp(emit: AgentEmit) {
|
||||||
if (codexApp) return codexApp;
|
if (codexApp) return codexApp;
|
||||||
codexAppStart ||= CodexAppClient.start(emit, () => {
|
codexAppStart ||= CodexAppClient.start(emit, () => {
|
||||||
codexApp = null;
|
codexApp = null;
|
||||||
codexThreadId = "";
|
loadedThreadId = "";
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
codexApp = await codexAppStart;
|
codexApp = await codexAppStart;
|
||||||
|
|||||||
@@ -197,15 +197,37 @@ test("shared thread events are broadcast with the active thread id", (t) => {
|
|||||||
test("new clients receive the current Codex state and later updates", (t) => {
|
test("new clients receive the current Codex state and later updates", (t) => {
|
||||||
const session = new CanvasSession();
|
const session = new CanvasSession();
|
||||||
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||||
const client = connect(session, "first");
|
session.trackCodexEvent("codex_approval", { requestId: "approval-1", threadId: "thread-2" });
|
||||||
|
const client = connect(session, "first", "thread-2");
|
||||||
t.after(() => client.close());
|
t.after(() => client.close());
|
||||||
|
|
||||||
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
const hello = client.event("hello");
|
||||||
|
assert.equal(field(hello, "protocolVersion"), 3);
|
||||||
|
assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" });
|
||||||
|
assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||||
|
assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]);
|
||||||
|
|
||||||
|
session.trackCodexEvent("codex_approval_resolved", { requestId: "approval-1" });
|
||||||
|
assert.deepEqual(session.codexPendingApprovals, []);
|
||||||
|
session.trackCodexEvent("codex_approval", { requestId: "approval-2", threadId: "thread-2" });
|
||||||
session.setCodexState({ busy: false });
|
session.setCodexState({ busy: false });
|
||||||
|
assert.deepEqual(session.codexPendingApprovals, [{ requestId: "approval-2", threadId: "thread-2" }]);
|
||||||
|
session.trackCodexEvent("agent_error", { message: "app-server exited" });
|
||||||
|
assert.deepEqual(session.codexPendingApprovals, []);
|
||||||
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Codex 写操作在多窗口之间互斥且不能与运行 turn 并发", () => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
assert.equal(session.beginCodexMutation(), true);
|
||||||
|
assert.equal(session.beginCodexMutation(), false);
|
||||||
|
session.endCodexMutation();
|
||||||
|
assert.equal(session.beginCodexMutation(), true);
|
||||||
|
session.endCodexMutation();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
assert.equal(session.beginCodexMutation(), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("a bound client remains the tool target while focus changes", async (t) => {
|
test("a bound client remains the tool target while focus changes", async (t) => {
|
||||||
const session = new CanvasSession();
|
const session = new CanvasSession();
|
||||||
const first = connect(session, "first");
|
const first = connect(session, "first");
|
||||||
@@ -230,7 +252,7 @@ test("a bound client remains the tool target while focus changes", async (t) =>
|
|||||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing the bound client falls back to the active client", async (t) => {
|
test("a disconnected bound client never falls back and can resume with the same client id", async (t) => {
|
||||||
const session = new CanvasSession();
|
const session = new CanvasSession();
|
||||||
const first = connect(session, "first");
|
const first = connect(session, "first");
|
||||||
const second = connect(session, "second");
|
const second = connect(session, "second");
|
||||||
@@ -244,17 +266,170 @@ test("closing the bound client falls back to the active client", async (t) => {
|
|||||||
session.activateClient("second");
|
session.activateClient("second");
|
||||||
first.close();
|
first.close();
|
||||||
|
|
||||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
await assert.rejects(session.callTool("canvas_get_state", {}), /当前没有已连接画布/);
|
||||||
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
|
assert.equal(second.event("tool_call"), undefined);
|
||||||
const call = second.event("tool_call");
|
|
||||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
const reconnected = connect(session, "first");
|
||||||
|
t.after(() => reconnected.close());
|
||||||
|
session.updateState(snapshot("canvas-first-reconnected"), "first");
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first-reconnected");
|
||||||
|
|
||||||
|
const result = session.callTool("canvas_create_text_node", { text: "reconnected" });
|
||||||
|
const call = reconnected.event("tool_call");
|
||||||
|
assert.equal(second.event("tool_call"), undefined);
|
||||||
|
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||||
assert.deepEqual(await result, { ok: true });
|
assert.deepEqual(await result, { ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("新连接会回放当前运行 turn 的最新事件快照", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("chat_message", "thread-1", { turnId: "turn-1", message: { id: "thread-1:turn-1:synthetic:user", itemId: "synthetic:user", clientMessageId: "local-message-1", role: "user", text: "问题" } });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "reasoning-1", type: "reasoning", text: "分析中" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
|
||||||
|
assert.deepEqual(client.events("chat_message"), [{ threadId: "thread-1", turnId: "turn-1", message: { id: "thread-1:turn-1:synthetic:user", itemId: "synthetic:user", clientMessageId: "local-message-1", role: "user", text: "问题" }, replayed: true }]);
|
||||||
|
assert.deepEqual(client.events("agent_event"), [{ threadId: "thread-1", turnId: "turn-1", type: "item.updated", item: { id: "reasoning-1", type: "reasoning", text: "分析中" }, replayed: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同一 item 的多次增量只回放最新内容", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "第一段" } });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "第一段和第二段" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
|
||||||
|
const events = client.events("agent_event") as Array<Record<string, unknown>>;
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
assert.equal(field(field(events[0], "item"), "text"), "第一段和第二段");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("增量事件重放时转换为完整文本快照", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", delta: "第一段" } });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", delta: "第二段" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
const [event] = client.events("agent_event") as Array<Record<string, unknown>>;
|
||||||
|
assert.deepEqual(field(event, "item"), { id: "assistant-1", type: "agent_message", text: "第一段第二段" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("并行 item 更新后重放仍保留开始顺序和命令字段", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "first", type: "command_execution", command: "first", cwd: "D:\\infinite-canvas" } });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "second", type: "command_execution", command: "second" } });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "first", type: "command_execution", delta: "output" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
const events = client.events("agent_event") as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
assert.deepEqual(events.map((event) => field(field(event, "item"), "id")), ["first", "second"]);
|
||||||
|
assert.deepEqual(field(events[0], "item"), { id: "first", type: "command_execution", command: "first", cwd: "D:\\infinite-canvas", text: "output" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("长 turn 不会淘汰仍在更新的活动条目快照", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.started", item: { id: "active-item", type: "command_execution", command: "long-running" } });
|
||||||
|
for (let index = 0; index < 260; index += 1) {
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.completed", item: { id: `completed-${index}`, type: "command_execution", command: `command-${index}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
assert.equal((client.events("agent_event") as Array<Record<string, unknown>>).some((event) => field(field(event, "item"), "id") === "active-item"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("内置生图事件会回放展示但标记为不可重复执行", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.completed", item: { id: "image-1", type: "image_generation", savedPath: "D:/image.png" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
|
||||||
|
assert.deepEqual(client.events("agent_event"), [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
type: "item.completed",
|
||||||
|
item: { id: "image-1", type: "image_generation", savedPath: "D:/image.png" },
|
||||||
|
replayed: true,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn 结束后保留实时快照,直到网页确认权威历史", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "回答" } });
|
||||||
|
session.setCodexState({ busy: false });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
assert.equal(client.events("agent_event").length, 1);
|
||||||
|
|
||||||
|
session.acknowledgeCodexHistory("thread-1", ["turn-1"]);
|
||||||
|
const next = connect(session, "second", "thread-1");
|
||||||
|
t.after(() => next.close());
|
||||||
|
assert.deepEqual(next.events("agent_event"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("开始下一 turn 时只回放当前 turn 的事件", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant", type: "agent_message", text: "第一轮" } });
|
||||||
|
session.setCodexState({ busy: false, turnId: "turn-1" });
|
||||||
|
session.setCodexState({ busy: true, turnId: "turn-2" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-2", type: "item.updated", item: { id: "assistant", type: "agent_message", text: "第二轮" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
const events = client.events("agent_event") as Array<Record<string, unknown>>;
|
||||||
|
assert.equal(events.length, 1);
|
||||||
|
assert.deepEqual(events.map((event) => field(field(event, "item"), "text")), ["第二轮"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同一用户消息从 pending 绑定 turn 后只回放最终版本", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "" });
|
||||||
|
session.emitThread("chat_message", "thread-1", { message: { id: "pending", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" } });
|
||||||
|
session.setCodexState({ turnId: "turn-1" });
|
||||||
|
session.emitThread("chat_message", "thread-1", { turnId: "turn-1", message: { id: "final", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" } });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
assert.deepEqual(client.events("chat_message"), [{
|
||||||
|
threadId: "thread-1",
|
||||||
|
turnId: "turn-1",
|
||||||
|
message: { id: "final", itemId: "synthetic:user", clientMessageId: "message-1", role: "user", text: "问题" },
|
||||||
|
replayed: true,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("切换活动线程会清除上一线程的实时快照", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.emitThread("agent_event", "thread-1", { turnId: "turn-1", type: "item.updated", item: { id: "assistant-1", type: "agent_message", text: "回答" } });
|
||||||
|
session.setCodexState({ busy: false, threadId: "thread-1", turnId: "turn-1" });
|
||||||
|
session.setCodexState({ threadId: "thread-2", turnId: "" });
|
||||||
|
session.setCodexState({ threadId: "thread-1", turnId: "" });
|
||||||
|
|
||||||
|
const client = connect(session, "first", "thread-1");
|
||||||
|
t.after(() => client.close());
|
||||||
|
assert.deepEqual(client.events("agent_event"), []);
|
||||||
|
});
|
||||||
|
|
||||||
/** 创建用于测试的画布 SSE 连接。 */
|
/** 创建用于测试的画布 SSE 连接。 */
|
||||||
function connect(session: CanvasSession, clientId: string) {
|
function connect(session: CanvasSession, clientId: string, activeThreadId = "") {
|
||||||
const response = new FakeSseResponse();
|
const response = new FakeSseResponse();
|
||||||
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse, activeThreadId);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +460,16 @@ class FakeSseResponse extends EventEmitter {
|
|||||||
|
|
||||||
/** 读取指定类型的首个 SSE 事件数据。 */
|
/** 读取指定类型的首个 SSE 事件数据。 */
|
||||||
event(type: string) {
|
event(type: string) {
|
||||||
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
return this.events(type)[0];
|
||||||
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
}
|
||||||
return data ? (JSON.parse(data) as unknown) : undefined;
|
|
||||||
|
/** 读取指定类型的全部 SSE 事件数据。 */
|
||||||
|
events(type: string) {
|
||||||
|
return this.chunks.flatMap((chunk) => {
|
||||||
|
if (!chunk.startsWith(`event: ${type}\n`)) return [];
|
||||||
|
const data = chunk.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||||
|
return data ? [JSON.parse(data) as unknown] : [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触发连接关闭事件。 */
|
/** 触发连接关闭事件。 */
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type { CanvasSnapshot } from "./types.js";
|
|||||||
|
|
||||||
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||||
|
type ReplayEvent = { type: string; payload: Record<string, unknown> };
|
||||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||||
|
export const AGENT_PROTOCOL_VERSION = 3;
|
||||||
|
|
||||||
const SITE_TOOLS = new Set<ToolName>([
|
const SITE_TOOLS = new Set<ToolName>([
|
||||||
"site_navigate",
|
"site_navigate",
|
||||||
@@ -30,8 +32,12 @@ export class CanvasSession {
|
|||||||
private clients = new Map<string, ServerResponse>();
|
private clients = new Map<string, ServerResponse>();
|
||||||
private clientFocusOrder = new Map<string, number>();
|
private clientFocusOrder = new Map<string, number>();
|
||||||
private pending = new Map<string, PendingRequest>();
|
private pending = new Map<string, PendingRequest>();
|
||||||
|
private pendingApprovals = new Map<string, Record<string, unknown>>();
|
||||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||||
private turnAttachments = new Map<string, TurnAttachment>();
|
private turnAttachments = new Map<string, TurnAttachment>();
|
||||||
|
private codexReplayEvents = new Map<string, ReplayEvent>();
|
||||||
|
private codexReplayActiveItems = new Set<string>();
|
||||||
|
private codexMutationBusy = false;
|
||||||
private activeClientId = "";
|
private activeClientId = "";
|
||||||
private boundClientId = "";
|
private boundClientId = "";
|
||||||
private focusSequence = 0;
|
private focusSequence = 0;
|
||||||
@@ -39,7 +45,7 @@ export class CanvasSession {
|
|||||||
|
|
||||||
/** 获取当前目标网页的画布状态。 */
|
/** 获取当前目标网页的画布状态。 */
|
||||||
private get canvasState() {
|
private get canvasState() {
|
||||||
return this.canvasStates.get(this.targetClientId) || null;
|
return this.clients.has(this.targetClientId) ? this.canvasStates.get(this.targetClientId) || null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
|
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
|
||||||
@@ -49,7 +55,7 @@ export class CanvasSession {
|
|||||||
|
|
||||||
/** 返回 Canvas Agent 当前连接状态。 */
|
/** 返回 Canvas Agent 当前连接状态。 */
|
||||||
health() {
|
health() {
|
||||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回 Codex 是否正在执行任务。 */
|
/** 返回 Codex 是否正在执行任务。 */
|
||||||
@@ -57,17 +63,84 @@ export class CanvasSession {
|
|||||||
return this.codexState.busy;
|
return this.codexState.busy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get codexThreadId() {
|
||||||
|
return this.codexState.threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断网页客户端是否仍连接到当前 Agent。 */
|
||||||
|
hasClient(clientId: string) {
|
||||||
|
return this.clients.has(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原子取得 Codex 写操作权限,避免多个网页并发切换或修改会话。 */
|
||||||
|
beginCodexMutation() {
|
||||||
|
if (this.codexState.busy || this.codexMutationBusy) return false;
|
||||||
|
this.codexMutationBusy = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放 Codex 写操作权限。 */
|
||||||
|
endCodexMutation() {
|
||||||
|
this.codexMutationBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回当前 Codex turn 的线程、turn 和发起网页。 */
|
||||||
|
get codexEventScope() {
|
||||||
|
return {
|
||||||
|
threadId: this.codexState.threadId,
|
||||||
|
turnId: this.codexState.busy ? this.codexState.turnId : "",
|
||||||
|
sourceClientId: this.codexState.busy ? this.boundClientId : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回刷新后仍需展示的 Codex 权限请求。 */
|
||||||
|
get codexPendingApprovals() {
|
||||||
|
return [...this.pendingApprovals.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟踪需要跨页面重连恢复的 Codex 权限请求。 */
|
||||||
|
trackCodexEvent(type: string, payload: Record<string, unknown>) {
|
||||||
|
const requestId = String(payload.requestId || "");
|
||||||
|
if (type === "codex_approval" && requestId) this.pendingApprovals.set(requestId, payload);
|
||||||
|
if (type === "codex_approval_resolved" && requestId) this.pendingApprovals.delete(requestId);
|
||||||
|
if (type === "agent_error") this.pendingApprovals.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/** 更新并广播 Codex 运行状态。 */
|
/** 更新并广播 Codex 运行状态。 */
|
||||||
setCodexState(patch: Partial<CodexState>) {
|
setCodexState(patch: Partial<CodexState>) {
|
||||||
const next = { ...this.codexState, ...patch };
|
const next = { ...this.codexState, ...patch };
|
||||||
|
const threadChanged = next.threadId !== this.codexState.threadId;
|
||||||
|
const turnChanged = Boolean(this.codexState.turnId && next.turnId && next.turnId !== this.codexState.turnId);
|
||||||
|
const nextTurnStarted = !this.codexState.busy && next.busy;
|
||||||
|
if (threadChanged || turnChanged || nextTurnStarted) {
|
||||||
|
this.codexReplayEvents.clear();
|
||||||
|
this.codexReplayActiveItems.clear();
|
||||||
|
}
|
||||||
|
if (!next.busy) {
|
||||||
|
if (this.boundClientId && !this.clients.has(this.boundClientId)) this.boundClientId = "";
|
||||||
|
}
|
||||||
if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return;
|
if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return;
|
||||||
this.codexState = next;
|
this.codexState = next;
|
||||||
logger.debug("Codex state changed", this.codexState);
|
logger.debug("Codex state changed", this.codexState);
|
||||||
this.emitAll("codex_state", this.codexState);
|
this.emitAll("codex_state", this.codexState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 权威历史已覆盖指定 turn 后,清理其断线重放事件。 */
|
||||||
|
acknowledgeCodexHistory(threadId: string, turnIds: string[]) {
|
||||||
|
const acknowledged = new Set(turnIds.filter(Boolean));
|
||||||
|
if (!threadId || !acknowledged.size) return;
|
||||||
|
this.codexReplayEvents.forEach((event, key) => {
|
||||||
|
const eventThreadId = String(event.payload.threadId || event.payload.thread_id || "");
|
||||||
|
const eventTurnId = String(event.payload.turnId || event.payload.turn_id || "");
|
||||||
|
if (eventThreadId === threadId && acknowledged.has(eventTurnId)) {
|
||||||
|
this.codexReplayEvents.delete(key);
|
||||||
|
this.codexReplayActiveItems.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
|
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
|
||||||
openEvents(url: URL, res: ServerResponse) {
|
openEvents(url: URL, res: ServerResponse, activeThreadId = "") {
|
||||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||||
const statusOnly = url.searchParams.get("role") === "status";
|
const statusOnly = url.searchParams.get("role") === "status";
|
||||||
logger.info("SSE client connected", { clientId, statusOnly });
|
logger.info("SSE client connected", { clientId, statusOnly });
|
||||||
@@ -80,7 +153,8 @@ export class CanvasSession {
|
|||||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
|
||||||
|
if (!statusOnly && activeThreadId && this.codexState.threadId === activeThreadId) this.codexReplayEvents.forEach((event) => sendEvent(res, event.type, event.payload));
|
||||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||||
res.on("close", () => {
|
res.on("close", () => {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -89,7 +163,6 @@ export class CanvasSession {
|
|||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
this.clientFocusOrder.delete(clientId);
|
this.clientFocusOrder.delete(clientId);
|
||||||
this.canvasStates.delete(clientId);
|
this.canvasStates.delete(clientId);
|
||||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
|
||||||
this.pending.forEach((item, requestId) => {
|
this.pending.forEach((item, requestId) => {
|
||||||
if (item.clientId !== clientId) return;
|
if (item.clientId !== clientId) return;
|
||||||
this.pending.delete(requestId);
|
this.pending.delete(requestId);
|
||||||
@@ -102,7 +175,7 @@ export class CanvasSession {
|
|||||||
/** 保存指定网页上报的最新画布快照。 */
|
/** 保存指定网页上报的最新画布快照。 */
|
||||||
updateState(body: unknown, clientId?: string) {
|
updateState(body: unknown, clientId?: string) {
|
||||||
const targetClientId = clientId || this.activeClientId;
|
const targetClientId = clientId || this.activeClientId;
|
||||||
if (!targetClientId) return;
|
if (!targetClientId || !this.clients.has(targetClientId)) return;
|
||||||
const state = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot;
|
const state = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot;
|
||||||
this.canvasStates.set(targetClientId, state);
|
this.canvasStates.set(targetClientId, state);
|
||||||
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: state.nodes?.length || 0, connections: state.connections?.length || 0 });
|
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: state.nodes?.length || 0, connections: state.connections?.length || 0 });
|
||||||
@@ -182,7 +255,52 @@ export class CanvasSession {
|
|||||||
|
|
||||||
/** 向全部网页广播带线程归属的事件。 */
|
/** 向全部网页广播带线程归属的事件。 */
|
||||||
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||||
this.emitAll(type, { ...payload, threadId });
|
const data: Record<string, unknown> = { ...payload, threadId };
|
||||||
|
const replayKey = codexReplayKey(type, data);
|
||||||
|
const eventTurnId = String(data.turnId || data.turn_id || "");
|
||||||
|
const currentScope = threadId === this.codexState.threadId && (!this.codexState.turnId || !eventTurnId || eventTurnId === this.codexState.turnId);
|
||||||
|
if (this.codexState.busy && currentScope && replayKey) {
|
||||||
|
const item = recordValue(data.item);
|
||||||
|
const eventType = String(data.type || "");
|
||||||
|
if (type === "agent_event" && item.id && (eventType === "item.started" || eventType === "item.updated")) this.codexReplayActiveItems.add(replayKey);
|
||||||
|
if (type === "agent_event" && item.id && eventType === "item.completed") this.codexReplayActiveItems.delete(replayKey);
|
||||||
|
if (type === "agent_event" && (eventType === "turn.completed" || eventType === "error")) this.clearReplayActiveTurn(threadId, eventTurnId);
|
||||||
|
const replayData = this.replaySnapshot(replayKey, data);
|
||||||
|
this.codexReplayEvents.set(replayKey, { type, payload: { ...replayData, replayed: true } });
|
||||||
|
while (this.codexReplayEvents.size > 240) {
|
||||||
|
const evictable = [...this.codexReplayEvents.keys()].find((key) => !this.codexReplayActiveItems.has(key));
|
||||||
|
if (!evictable) break;
|
||||||
|
this.codexReplayEvents.delete(evictable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emitAll(type, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为断线重连保存完整的最新文本快照,实时连接仍只接收增量。 */
|
||||||
|
private replaySnapshot(replayKey: string, data: Record<string, unknown>) {
|
||||||
|
if (data.type !== "item.updated" && data.type !== "item.completed") return data;
|
||||||
|
const item = recordValue(data.item);
|
||||||
|
if (!item.id) return data;
|
||||||
|
const previous = recordValue(recordValue(this.codexReplayEvents.get(replayKey)?.payload).item);
|
||||||
|
const delta = String(item.delta || "");
|
||||||
|
if (!delta) return data;
|
||||||
|
const previousText = String(previous.text || "");
|
||||||
|
const { delta: _delta, ...snapshotItem } = item;
|
||||||
|
return { ...data, item: { ...previous, ...snapshotItem, text: `${previousText}${delta}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearReplayActiveTurn(threadId: string, turnId: string) {
|
||||||
|
const prefix = `item:${turnId}:`;
|
||||||
|
this.codexReplayActiveItems.forEach((key) => {
|
||||||
|
if (key.startsWith(prefix)) this.codexReplayActiveItems.delete(key);
|
||||||
|
});
|
||||||
|
if (!turnId) return;
|
||||||
|
this.codexReplayActiveItems.forEach((key) => {
|
||||||
|
const event = this.codexReplayEvents.get(key);
|
||||||
|
const eventThreadId = String(event?.payload.threadId || event?.payload.thread_id || "");
|
||||||
|
const eventTurnId = String(event?.payload.turnId || event?.payload.turn_id || "");
|
||||||
|
if (eventThreadId === threadId && eventTurnId === turnId) this.codexReplayActiveItems.delete(key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验工具参数并将调用分派到当前目标网页。 */
|
/** 校验工具参数并将调用分派到当前目标网页。 */
|
||||||
@@ -253,6 +371,31 @@ export class CanvasSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为运行中 turn 的可重放事件生成稳定键。 */
|
||||||
|
function codexReplayKey(type: string, payload: Record<string, unknown>) {
|
||||||
|
const turnId = String(payload.turnId || payload.turn_id || "");
|
||||||
|
if (type === "chat_message") {
|
||||||
|
const message = recordValue(payload.message);
|
||||||
|
const clientMessageId = String(message.clientMessageId || "");
|
||||||
|
if (clientMessageId) return `chat:${clientMessageId}`;
|
||||||
|
const messageId = String(message.itemId || message.id || "");
|
||||||
|
return messageId ? `chat:${turnId}:${messageId}` : "";
|
||||||
|
}
|
||||||
|
if (type === "agent_error") return `error:${turnId}`;
|
||||||
|
if (type !== "agent_event") return "";
|
||||||
|
const item = recordValue(payload.item);
|
||||||
|
if (item.id) return `item:${turnId}:${String(item.id)}`;
|
||||||
|
const eventType = String(payload.type || "");
|
||||||
|
if (eventType === "plan.updated") return `plan:${turnId}`;
|
||||||
|
if (eventType === "usage.updated") return `usage:${turnId}`;
|
||||||
|
if (eventType === "turn.completed" || eventType === "error") return `${eventType}:${turnId}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordValue(value: unknown) {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
/** 向 SSE 连接写入一个事件。 */
|
/** 向 SSE 连接写入一个事件。 */
|
||||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||||
|
|||||||
+131
-51
@@ -4,11 +4,13 @@ import path from "node:path";
|
|||||||
import express, { type NextFunction, type Request, type Response } from "express";
|
import express, { type NextFunction, type Request, type Response } from "express";
|
||||||
|
|
||||||
import { runClaudeTurn } from "../agent/claude.js";
|
import { runClaudeTurn } from "../agent/claude.js";
|
||||||
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
|
import { archiveCodexThread, interruptCodexTurn, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||||
|
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
|
||||||
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||||
import { CanvasSession } from "../canvas/session.js";
|
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
|
||||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { checkVersions } from "../version-check.js";
|
||||||
|
|
||||||
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
||||||
export function startHttpServer() {
|
export function startHttpServer() {
|
||||||
@@ -20,16 +22,45 @@ export function startHttpServer() {
|
|||||||
const session = new CanvasSession();
|
const session = new CanvasSession();
|
||||||
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
||||||
const emit = (type: string, payload: unknown) => {
|
const emit = (type: string, payload: unknown) => {
|
||||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
|
||||||
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
|
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||||
|
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
|
||||||
|
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
|
||||||
|
const sourceClientId = String(value.sourceClientId || scope.sourceClientId || "");
|
||||||
|
const data = {
|
||||||
|
...value,
|
||||||
|
...(threadId ? { threadId, thread_id: threadId } : {}),
|
||||||
|
...(turnId ? { turnId, turn_id: turnId } : {}),
|
||||||
|
...(sourceClientId ? { sourceClientId } : {}),
|
||||||
|
};
|
||||||
|
session.trackCodexEvent(type, data);
|
||||||
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
||||||
};
|
};
|
||||||
/** 保存并广播当前站点工作空间的活跃线程。 */
|
/** 保存并广播当前站点工作空间的活跃线程。 */
|
||||||
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
||||||
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
||||||
|
if (!session.codexBusy && session.codexThreadId !== activeThreadId) session.setCodexState({ threadId: activeThreadId, turnId: "" });
|
||||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||||
return workspace;
|
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();
|
const app = express();
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
app.use(express.json({ limit: "30mb" }));
|
app.use(express.json({ limit: "30mb" }));
|
||||||
@@ -50,12 +81,14 @@ export function startHttpServer() {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.get("/health", (_req, res) => res.json(session.health()));
|
app.get("/health", (_req, res) => res.json(session.health()));
|
||||||
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
|
app.get("/config", (_req, res) => res.json({ ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, url: config.url, hasToken: true }));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (validToken(req, requestUrl(req, config), config.token)) return next();
|
if (validToken(req, requestUrl(req, config), config.token)) return next();
|
||||||
res.status(401).json({ ok: false, error: "invalid token" });
|
res.status(401).json({ ok: false, error: "invalid token" });
|
||||||
});
|
});
|
||||||
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
|
app.get("/events", (req, res) => {
|
||||||
|
session.openEvents(requestUrl(req, config), res, ensureSiteWorkspace(config).activeThreadId || "");
|
||||||
|
});
|
||||||
app.post("/canvas/state", (req, res) => {
|
app.post("/canvas/state", (req, res) => {
|
||||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
@@ -95,100 +128,129 @@ export function startHttpServer() {
|
|||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
res.json({ ok: true, workspace });
|
res.json({ ok: true, workspace });
|
||||||
});
|
});
|
||||||
|
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
|
||||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||||
res.json({ ok: true, workspace, ...result });
|
res.json({ ok: true, workspace, ...result });
|
||||||
}));
|
}));
|
||||||
app.post("/agent/codex/threads/new", route(async (req, res) => {
|
app.post("/agent/codex/threads/new", codexMutation(async (req, res) => {
|
||||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||||
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
|
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true, sourceClientId: String(req.body?.clientId || "") });
|
||||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||||
}));
|
}));
|
||||||
app.post("/agent/codex/threads/reset", (req, res) => {
|
app.post("/agent/codex/threads/reset", codexMutation((req, res) => {
|
||||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
const clientId = String(req.body?.clientId || "");
|
||||||
res.json({ ok: true, workspace: setActiveThread("", { emptyThread: true, draftThread: true }) });
|
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) => {
|
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
const threadId = routeParam(req.params.threadId);
|
const threadId = routeParam(req.params.threadId);
|
||||||
try {
|
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
|
||||||
} catch (error) {
|
|
||||||
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
|
|
||||||
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
app.post("/agent/codex/history/ack", (req, res) => {
|
||||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
const threadId = String(req.body?.threadId || "");
|
||||||
|
const turnIds = Array.isArray(req.body?.turnIds) ? req.body.turnIds.map(String) : [];
|
||||||
|
session.acknowledgeCodexHistory(threadId, turnIds);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.post("/agent/codex/threads/:threadId/resume", codexMutation(async (req, res) => {
|
||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
const threadId = routeParam(req.params.threadId);
|
const threadId = routeParam(req.params.threadId);
|
||||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||||
const nextWorkspace = setActiveThread(threadId);
|
const nextWorkspace = setActiveThread(threadId, { sourceClientId: String(req.body?.clientId || "") });
|
||||||
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||||
}));
|
}));
|
||||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
app.post("/agent/codex/threads/:threadId/delete", codexMutation(async (req, res) => {
|
||||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
const threadId = routeParam(req.params.threadId);
|
const threadId = routeParam(req.params.threadId);
|
||||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||||
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
|
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}));
|
}));
|
||||||
app.post("/agent/codex/turn", route(async (req, res) => {
|
app.post("/agent/codex/turn", codexMutation(async (req, res) => {
|
||||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
|
||||||
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
||||||
const workspace = ensureSiteWorkspace(config);
|
const workspace = ensureSiteWorkspace(config);
|
||||||
const prompt = String(req.body?.prompt || "");
|
const prompt = String(req.body?.prompt || "");
|
||||||
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
|
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
|
||||||
const clientId = String(req.body?.clientId || "");
|
const clientId = String(req.body?.clientId || "");
|
||||||
logger.info("Codex turn accepted", { threadId: req.body?.threadId, promptLength: prompt.length, attachmentCount: attachments.length });
|
if (!clientId || !session.hasClient(clientId)) return res.status(409).json({ ok: false, error: "发起任务的网页已断开,请重新连接后再试" });
|
||||||
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
const requestedThreadId = String(req.body?.threadId || "");
|
||||||
|
const activeThreadId = workspace.activeThreadId || "";
|
||||||
|
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
|
||||||
|
const model = String(req.body?.model || "") || undefined;
|
||||||
|
const effort = reasoningEffort(req.body?.effort);
|
||||||
|
const messageId = String(req.body?.messageId || Date.now());
|
||||||
|
const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`);
|
||||||
|
let threadId = activeThreadId;
|
||||||
|
logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
|
||||||
|
session.bindClient(clientId);
|
||||||
|
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||||
try {
|
try {
|
||||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
|
||||||
let turnId = "";
|
let turnId = "";
|
||||||
if (!threadId) {
|
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 || "");
|
threadId = String((thread as Record<string, unknown>).id || "");
|
||||||
setActiveThread(threadId, { emptyThread: true });
|
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
|
||||||
} else if (threadId !== workspace.activeThreadId) {
|
|
||||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
|
||||||
setActiveThread(threadId);
|
|
||||||
}
|
}
|
||||||
|
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||||
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
|
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
|
||||||
const chatMessage = {
|
session.emitThread("chat_message", threadId, {
|
||||||
sourceClientId: clientId,
|
sourceClientId: clientId,
|
||||||
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
|
message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText },
|
||||||
|
});
|
||||||
|
let chatTurnId = "";
|
||||||
|
/** 将包装层日志和兜底错误固定广播到当前 turn。 */
|
||||||
|
const lifecycleEmit = (type: string, payload: unknown) => {
|
||||||
|
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||||
|
const eventThreadId = String(value.threadId || value.thread_id || threadId);
|
||||||
|
const eventTurnId = String(value.turnId || value.turn_id || turnId);
|
||||||
|
const sourceClientId = String(value.sourceClientId || clientId);
|
||||||
|
session.emitThread(type, eventThreadId, {
|
||||||
|
...value,
|
||||||
|
threadId: eventThreadId,
|
||||||
|
thread_id: eventThreadId,
|
||||||
|
...(eventTurnId ? { turnId: eventTurnId, turn_id: eventTurnId } : {}),
|
||||||
|
...(sourceClientId ? { sourceClientId } : {}),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
let chatThreadId = "";
|
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), lifecycleEmit, attachments, {
|
||||||
/** 将当前 turn 事件固定广播到实际线程。 */
|
|
||||||
const turnEmit = (type: string, payload: unknown) => {
|
|
||||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
|
||||||
session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) });
|
|
||||||
};
|
|
||||||
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
|
|
||||||
threadId,
|
threadId,
|
||||||
cwd: workspace.workspacePath,
|
cwd: workspace.workspacePath,
|
||||||
permissionMode: permissionMode(req.body?.permissionMode),
|
permissionMode: permissionMode(req.body?.permissionMode),
|
||||||
|
model,
|
||||||
|
effort,
|
||||||
appEmit: emit,
|
appEmit: emit,
|
||||||
onStart: clientId ? () => session.bindClient(clientId) : undefined,
|
onStart: () => session.bindClient(clientId),
|
||||||
onThread: (actualThreadId) => {
|
onThread: (actualThreadId) => {
|
||||||
|
const threadChanged = actualThreadId !== threadId;
|
||||||
if (actualThreadId !== threadId) {
|
if (actualThreadId !== threadId) {
|
||||||
threadId = actualThreadId;
|
threadId = actualThreadId;
|
||||||
setActiveThread(threadId, { emptyThread: true });
|
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
|
||||||
}
|
}
|
||||||
session.setCodexState({ busy: true, threadId, turnId: "" });
|
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||||
if (chatThreadId !== threadId) {
|
if (threadChanged) {
|
||||||
chatThreadId = threadId;
|
session.emitThread("chat_message", threadId, {
|
||||||
session.emitThread("chat_message", threadId, chatMessage);
|
sourceClientId: clientId,
|
||||||
|
message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTurn: (actualTurnId) => {
|
onTurn: (actualTurnId) => {
|
||||||
turnId = actualTurnId;
|
turnId = actualTurnId;
|
||||||
logger.info("Codex turn started", { threadId, turnId });
|
if (chatTurnId !== turnId) {
|
||||||
|
chatTurnId = turnId;
|
||||||
|
session.emitThread("chat_message", threadId, {
|
||||||
|
turnId,
|
||||||
|
sourceClientId: clientId,
|
||||||
|
message: { id: `${threadId}:${turnId}:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId, role: "user", text: messageText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info("Codex turn started", { threadId, turnId, model: model || "default", reasoningEffort: effort || "default" });
|
||||||
session.setCodexState({ busy: true, threadId, turnId });
|
session.setCodexState({ busy: true, threadId, turnId });
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
@@ -200,10 +262,23 @@ export function startHttpServer() {
|
|||||||
});
|
});
|
||||||
res.json({ ok: true, threadId });
|
res.json({ ok: true, threadId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
session.releaseClient(clientId);
|
||||||
|
session.setCodexState({ busy: false, threadId, turnId: "" });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** 将 Codex 写操作串行化,避免多窗口在异步请求期间交叉修改会话。 */
|
||||||
|
function codexMutation(handler: (req: Request, res: Response) => unknown | Promise<unknown>) {
|
||||||
|
return route(async (req, res) => {
|
||||||
|
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, error: "Codex 正在运行或正在切换会话,请稍后重试" });
|
||||||
|
try {
|
||||||
|
return await handler(req, res);
|
||||||
|
} finally {
|
||||||
|
session.endCodexMutation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
app.post("/agent/codex/approval", route(async (req, res) => {
|
app.post("/agent/codex/approval", route(async (req, res) => {
|
||||||
const decision = String(req.body?.decision || "");
|
const decision = String(req.body?.decision || "");
|
||||||
if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" });
|
if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" });
|
||||||
@@ -223,6 +298,7 @@ export function startHttpServer() {
|
|||||||
|
|
||||||
app.listen(port, "127.0.0.1", () => {
|
app.listen(port, "127.0.0.1", () => {
|
||||||
console.log("Infinite Canvas Agent");
|
console.log("Infinite Canvas Agent");
|
||||||
|
checkVersions();
|
||||||
console.log(`Local URL: ${config.url}`);
|
console.log(`Local URL: ${config.url}`);
|
||||||
console.log(`Connect token: ${config.token}`);
|
console.log(`Connect token: ${config.token}`);
|
||||||
console.log("Codex MCP is not installed by this command.");
|
console.log("Codex MCP is not installed by this command.");
|
||||||
@@ -247,6 +323,10 @@ function permissionMode(value: unknown): AgentPermissionMode {
|
|||||||
return value === "automatic" || value === "full" ? value : "request";
|
return value === "automatic" || value === "full" ? value : "request";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
|
||||||
|
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
||||||
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
||||||
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
||||||
|
|||||||
@@ -11,47 +11,44 @@ import {formatDateForFilename} from "./date.js";
|
|||||||
export class Logger {
|
export class Logger {
|
||||||
readonly enabled = process.argv.includes("--debug");
|
readonly enabled = process.argv.includes("--debug");
|
||||||
readonly filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
|
readonly filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
|
||||||
private readonly logger: WinstonLogger | null;
|
private readonly logger: WinstonLogger;
|
||||||
|
|
||||||
/** 根据命令行 Debug 参数初始化日志输出。 */
|
/** 普通模式输出 Info 以上日志,Debug 模式额外输出 Debug 并写入文件。 */
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!this.enabled) {
|
|
||||||
this.logger = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
|
|
||||||
const line = format.printf(({level, message, timestamp, details}) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
|
const line = format.printf(({level, message, timestamp, details}) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
|
||||||
|
const output = format.combine(format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}), line);
|
||||||
|
if (this.enabled) fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
|
||||||
this.logger = winston.createLogger({
|
this.logger = winston.createLogger({
|
||||||
level: "debug",
|
level: this.enabled ? "debug" : "info",
|
||||||
transports: [
|
transports: [
|
||||||
new transports.Console({format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
|
new transports.Console({format: output}),
|
||||||
new transports.File({filename: this.filePath, format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
|
...(this.enabled ? [new transports.File({filename: this.filePath, format: output})] : []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输出 Debug 级别日志。 */
|
/** 输出 Debug 级别日志。 */
|
||||||
debug(message: string, details?: unknown) {
|
debug(message: string, details?: unknown) {
|
||||||
if (details === undefined) this.logger?.debug(message);
|
if (details === undefined) this.logger.debug(message);
|
||||||
else this.logger?.debug(message, {details: sanitize(details)});
|
else this.logger.debug(message, {details: sanitize(details)});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输出 Info 级别日志。 */
|
/** 输出 Info 级别日志。 */
|
||||||
info(message: string, details?: unknown) {
|
info(message: string, details?: unknown) {
|
||||||
if (details === undefined) this.logger?.info(message);
|
if (details === undefined) this.logger.info(message);
|
||||||
else this.logger?.info(message, {details: sanitize(details)});
|
else this.logger.info(message, {details: sanitize(details)});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输出 Warn 级别日志。 */
|
/** 输出 Warn 级别日志。 */
|
||||||
warn(message: string, details?: unknown) {
|
warn(message: string, details?: unknown) {
|
||||||
if (details === undefined) this.logger?.warn(message);
|
if (details === undefined) this.logger.warn(message);
|
||||||
else this.logger?.warn(message, {details: sanitize(details)});
|
else this.logger.warn(message, {details: sanitize(details)});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输出 Error 级别日志。 */
|
/** 输出 Error 级别日志。 */
|
||||||
error(message: string, details?: unknown) {
|
error(message: string, details?: unknown) {
|
||||||
if (details === undefined) this.logger?.error(message);
|
if (details === undefined) this.logger.error(message);
|
||||||
else this.logger?.error(message, {details: sanitize(details)});
|
else this.logger.error(message, {details: sanitize(details)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { VERSION } from "./config.js";
|
||||||
|
import { logger } from "./utils/logger.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const CODEX_VERSION = String((require("@openai/codex/package.json") as { version: string }).version);
|
||||||
|
|
||||||
|
/** 输出当前版本,并在后台检查 npm 最新版本。 */
|
||||||
|
export function checkVersions() {
|
||||||
|
const localCodexVersion = commandVersion("codex");
|
||||||
|
logger.info("Canvas Agent version", { version: VERSION });
|
||||||
|
logger.info("Bundled Codex version", { version: CODEX_VERSION });
|
||||||
|
logger.info("Local Codex version", { version: localCodexVersion || "not found" });
|
||||||
|
if (!localCodexVersion) {
|
||||||
|
logger.warn("Local Codex was not found. Install the latest version with: npm install -g @openai/codex@latest");
|
||||||
|
} else if (localCodexVersion !== CODEX_VERSION) {
|
||||||
|
logger.warn(`Bundled Codex ${CODEX_VERSION} does not match local Codex ${localCodexVersion}. Keep both current with: npm install -g @openai/codex@latest && npx -y @basketikun/canvas-agent@latest`);
|
||||||
|
}
|
||||||
|
void checkLatestVersions(localCodexVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询 npm,提醒升级不再维护的旧版本。 */
|
||||||
|
async function checkLatestVersions(localCodexVersion: string) {
|
||||||
|
try {
|
||||||
|
const [latestAgent, latestCodex] = await Promise.all([
|
||||||
|
npmVersion("@basketikun/canvas-agent"),
|
||||||
|
npmVersion("@openai/codex"),
|
||||||
|
]);
|
||||||
|
if (isOlder(VERSION, latestAgent)) logger.warn(`Update available: Canvas Agent ${VERSION} -> ${latestAgent}. Run: npx -y @basketikun/canvas-agent@latest`);
|
||||||
|
if (isOlder(CODEX_VERSION, latestCodex)) logger.warn(`Update available: bundled Codex ${CODEX_VERSION} -> ${latestCodex}. Upgrade Canvas Agent with: npx -y @basketikun/canvas-agent@latest`);
|
||||||
|
if (localCodexVersion && isOlder(localCodexVersion, latestCodex)) logger.warn(`Update available: local Codex ${localCodexVersion} -> ${latestCodex}. Run: npm install -g @openai/codex@latest`);
|
||||||
|
} catch {
|
||||||
|
logger.warn("Unable to check the latest npm versions; startup will continue.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取本机命令输出中的语义版本号。 */
|
||||||
|
function commandVersion(command: string) {
|
||||||
|
try {
|
||||||
|
return execFileSync(command, ["--version"], { encoding: "utf8", timeout: 5_000 }).match(/\d+\.\d+\.\d+/)?.[0] || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 npm 包的最新版本。 */
|
||||||
|
async function npmVersion(name: string) {
|
||||||
|
const command = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
const { stdout } = await execFileAsync(command, ["view", name, "version"], { encoding: "utf8", timeout: 10_000 });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 比较仅包含数字段的稳定版语义版本。 */
|
||||||
|
function isOlder(current: string, latest: string) {
|
||||||
|
const left = current.split(".").map(Number);
|
||||||
|
const right = latest.split(".").map(Number);
|
||||||
|
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
||||||
|
if ((left[index] || 0) !== (right[index] || 0)) return (left[index] || 0) < (right[index] || 0);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;此时不应生成空历史记录,第一次发送消息时才创建线程;多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话。
|
- 画布节点缩放稳定性:选中任意节点并反复拖动四角缩放时,节点工具条应在缩放期间隐藏、松开后恢复,页面不应再出现 `Maximum update depth exceeded`。
|
||||||
|
- Agent MCP 初始化状态:首次进入空白对话或点击「新对话」后,无需先发送消息,对话区应立即显示 Codex 会话准备,并同时列出 `codex_apps`、`infinite-canvas`、`node_repl` 等全部 MCP 服务各自的启动、就绪或失败状态;初始化期间可输入并保留草稿,但发送按钮、上传和回车提交应禁用,全部完成后显示服务数量、恢复发送并复用同一个预热线程;右侧日志应记录同样的真实状态,`starting` 与 `ready` 归入信息且就绪显示绿色成功图标,只有 `failed` 归入错误、`cancelled` 归入警告,模型开始处理后不应残留初始化文案。
|
||||||
|
- Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度。
|
||||||
|
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;后台应立即创建线程并预热 MCP,多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话或重复创建线程。
|
||||||
- Agent 读取画布卡片:读取当前画布完成后,卡片应按非零类型显示文本、图片、配置、视频、音频、分组、其他节点及连线数量,例如「3 个文本、5 张图片、2 个配置、4 条连线」;空画布应显示「当前画布为空」,执行失败时仍应显示错误信息,刷新恢复历史后统计保持一致。
|
- Agent 读取画布卡片:读取当前画布完成后,卡片应按非零类型显示文本、图片、配置、视频、音频、分组、其他节点及连线数量,例如「3 个文本、5 张图片、2 个配置、4 条连线」;空画布应显示「当前画布为空」,执行失败时仍应显示错误信息,刷新恢复历史后统计保持一致。
|
||||||
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
|
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
|
||||||
- Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。
|
- Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。
|
||||||
@@ -13,16 +16,20 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
- Agent 顶部栏:标题只显示垂直居中的「Agent」,连接、对话、历史、日志、新对话和收起操作应位于同一行;面板较窄时标签仍可横向滚动且操作不应错位。
|
- Agent 顶部栏:标题只显示垂直居中的「Agent」,连接、对话、历史、日志、新对话和收起操作应位于同一行;面板较窄时标签仍可横向滚动且操作不应错位。
|
||||||
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
|
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
|
||||||
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
|
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
|
||||||
- Canvas Agent Codex 升级:启动 Canvas Agent 后应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,实际运行的 Codex CLI 版本应为 0.145.0;运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
|
- Canvas Agent Codex 升级:启动 Canvas Agent 后应输出 Canvas Agent、内置 Codex 和本机 Codex 版本,实际运行的内置 Codex 应为 0.146.0;内置与本机版本不一致、未安装本机 Codex,或 npm 存在更新版本时应显示对应升级提醒,npm 检查失败不应阻止服务启动;Agent 应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
|
||||||
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
|
- Canvas Agent Debug:普通启动应通过公共 logger 输出 Info、Warn 和 Error 日志;使用 `npx -y @basketikun/canvas-agent --debug` 启动后还应输出 Debug 日志并显示日志文件路径,终端统一采用“`YYYY-MM-DD HH:mm:ss` 级别 消息 详情”的纯文本单行格式,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;日志中不应出现连接 token 或图片 Data URL 原文。
|
||||||
|
- Canvas Agent Codex 日志:Codex app-server 输出带颜色或样式控制符及 UTC 时间的 stderr 时,本地日志和网页诊断日志应只显示一份 `YYYY-MM-DD HH:mm:ss` 本地时间和干净文本,不应出现重复 ISO 时间或 `[2m`、`[31m` 等 ANSI 转义内容。
|
||||||
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
|
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
|
||||||
|
- Agent 排查日志:右侧「日志」中的排查视图应显示连接摘要,并可按错误、警告和信息筛选;向下查看日志时仅列表内部滚动,标题、连接状态、筛选数量和操作按钮保持固定;连续相同事件应折叠计数,单条日志可展开查看完整详情;原始 JSON、复制全部、复制最近错误和清空功能应继续可用,浅色与深色主题下均应清晰。
|
||||||
|
- Agent 排查日志顺序与跟随:日志应按时间从旧到新排列,进入日志或切换筛选时定位到最新事件;停留底部时自动跟随新日志,展开最后一条后应继续停留在完整详情底部,向上浏览后暂停跟随并显示与对话区视觉高度一致的居中圆形向下箭头,新日志数量只在悬浮提示中显示;列表底部不应出现额外留白,点击按钮后回到底部并恢复跟随。
|
||||||
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
|
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
|
||||||
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
|
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
|
||||||
|
- Agent 对话实时与历史一致性:运行包含思考、命令及画布工具的较长任务,在执行中刷新发起页面或打开第二个页面时,已出现的用户消息、思考过程、工具卡和回复前缀均应保留并继续更新;同一思考或工具只显示一张卡片,任务结束及再次刷新后内容、顺序和状态应一致。切换页面焦点不能改变本轮画布工具目标,发起页面断开后不得把图片或写操作转交其他画布,使用相同页面身份重连后才能继续处理。
|
||||||
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
|
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
|
||||||
- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,完成事件不应再用「已完成分析」覆盖已经收到的摘要;命令执行也应使用无边框紧凑行,显示「已执行 1 条命令」和单行命令预览,点击后再展开工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。
|
- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,多行代码块和文本流程图应保留原始换行并允许横向滚动,完成事件不应再用「已完成分析」覆盖已经收到的摘要;同一轮连续命令应合并为显示数量的无边框折叠行,折叠时不显示命令预览,单条展开后直接显示详情,多条展开后可逐条查看工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。
|
||||||
- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。
|
- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;提交决定后卡片应保持禁用等待,只有 Codex 确认处理后才移除;等待审批时刷新页面,未处理的审批卡应自动恢复且不能让任务永久卡住;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。
|
||||||
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||||
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
|
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话,并在后台创建新线程、预热 MCP;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||||
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
|
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
|
||||||
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧显示为约 40px 的紧凑缩略图,不再撑大消息区域,单击缩略图应打开大图预览,打开或关闭预览不应改变消息间距或产生额外空行;任务运行中、完成同步历史、切换页面及重新进入历史对话后都应显示浏览器本地保存的图片缩略图,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中;删除历史会话后应同步清理对应缩略图。
|
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧显示为约 40px 的紧凑缩略图,不再撑大消息区域,单击缩略图应打开大图预览,打开或关闭预览不应改变消息间距或产生额外空行;任务运行中、完成同步历史、切换页面及重新进入历史对话后都应显示浏览器本地保存的图片缩略图,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中;删除历史会话后应同步清理对应缩略图。
|
||||||
- 画布文本复制:在 Agent 对话或节点信息详情中用鼠标选中文字后,按 `Ctrl/Cmd + C` 应复制所选文本,不应触发画布节点复制;未选中文字且焦点位于画布时,原有节点复制快捷键应保持可用。
|
- 画布文本复制:在 Agent 对话或节点信息详情中用鼠标选中文字后,按 `Ctrl/Cmd + C` 应复制所选文本,不应触发画布节点复制;未选中文字且焦点位于画布时,原有节点复制快捷键应保持可用。
|
||||||
@@ -44,9 +51,10 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
|
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
|
||||||
- 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
|
- 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
|
||||||
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
||||||
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。
|
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;非运行状态关闭当前页面后应回退到最近聚焦且仍连接的页面,运行中关闭发起页面则不得把操作转交其他画布,只有同一页面身份重连后才能继续;其他页面回传同一请求结果应被拒绝。
|
||||||
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
|
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
|
||||||
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
|
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
|
||||||
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
|
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
|
||||||
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示向下箭头且新消息不强制打断阅读,点击箭头后平滑回到底部并继续跟随新消息。
|
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示与日志完全相同尺寸、位置和样式的居中圆形向下箭头,新消息不强制打断阅读,对话底部不应出现额外留白,点击按钮后平滑回到底部并继续跟随新消息。
|
||||||
- Agent 消息区分:用户消息应在右侧使用透明无气泡排版,AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
|
- Agent 消息区分:用户消息应在右侧使用透明无气泡排版,AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
|
||||||
|
- Agent 命令记录:命令执行中应显示运行状态;完成后,同一轮相邻命令应合并为「已执行 N 条命令」折叠行且默认不显示原始命令,单条展开后直接显示完整详情,多条展开后可分别查看命令、输出和退出状态;对话结束或刷新后分组与记录不应消失。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||||
|
|
||||||
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
|
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
|
||||||
|
|
||||||
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
export async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||||
try {
|
try {
|
||||||
@@ -19,13 +19,17 @@ export async function activateAgentClient(endpoint: string, token: string, clien
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
|
export async function postToolResult(endpoint: string, token: string, clientId: string, body: { requestId: string; result?: unknown; error?: string }) {
|
||||||
await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
await fetchAgentJson(endpoint, token, `/canvas/result?clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postCodexApproval(endpoint: string, token: string, requestId: string, decision: "accept" | "acceptForSession" | "decline") {
|
export async function postCodexApproval(endpoint: string, token: string, requestId: string, decision: "accept" | "acceptForSession" | "decline") {
|
||||||
await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) });
|
await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function acknowledgeCodexHistory(endpoint: string, token: string, threadId: string, turnIds: string[]) {
|
||||||
|
await fetchAgentJson(endpoint, token, "/agent/codex/history/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ threadId, turnIds }) });
|
||||||
|
}
|
||||||
|
|
||||||
export async function revealAgentLocalFile(endpoint: string, token: string, path: string) {
|
export async function revealAgentLocalFile(endpoint: string, token: string, path: string) {
|
||||||
await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
|
await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useRef, type ReactNode } from "react";
|
import { useRef, type ReactNode } from "react";
|
||||||
import { Button, Dropdown, Tooltip } from "antd";
|
import { Button, Dropdown, Tooltip } from "antd";
|
||||||
import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
|
import { ArrowUp, Check, ChevronUp, Cpu, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||||
import type { AgentPermissionMode } from "@/stores/use-agent-store";
|
import type { AgentModel, AgentPermissionMode, AgentReasoningEffort } from "@/stores/use-agent-store";
|
||||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||||
|
|
||||||
export function AgentChatComposer({
|
export function AgentChatComposer({
|
||||||
@@ -23,6 +24,11 @@ export function AgentChatComposer({
|
|||||||
onConfirmToolsChange,
|
onConfirmToolsChange,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
onPermissionModeChange,
|
onPermissionModeChange,
|
||||||
|
models,
|
||||||
|
model,
|
||||||
|
reasoningEffort,
|
||||||
|
onModelChange,
|
||||||
|
onReasoningEffortChange,
|
||||||
left,
|
left,
|
||||||
}: {
|
}: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -40,6 +46,11 @@ export function AgentChatComposer({
|
|||||||
onConfirmToolsChange?: (confirmTools: boolean) => void;
|
onConfirmToolsChange?: (confirmTools: boolean) => void;
|
||||||
permissionMode?: AgentPermissionMode;
|
permissionMode?: AgentPermissionMode;
|
||||||
onPermissionModeChange?: (permissionMode: AgentPermissionMode) => void;
|
onPermissionModeChange?: (permissionMode: AgentPermissionMode) => void;
|
||||||
|
models?: AgentModel[];
|
||||||
|
model?: string;
|
||||||
|
reasoningEffort?: AgentReasoningEffort | "";
|
||||||
|
onModelChange?: (model: string) => void;
|
||||||
|
onReasoningEffortChange?: (effort: AgentReasoningEffort) => void;
|
||||||
left?: ReactNode;
|
left?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -74,6 +85,7 @@ export function AgentChatComposer({
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!isPlainEnterKey(event)) return;
|
if (!isPlainEnterKey(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
void onSubmit();
|
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"
|
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"
|
||||||
@@ -89,12 +101,13 @@ export function AgentChatComposer({
|
|||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}} />
|
}} />
|
||||||
<Tooltip title="上传图片">
|
<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>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
|
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
|
||||||
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
|
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
|
||||||
|
{models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelControls models={models} model={model} reasoningEffort={reasoningEffort} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : null}
|
||||||
{left}
|
{left}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
@@ -110,6 +123,41 @@ export function AgentChatComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgentModelControls({ models, model, reasoningEffort, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) {
|
||||||
|
const current = models.find((item) => item.model === model) || models[0];
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<Select value={model} onValueChange={onModelChange}>
|
||||||
|
<SelectTrigger className="h-9 min-w-0 max-w-36 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" title={current.displayName || current.model} aria-label="选择 Codex 模型">
|
||||||
|
<Cpu className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">{current.displayName || current.model}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] w-64 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
|
||||||
|
{models.map((item) => <SelectItem key={item.model} value={item.model}>{item.displayName || item.model}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={reasoningEffort} onValueChange={(value) => onReasoningEffortChange(value as AgentReasoningEffort)}>
|
||||||
|
<SelectTrigger className="h-9 rounded-full border-0 bg-transparent px-2.5 text-xs font-medium shadow-none hover:bg-black/5 focus:ring-0 dark:bg-transparent dark:hover:bg-white/10" aria-label="选择推理强度">
|
||||||
|
<span>{effortLabels[reasoningEffort]}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent data-canvas-no-zoom position="popper" side="top" align="start" sideOffset={6} className="z-[1200] min-w-32 rounded-xl border border-border/70 bg-popover p-1 shadow-xl">
|
||||||
|
{current.supportedReasoningEfforts.map((item) => <SelectItem key={item.reasoningEffort} value={item.reasoningEffort}>{effortLabels[item.reasoningEffort]}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effortLabels: Record<AgentReasoningEffort, string> = {
|
||||||
|
minimal: "最低",
|
||||||
|
low: "轻度",
|
||||||
|
medium: "中",
|
||||||
|
high: "高",
|
||||||
|
xhigh: "极高",
|
||||||
|
max: "最高",
|
||||||
|
ultra: "Ultra",
|
||||||
|
};
|
||||||
|
|
||||||
function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) {
|
function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) {
|
||||||
const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0];
|
const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||||
import { App, Button, Image, Modal } from "antd";
|
import { App, Button, Image, Modal } from "antd";
|
||||||
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
|
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
|
||||||
import { Streamdown, type LinkSafetyModalProps } from "streamdown";
|
import { Streamdown, type LinkSafetyModalProps } from "streamdown";
|
||||||
@@ -23,6 +23,7 @@ const streamdownProps = {
|
|||||||
openLink: "继续打开",
|
openLink: "继续打开",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
const streamdownAnimation = { duration: 20, stagger: 0, sep: "word" } as const;
|
||||||
|
|
||||||
function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
|
function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
@@ -127,7 +128,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
|
|||||||
{isUser ? (
|
{isUser ? (
|
||||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||||
) : (
|
) : (
|
||||||
<Streamdown {...streamdownProps} animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||||
)}
|
)}
|
||||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
|
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
|
||||||
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
|
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
|
||||||
@@ -181,9 +182,9 @@ export function AgentApprovalCard({ approval, theme, onDecision }: { approval: A
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
<div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
||||||
<Button danger type="text" className="!h-8" onClick={() => onDecision("decline")}>拒绝</Button>
|
<Button danger type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "decline"} onClick={() => onDecision("decline")}>拒绝</Button>
|
||||||
<Button type="text" className="!h-8" onClick={() => onDecision("accept")}>允许一次</Button>
|
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "accept"} onClick={() => onDecision("accept")}>允许一次</Button>
|
||||||
<Button type="text" className="!h-8" style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</Button>
|
<Button type="text" className="!h-8" disabled={Boolean(approval.deciding)} loading={approval.deciding === "acceptForSession"} style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -194,26 +195,32 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
|
|||||||
if (plan) return <AgentPlanCard title={title} plan={plan} theme={theme} />;
|
if (plan) return <AgentPlanCard title={title} plan={plan} theme={theme} />;
|
||||||
const kind = String(objectField(detail, "kind") || "");
|
const kind = String(objectField(detail, "kind") || "");
|
||||||
if (kind === "reasoning") return <AgentReasoningSummary text={text} detail={detail} theme={theme} />;
|
if (kind === "reasoning") return <AgentReasoningSummary text={text} detail={detail} theme={theme} />;
|
||||||
if (kind === "command") return <AgentCommandSummary text={text} detail={detail} theme={theme} />;
|
if (kind === "command") return <AgentCommandGroup items={[{ id: title, text, detail }]} theme={theme} />;
|
||||||
const state = toolCardState(title, text, detail);
|
const state = toolCardState(title, text, detail);
|
||||||
const view = userDetail(detail);
|
const view = userDetail(detail);
|
||||||
const showText = title !== "读取画布" || text !== "已读取当前画布内容";
|
const showText = title !== "读取画布" || text !== "已读取当前画布内容";
|
||||||
return (
|
const className = "group min-w-0 rounded-xl border px-3 py-2.5 text-left";
|
||||||
<details className="group min-w-0 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
|
const style = { borderColor: theme.node.stroke, background: "transparent", color: theme.node.text };
|
||||||
<summary className={`list-none ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
const content = (
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm leading-5">
|
<>
|
||||||
<span className="shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
|
<div className="flex min-w-0 items-center gap-2 text-sm leading-5">
|
||||||
<span className="min-w-0 truncate font-medium">{title}</span>
|
<span className="shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
|
||||||
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
|
<span className="min-w-0 truncate font-medium">{title}</span>
|
||||||
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
|
<span className="shrink-0 text-[11px]" 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>
|
||||||
|
{showText ? (
|
||||||
|
<div className={`mt-1 whitespace-pre-wrap break-words pl-6 text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||||
|
{text}
|
||||||
</div>
|
</div>
|
||||||
{showText ? (
|
) : null}
|
||||||
<div className={`mt-1 whitespace-pre-wrap break-words pl-6 text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
|
</>
|
||||||
{text}
|
);
|
||||||
</div>
|
if (!view) return <div className={className} style={style}>{content}</div>;
|
||||||
) : null}
|
return (
|
||||||
</summary>
|
<details className={className} style={style}>
|
||||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
<summary className="list-none cursor-pointer">{content}</summary>
|
||||||
|
<div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div>
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -231,33 +238,87 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
|
|||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}>
|
<div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}>
|
||||||
<Streamdown {...streamdownProps} animated isAnimating={running}>{text}</Streamdown>
|
<Streamdown {...streamdownProps} animated={streamdownAnimation} isAnimating={running}>{text}</Streamdown>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentCommandSummary({ text, detail, theme }: { text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
type AgentCommandItem = Pick<AgentChatMessageItem, "id" | "text" | "detail">;
|
||||||
const view = userDetail(detail);
|
|
||||||
const status = String(objectField(detail, "status") || "");
|
export function AgentCommandGroup({ items, theme }: { items: AgentCommandItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
const running = ["inProgress", "in_progress", "running", "started", "pending"].includes(status);
|
const states = items.map((item) => commandViewState(item.detail));
|
||||||
const failed = ["failed", "error"].includes(status);
|
const running = states.some((state) => state.running);
|
||||||
const color = failed ? "#dc2626" : running ? "#d97706" : theme.node.muted;
|
const failed = states.filter((state) => state.failed).length;
|
||||||
|
const expandable = items.some((item) => Boolean(item.text.trim() || userDetail(item.detail)));
|
||||||
|
const color = running ? "#d97706" : failed ? "#dc2626" : theme.node.muted;
|
||||||
|
const label = running
|
||||||
|
? items.length > 1 ? `正在执行 ${items.length} 条命令` : "正在执行命令"
|
||||||
|
: `已执行 ${items.length} 条命令${failed ? ` · ${failed} 条失败` : ""}`;
|
||||||
|
const header = (
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}>
|
||||||
|
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />}
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
{expandable ? <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!expandable) return <div className="min-w-0 py-1 text-left">{header}</div>;
|
||||||
return (
|
return (
|
||||||
<details className="group min-w-0 text-left">
|
<details className="group min-w-0 text-left">
|
||||||
<summary className={`list-none py-1 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
|
<summary className="cursor-pointer list-none py-1">{header}</summary>
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm" style={{ color }}>
|
{items.length === 1
|
||||||
{running ? <LoaderCircle className="size-4 shrink-0 animate-spin" /> : <TerminalSquare className="size-4 shrink-0" />}
|
? <AgentSingleCommand item={items[0]} theme={theme} />
|
||||||
<span className="font-medium">{failed ? "命令执行失败" : running ? "正在执行命令" : "已执行 1 条命令"}</span>
|
: <div className="ml-6 mt-1">{items.map((item, index) => <AgentCommandEntry key={item.id} item={item} index={index} theme={theme} />)}</div>
|
||||||
{view ? <ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" /> : null}
|
}
|
||||||
</div>
|
|
||||||
<div className="mt-1 truncate pl-6 font-mono text-[12px] leading-5" style={{ color: failed ? color : theme.node.muted }} title={text}>{text}</div>
|
|
||||||
</summary>
|
|
||||||
{view ? <div className="ml-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgentSingleCommand({ item, theme }: { item: AgentCommandItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
|
const view = userDetail(item.detail);
|
||||||
|
return (
|
||||||
|
<div className="ml-6 pb-1">
|
||||||
|
{item.text ? <div className="mt-1.5 whitespace-pre-wrap break-all font-mono text-[11px] leading-5" style={{ color: theme.node.text }}>{item.text}</div> : null}
|
||||||
|
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentCommandEntry({ item, index, theme }: { item: AgentCommandItem; index: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const detailId = useId();
|
||||||
|
const view = userDetail(item.detail);
|
||||||
|
const state = commandViewState(item.detail);
|
||||||
|
const status = state.failed ? "执行失败" : state.running ? "执行中" : "已完成";
|
||||||
|
const color = state.failed ? "#dc2626" : state.running ? "#d97706" : "#16a34a";
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="w-4 shrink-0 text-center text-[10px] tabular-nums opacity-50" style={{ color: theme.node.muted }}>{index + 1}</span>
|
||||||
|
<code className="min-w-0 flex-1 truncate text-[11px] leading-5" style={{ color: theme.node.text }} title={item.text}>{item.text || "命令"}</code>
|
||||||
|
<span className="shrink-0" style={{ color }} title={status} aria-label={status}>
|
||||||
|
{state.running ? <LoaderCircle className="size-3.5 animate-spin" /> : state.failed ? <XCircle className="size-3.5" /> : <CheckCircle2 className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
{view ? <ChevronRight className={`size-3.5 shrink-0 transition-transform ${open ? "rotate-90" : ""}`} style={{ color: theme.node.muted }} /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={index ? "border-t" : ""} style={{ borderColor: theme.node.stroke }}>
|
||||||
|
{view
|
||||||
|
? <button type="button" className="flex w-full min-w-0 items-center gap-2 py-2 text-left" aria-expanded={open} aria-controls={detailId} onClick={() => setOpen((value) => !value)}>{content}</button>
|
||||||
|
: <div className="flex min-w-0 items-center gap-2 py-2 text-left">{content}</div>}
|
||||||
|
{view && open ? <div id={detailId} className="pb-2 pl-6"><AgentDetailBlock detail={view} theme={theme} /></div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandViewState(detail: unknown) {
|
||||||
|
const status = String(objectField(detail, "status") || "").toLowerCase();
|
||||||
|
return {
|
||||||
|
running: ["inprogress", "in_progress", "running", "started", "pending"].includes(status),
|
||||||
|
failed: ["failed", "error"].includes(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const completed = plan.tasks.filter((item) => item.status === "completed").length;
|
const completed = plan.tasks.filter((item) => item.status === "completed").length;
|
||||||
@@ -288,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);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -299,11 +360,25 @@ export function AgentWorkingMessage({ text, activityKey, theme }: { text: string
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-0 py-1" aria-live="polite">
|
<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 }}>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button, Tooltip } from "antd";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { motion, useSpring, useTransform } from "motion/react";
|
import { motion, useSpring, useTransform } from "motion/react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
|
import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops";
|
||||||
import { useAgentStore, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store";
|
import { useAgentStore, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||||
import { AgentApprovalCard, AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
|
import { AgentApprovalCard, AgentChatMessage, AgentCommandGroup, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message";
|
||||||
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
|
import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters";
|
||||||
|
import { AgentScrollToBottom } from "./agent-scroll-to-bottom";
|
||||||
|
|
||||||
const SCROLL_BOTTOM_THRESHOLD = 48;
|
const SCROLL_BOTTOM_THRESHOLD = 48;
|
||||||
|
const historyMessageStyle = { contentVisibility: "auto", containIntrinsicSize: "0 80px" } as const;
|
||||||
|
|
||||||
export function AgentChatTimeline({
|
export function AgentChatTimeline({
|
||||||
theme,
|
theme,
|
||||||
@@ -31,11 +31,15 @@ export function AgentChatTimeline({
|
|||||||
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
|
onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void;
|
||||||
}) {
|
}) {
|
||||||
const messages = useAgentStore((state) => state.messages);
|
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 listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const followMessagesRef = useRef(true);
|
const followMessagesRef = useRef(true);
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
const streaming = messages.some((message) => message.streamId);
|
const streaming = messages.some((message) => message.streamId);
|
||||||
const working = workingActivity(messages.at(-1));
|
const working = bootstrapStatus || workingActivity(messages.at(-1));
|
||||||
const updateScrollState = useCallback(() => {
|
const updateScrollState = useCallback(() => {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -54,36 +58,42 @@ export function AgentChatTimeline({
|
|||||||
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [messages, pendingApprovals, pendingTool, scrollToBottom, updateScrollState, waiting]);
|
}, [messages, pendingApprovals, pendingTool, scrollToBottom, updateScrollState, waiting]);
|
||||||
|
useEffect(() => {
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!content) return;
|
||||||
|
let frame = 0;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState()));
|
||||||
|
});
|
||||||
|
observer.observe(content);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [scrollToBottom, updateScrollState]);
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<div ref={listRef} className="thin-scrollbar h-full select-text space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
|
<div ref={listRef} className="thin-scrollbar h-full select-text overflow-y-auto" onScroll={updateScrollState}>
|
||||||
{messages.map((item) => (
|
<div ref={contentRef} className="space-y-4 px-4 pt-4">
|
||||||
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} />
|
{timeline.map((entry) => entry.type === "commands"
|
||||||
))}
|
? <AgentCommandGroupRow key={entry.id} items={entry.items} theme={theme} />
|
||||||
{pendingTool ? (
|
: <AgentChatMessageRow key={entry.item.id} item={entry.item} theme={theme} />)}
|
||||||
<AgentPendingToolCard
|
{pendingTool ? (
|
||||||
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
<AgentPendingToolCard
|
||||||
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
|
||||||
theme={theme}
|
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
|
||||||
onReject={onRejectTool}
|
theme={theme}
|
||||||
onApprove={onApproveTool}
|
onReject={onRejectTool}
|
||||||
/>
|
onApprove={onApproveTool}
|
||||||
) : null}
|
/>
|
||||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
) : null}
|
||||||
{(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||||
|
{(sending || waiting || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showScrollToBottom ? (
|
{showScrollToBottom ? (
|
||||||
<Tooltip title="滚动到底部" placement="left">
|
<AgentScrollToBottom theme={theme} title="查看最新消息" onClick={() => scrollToBottom()} />
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
shape="circle"
|
|
||||||
aria-label="滚动到底部"
|
|
||||||
className="!absolute bottom-3 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5"
|
|
||||||
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
|
||||||
icon={<ChevronDown className="size-4" />}
|
|
||||||
onClick={() => scrollToBottom()}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -101,12 +111,52 @@ export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes
|
|||||||
|
|
||||||
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
|
<div style={item.streamId ? undefined : historyMessageStyle}>
|
||||||
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
|
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AgentCommandGroupRow = memo(function AgentCommandGroupRow({ items, theme }: { items: AgentChatItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
|
return (
|
||||||
|
<div style={items.some((item) => item.streamId) ? undefined : historyMessageStyle}>
|
||||||
|
<AgentCommandGroup items={items} theme={theme} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type AgentTimelineEntry = { type: "message"; item: AgentChatItem } | { type: "commands"; id: string; items: AgentChatItem[] };
|
||||||
|
|
||||||
|
function groupTimelineMessages(messages: AgentChatItem[]) {
|
||||||
|
const timeline: AgentTimelineEntry[] = [];
|
||||||
|
let commands: AgentChatItem[] = [];
|
||||||
|
let commandScope = "";
|
||||||
|
const flushCommands = () => {
|
||||||
|
if (!commands.length) return;
|
||||||
|
timeline.push({ type: "commands", id: `commands:${commands[0].id}`, items: commands });
|
||||||
|
commands = [];
|
||||||
|
commandScope = "";
|
||||||
|
};
|
||||||
|
messages.forEach((item) => {
|
||||||
|
if (isPlanMessage(item)) return;
|
||||||
|
if (isCommandMessage(item)) {
|
||||||
|
const scope = item.threadId && item.turnId ? `${item.threadId}\0${item.turnId}` : item.id;
|
||||||
|
if (commands.length && scope !== commandScope) flushCommands();
|
||||||
|
commands.push(item);
|
||||||
|
commandScope = scope;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushCommands();
|
||||||
|
timeline.push({ type: "message", item });
|
||||||
|
});
|
||||||
|
flushCommands();
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommandMessage(item: AgentChatItem) {
|
||||||
|
return item.role === "tool" && item.detail && typeof item.detail === "object" && (item.detail as { kind?: unknown }).kind === "command";
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
export function AgentUsageBar({ usage, theme }: { usage: AgentTokenUsage; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
|
<div className="flex items-center justify-center gap-4 px-4 pt-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas
|
|||||||
import { randomId } from "@/lib/utils";
|
import { randomId } from "@/lib/utils";
|
||||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
|
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
|
||||||
import type { AgentChatAttachment } from "./agent-chat-message";
|
import type { AgentChatAttachment } from "./agent-chat-message";
|
||||||
|
export const REASONING_PLACEHOLDER = "正在分析任务…";
|
||||||
|
|
||||||
export type AgentEventPayload = {
|
export type AgentEventPayload = {
|
||||||
agent?: string;
|
agent?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
thread_id?: string;
|
thread_id?: string;
|
||||||
|
turnId?: string;
|
||||||
turn_id?: string;
|
turn_id?: string;
|
||||||
|
sourceClientId?: string;
|
||||||
|
replayed?: boolean;
|
||||||
item?: AgentEventItem;
|
item?: AgentEventItem;
|
||||||
error?: { message?: string };
|
error?: { message?: string };
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -60,7 +64,6 @@ export function agentAttachmentToChatAttachment(item: AgentAttachment): AgentCha
|
|||||||
|
|
||||||
export function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
export function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
|
||||||
const item = event.item;
|
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" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -70,45 +73,50 @@ export function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatIte
|
|||||||
if (!item || (event.type !== "item.started" && event.type !== "item.completed")) return null;
|
if (!item || (event.type !== "item.started" && event.type !== "item.completed")) return null;
|
||||||
const completed = event.type === "item.completed";
|
const completed = event.type === "item.completed";
|
||||||
const status = String(item.status || (completed ? "completed" : "inProgress"));
|
const status = String(item.status || (completed ? "completed" : "inProgress"));
|
||||||
|
const failed = Boolean(item.error?.message) || item.success === false || ["failed", "error"].includes(status);
|
||||||
|
const itemStatus = failed ? "failed" : status;
|
||||||
if (item.type === "reasoning") {
|
if (item.type === "reasoning") {
|
||||||
const text = readableText(item.summary) || (completed ? "已完成分析" : activityPlaceholder(item.type));
|
const text = readableText(item.summary);
|
||||||
return { role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status } };
|
if (completed && !text) return null;
|
||||||
|
return { role: "tool", title: "思考摘要", text: text || activityPlaceholder(item.type), detail: { kind: "reasoning", status: itemStatus } };
|
||||||
}
|
}
|
||||||
if (item.type === "plan") {
|
if (item.type === "plan") {
|
||||||
const text = stringText(item.text) || activityPlaceholder(item.type);
|
const text = stringText(item.text);
|
||||||
return { role: "tool", title: "执行计划", text, detail: { kind: "plan", status } };
|
if (completed && !text && !item.error?.message) return null;
|
||||||
|
return { role: "tool", title: "执行计划", text: item.error?.message || text || activityPlaceholder(item.type), detail: { kind: "plan", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
}
|
}
|
||||||
if (item.type === "command_execution") {
|
if (item.type === "command_execution") {
|
||||||
const command = stringText(item.command) || activityPlaceholder(item.type);
|
const command = stringText(item.command);
|
||||||
return { role: "tool", title: "执行命令", text: command, detail: commandActivityDetail(item, status) };
|
const text = command || (completed ? failed ? "命令执行失败" : "命令已完成" : activityPlaceholder(item.type));
|
||||||
|
return { role: "tool", title: "执行命令", text, detail: commandActivityDetail(item, itemStatus) };
|
||||||
}
|
}
|
||||||
if (item.type === "file_change") {
|
if (item.type === "file_change") {
|
||||||
const files = activityFiles(item.changes);
|
const files = activityFiles(item.changes);
|
||||||
return { role: "tool", title: "修改文件", text: fileActivitySummary(files, completed), detail: { kind: "file", status, files } };
|
return { role: "tool", title: "修改文件", text: item.error?.message || fileActivitySummary(files, completed), detail: { kind: "file", status: itemStatus, files, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
}
|
}
|
||||||
if (item.type === "web_search") {
|
if (item.type === "web_search") {
|
||||||
return { role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status, rows: webSearchDetailRows(item) } };
|
return { role: "tool", title: "搜索资料", text: item.error?.message || webSearchSummary(item), detail: { kind: "search", status: itemStatus, rows: webSearchDetailRows(item), ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
}
|
}
|
||||||
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: stringText(item.path) || "正在查看图片", detail: { kind: "image", status } };
|
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: item.error?.message || stringText(item.path) || (completed ? "已查看图片" : "正在查看图片"), detail: { kind: "image", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
if (item.type === "image_generation") return { role: "tool", title: "内置生图", text: completed ? "图片生成完成" : "正在生成图片…", detail: { kind: "image", status } };
|
if (item.type === "image_generation") {
|
||||||
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…", detail: { kind: "context", status } };
|
return { role: "tool", title: "内置生图", text: item.error?.message || (completed ? failed ? "图片生成失败" : "图片生成完成" : "正在生成图片…"), detail: { kind: "image", status: itemStatus, savedPath: item.savedPath, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
if (isMcpToolItem(item) && isReadTool(String(item.tool || ""))) {
|
}
|
||||||
|
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: item.error?.message || (completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…"), detail: { kind: "context", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
|
if (isMcpToolItem(item)) {
|
||||||
const name = 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) };
|
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}…`, detail: toolDetail(item, itemStatus) };
|
||||||
}
|
}
|
||||||
if (item.type === "dynamic_tool_call") {
|
if (item.type === "dynamic_tool_call") {
|
||||||
const name = String(item.tool || "");
|
const name = String(item.tool || "");
|
||||||
const title = toolName(name);
|
const title = toolName(name);
|
||||||
const failed = Boolean(item.error) || item.success === false || ["failed", "error"].includes(status);
|
|
||||||
const currentStatus = failed ? "failed" : status;
|
|
||||||
return {
|
return {
|
||||||
role: "tool",
|
role: "tool",
|
||||||
title,
|
title,
|
||||||
text: completed ? item.error?.message || readableText(item.contentItems) || `${title}${failed ? "失败" : "完成"}` : `正在${toolAction(name)}…`,
|
text: completed ? item.error?.message || readableText(item.contentItems) : `正在${toolAction(name)}…`,
|
||||||
detail: toolDetail(item, currentStatus),
|
detail: toolDetail(item, itemStatus),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: completed ? "已完成协作任务" : "正在协作处理任务…", detail: { kind: "tool", status } };
|
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: item.error?.message || (completed ? failed ? "协作任务失败" : "已完成协作任务" : "正在协作处理任务…"), detail: { kind: "tool", status: itemStatus, ...(item.error?.message ? { output: item.error.message } : {}) } };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +155,7 @@ export function activityDeltaFallback(item: AgentEventItem, delta: string): Agen
|
|||||||
export function activityPlaceholder(type?: string) {
|
export function activityPlaceholder(type?: string) {
|
||||||
if (type === "plan") return "正在整理执行步骤…";
|
if (type === "plan") return "正在整理执行步骤…";
|
||||||
if (type === "command_execution") return "正在执行命令…";
|
if (type === "command_execution") return "正在执行命令…";
|
||||||
return "正在分析任务…";
|
return REASONING_PLACEHOLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activityKind(type?: string) {
|
export function activityKind(type?: string) {
|
||||||
@@ -163,7 +171,8 @@ export function activityDetail(value: unknown, kind: string, status: string): Ag
|
|||||||
|
|
||||||
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
|
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
|
||||||
const rows = [detailRow("工作目录", item.cwd), detailRow("退出状态", item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : []));
|
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) };
|
const commandStatus = typeof item.exitCode === "number" && item.exitCode !== 0 ? "failed" : status;
|
||||||
|
return { kind: "command", status: commandStatus, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityFiles(value: unknown) {
|
function activityFiles(value: unknown) {
|
||||||
@@ -184,7 +193,7 @@ function webSearchSummary(item: AgentEventItem) {
|
|||||||
const action = item.action;
|
const action = item.action;
|
||||||
const type = stringText(objectField(action, "type"));
|
const type = stringText(objectField(action, "type"));
|
||||||
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
|
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
|
||||||
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "相关内容"}”`;
|
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "内容"}”`;
|
||||||
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +205,7 @@ function webSearchDetailRows(item: AgentEventItem) {
|
|||||||
function readableText(value: unknown): string {
|
function readableText(value: unknown): string {
|
||||||
if (typeof value === "string") return value.trim();
|
if (typeof value === "string") return value.trim();
|
||||||
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||||
|
if (!value || typeof value !== "object") return "";
|
||||||
return readableText(objectField(value, "text"));
|
return readableText(objectField(value, "text"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +237,19 @@ export function isCurrentThreadEvent(event: { threadId?: string; thread_id?: str
|
|||||||
return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId;
|
return Boolean(threadId) && threadId === useAgentStore.getState().activeThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function registerLiveAgentTurn(
|
||||||
|
event: { replayed?: boolean; threadId?: string; thread_id?: string; turnId?: string; turn_id?: string },
|
||||||
|
authoritativeTurns: ReadonlySet<string>,
|
||||||
|
liveTurns: Set<string>,
|
||||||
|
) {
|
||||||
|
const threadId = event.threadId || event.thread_id || "";
|
||||||
|
const turnId = event.turnId || event.turn_id || "";
|
||||||
|
const key = threadId && turnId ? `${threadId}\0${turnId}` : "";
|
||||||
|
if (event.replayed && key && authoritativeTurns.has(key)) return false;
|
||||||
|
if (key) liveTurns.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
export function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
|
||||||
const head = [
|
const head = [
|
||||||
"Infinite Canvas Agent 诊断",
|
"Infinite Canvas Agent 诊断",
|
||||||
@@ -325,26 +348,23 @@ export function toolName(name: string) {
|
|||||||
return name ? `调用工具:${name}` : "工具操作";
|
return name ? `调用工具:${name}` : "工具操作";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function siteToolSummary(name: string, result: unknown) {
|
function siteToolSummary(name: string, result: unknown, input: unknown) {
|
||||||
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
|
||||||
|
if (name === "site_navigate") return `已打开${routeName(stringText(objectField(input, "path")) || "/")}`;
|
||||||
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
if (name === "canvas_list_projects") return `共 ${numberField(data, "total")} 个画布`;
|
||||||
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
|
||||||
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
if (name === "assets_list") return `共 ${numberField(data, "total")} 个资产`;
|
||||||
if (name === "assets_add") return "已加入我的资产";
|
if (name === "assets_add") return "已加入我的素材";
|
||||||
if (name === "generation_get_status") {
|
if (name === "generation_get_status") {
|
||||||
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
|
const summary = data.summary && typeof data.summary === "object" ? (data.summary as Record<string, unknown>) : {};
|
||||||
return `共 ${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`;
|
return `共 ${numberField(data, "total")} 个任务,排队 ${numberField(summary, "queued")},运行中 ${numberField(summary, "running")},成功 ${numberField(summary, "succeeded")},失败 ${numberField(summary, "failed")}`;
|
||||||
}
|
}
|
||||||
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
|
||||||
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
|
||||||
return "已完成";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isReadTool(name: string) {
|
function isMcpToolItem(item?: AgentEventItem): item is AgentEventItem & { type: "mcp_tool_call" } {
|
||||||
return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMcpToolItem(item?: AgentEventItem) {
|
|
||||||
return item?.type === "mcp_tool_call";
|
return item?.type === "mcp_tool_call";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,23 +378,26 @@ export function toolCallDetail(name: string, input: unknown, status: string, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toolInputRows(name: string, input: unknown) {
|
function toolInputRows(name: string, input: unknown) {
|
||||||
|
input = parseToolArguments(input);
|
||||||
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
|
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 === "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_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_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] : []));
|
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "attachmentIds")) ? (objectField(input, "attachmentIds") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolSummary(item?: AgentEventItem) {
|
export function toolSummary(item?: AgentEventItem) {
|
||||||
const result = parseToolResult(item?.result);
|
const result = parseToolResult(item?.result);
|
||||||
|
const name = String(item?.tool || "");
|
||||||
|
if (name === "site_navigate" || isSiteTool(name)) return siteToolSummary(name, result, parseToolArguments(item?.arguments));
|
||||||
const nodeField = objectField(result, "nodes");
|
const nodeField = objectField(result, "nodes");
|
||||||
const connectionField = objectField(result, "connections");
|
const connectionField = objectField(result, "connections");
|
||||||
const nodes = Array.isArray(nodeField) ? nodeField : [];
|
const nodes = Array.isArray(nodeField) ? nodeField : [];
|
||||||
const connections = Array.isArray(connectionField) ? connectionField : [];
|
const connections = Array.isArray(connectionField) ? connectionField : [];
|
||||||
if (item?.tool === "canvas_get_state" && (Array.isArray(nodeField) || Array.isArray(connectionField))) return canvasContentSummary(nodes, connections.length);
|
if (name === "canvas_get_state") return Array.isArray(nodeField) || Array.isArray(connectionField) ? canvasContentSummary(nodes, connections.length) : "已读取当前画布内容";
|
||||||
if (Array.isArray(nodeField) || Array.isArray(connectionField)) return `读取到 ${nodes.length} 个节点,${connections.length} 条连线`;
|
if (name === "canvas_get_selection") return "已读取当前选中内容";
|
||||||
return "工具调用完成";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasContentSummary(nodes: unknown[], connections: number) {
|
function canvasContentSummary(nodes: unknown[], connections: number) {
|
||||||
@@ -479,17 +502,6 @@ function numberField(value: unknown, key: string) {
|
|||||||
return typeof field === "number" ? field : 0;
|
return typeof field === "number" ? field : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeAgentText(prev: string, next: string) {
|
|
||||||
if (!next || prev === next || prev.endsWith(next)) return prev;
|
|
||||||
if (next.startsWith(prev)) return next;
|
|
||||||
for (let size = Math.min(prev.length, next.length); size > 0; size--) {
|
|
||||||
if (prev.endsWith(next.slice(0, size))) return `${prev}${next.slice(size)}`;
|
|
||||||
}
|
|
||||||
const half = Math.floor(prev.length / 2);
|
|
||||||
if (prev.length > 12 && next.length > 12 && prev.slice(half) === next.slice(0, prev.length - half)) return prev;
|
|
||||||
return `${prev}${next}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
|
export function promptWithAttachments(text: string, attachments: AgentAttachment[]) {
|
||||||
return text || (attachments.length ? "请处理上传的图片附件。" : "");
|
return text || (attachments.length ? "请处理上传的图片附件。" : "");
|
||||||
}
|
}
|
||||||
@@ -506,15 +518,63 @@ export function isCanvasWriteTool(name: string) {
|
|||||||
return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes";
|
return name === "canvas_apply_ops" || name === "canvas_create_attachment_nodes";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseToolArguments(value: unknown) {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentMessageId(threadId: string, turnId: string, itemId: string) {
|
||||||
|
return `${threadId}:${turnId}:${itemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopeChatItem(item: AgentChatItem, threadId: string, turnId: string) {
|
||||||
|
const scopeTurnId = turnId || "pending";
|
||||||
|
const prefix = `${threadId || "local"}:${scopeTurnId}:`;
|
||||||
|
const sourceItemId = item.itemId || (item.id.startsWith(prefix) ? item.id.slice(prefix.length) : item.id);
|
||||||
|
const itemId = item.role === "user" ? "synthetic:user" : sourceItemId;
|
||||||
|
return { ...item, id: agentMessageId(threadId || "local", scopeTurnId, itemId), itemId, threadId, turnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindPendingTurnMessages(messages: AgentChatItem[], threadId: string, turnId: string) {
|
||||||
|
const index = messages.findLastIndex((item) => item.role === "user" && item.threadId === threadId && !item.turnId);
|
||||||
|
if (index < 0) return messages;
|
||||||
|
return messages.map((item, itemIndex) => itemIndex === index ? scopeChatItem(item, threadId, turnId) : item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertAgentMessage(messages: AgentChatItem[], item: AgentChatItem) {
|
||||||
|
const index = messages.findIndex((current) => current.id === item.id);
|
||||||
|
if (index < 0) return [...messages, item];
|
||||||
|
const current = messages[index];
|
||||||
|
const next = { ...current, ...item, attachments: item.attachments || current.attachments, historyText: item.historyText || current.historyText };
|
||||||
|
return messages.map((message, itemIndex) => itemIndex === index ? next : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAgentMessages(snapshot: AgentChatItem[], current: AgentChatItem[], threadId: string, liveTurnKeys: ReadonlySet<string>) {
|
||||||
|
let messages = [...snapshot];
|
||||||
|
current.filter((item) => item.threadId === threadId).forEach((item) => {
|
||||||
|
const live = Boolean(item.turnId && liveTurnKeys.has(`${threadId}\0${item.turnId}`));
|
||||||
|
const index = messages.findIndex((message) => message.id === item.id);
|
||||||
|
if (index < 0) {
|
||||||
|
if (!item.turnId || live) messages = upsertAgentMessage(messages, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const history = messages[index];
|
||||||
|
const next = live
|
||||||
|
? { ...history, ...item, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText }
|
||||||
|
: { ...history, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText };
|
||||||
|
messages = messages.map((message, itemIndex) => itemIndex === index ? next : message);
|
||||||
|
});
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeHistoryMessages(messages: AgentChatItem[]) {
|
export function normalizeHistoryMessages(messages: AgentChatItem[]) {
|
||||||
return messages
|
return messages
|
||||||
.map((item, index) => ({
|
.filter((item) => (normalizeText(item.text) || item.role === "tool") && item.itemId && item.threadId && item.turnId)
|
||||||
...item,
|
.map(({ streamId: _streamId, ...item }) => scopeChatItem({ ...item, text: normalizeText(item.text) } as AgentChatItem, item.threadId!, item.turnId!));
|
||||||
id: item.id || `history-${index}`,
|
|
||||||
text: normalizeText(item.text),
|
|
||||||
streamId: undefined,
|
|
||||||
}))
|
|
||||||
.filter((item) => item.text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
||||||
@@ -523,22 +583,35 @@ export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessag
|
|||||||
.reverse()
|
.reverse()
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (item.role !== "user") return item;
|
if (item.role !== "user") return item;
|
||||||
const index = currentUsers.findIndex((current) => current.text === item.text || current.historyText === item.text);
|
let index = item.clientMessageId
|
||||||
|
? currentUsers.findIndex((current) => current.clientMessageId === item.clientMessageId && current.threadId === item.threadId)
|
||||||
|
: -1;
|
||||||
|
if (index < 0 && item.turnId) index = currentUsers.findIndex((current) => current.turnId === item.turnId && current.threadId === item.threadId);
|
||||||
|
if (index < 0) {
|
||||||
|
const candidates = currentUsers
|
||||||
|
.map((current, candidateIndex) => ({ current, candidateIndex }))
|
||||||
|
.filter(({ current }) => current.threadId === item.threadId && (current.text === item.text || current.historyText === item.text));
|
||||||
|
if (new Set(candidates.map(({ current }) => current.clientMessageId || current.id)).size === 1) index = candidates[0]?.candidateIndex ?? -1;
|
||||||
|
}
|
||||||
if (index < 0) return item;
|
if (index < 0) return item;
|
||||||
const current = currentUsers.splice(index, 1)[0];
|
const current = currentUsers.splice(index, 1)[0];
|
||||||
return { ...item, id: current.id, text: current.text, historyText: current.historyText, attachments: current.attachments };
|
return { ...item, text: current.text, historyText: current.historyText, attachments: current.attachments };
|
||||||
})
|
})
|
||||||
.reverse();
|
.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeHistoryMessages(historyMessages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
export function reasoningActivityText(items: Record<string, string>, fallback = "") {
|
||||||
if (!currentMessages.length) return historyMessages;
|
const summaries = Object.values(items).map((item) => item.trim()).filter(isReasoningSummary);
|
||||||
const remaining = [...historyMessages];
|
return summaries.join("\n\n") || fallback || REASONING_PLACEHOLDER;
|
||||||
const messages = currentMessages.map((current) => {
|
}
|
||||||
const index = remaining.findIndex((history) => history.id === current.id || ((current.role === "user" || current.role === "assistant") && history.role === current.role && history.text === current.text));
|
|
||||||
if (index < 0) return current;
|
export function isReasoningSummary(value = "") {
|
||||||
const history = remaining.splice(index, 1)[0];
|
const text = value.trim();
|
||||||
return { ...current, ...history, id: current.id, attachments: current.attachments || history.attachments };
|
return Boolean(text && text !== REASONING_PLACEHOLDER && text !== "已完成分析");
|
||||||
});
|
}
|
||||||
return [...messages, ...remaining];
|
|
||||||
|
export function mergeStreamText(prefix: string, incoming: string) {
|
||||||
|
if (!prefix || incoming.startsWith(prefix)) return incoming || prefix;
|
||||||
|
if (!incoming || prefix.startsWith(incoming)) return prefix;
|
||||||
|
return incoming.length >= prefix.length ? incoming : prefix;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function AgentHistoryView({
|
|||||||
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
|
const [selectedIds, setSelectedIds] = useState(() => new Set<string>());
|
||||||
const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id));
|
const selectedThreads = threads.filter((thread) => selectedIds.has(thread.id));
|
||||||
const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length;
|
const allSelected = Boolean(threads.length) && selectedThreads.length === threads.length;
|
||||||
|
const canResume = connected && !loading && !busy;
|
||||||
const toggleThread = (threadId: string) => {
|
const toggleThread = (threadId: string) => {
|
||||||
setSelectedIds((current) => {
|
setSelectedIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -76,12 +77,15 @@ export function AgentHistoryView({
|
|||||||
<div
|
<div
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={canResume ? 0 : -1}
|
||||||
className="cursor-pointer rounded-lg border px-2.5 py-2 transition hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20 dark:hover:bg-white/10"
|
aria-disabled={!canResume}
|
||||||
|
className={`${canResume ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/10" : "cursor-default opacity-60"} rounded-lg border px-2.5 py-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20`}
|
||||||
style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
style={{ borderColor: active ? theme.node.text : theme.node.stroke, background: "transparent", color: theme.node.text }}
|
||||||
onClick={() => onResumeThread(thread.id)}
|
onClick={() => {
|
||||||
|
if (canResume) onResumeThread(thread.id);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
if (!canResume || (event.key !== "Enter" && event.key !== " ")) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onResumeThread(thread.id);
|
onResumeThread(thread.id);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button, Segmented } from "antd";
|
import { Button, Segmented, Tooltip } from "antd";
|
||||||
import copyToClipboard from "copy-to-clipboard";
|
import copyToClipboard from "copy-to-clipboard";
|
||||||
import { Copy, Trash2 } from "lucide-react";
|
import { CheckCircle2, ChevronDown, CircleAlert, CircleDot, Copy, Trash2, TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import type { AgentEventLog } from "@/stores/use-agent-store";
|
import type { AgentEventLog } from "@/stores/use-agent-store";
|
||||||
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
|
import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters";
|
||||||
|
import { AgentScrollToBottom } from "./agent-scroll-to-bottom";
|
||||||
|
|
||||||
|
type LogFilter = "all" | "error" | "warning" | "info";
|
||||||
|
type DisplayLog = AgentEventLog & { count: number; detail: string; displayText: string; level: Exclude<LogFilter, "all">; signature: string; success: boolean };
|
||||||
|
const SCROLL_BOTTOM_THRESHOLD = 48;
|
||||||
|
|
||||||
export function AgentLogView({
|
export function AgentLogView({
|
||||||
logs,
|
logs,
|
||||||
@@ -23,9 +28,71 @@ export function AgentLogView({
|
|||||||
onCopyBlocked: (text: string) => void;
|
onCopyBlocked: (text: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [mode, setMode] = useState<"text" | "json">("text");
|
const [mode, setMode] = useState<"text" | "json">("text");
|
||||||
|
const [filter, setFilter] = useState<LogFilter>("all");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const followLogsRef = useRef(true);
|
||||||
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
|
const [newLogCount, setNewLogCount] = useState(0);
|
||||||
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
|
||||||
const lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${item.text}`));
|
const displayLogs = useMemo(() => prepareLogs(logs), [logs]);
|
||||||
|
const counts = useMemo(
|
||||||
|
() => ({
|
||||||
|
all: displayLogs.reduce((sum, item) => sum + item.count, 0),
|
||||||
|
error: displayLogs.filter((item) => item.level === "error").reduce((sum, item) => sum + item.count, 0),
|
||||||
|
warning: displayLogs.filter((item) => item.level === "warning").reduce((sum, item) => sum + item.count, 0),
|
||||||
|
info: displayLogs.filter((item) => item.level === "info").reduce((sum, item) => sum + item.count, 0),
|
||||||
|
}),
|
||||||
|
[displayLogs],
|
||||||
|
);
|
||||||
|
const visibleLogs = filter === "all" ? displayLogs : displayLogs.filter((item) => item.level === filter);
|
||||||
|
const visibleLogCount = visibleLogs.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
const previousVisibleCountRef = useRef(visibleLogCount);
|
||||||
|
const lastError = [...logs].reverse().find((item) => logLevel(item) === "error");
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
||||||
|
followLogsRef.current = atBottom;
|
||||||
|
setShowScrollToBottom(!atBottom);
|
||||||
|
if (atBottom) setNewLogCount(0);
|
||||||
|
}, []);
|
||||||
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
followLogsRef.current = true;
|
||||||
|
list.scrollTo({ top: list.scrollHeight, behavior });
|
||||||
|
setShowScrollToBottom(false);
|
||||||
|
setNewLogCount(0);
|
||||||
|
}, []);
|
||||||
|
const handleLastLogToggle = useCallback((open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
requestAnimationFrame(updateScrollState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!followLogsRef.current) return;
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom("auto")));
|
||||||
|
}, [scrollToBottom, updateScrollState]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "text") return;
|
||||||
|
const frame = requestAnimationFrame(() => scrollToBottom("auto"));
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [filter, mode, scrollToBottom]);
|
||||||
|
useEffect(() => {
|
||||||
|
const previousCount = previousVisibleCountRef.current;
|
||||||
|
const addedCount = Math.max(0, visibleLogCount - previousCount);
|
||||||
|
previousVisibleCountRef.current = visibleLogCount;
|
||||||
|
if (mode !== "text") return;
|
||||||
|
if (visibleLogCount < previousCount) setNewLogCount(0);
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
if (followLogsRef.current) scrollToBottom("auto");
|
||||||
|
else {
|
||||||
|
if (addedCount) setNewLogCount((count) => count + addedCount);
|
||||||
|
updateScrollState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [mode, scrollToBottom, updateScrollState, visibleLogCount]);
|
||||||
const copy = async (value = content, tip = "日志已复制") => {
|
const copy = async (value = content, tip = "日志已复制") => {
|
||||||
if (await copyToClipboard(value)) {
|
if (await copyToClipboard(value)) {
|
||||||
onCopied(tip);
|
onCopied(tip);
|
||||||
@@ -33,15 +100,14 @@ export function AgentLogView({
|
|||||||
}
|
}
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
textareaRef.current?.select();
|
textareaRef.current?.select();
|
||||||
onCopyBlocked("已选中日志,请手动复制");
|
onCopyBlocked(mode === "json" ? "已选中日志,请手动复制" : "复制失败,请切换到原始 JSON 后手动复制");
|
||||||
};
|
};
|
||||||
|
const connectionLabel = context.connected ? "在线" : context.enabled ? "连接中" : "未启用";
|
||||||
return (
|
return (
|
||||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
<div className="min-h-0 flex-1 overflow-hidden px-4 py-3">
|
||||||
<div className="flex min-h-full flex-col gap-3">
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-base font-semibold leading-6">运行日志</div>
|
<div className="text-base font-semibold leading-6">运行日志</div>
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<Segmented
|
<Segmented
|
||||||
size="small"
|
size="small"
|
||||||
value={mode}
|
value={mode}
|
||||||
@@ -51,31 +117,318 @@ export function AgentLogView({
|
|||||||
{ label: "原始 JSON", value: "json" },
|
{ label: "原始 JSON", value: "json" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<span className="text-xs" style={{ color: theme.node.muted }}>
|
|
||||||
{logs.length} 条
|
<div className="border-y py-2.5" style={{ borderColor: theme.node.stroke }}>
|
||||||
</span>
|
<div className="flex items-start gap-2.5">
|
||||||
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
|
<span className={`mt-1.5 size-2 shrink-0 rounded-full ${context.connected ? "bg-emerald-500" : context.enabled ? "bg-amber-500" : "bg-current opacity-30"}`} />
|
||||||
复制
|
<div className="min-w-0 flex-1">
|
||||||
</Button>
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
|
<span className="font-medium">{connectionLabel}</span>
|
||||||
最近错误
|
<span className="truncate" style={{ color: theme.node.muted }}>
|
||||||
</Button>
|
{context.activity}
|
||||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
|
</span>
|
||||||
清空
|
</div>
|
||||||
</Button>
|
<div className="mt-0.5 truncate font-mono text-[11px] leading-4" style={{ color: theme.node.faint }} title={context.endpoint}>
|
||||||
|
{context.endpoint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right text-[11px] leading-4" style={{ color: theme.node.muted }}>
|
||||||
|
<div>{context.messages} 条消息</div>
|
||||||
|
<div>{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
{mode === "text" ? (
|
||||||
readOnly
|
<>
|
||||||
value={content}
|
<div className="flex items-center justify-between gap-2">
|
||||||
className="thin-scrollbar min-h-[360px] flex-1 resize-none rounded-lg border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
<Segmented
|
||||||
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
size="small"
|
||||||
onFocus={(event) => event.currentTarget.select()}
|
value={filter}
|
||||||
/>
|
onChange={(value) => setFilter(value as LogFilter)}
|
||||||
|
options={[
|
||||||
|
{ label: `全部 ${counts.all}`, value: "all" },
|
||||||
|
{ label: `错误 ${counts.error}`, value: "error" },
|
||||||
|
{ label: `警告 ${counts.warning}`, value: "warning" },
|
||||||
|
{ label: `信息 ${counts.info}`, value: "info" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
|
||||||
|
</div>
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
|
<div ref={listRef} tabIndex={0} aria-label="排查日志列表" className="thin-scrollbar h-full overflow-y-auto border-y focus-visible:outline-none" style={{ borderColor: theme.node.stroke }} onScroll={updateScrollState}>
|
||||||
|
{visibleLogs.map((item, index) => (
|
||||||
|
<LogRow key={item.id} item={item} theme={theme} onToggle={index === visibleLogs.length - 1 ? handleLastLogToggle : undefined} />
|
||||||
|
))}
|
||||||
|
{!visibleLogs.length ? (
|
||||||
|
<div className="px-3 py-10 text-center text-sm" style={{ color: theme.node.muted }}>
|
||||||
|
{logs.length ? "当前筛选下没有日志" : "暂无事件日志"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showScrollToBottom ? (
|
||||||
|
<AgentScrollToBottom
|
||||||
|
theme={theme}
|
||||||
|
title={newLogCount ? `${newLogCount} 条新日志` : "查看最新日志"}
|
||||||
|
ariaLabel={newLogCount ? `${newLogCount} 条新日志,查看最新日志` : "查看最新日志"}
|
||||||
|
className="!bottom-52"
|
||||||
|
onClick={() => scrollToBottom()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs" style={{ color: theme.node.muted }}>
|
||||||
|
完整诊断数据 · {logs.length} 条
|
||||||
|
</span>
|
||||||
|
<LogActions logs={logs} lastError={lastError} onClear={onClear} onCopy={(value, tip) => void copy(value, tip)} context={context} />
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
readOnly
|
||||||
|
value={content}
|
||||||
|
className="thin-scrollbar min-h-0 flex-1 resize-none rounded-md border bg-transparent p-3 font-mono text-xs leading-5 outline-none"
|
||||||
|
style={{ borderColor: theme.node.stroke, color: theme.node.text }}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LogActions({ logs, lastError, context, onClear, onCopy }: { logs: AgentEventLog[]; lastError?: AgentEventLog; context: AgentLogContext; onClear: () => void; onCopy: (value?: string, tip?: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
<Tooltip title="复制全部日志">
|
||||||
|
<Button type="text" size="small" shape="circle" aria-label="复制全部日志" icon={<Copy className="size-3.5" />} onClick={() => onCopy()} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="复制最近错误">
|
||||||
|
<Button type="text" size="small" shape="circle" aria-label="复制最近错误" disabled={!lastError} icon={<CircleAlert className="size-3.5" />} onClick={() => lastError && onCopy(formatLogText([lastError], context), "最近错误已复制")} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="清空日志">
|
||||||
|
<Button danger type="text" size="small" shape="circle" aria-label="清空日志" disabled={!logs.length} icon={<Trash2 className="size-3.5" />} onClick={onClear} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogRow({ item, theme, onToggle }: { item: DisplayLog; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onToggle?: (open: boolean) => void }) {
|
||||||
|
const tone = item.level === "error" ? "text-red-600 dark:text-red-400" : item.level === "warning" ? "text-amber-600 dark:text-amber-400" : item.success ? "text-emerald-600 dark:text-emerald-400" : "";
|
||||||
|
const Icon = item.level === "error" ? CircleAlert : item.level === "warning" ? TriangleAlert : item.success ? CheckCircle2 : CircleDot;
|
||||||
|
return (
|
||||||
|
<details className="group border-b last:border-b-0" style={{ borderColor: theme.node.stroke }} onToggle={(event) => onToggle?.(event.currentTarget.open)}>
|
||||||
|
<summary className="cursor-pointer list-none px-1 py-2.5 transition hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-current/20 dark:hover:bg-white/10 [&::-webkit-details-marker]:hidden">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Icon className={`mt-0.5 size-4 shrink-0 ${tone}`} style={tone ? undefined : { color: theme.node.muted }} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="shrink-0 font-mono text-[10px] leading-5" style={{ color: theme.node.faint }}>
|
||||||
|
{item.time}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium leading-5">{item.title}</span>
|
||||||
|
{item.count > 1 ? (
|
||||||
|
<span className="shrink-0 text-[10px] leading-4" style={{ color: theme.node.muted }}>
|
||||||
|
重复 {item.count} 次
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.displayText !== item.title ? (
|
||||||
|
<div className="line-clamp-2 whitespace-pre-wrap break-words text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||||
|
{item.displayText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="mt-1 size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.faint }} />
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div className="pb-3 pl-[34px] pr-2">
|
||||||
|
<div className="mb-1 text-[10px] font-medium" style={{ color: theme.node.faint }}>
|
||||||
|
详细信息
|
||||||
|
</div>
|
||||||
|
<pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md border p-2.5 font-mono text-[11px] leading-5" style={{ borderColor: theme.node.stroke, background: theme.node.panel, color: theme.node.text }}>
|
||||||
|
{item.detail}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareLogs(logs: AgentEventLog[]) {
|
||||||
|
return logs.flatMap(expandLog).reduce<DisplayLog[]>((result, item) => {
|
||||||
|
const previous = result.at(-1);
|
||||||
|
if (previous && previous.signature === item.signature) {
|
||||||
|
previous.count += 1;
|
||||||
|
previous.time = item.time;
|
||||||
|
previous.detail = item.detail;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.push(item);
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandLog(item: AgentEventLog): DisplayLog[] {
|
||||||
|
const entries = parseJsonEntries(item.raw ?? item.text);
|
||||||
|
return (entries.length ? entries : [undefined]).map((entry, index) => {
|
||||||
|
const displayText = compactLogText(entry === undefined ? item.text : logSummary(entry)) || item.title;
|
||||||
|
const level = logLevel(item, entry);
|
||||||
|
const title = entry === undefined ? item.title : logTitle(item.title, entry);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: entries.length > 1 ? `${item.id}-${index}` : item.id,
|
||||||
|
time: logTime(item.time, entry),
|
||||||
|
title,
|
||||||
|
text: displayText,
|
||||||
|
raw: entry ?? item.raw,
|
||||||
|
count: 1,
|
||||||
|
detail: entry === undefined ? stripAnsi(safeString(item.raw ?? item.text)) || item.text : safeJson(entry),
|
||||||
|
displayText,
|
||||||
|
level,
|
||||||
|
signature: `${level}\n${title}\n${logSignature(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";
|
||||||
|
if (declared.includes("info")) return "info";
|
||||||
|
const text = `${item.title}\n${item.text}\n${safeString(item.raw)}`;
|
||||||
|
if (/错误|失败|异常|中断|断开|拒绝|\berror\b|\bfailed\b|\bfatal\b|exception/i.test(text)) return "error";
|
||||||
|
if (/警告|重试|未找到|不可用|\bwarn(?:ing)?\b|deprecated|missing|unavailable/i.test(text)) return "warning";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaredLogLevel(value: unknown): DisplayLog["level"] | "" {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||||
|
const level = String((value as Record<string, unknown>).level || "").toLowerCase();
|
||||||
|
if (["error", "fatal"].includes(level)) return "error";
|
||||||
|
if (["warn", "warning"].includes(level)) return "warning";
|
||||||
|
if (["info", "debug", "trace"].includes(level)) return "info";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTitle(fallback: string, value: unknown) {
|
||||||
|
if (fallback !== "日志" || !value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||||
|
const target = String((value as Record<string, unknown>).target || "").toLowerCase();
|
||||||
|
if (target.includes("skill")) return "技能加载";
|
||||||
|
if (target.includes("plugin")) return "插件";
|
||||||
|
if (target.includes("mcp") || target.includes("rmcp")) return "MCP";
|
||||||
|
if (target.includes("shell")) return "终端";
|
||||||
|
if (target.includes("state_db")) return "会话存储";
|
||||||
|
return "Codex";
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTime(fallback: string, value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||||
|
const timestamp = (value as Record<string, unknown>).timestamp;
|
||||||
|
if (typeof timestamp !== "string") return fallback;
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return Number.isNaN(date.getTime()) ? fallback : date.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonEntries(value: unknown): unknown[] {
|
||||||
|
if (value && typeof value === "object") return [value];
|
||||||
|
if (typeof value !== "string") return [];
|
||||||
|
const text = stripAnsi(value).trim();
|
||||||
|
if (!text) return [];
|
||||||
|
try {
|
||||||
|
return [JSON.parse(text)];
|
||||||
|
} catch {
|
||||||
|
const entries: unknown[] = [];
|
||||||
|
let start = -1;
|
||||||
|
let depth = 0;
|
||||||
|
let quoted = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const character = text[index];
|
||||||
|
if (start < 0) {
|
||||||
|
if (character !== "{" && character !== "[") continue;
|
||||||
|
start = index;
|
||||||
|
depth = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quoted) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (character === "\\") escaped = true;
|
||||||
|
else if (character === '"') quoted = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"') quoted = true;
|
||||||
|
else if (character === "{" || character === "[") depth += 1;
|
||||||
|
else if (character === "}" || character === "]") depth -= 1;
|
||||||
|
if (depth !== 0) continue;
|
||||||
|
try {
|
||||||
|
entries.push(JSON.parse(text.slice(start, index + 1)));
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON fragments and continue scanning the stderr chunk.
|
||||||
|
}
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSummary(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (Array.isArray(value)) return value.map(logSummary).filter(Boolean).slice(0, 3).join(" · ");
|
||||||
|
if (!value || typeof value !== "object") return String(value ?? "");
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const fields = record.fields && typeof record.fields === "object" && !Array.isArray(record.fields) ? (record.fields as Record<string, unknown>) : null;
|
||||||
|
if (fields) {
|
||||||
|
for (const key of ["message", "msg", "reason", "summary", "text", "error"]) {
|
||||||
|
if (fields[key] === undefined) continue;
|
||||||
|
const summary = logSummary(fields[key]);
|
||||||
|
if (summary) return summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ["message", "msg", "reason", "summary", "text", "error"]) {
|
||||||
|
if (record[key] === undefined) continue;
|
||||||
|
const summary = logSummary(record[key]);
|
||||||
|
if (summary) return summary;
|
||||||
|
}
|
||||||
|
return [record.method, record.type, record.tool, record.path, record.url].filter((item) => typeof item === "string" && item).join(" · ") || safeJson(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactLogText(value: string) {
|
||||||
|
return stripAnsi(value).replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSignature(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/[a-z]:\\[^\r\n]*?skill\.md/gi, "<SKILL.md>")
|
||||||
|
.replace(/file:\/\/\/[^\s"']+/gi, "<路径>")
|
||||||
|
.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, "<ID>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAnsi(value: string) {
|
||||||
|
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeString(value: unknown) {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return safeJson(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function AgentPanel() {
|
|||||||
initial={{ width: 0, opacity: 0 }}
|
initial={{ width: 0, opacity: 0 }}
|
||||||
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
|
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
|
||||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||||
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
|
style={{ overflow: "clip", pointerEvents: panelOpen && !panelClosing ? undefined : "none" }}
|
||||||
>
|
>
|
||||||
<motion.aside
|
<motion.aside
|
||||||
className="relative flex h-full shrink-0 flex-col border-l"
|
className="relative flex h-full shrink-0 flex-col border-l"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button, Tooltip } from "antd";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
|
||||||
|
export function AgentScrollToBottom({
|
||||||
|
theme,
|
||||||
|
title,
|
||||||
|
ariaLabel = title,
|
||||||
|
className = "",
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||||
|
title: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={title} placement="top">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`!absolute bottom-6 left-1/2 z-10 !h-8 !w-8 !min-w-8 -translate-x-1/2 backdrop-blur transition hover:-translate-y-0.5 ${className}`}
|
||||||
|
style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}
|
||||||
|
icon={<ChevronDown className="size-4" />}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,9 @@ type CanvasNodeProps = {
|
|||||||
onHoverStart: (nodeId: string) => void;
|
onHoverStart: (nodeId: string) => void;
|
||||||
onHoverEnd: (nodeId: string) => void;
|
onHoverEnd: (nodeId: string) => void;
|
||||||
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
||||||
|
onResizeStart: (nodeId: string) => void;
|
||||||
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||||||
|
onResizeEnd: (nodeId: string) => void;
|
||||||
onContentChange: (nodeId: string, content: string) => void;
|
onContentChange: (nodeId: string, content: string) => void;
|
||||||
onTitleChange: (nodeId: string, title: string) => void;
|
onTitleChange: (nodeId: string, title: string) => void;
|
||||||
onToggleBatch?: (nodeId: string) => void;
|
onToggleBatch?: (nodeId: string) => void;
|
||||||
@@ -105,7 +107,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
onHoverStart,
|
onHoverStart,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
onConnectStart,
|
onConnectStart,
|
||||||
|
onResizeStart,
|
||||||
onResize,
|
onResize,
|
||||||
|
onResizeEnd,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
onToggleBatch,
|
onToggleBatch,
|
||||||
@@ -261,11 +265,13 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
resizeRef.current.isResizing = false;
|
resizeRef.current.isResizing = false;
|
||||||
window.removeEventListener("mousemove", handleResizeMove);
|
window.removeEventListener("mousemove", handleResizeMove);
|
||||||
window.removeEventListener("mouseup", handleResizeUp);
|
window.removeEventListener("mouseup", handleResizeUp);
|
||||||
}, [handleResizeMove]);
|
onResizeEnd(data.id);
|
||||||
|
}, [data.id, handleResizeMove, onResizeEnd]);
|
||||||
|
|
||||||
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
|
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
onResizeStart(data.id);
|
||||||
resizeRef.current = {
|
resizeRef.current = {
|
||||||
isResizing: true,
|
isResizing: true,
|
||||||
corner,
|
corner,
|
||||||
@@ -286,8 +292,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("mousemove", handleResizeMove);
|
window.removeEventListener("mousemove", handleResizeMove);
|
||||||
window.removeEventListener("mouseup", handleResizeUp);
|
window.removeEventListener("mouseup", handleResizeUp);
|
||||||
|
if (resizeRef.current.isResizing) onResizeEnd(data.id);
|
||||||
};
|
};
|
||||||
}, [handleResizeMove, handleResizeUp]);
|
}, [data.id, handleResizeMove, handleResizeUp, onResizeEnd]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ function InfiniteCanvasPage() {
|
|||||||
const agentPanelOpen = useAgentStore((state) => state.panelOpen);
|
const agentPanelOpen = useAgentStore((state) => state.panelOpen);
|
||||||
const toggleAgentPanel = useAgentStore((state) => state.togglePanel);
|
const toggleAgentPanel = useAgentStore((state) => state.togglePanel);
|
||||||
const openAgentPanel = useAgentStore((state) => state.openPanel);
|
const openAgentPanel = useAgentStore((state) => state.openPanel);
|
||||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
|
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
|
||||||
@@ -242,6 +241,7 @@ function InfiniteCanvasPage() {
|
|||||||
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
|
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
|
||||||
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
|
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
|
||||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||||
|
const [isNodeResizing, setIsNodeResizing] = useState(false);
|
||||||
const [dropTargetGroupId, setDropTargetGroupId] = useState<string | null>(null);
|
const [dropTargetGroupId, setDropTargetGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
const nodesRef = useRef(nodes);
|
const nodesRef = useRef(nodes);
|
||||||
@@ -353,11 +353,6 @@ function InfiniteCanvasPage() {
|
|||||||
void restore();
|
void restore();
|
||||||
}, [hydrated, navigate, openProject, projectId]);
|
}, [hydrated, navigate, openProject, projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectLoaded) return;
|
|
||||||
setAgentState({ activeThreadId: "", messages: [], tokenUsage: null, pendingTool: null });
|
|
||||||
}, [projectId, projectLoaded, setAgentState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return;
|
if (!projectLoaded || !["new", "recent", "choose"].includes(searchParams.get("mode") || "")) return;
|
||||||
if (!searchParams.has("agentUrl")) openAgentPanel();
|
if (!searchParams.has("agentUrl")) openAgentPanel();
|
||||||
@@ -1469,6 +1464,9 @@ function InfiniteCanvasPage() {
|
|||||||
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node)));
|
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, width, height, position: position || node.position } : node)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeResizeStart = useCallback(() => setIsNodeResizing(true), []);
|
||||||
|
const handleNodeResizeEnd = useCallback(() => setIsNodeResizing(false), []);
|
||||||
|
|
||||||
const toggleNodeFreeResize = useCallback((nodeId: string) => {
|
const toggleNodeFreeResize = useCallback((nodeId: string) => {
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
prev.map((node) => {
|
prev.map((node) => {
|
||||||
@@ -2877,7 +2875,9 @@ function InfiniteCanvasPage() {
|
|||||||
onHoverStart={handleNodeHoverStart}
|
onHoverStart={handleNodeHoverStart}
|
||||||
onHoverEnd={handleNodeHoverEnd}
|
onHoverEnd={handleNodeHoverEnd}
|
||||||
onConnectStart={handleConnectStart}
|
onConnectStart={handleConnectStart}
|
||||||
|
onResizeStart={handleNodeResizeStart}
|
||||||
onResize={handleNodeResize}
|
onResize={handleNodeResize}
|
||||||
|
onResizeEnd={handleNodeResizeEnd}
|
||||||
onContentChange={handleNodeContentChange}
|
onContentChange={handleNodeContentChange}
|
||||||
onTitleChange={handleNodeTitleChange}
|
onTitleChange={handleNodeTitleChange}
|
||||||
onToggleBatch={toggleBatchExpanded}
|
onToggleBatch={toggleBatchExpanded}
|
||||||
@@ -2916,7 +2916,7 @@ function InfiniteCanvasPage() {
|
|||||||
</InfiniteCanvas>
|
</InfiniteCanvas>
|
||||||
|
|
||||||
<CanvasNodeHoverToolbar
|
<CanvasNodeHoverToolbar
|
||||||
node={isNodeDragging || nodeImageSettingsOpen ? null : toolbarNode}
|
node={isNodeDragging || isNodeResizing || nodeImageSettingsOpen ? null : toolbarNode}
|
||||||
viewport={viewport}
|
viewport={viewport}
|
||||||
extraTools={toolbarNode ? buildNodeToolbarItems(toolbarNode) : undefined}
|
extraTools={toolbarNode ? buildNodeToolbarItems(toolbarNode) : undefined}
|
||||||
onKeep={keepNodeToolbar}
|
onKeep={keepNodeToolbar}
|
||||||
|
|||||||
@@ -3,33 +3,127 @@ import localforage from "localforage";
|
|||||||
import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||||
import type { AgentAttachment, AgentChatItem } from "@/stores/use-agent-store";
|
import type { AgentAttachment, AgentChatItem } from "@/stores/use-agent-store";
|
||||||
|
|
||||||
export type StoredAgentUserMessage = Pick<AgentChatItem, "id" | "text" | "attachments"> & { role: "user"; historyText: string };
|
export type StoredAgentUserMessage = Pick<AgentChatItem, "id" | "text" | "attachments"> & { role: "user"; historyText: string; threadId?: string; turnId?: string };
|
||||||
|
|
||||||
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" });
|
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" });
|
||||||
|
const mutations = new Map<string, Promise<void>>();
|
||||||
const indexKey = (threadId: string) => `thread:${threadId}`;
|
const indexKey = (threadId: string) => `thread:${threadId}`;
|
||||||
const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`;
|
const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`;
|
||||||
|
const pendingKey = (messageId: string) => `pending:${messageId}`;
|
||||||
|
const threadMutationKey = (threadId: string) => `thread:${threadId}`;
|
||||||
|
const pendingMutationKey = (messageId: string) => `pending:${messageId}`;
|
||||||
|
|
||||||
export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
||||||
if (!message.attachments?.length) return;
|
if (!message.attachments?.length) return;
|
||||||
const attachments = await Promise.all((message.attachments || []).map(createThumbnail));
|
if (!threadId) return savePendingAgentUserMessage(message);
|
||||||
await store.setItem(messageKey(threadId, message.id), { ...message, attachments });
|
await saveThreadAgentUserMessage(threadId, message);
|
||||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
}
|
||||||
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
|
|
||||||
|
/** Persist attachments before a turn is accepted. The record is moved to a thread after the server assigns one. */
|
||||||
|
export async function savePendingAgentUserMessage(message: StoredAgentUserMessage) {
|
||||||
|
if (!message.id || !message.attachments?.length) return;
|
||||||
|
await mutateScopes([pendingMutationKey(message.id)], async () => {
|
||||||
|
const attachments = await Promise.all(message.attachments!.map(createThumbnail));
|
||||||
|
await store.setItem(pendingKey(message.id), { ...message, threadId: undefined, turnId: undefined, attachments });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePendingAgentUserMessage(messageId: string) {
|
||||||
|
if (!messageId) return;
|
||||||
|
await mutateScopes([pendingMutationKey(messageId)], () => store.removeItem(pendingKey(messageId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readAgentUserMessages(threadId: string) {
|
export async function readAgentUserMessages(threadId: string) {
|
||||||
|
await mutations.get(threadMutationKey(threadId))?.catch(() => undefined);
|
||||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||||
return (await Promise.all(ids.map((id) => store.getItem<StoredAgentUserMessage>(messageKey(threadId, id))))).filter((item): item is StoredAgentUserMessage => Boolean(item));
|
return (await Promise.all(ids.map((id) => store.getItem<StoredAgentUserMessage>(messageKey(threadId, id))))).filter((item): item is StoredAgentUserMessage => Boolean(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bind a pending message to the server thread, preserving an already-known turn id. */
|
||||||
|
export async function bindPendingAgentUserMessage(threadId: string, messageId: string, turnId = "") {
|
||||||
|
if (!threadId || !messageId) return;
|
||||||
|
await mutateScopes([pendingMutationKey(messageId), threadMutationKey(threadId)], async () => {
|
||||||
|
const pending = await store.getItem<StoredAgentUserMessage>(pendingKey(messageId));
|
||||||
|
const key = messageKey(threadId, messageId);
|
||||||
|
const existing = await store.getItem<StoredAgentUserMessage>(key);
|
||||||
|
if (!pending && !existing) return;
|
||||||
|
const message = mergeStoredMessage(existing, pending, threadId, turnId);
|
||||||
|
await putThreadMessage(threadId, key, message);
|
||||||
|
if (pending) await store.removeItem(pendingKey(messageId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindAgentUserMessageTurn(threadId: string, messageId: string, turnId: string) {
|
||||||
|
await bindPendingAgentUserMessage(threadId, messageId, turnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveAgentUserMessage(fromThreadId: string, toThreadId: string, messageId: string) {
|
||||||
|
if (!toThreadId || !messageId || fromThreadId === toThreadId) return bindPendingAgentUserMessage(toThreadId, messageId);
|
||||||
|
const scopes = [pendingMutationKey(messageId), threadMutationKey(toThreadId), ...(fromThreadId ? [threadMutationKey(fromThreadId)] : [])];
|
||||||
|
await mutateScopes(scopes, async () => {
|
||||||
|
const pending = await store.getItem<StoredAgentUserMessage>(pendingKey(messageId));
|
||||||
|
const fromKey = fromThreadId ? messageKey(fromThreadId, messageId) : "";
|
||||||
|
const from = fromKey ? await store.getItem<StoredAgentUserMessage>(fromKey) : null;
|
||||||
|
const toKey = messageKey(toThreadId, messageId);
|
||||||
|
const existing = await store.getItem<StoredAgentUserMessage>(toKey);
|
||||||
|
const source = pending || from;
|
||||||
|
if (!source && !existing) return;
|
||||||
|
await putThreadMessage(toThreadId, toKey, mergeStoredMessage(existing, source, toThreadId));
|
||||||
|
if (pending) await store.removeItem(pendingKey(messageId));
|
||||||
|
if (from && fromThreadId) await removeThreadMessage(fromThreadId, fromKey, messageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAgentThreadMessages(threadIds: string[]) {
|
export async function deleteAgentThreadMessages(threadIds: string[]) {
|
||||||
await Promise.all(
|
await mutateScopes(threadIds.map(threadMutationKey), async () => {
|
||||||
threadIds.map(async (threadId) => {
|
await Promise.all(threadIds.map(async (threadId) => {
|
||||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||||
await Promise.all(ids.map((id) => store.removeItem(messageKey(threadId, id))));
|
await Promise.all(ids.map((id) => store.removeItem(messageKey(threadId, id))));
|
||||||
await store.removeItem(indexKey(threadId));
|
await store.removeItem(indexKey(threadId));
|
||||||
}),
|
}));
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveThreadAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
||||||
|
await mutateScopes([threadMutationKey(threadId)], async () => {
|
||||||
|
const attachments = await Promise.all(message.attachments!.map(createThumbnail));
|
||||||
|
await putThreadMessage(threadId, messageKey(threadId, message.id), { ...message, threadId, attachments });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putThreadMessage(threadId: string, key: string, message: StoredAgentUserMessage) {
|
||||||
|
await store.setItem(key, { ...message, threadId });
|
||||||
|
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||||
|
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStoredMessage(existing: StoredAgentUserMessage | null, source: StoredAgentUserMessage | null | undefined, threadId: string, turnId = "") {
|
||||||
|
const message = { ...(source || {}), ...(existing || {}) } as StoredAgentUserMessage;
|
||||||
|
if (!message.attachments?.length && source?.attachments?.length) message.attachments = source.attachments;
|
||||||
|
if (!message.text && source?.text) message.text = source.text;
|
||||||
|
if (!message.historyText && source?.historyText) message.historyText = source.historyText;
|
||||||
|
return { ...message, threadId, ...(turnId ? { turnId } : message.turnId ? { turnId: message.turnId } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeThreadMessage(threadId: string, key: string, messageId: string) {
|
||||||
|
await store.removeItem(key);
|
||||||
|
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||||
|
const remaining = ids.filter((id) => id !== messageId);
|
||||||
|
if (remaining.length) await store.setItem(indexKey(threadId), remaining);
|
||||||
|
else await store.removeItem(indexKey(threadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutateScopes(scopes: string[], mutation: () => Promise<void>) {
|
||||||
|
const ids = [...new Set(scopes.filter(Boolean))].sort();
|
||||||
|
const operation = Promise.all(ids.map((id) => mutations.get(id)?.catch(() => undefined))).then(mutation);
|
||||||
|
ids.forEach((id) => mutations.set(id, operation));
|
||||||
|
try {
|
||||||
|
await operation;
|
||||||
|
} finally {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (mutations.get(id) === operation) mutations.delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createThumbnail(attachment: AgentAttachment): Promise<AgentAttachment> {
|
async function createThumbnail(attachment: AgentAttachment): Promise<AgentAttachment> {
|
||||||
|
|||||||
@@ -4,14 +4,25 @@ import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-age
|
|||||||
|
|
||||||
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
|
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
|
||||||
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
|
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
|
||||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
export type AgentChatItem = { id: string; itemId?: string; clientMessageId?: string; threadId?: string; turnId?: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string; activityItems?: Record<string, string> };
|
||||||
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
||||||
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
|
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
|
||||||
export type AgentPermissionMode = "request" | "automatic" | "full";
|
export type AgentPermissionMode = "request" | "automatic" | "full";
|
||||||
export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown };
|
export type AgentReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
||||||
|
export type AgentModel = {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
displayName: string;
|
||||||
|
defaultReasoningEffort: AgentReasoningEffort;
|
||||||
|
supportedReasoningEfforts: Array<{ reasoningEffort: AgentReasoningEffort; description?: string }>;
|
||||||
|
isDefault?: boolean;
|
||||||
|
};
|
||||||
|
export type AgentApprovalDecision = "accept" | "acceptForSession" | "decline";
|
||||||
|
export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown; deciding?: AgentApprovalDecision };
|
||||||
export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean };
|
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 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 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";
|
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
|
||||||
|
|
||||||
const CONNECT_TIMEOUT_MS = 6000;
|
const CONNECT_TIMEOUT_MS = 6000;
|
||||||
@@ -38,12 +49,18 @@ type AgentStore = {
|
|||||||
eventLogs: AgentEventLog[];
|
eventLogs: AgentEventLog[];
|
||||||
threads: AgentThreadSummary[];
|
threads: AgentThreadSummary[];
|
||||||
activeThreadId: string;
|
activeThreadId: string;
|
||||||
|
activeTurnId: string;
|
||||||
workspacePath: string;
|
workspacePath: string;
|
||||||
loadingThreads: boolean;
|
loadingThreads: boolean;
|
||||||
activeTab: AgentPanelTab;
|
activeTab: AgentPanelTab;
|
||||||
confirmTools: boolean;
|
confirmTools: boolean;
|
||||||
permissionMode: AgentPermissionMode;
|
permissionMode: AgentPermissionMode;
|
||||||
|
models: AgentModel[];
|
||||||
|
model: string;
|
||||||
|
reasoningEffort: AgentReasoningEffort | "";
|
||||||
activity: string;
|
activity: string;
|
||||||
|
bootstrapStatus: AgentBootstrapStatus | null;
|
||||||
|
mcpStartupStatuses: Record<string, AgentBootstrapStatus>;
|
||||||
connectError: string;
|
connectError: string;
|
||||||
pendingTool: AgentPendingToolCall | null;
|
pendingTool: AgentPendingToolCall | null;
|
||||||
pendingApprovals: AgentPendingApproval[];
|
pendingApprovals: AgentPendingApproval[];
|
||||||
@@ -81,12 +98,18 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
eventLogs: [],
|
eventLogs: [],
|
||||||
threads: [],
|
threads: [],
|
||||||
activeThreadId: "",
|
activeThreadId: "",
|
||||||
|
activeTurnId: "",
|
||||||
workspacePath: "",
|
workspacePath: "",
|
||||||
loadingThreads: false,
|
loadingThreads: false,
|
||||||
activeTab: "setup",
|
activeTab: "setup",
|
||||||
confirmTools: false,
|
confirmTools: false,
|
||||||
permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request",
|
permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request",
|
||||||
|
models: [],
|
||||||
|
model: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-model") || "",
|
||||||
|
reasoningEffort: typeof window === "undefined" ? "" : (localStorage.getItem("canvas-agent-reasoning-effort") as AgentReasoningEffort) || "",
|
||||||
activity: "就绪",
|
activity: "就绪",
|
||||||
|
bootstrapStatus: null,
|
||||||
|
mcpStartupStatuses: {},
|
||||||
connectError: "",
|
connectError: "",
|
||||||
pendingTool: null,
|
pendingTool: null,
|
||||||
pendingApprovals: [],
|
pendingApprovals: [],
|
||||||
@@ -96,7 +119,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
if (!get().panelMounted || get().panelClosing) return;
|
if (!get().panelMounted || get().panelClosing) return;
|
||||||
set({ panelOpen: false, panelClosing: true });
|
set({ panelOpen: false, panelClosing: true });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (get().panelClosing) set({ panelMounted: false, panelClosing: false });
|
if (get().panelClosing) set({ panelClosing: false });
|
||||||
}, CANVAS_AGENT_PANEL_MOTION_MS);
|
}, CANVAS_AGENT_PANEL_MOTION_MS);
|
||||||
},
|
},
|
||||||
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
|
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
|
||||||
@@ -122,9 +145,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|||||||
agentSource = null;
|
agentSource = null;
|
||||||
if (connectTimer) clearTimeout(connectTimer);
|
if (connectTimer) clearTimeout(connectTimer);
|
||||||
connectTimer = null;
|
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.slice(-120), item] })),
|
addMessage: (item) => set((state) => ({ messages: [...state.messages, item] })),
|
||||||
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
|
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
|
||||||
clearEventLogs: () => set({ eventLogs: [] }),
|
clearEventLogs: () => set({ eventLogs: [] }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -523,6 +523,10 @@
|
|||||||
line-height: 1.15rem;
|
line-height: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-streamdown [data-streamdown="code-block-body"] code > span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-streamdown [data-streamdown="code-block-actions"] {
|
.agent-streamdown [data-streamdown="code-block-actions"] {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user