@xiaohhhh1/canvas-agent 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/agent-instructions.md +21 -0
- package/dist/agent/claude.d.ts +3 -0
- package/dist/agent/claude.js +46 -0
- package/dist/agent/codex-client.d.ts +73 -0
- package/dist/agent/codex-client.js +438 -0
- package/dist/agent/codex-history.d.ts +25 -0
- package/dist/agent/codex-history.js +405 -0
- package/dist/agent/codex-protocol.d.ts +208 -0
- package/dist/{agents.d.ts → agent/codex.d.ts} +34 -31
- package/dist/agent/codex.js +210 -0
- package/dist/agent/types.d.ts +14 -0
- package/dist/agent/types.js +1 -0
- package/dist/canvas/operations.d.ts +13 -0
- package/dist/canvas/operations.js +161 -0
- package/dist/{schemas.d.ts → canvas/schemas.d.ts} +21 -20
- package/dist/{schemas.js → canvas/schemas.js} +1 -0
- package/dist/{canvas-session.d.ts → canvas/session.d.ts} +21 -1
- package/dist/canvas/session.js +256 -0
- package/dist/{tools.d.ts → canvas/tools.d.ts} +13 -8
- package/dist/{tools.js → canvas/tools.js} +5 -0
- package/dist/{types.d.ts → canvas/types.d.ts} +1 -10
- package/dist/canvas/types.js +1 -0
- package/dist/config.d.ts +5 -1
- package/dist/config.js +22 -4
- package/dist/index.js +2 -2
- package/dist/server/http.d.ts +2 -0
- package/dist/{http-server.js → server/http.js} +100 -16
- package/dist/server/mcp.d.ts +2 -0
- package/dist/{mcp-server.js → server/mcp.js} +5 -2
- package/dist/utils/date.d.ts +2 -0
- package/dist/utils/date.js +7 -0
- package/dist/utils/logger.d.ts +17 -0
- package/dist/utils/logger.js +83 -0
- package/dist/utils/value.d.ts +5 -0
- package/dist/utils/value.js +8 -0
- package/package.json +7 -4
- package/dist/agents.js +0 -557
- package/dist/canvas-session.js +0 -391
- package/dist/http-server.d.ts +0 -1
- package/dist/mcp-server.d.ts +0 -1
- /package/dist/{types.js → agent/codex-protocol.js} +0 -0
package/README.md
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
npx -y @xiaohhhh1/canvas-agent
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
需要排查连接、线程、Codex app-server 或工具调用问题时,可开启 Debug 模式:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y @basketikun/canvas-agent --debug
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Debug 日志会以 `[DEBUG][HH:mm:ss]` 等传统格式输出到终端,并按启动日期保存到 `~/.infinite-canvas/logs/canvas-agent-YYYY-MM-DD.log`。终端日志带级别颜色,文件日志为纯文本;日志包含 HTTP、SSE、线程、turn、Codex app-server 和工具调用事件,token 与图片 Data URL 会自动隐藏。
|
|
18
|
+
|
|
11
19
|
本仓库开发时也可以直接运行:
|
|
12
20
|
|
|
13
21
|
```bash
|
|
@@ -126,7 +134,7 @@ default_tools_approval_mode = "approve"
|
|
|
126
134
|
|
|
127
135
|
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
|
|
128
136
|
|
|
129
|
-
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed`
|
|
137
|
+
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把任务进度、计划、搜索、文件修改与工具操作整理为中文过程时间线。
|
|
130
138
|
|
|
131
139
|
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
|
|
132
140
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Infinite Canvas Agent
|
|
2
|
+
|
|
3
|
+
你正在帮助用户操作 Infinite Canvas 网站。
|
|
4
|
+
|
|
5
|
+
- 用户要求操作画布时,默认目标就是网页当前已经打开的画布。需要了解内容时先使用 `canvas_get_state`;读取成功后直接在该画布执行任务,不要调用 `canvas_list_projects`,也不要用 `site_navigate` 重复进入画布。
|
|
6
|
+
- 只有用户明确要求查看、选择或切换其他画布,或者 `canvas_get_state` 明确提示当前没有已连接画布时,才使用 `canvas_list_projects` 和 `site_navigate`。`site_navigate` 可跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`。
|
|
7
|
+
- 修改当前画布时根据任务使用已配置的 infinite-canvas MCP 工具;复杂批量改动使用 `canvas_apply_ops`。
|
|
8
|
+
- 用户要求把上传附件放入画布或作为生成参考图时,必须先用 `canvas_create_attachment_nodes` 创建真实图片节点,再把节点 ID 传给生成流程,不要创建空图片占位节点。
|
|
9
|
+
- 生图与视频工作台分别使用 `workbench_image_*`、`workbench_video_*` 工具;提示词和素材分别使用 `prompts_search`、`assets_*` 工具。
|
|
10
|
+
- 用户要求生成图片、视频、音频或文本时,默认调用对应的 `canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_generate_text`,通过当前画布的生成节点完成任务。
|
|
11
|
+
- 只有用户明确要求使用“Codex 内置生图”“ImageGen 技能”或意思明确相同的能力时,才使用 Codex 自带的 `imagegen`;不要因为用户只说“生成图片”就自行改用内置生图。内置生图完成后,其结果会由 Canvas Agent 自动展示到对话并插入当前画布,无需再创建空节点或重复生成。
|
|
12
|
+
- 只有用户明确说要在生图/视频工作台生成时,才使用 `workbench_image_*`、`workbench_video_*`。生成任务提交后应说明已经在画布或工作台开始生成,不要在实际没有结果时声称“已生成”。
|
|
13
|
+
- 需要生成内容时直接调用对应生成工具,不要绑定特定业务场景,不要模拟鼠标点击,不要要求用户手动复制 JSON。
|
|
14
|
+
|
|
15
|
+
## 流程 C:脚本分镜到视频
|
|
16
|
+
|
|
17
|
+
- 用户明确选择流程 C 时,必须新建独立画布、清单和归档目录,不得混用流程 A 或 B 的资产。
|
|
18
|
+
- 先使用原始同 SKU 商品图和完整脚本生成 9:16、10 秒导演分镜板;每镜必须包含时长、画面/镜头、对应配音、音效/BGM 节奏和情绪,并附专业技术规格栏。
|
|
19
|
+
- 原始商品图是唯一商品标准:每一格都必须保持颜色、外形、文字或 Logo、比例、部件和数量一致。分镜通过逐格检查前,严禁提交视频任务。
|
|
20
|
+
- 视频阶段必须同时提供已通过的分镜板和原始商品图。技术失败、超时、明显商品不一致或明显分镜错误时自动重做;提示词、参考图或平台合规问题才暂停等待用户修改。
|
|
21
|
+
- 未配置流程 C 的第三方视频接口时,只能完成分镜准备和验收,明确说明“待管理员配置视频接口”,不得假装已提交或产生扣费。
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { AGENT_PROMPT } from "../config.js";
|
|
3
|
+
import { errorMessage } from "../utils/value.js";
|
|
4
|
+
/** 使用 Claude CLI 执行一次带 Canvas Agent 工具的任务。 */
|
|
5
|
+
export function runClaudeTurn(prompt, emit) {
|
|
6
|
+
const fullPrompt = withAgentPrompt(prompt);
|
|
7
|
+
if (!fullPrompt)
|
|
8
|
+
return;
|
|
9
|
+
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", fullPrompt], emit);
|
|
10
|
+
if (child)
|
|
11
|
+
pipeJsonLines(child, emit, "claude");
|
|
12
|
+
}
|
|
13
|
+
/** 为 Claude CLI 请求拼接 Canvas Agent 指令。 */
|
|
14
|
+
function withAgentPrompt(prompt) {
|
|
15
|
+
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
|
16
|
+
}
|
|
17
|
+
/** 将 Claude CLI 的 JSON Lines 输出转换为 Agent 事件。 */
|
|
18
|
+
function pipeJsonLines(child, emit, agent) {
|
|
19
|
+
let out = "";
|
|
20
|
+
child.stdout?.on("data", (chunk) => {
|
|
21
|
+
out += chunk.toString();
|
|
22
|
+
const lines = out.split(/\r?\n/);
|
|
23
|
+
out = lines.pop() || "";
|
|
24
|
+
lines.filter(Boolean).forEach((line) => {
|
|
25
|
+
try {
|
|
26
|
+
emit("agent_event", { agent, ...JSON.parse(line) });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
emit("agent_event", { agent, type: "raw", text: line });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
|
34
|
+
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
|
35
|
+
child.on("close", (code) => emit("agent_done", { agent, code }));
|
|
36
|
+
}
|
|
37
|
+
/** 启动外部 Agent CLI,并将同步启动异常转换为事件。 */
|
|
38
|
+
function spawnAgent(name, args, emit) {
|
|
39
|
+
try {
|
|
40
|
+
return spawn(name, args, { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", windowsHide: true });
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
emit("agent_error", { message: errorMessage(error) });
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CodexPlanUpdate, CodexRequestParams } from "./codex-protocol.js";
|
|
2
|
+
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
|
3
|
+
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
|
4
|
+
export declare class CodexAppClient {
|
|
5
|
+
private child;
|
|
6
|
+
private emit;
|
|
7
|
+
private nextId;
|
|
8
|
+
private buffer;
|
|
9
|
+
private currentThreadId;
|
|
10
|
+
private currentTurnId;
|
|
11
|
+
private textByItem;
|
|
12
|
+
private lastUsage;
|
|
13
|
+
private pending;
|
|
14
|
+
private activeTurns;
|
|
15
|
+
private completedTurns;
|
|
16
|
+
private pendingDeltas;
|
|
17
|
+
private plansByTurn;
|
|
18
|
+
private approvalRequests;
|
|
19
|
+
/** 保存 app-server 子进程和事件出口。 */
|
|
20
|
+
private constructor();
|
|
21
|
+
/** 启动并初始化 Codex app-server。 */
|
|
22
|
+
static start(emit: AgentEmit, onExit: () => void): Promise<CodexAppClient>;
|
|
23
|
+
/** 创建新的 Codex 线程。 */
|
|
24
|
+
startThread(cwd?: string, permissionMode?: AgentPermissionMode): Promise<import("./codex-protocol.js").CodexThread>;
|
|
25
|
+
/** 恢复已有 Codex 线程。 */
|
|
26
|
+
resumeThread(threadId: string, cwd?: string, permissionMode?: AgentPermissionMode): Promise<import("./codex-protocol.js").CodexThread>;
|
|
27
|
+
/** 查询 Codex 线程列表。 */
|
|
28
|
+
listThreads(params: CodexRequestParams<"thread/list">): Promise<{
|
|
29
|
+
data: import("./codex-protocol.js").CodexThread[];
|
|
30
|
+
nextCursor: string | null;
|
|
31
|
+
backwardsCursor: string | null;
|
|
32
|
+
}>;
|
|
33
|
+
/** 读取指定 Codex 线程。 */
|
|
34
|
+
readThread(threadId: string, includeTurns?: boolean): Promise<{
|
|
35
|
+
thread: import("./codex-protocol.js").CodexThread;
|
|
36
|
+
}>;
|
|
37
|
+
/** 归档指定 Codex 线程。 */
|
|
38
|
+
archiveThread(threadId: string): Promise<Record<string, never>>;
|
|
39
|
+
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
|
40
|
+
planUpdates(threadId: string): CodexPlanUpdate[];
|
|
41
|
+
/** 清理已归档线程的任务计划缓存。 */
|
|
42
|
+
clearPlanUpdates(threadId: string): void;
|
|
43
|
+
/** 启动一个 Codex turn 并等待完成通知。 */
|
|
44
|
+
startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void): Promise<void>;
|
|
45
|
+
/** 中断当前正在运行的 Codex turn。 */
|
|
46
|
+
interruptCurrentTurn(): Promise<boolean>;
|
|
47
|
+
/** 回复网页端已经确认的 Codex 权限请求。 */
|
|
48
|
+
resolveApproval(requestId: string, decision: string): boolean;
|
|
49
|
+
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
|
50
|
+
private request;
|
|
51
|
+
/** 发送无需响应的 JSON-RPC 通知。 */
|
|
52
|
+
private notify;
|
|
53
|
+
/** 将 JSON-RPC 消息写入 app-server 标准输入。 */
|
|
54
|
+
private write;
|
|
55
|
+
/** 按行解析 app-server 标准输出。 */
|
|
56
|
+
private read;
|
|
57
|
+
/** 分派单条 JSON-RPC 响应、请求或通知。 */
|
|
58
|
+
private handle;
|
|
59
|
+
/** 转换并广播 app-server 通知。 */
|
|
60
|
+
private handleNotification;
|
|
61
|
+
/** 合并并广播 Agent 文本或执行输出增量。 */
|
|
62
|
+
private emitDelta;
|
|
63
|
+
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
|
64
|
+
private flushDelta;
|
|
65
|
+
/** 自动回复 app-server 发起的授权或交互请求。 */
|
|
66
|
+
private answerServerRequest;
|
|
67
|
+
/** 完成指定 JSON-RPC 请求。 */
|
|
68
|
+
private resolve;
|
|
69
|
+
/** 拒绝指定 JSON-RPC 请求。 */
|
|
70
|
+
private reject;
|
|
71
|
+
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
|
72
|
+
private failAll;
|
|
73
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { VERSION } from "../config.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
import { field } from "../utils/value.js";
|
|
8
|
+
const canvasAgentMcp = canvasAgentMcpCommand();
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const STREAM_UPDATE_INTERVAL_MS = 40;
|
|
11
|
+
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
|
12
|
+
export class CodexAppClient {
|
|
13
|
+
child;
|
|
14
|
+
emit;
|
|
15
|
+
nextId = 1;
|
|
16
|
+
buffer = "";
|
|
17
|
+
currentThreadId = "";
|
|
18
|
+
currentTurnId = "";
|
|
19
|
+
textByItem = new Map();
|
|
20
|
+
lastUsage = null;
|
|
21
|
+
pending = new Map();
|
|
22
|
+
activeTurns = new Map();
|
|
23
|
+
completedTurns = new Map();
|
|
24
|
+
pendingDeltas = new Map();
|
|
25
|
+
plansByTurn = new Map();
|
|
26
|
+
approvalRequests = new Map();
|
|
27
|
+
/** 保存 app-server 子进程和事件出口。 */
|
|
28
|
+
constructor(child, emit) {
|
|
29
|
+
this.child = child;
|
|
30
|
+
this.emit = emit;
|
|
31
|
+
}
|
|
32
|
+
/** 启动并初始化 Codex app-server。 */
|
|
33
|
+
static async start(emit, onExit) {
|
|
34
|
+
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
|
|
35
|
+
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
36
|
+
const client = new CodexAppClient(child, emit);
|
|
37
|
+
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
|
38
|
+
child.stderr?.on("data", (chunk) => {
|
|
39
|
+
const text = chunk.toString();
|
|
40
|
+
logger.warn("Codex app-server stderr", { text });
|
|
41
|
+
emit("agent_log", { text });
|
|
42
|
+
});
|
|
43
|
+
child.on("error", (error) => {
|
|
44
|
+
logger.error("Codex app-server process error", error);
|
|
45
|
+
emit("agent_error", { message: error.message });
|
|
46
|
+
});
|
|
47
|
+
child.on("exit", (code) => {
|
|
48
|
+
logger.warn("Codex app-server exited", { code });
|
|
49
|
+
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
|
50
|
+
onExit();
|
|
51
|
+
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
|
52
|
+
});
|
|
53
|
+
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
|
54
|
+
client.notify("initialized");
|
|
55
|
+
return client;
|
|
56
|
+
}
|
|
57
|
+
/** 创建新的 Codex 线程。 */
|
|
58
|
+
async startThread(cwd, permissionMode = "request") {
|
|
59
|
+
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
|
60
|
+
if (!thread.id)
|
|
61
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
62
|
+
return thread;
|
|
63
|
+
}
|
|
64
|
+
/** 恢复已有 Codex 线程。 */
|
|
65
|
+
async resumeThread(threadId, cwd, permissionMode = "request") {
|
|
66
|
+
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
|
|
67
|
+
if (!thread.id)
|
|
68
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
69
|
+
return thread;
|
|
70
|
+
}
|
|
71
|
+
/** 查询 Codex 线程列表。 */
|
|
72
|
+
listThreads(params) {
|
|
73
|
+
return this.request("thread/list", params);
|
|
74
|
+
}
|
|
75
|
+
/** 读取指定 Codex 线程。 */
|
|
76
|
+
readThread(threadId, includeTurns = true) {
|
|
77
|
+
return this.request("thread/read", { threadId, includeTurns });
|
|
78
|
+
}
|
|
79
|
+
/** 归档指定 Codex 线程。 */
|
|
80
|
+
archiveThread(threadId) {
|
|
81
|
+
return this.request("thread/archive", { threadId });
|
|
82
|
+
}
|
|
83
|
+
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
|
84
|
+
planUpdates(threadId) {
|
|
85
|
+
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
|
86
|
+
}
|
|
87
|
+
/** 清理已归档线程的任务计划缓存。 */
|
|
88
|
+
clearPlanUpdates(threadId) {
|
|
89
|
+
this.plansByTurn.forEach((item, turnId) => {
|
|
90
|
+
if (item.threadId === threadId)
|
|
91
|
+
this.plansByTurn.delete(turnId);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** 启动一个 Codex turn 并等待完成通知。 */
|
|
95
|
+
async startTurn(threadId, prompt, images, permissionMode, onTurn) {
|
|
96
|
+
this.currentThreadId = threadId;
|
|
97
|
+
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
|
|
98
|
+
const turnId = turn.id;
|
|
99
|
+
if (!turnId)
|
|
100
|
+
throw new Error("Codex app-server 没有返回 turn id");
|
|
101
|
+
this.currentTurnId = turnId;
|
|
102
|
+
onTurn?.(turnId);
|
|
103
|
+
const completed = this.completedTurns.get(turnId);
|
|
104
|
+
if (this.completedTurns.has(turnId)) {
|
|
105
|
+
this.completedTurns.delete(turnId);
|
|
106
|
+
this.currentThreadId = "";
|
|
107
|
+
this.currentTurnId = "";
|
|
108
|
+
if (completed)
|
|
109
|
+
throw completed;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
|
113
|
+
}
|
|
114
|
+
/** 中断当前正在运行的 Codex turn。 */
|
|
115
|
+
async interruptCurrentTurn() {
|
|
116
|
+
const threadId = this.currentThreadId;
|
|
117
|
+
const turnId = this.currentTurnId;
|
|
118
|
+
if (!threadId || !turnId)
|
|
119
|
+
return false;
|
|
120
|
+
try {
|
|
121
|
+
logger.warn("Interrupting active Codex turn", { threadId, turnId });
|
|
122
|
+
await this.request("turn/interrupt", { threadId, turnId });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
logger.warn("Failed to interrupt Codex turn", { error, threadId, turnId });
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** 回复网页端已经确认的 Codex 权限请求。 */
|
|
131
|
+
resolveApproval(requestId, decision) {
|
|
132
|
+
const request = this.approvalRequests.get(requestId);
|
|
133
|
+
if (!request)
|
|
134
|
+
return false;
|
|
135
|
+
this.approvalRequests.delete(requestId);
|
|
136
|
+
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
|
|
137
|
+
const result = request.method === "item/permissions/requestApproval"
|
|
138
|
+
? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
|
|
139
|
+
: { decision };
|
|
140
|
+
this.write({ id: request.id, result });
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
|
144
|
+
request(method, params) {
|
|
145
|
+
const id = this.nextId++;
|
|
146
|
+
this.write({ id, method, params });
|
|
147
|
+
return new Promise((resolve, reject) => this.pending.set(id, { resolve: (result) => resolve(result), reject }));
|
|
148
|
+
}
|
|
149
|
+
/** 发送无需响应的 JSON-RPC 通知。 */
|
|
150
|
+
notify(method, params) {
|
|
151
|
+
this.write(params === undefined ? { method } : { method, params });
|
|
152
|
+
}
|
|
153
|
+
/** 将 JSON-RPC 消息写入 app-server 标准输入。 */
|
|
154
|
+
write(value) {
|
|
155
|
+
const method = String(field(value, "method") || "");
|
|
156
|
+
const params = field(value, "params");
|
|
157
|
+
if (method)
|
|
158
|
+
logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
|
|
159
|
+
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
|
160
|
+
}
|
|
161
|
+
/** 按行解析 app-server 标准输出。 */
|
|
162
|
+
read(chunk) {
|
|
163
|
+
this.buffer += chunk;
|
|
164
|
+
const lines = this.buffer.split(/\r?\n/);
|
|
165
|
+
this.buffer = lines.pop() || "";
|
|
166
|
+
lines.filter(Boolean).forEach((line) => {
|
|
167
|
+
try {
|
|
168
|
+
this.handle(JSON.parse(line));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger.warn("Invalid Codex app-server output", { error, line });
|
|
172
|
+
this.emit("agent_log", { text: line });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** 分派单条 JSON-RPC 响应、请求或通知。 */
|
|
177
|
+
handle(message) {
|
|
178
|
+
const id = Number(message.id);
|
|
179
|
+
if (message.error && this.pending.has(id)) {
|
|
180
|
+
const error = String(field(message.error, "message") || "Codex request failed");
|
|
181
|
+
if (/not materialized yet.*includeTurns/i.test(error))
|
|
182
|
+
logger.debug("Codex thread has no messages yet", { id });
|
|
183
|
+
else
|
|
184
|
+
logger.warn("Codex request failed", { id, error });
|
|
185
|
+
return this.reject(id, error);
|
|
186
|
+
}
|
|
187
|
+
if (this.pending.has(id))
|
|
188
|
+
return this.resolve(id, message.result);
|
|
189
|
+
if (typeof message.method === "string" && "id" in message)
|
|
190
|
+
return this.answerServerRequest(message);
|
|
191
|
+
if (typeof message.method === "string")
|
|
192
|
+
this.handleNotification(message.method, (message.params || {}));
|
|
193
|
+
}
|
|
194
|
+
/** 转换并广播 app-server 通知。 */
|
|
195
|
+
handleNotification(method, params) {
|
|
196
|
+
if (method === "serverRequest/resolved") {
|
|
197
|
+
const requestId = String(field(params, "requestId") || "");
|
|
198
|
+
if (requestId)
|
|
199
|
+
this.approvalRequests.delete(requestId);
|
|
200
|
+
this.emit("codex_approval_resolved", { requestId, ...params });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated"))
|
|
204
|
+
params = { ...params, threadId: this.currentThreadId };
|
|
205
|
+
if (method === "item/agentMessage/delta") {
|
|
206
|
+
const value = params;
|
|
207
|
+
this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
|
|
208
|
+
return this.emitDelta("agent_message", value);
|
|
209
|
+
}
|
|
210
|
+
if (method === "item/plan/delta")
|
|
211
|
+
return this.emitDelta("plan", params);
|
|
212
|
+
if (method === "item/reasoning/summaryTextDelta")
|
|
213
|
+
return this.emitDelta("reasoning", params);
|
|
214
|
+
if (method === "item/commandExecution/outputDelta")
|
|
215
|
+
return this.emitDelta("command_execution", params);
|
|
216
|
+
if (method === "turn/plan/updated") {
|
|
217
|
+
const value = params;
|
|
218
|
+
const update = { ...value, threadId: value.threadId || "" };
|
|
219
|
+
if (update.threadId && update.turnId)
|
|
220
|
+
this.plansByTurn.set(update.turnId, update);
|
|
221
|
+
params = update;
|
|
222
|
+
}
|
|
223
|
+
if (method === "thread/tokenUsage/updated") {
|
|
224
|
+
this.lastUsage = normalizeUsage(params);
|
|
225
|
+
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const event = normalizeCodexNotification(method, params);
|
|
229
|
+
if (!event)
|
|
230
|
+
return;
|
|
231
|
+
if (event.type === "item.completed") {
|
|
232
|
+
const item = field(event, "item");
|
|
233
|
+
const id = String(field(item, "id") || "");
|
|
234
|
+
this.flushDelta(id);
|
|
235
|
+
const streamedText = this.textByItem.get(id);
|
|
236
|
+
if (item?.type === "agent_message" && streamedText && !item.text)
|
|
237
|
+
item.text = streamedText;
|
|
238
|
+
if (id)
|
|
239
|
+
this.textByItem.delete(id);
|
|
240
|
+
}
|
|
241
|
+
if (event.type === "turn.completed") {
|
|
242
|
+
const turn = field(params, "turn");
|
|
243
|
+
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
|
|
244
|
+
const plan = this.plansByTurn.get(turnId);
|
|
245
|
+
if (plan)
|
|
246
|
+
this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
|
247
|
+
}
|
|
248
|
+
if (event.type === "turn.completed")
|
|
249
|
+
event.usage = this.lastUsage;
|
|
250
|
+
this.emit("agent_event", { agent: "codex", ...event });
|
|
251
|
+
if (event.type === "turn.completed") {
|
|
252
|
+
const turn = params.turn;
|
|
253
|
+
const turnId = turn.id;
|
|
254
|
+
const pending = this.activeTurns.get(turnId);
|
|
255
|
+
const error = turn.error;
|
|
256
|
+
if (pending) {
|
|
257
|
+
this.activeTurns.delete(turnId);
|
|
258
|
+
error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
|
|
259
|
+
}
|
|
260
|
+
else if (turnId) {
|
|
261
|
+
this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
|
|
262
|
+
}
|
|
263
|
+
if (turnId === this.currentTurnId) {
|
|
264
|
+
this.currentThreadId = "";
|
|
265
|
+
this.currentTurnId = "";
|
|
266
|
+
}
|
|
267
|
+
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** 合并并广播 Agent 文本或执行输出增量。 */
|
|
271
|
+
emitDelta(itemType, params) {
|
|
272
|
+
const id = params.itemId;
|
|
273
|
+
const pending = this.pendingDeltas.get(id);
|
|
274
|
+
if (pending) {
|
|
275
|
+
pending.delta += params.delta;
|
|
276
|
+
pending.itemType = itemType;
|
|
277
|
+
pending.params = params;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.pendingDeltas.set(id, {
|
|
281
|
+
delta: params.delta,
|
|
282
|
+
itemType,
|
|
283
|
+
params,
|
|
284
|
+
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
|
288
|
+
flushDelta(id) {
|
|
289
|
+
const pending = this.pendingDeltas.get(id);
|
|
290
|
+
if (!pending)
|
|
291
|
+
return;
|
|
292
|
+
clearTimeout(pending.timer);
|
|
293
|
+
this.pendingDeltas.delete(id);
|
|
294
|
+
if (pending.delta)
|
|
295
|
+
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params) });
|
|
296
|
+
}
|
|
297
|
+
/** 自动回复 app-server 发起的授权或交互请求。 */
|
|
298
|
+
answerServerRequest(message) {
|
|
299
|
+
const method = String(message.method);
|
|
300
|
+
const params = field(message, "params") || {};
|
|
301
|
+
if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) {
|
|
302
|
+
const requestId = String(message.id);
|
|
303
|
+
this.approvalRequests.set(requestId, { id: Number(message.id), method, params });
|
|
304
|
+
this.emit("codex_approval", { requestId, method, ...params });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
|
308
|
+
this.write({ id: message.id, result });
|
|
309
|
+
this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result });
|
|
310
|
+
}
|
|
311
|
+
/** 完成指定 JSON-RPC 请求。 */
|
|
312
|
+
resolve(id, result) {
|
|
313
|
+
const pending = this.pending.get(id);
|
|
314
|
+
if (pending)
|
|
315
|
+
(this.pending.delete(id), pending.resolve(result));
|
|
316
|
+
}
|
|
317
|
+
/** 拒绝指定 JSON-RPC 请求。 */
|
|
318
|
+
reject(id, message) {
|
|
319
|
+
const pending = this.pending.get(id);
|
|
320
|
+
if (pending)
|
|
321
|
+
(this.pending.delete(id), pending.reject(new Error(message)));
|
|
322
|
+
}
|
|
323
|
+
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
|
324
|
+
failAll(message) {
|
|
325
|
+
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
|
326
|
+
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
|
327
|
+
this.pending.clear();
|
|
328
|
+
this.activeTurns.clear();
|
|
329
|
+
this.pendingDeltas.clear();
|
|
330
|
+
this.textByItem.clear();
|
|
331
|
+
this.approvalRequests.clear();
|
|
332
|
+
this.currentThreadId = "";
|
|
333
|
+
this.currentTurnId = "";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
|
|
337
|
+
function canvasAgentMcpCommand() {
|
|
338
|
+
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
|
339
|
+
const entry = path.resolve(current || fileURLToPath(new URL("../index.js", import.meta.url)));
|
|
340
|
+
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
|
341
|
+
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
|
342
|
+
}
|
|
343
|
+
/** 生成 Codex app-server 使用的 MCP 配置。 */
|
|
344
|
+
function codexConfig(permissionMode) {
|
|
345
|
+
return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
|
346
|
+
}
|
|
347
|
+
function threadSettings(permissionMode) {
|
|
348
|
+
return { approvalPolicy: permissionMode === "full" ? "never" : "on-request", sandbox: permissionMode === "full" ? "danger-full-access" : "workspace-write", config: codexConfig(permissionMode) };
|
|
349
|
+
}
|
|
350
|
+
function turnSettings(permissionMode) {
|
|
351
|
+
return {
|
|
352
|
+
approvalPolicy: permissionMode === "full" ? "never" : "on-request",
|
|
353
|
+
sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" } : { type: "workspaceWrite", networkAccess: false },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/** 将文本和本地图片转换为 Codex turn 输入。 */
|
|
357
|
+
function codexInput(prompt, images) {
|
|
358
|
+
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
|
|
359
|
+
}
|
|
360
|
+
/** 将 app-server 通知转换为前端使用的 Agent 事件。 */
|
|
361
|
+
function normalizeCodexNotification(method, params) {
|
|
362
|
+
const scope = codexEventScope(params);
|
|
363
|
+
if (method === "thread/started")
|
|
364
|
+
return { type: "thread.started", ...scope };
|
|
365
|
+
if (method === "turn/started")
|
|
366
|
+
return { type: "turn.started", ...scope };
|
|
367
|
+
if (method === "turn/completed")
|
|
368
|
+
return { type: "turn.completed", status: field(field(params, "turn"), "status"), error: field(field(params, "turn"), "error"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
|
|
369
|
+
if (method === "turn/plan/updated")
|
|
370
|
+
return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope };
|
|
371
|
+
if (method === "item/started")
|
|
372
|
+
return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
|
373
|
+
if (method === "item/completed")
|
|
374
|
+
return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
|
375
|
+
if (method === "error")
|
|
376
|
+
return { type: "error", message: field(field(params, "error"), "message"), ...scope };
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** 提取 Codex 事件所属的线程和 turn。 */
|
|
380
|
+
function codexEventScope(params) {
|
|
381
|
+
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
|
382
|
+
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
|
383
|
+
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
|
384
|
+
}
|
|
385
|
+
/** 统一 app-server item 的类型和参数格式。 */
|
|
386
|
+
function normalizeItem(item) {
|
|
387
|
+
const value = item && typeof item === "object" ? { ...item } : {};
|
|
388
|
+
if (value.type === "agentMessage")
|
|
389
|
+
value.type = "agent_message";
|
|
390
|
+
if (value.type === "mcpToolCall")
|
|
391
|
+
value.type = "mcp_tool_call";
|
|
392
|
+
if (value.type === "commandExecution")
|
|
393
|
+
value.type = "command_execution";
|
|
394
|
+
if (value.type === "fileChange")
|
|
395
|
+
value.type = "file_change";
|
|
396
|
+
if (value.type === "dynamicToolCall")
|
|
397
|
+
value.type = "dynamic_tool_call";
|
|
398
|
+
if (value.type === "collabToolCall")
|
|
399
|
+
value.type = "collab_tool_call";
|
|
400
|
+
if (value.type === "webSearch")
|
|
401
|
+
value.type = "web_search";
|
|
402
|
+
if (value.type === "imageView")
|
|
403
|
+
value.type = "image_view";
|
|
404
|
+
if (value.type === "imageGeneration")
|
|
405
|
+
value.type = "image_generation";
|
|
406
|
+
if (value.type === "contextCompaction")
|
|
407
|
+
value.type = "context_compaction";
|
|
408
|
+
if (value.type === "agent_message" && typeof value.id === "string")
|
|
409
|
+
value.text = String(value.text || "");
|
|
410
|
+
if ("arguments" in value)
|
|
411
|
+
value.arguments = parseMaybeJson(value.arguments);
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
/** 将 Codex token usage 转换为前端字段。 */
|
|
415
|
+
function normalizeUsage(params) {
|
|
416
|
+
const last = params.tokenUsage.last;
|
|
417
|
+
return {
|
|
418
|
+
input_tokens: last.inputTokens,
|
|
419
|
+
cached_input_tokens: last.cachedInputTokens,
|
|
420
|
+
output_tokens: last.outputTokens,
|
|
421
|
+
reasoning_output_tokens: last.reasoningOutputTokens,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** 尝试将字符串解析为 JSON,失败时保留原值。 */
|
|
425
|
+
function parseMaybeJson(value) {
|
|
426
|
+
if (typeof value !== "string")
|
|
427
|
+
return value;
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(value);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** 定位当前依赖中 Codex CLI 的执行文件。 */
|
|
436
|
+
function codexBin() {
|
|
437
|
+
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
|
438
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CodexPlanUpdate } from "./codex-protocol.js";
|
|
2
|
+
type AgentHistoryMessage = {
|
|
3
|
+
id: string;
|
|
4
|
+
role: "user" | "assistant" | "tool" | "error";
|
|
5
|
+
title?: string;
|
|
6
|
+
text: string;
|
|
7
|
+
detail?: unknown;
|
|
8
|
+
streamId?: string;
|
|
9
|
+
};
|
|
10
|
+
/** 将 Codex 线程转换为列表展示所需的摘要。 */
|
|
11
|
+
export declare function summarizeCodexThread(thread: unknown): {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
preview: string;
|
|
15
|
+
name: string | null;
|
|
16
|
+
cwd: string;
|
|
17
|
+
status: string;
|
|
18
|
+
source: unknown;
|
|
19
|
+
threadSource: unknown;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
};
|
|
23
|
+
/** 将 Codex turn items 转换为网页聊天历史。 */
|
|
24
|
+
export declare function threadMessages(thread: unknown, planUpdates?: CodexPlanUpdate[]): AgentHistoryMessage[];
|
|
25
|
+
export {};
|