@kobinflow/canvas-agent 0.6.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.
Files changed (50) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +159 -0
  3. package/agent-instructions.md +13 -0
  4. package/dist/agent/claude.d.ts +3 -0
  5. package/dist/agent/claude.js +54 -0
  6. package/dist/agent/codex-client.d.ts +139 -0
  7. package/dist/agent/codex-client.js +937 -0
  8. package/dist/agent/codex-event-history.d.ts +21 -0
  9. package/dist/agent/codex-event-history.js +163 -0
  10. package/dist/agent/codex-history.d.ts +46 -0
  11. package/dist/agent/codex-history.js +822 -0
  12. package/dist/agent/codex-protocol.d.ts +335 -0
  13. package/dist/agent/codex-protocol.js +1 -0
  14. package/dist/agent/codex.d.ts +178 -0
  15. package/dist/agent/codex.js +509 -0
  16. package/dist/agent/message-metadata.d.ts +51 -0
  17. package/dist/agent/message-metadata.js +378 -0
  18. package/dist/agent/types.d.ts +45 -0
  19. package/dist/agent/types.js +1 -0
  20. package/dist/canvas/operations.d.ts +13 -0
  21. package/dist/canvas/operations.js +167 -0
  22. package/dist/canvas/schemas.d.ts +989 -0
  23. package/dist/canvas/schemas.js +155 -0
  24. package/dist/canvas/session.d.ts +163 -0
  25. package/dist/canvas/session.js +523 -0
  26. package/dist/canvas/tools.d.ts +214 -0
  27. package/dist/canvas/tools.js +27 -0
  28. package/dist/canvas/types.d.ts +34 -0
  29. package/dist/canvas/types.js +1 -0
  30. package/dist/config.d.ts +30 -0
  31. package/dist/config.js +86 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +7 -0
  34. package/dist/server/http.d.ts +2 -0
  35. package/dist/server/http.js +576 -0
  36. package/dist/server/mcp.d.ts +2 -0
  37. package/dist/server/mcp.js +27 -0
  38. package/dist/skills/store.d.ts +54 -0
  39. package/dist/skills/store.js +426 -0
  40. package/dist/utils/agent-runtime.d.ts +8 -0
  41. package/dist/utils/agent-runtime.js +33 -0
  42. package/dist/utils/date.d.ts +2 -0
  43. package/dist/utils/date.js +7 -0
  44. package/dist/utils/logger.d.ts +17 -0
  45. package/dist/utils/logger.js +81 -0
  46. package/dist/utils/value.d.ts +5 -0
  47. package/dist/utils/value.js +8 -0
  48. package/dist/version-check.d.ts +2 -0
  49. package/dist/version-check.js +65 -0
  50. package/package.json +55 -0
@@ -0,0 +1,523 @@
1
+ import crypto from "node:crypto";
2
+ import { logger } from "../utils/logger.js";
3
+ import { buildCanvasToolRequest, fitAttachmentNodeSize } from "./operations.js";
4
+ import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
5
+ export const AGENT_PROTOCOL_VERSION = 6;
6
+ const SITE_TOOLS = new Set([
7
+ "site_navigate",
8
+ "canvas_list_projects",
9
+ "workbench_image_get_config",
10
+ "workbench_image_generate",
11
+ "workbench_video_get_config",
12
+ "workbench_video_generate",
13
+ "prompts_search",
14
+ "assets_list",
15
+ "assets_add",
16
+ "generation_get_status",
17
+ ]);
18
+ /** 管理网页画布连接、状态、附件和工具请求。 */
19
+ export class CanvasSession {
20
+ clients = new Map();
21
+ clientFocusOrder = new Map();
22
+ pending = new Map();
23
+ pendingApprovals = new Map();
24
+ canvasStates = new Map();
25
+ turnAttachments = new Map();
26
+ codexReplayEvents = new Map();
27
+ codexReplayActiveItems = new Set();
28
+ codexMutationBusy = false;
29
+ activeClientId = "";
30
+ boundClientId = "";
31
+ focusSequence = 0;
32
+ codexState = { busy: false, threadId: "", turnId: "" };
33
+ conversationState;
34
+ conversationInventoryComplete = false;
35
+ preparedConversationThreadId = "";
36
+ constructor(activeThreadId = "") {
37
+ this.conversationState = {
38
+ revision: 1,
39
+ conversationId: activeThreadId || crypto.randomUUID(),
40
+ threadId: activeThreadId,
41
+ status: activeThreadId ? "ready" : "idle",
42
+ mcpStatuses: {},
43
+ };
44
+ }
45
+ /** 获取当前目标网页的画布状态。 */
46
+ get canvasState() {
47
+ return this.clients.has(this.targetClientId) ? this.canvasStates.get(this.targetClientId) || null : null;
48
+ }
49
+ /** 获取当前 turn 绑定或最近激活的网页客户端。 */
50
+ get targetClientId() {
51
+ return this.boundClientId || this.activeClientId;
52
+ }
53
+ /** 返回 Canvas Agent 当前连接状态。 */
54
+ health() {
55
+ return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy, conversation: this.conversationStateSnapshot };
56
+ }
57
+ /** 返回 Codex 是否正在执行任务。 */
58
+ get codexBusy() {
59
+ return this.codexState.busy;
60
+ }
61
+ get codexThreadId() {
62
+ return this.codexState.threadId;
63
+ }
64
+ /** Return a copy that callers can restore after a temporary Codex operation. */
65
+ get codexStateSnapshot() {
66
+ return { ...this.codexState };
67
+ }
68
+ /** 返回站点级对话的权威快照。 */
69
+ get conversationStateSnapshot() {
70
+ return { ...this.conversationState, mcpStatuses: { ...this.conversationState.mcpStatuses } };
71
+ }
72
+ /** 原子开始一次新建或恢复对话流程。 */
73
+ beginConversation(options = {}) {
74
+ this.conversationInventoryComplete = false;
75
+ this.preparedConversationThreadId = "";
76
+ return this.updateConversation({
77
+ conversationId: options.conversationId || options.threadId || crypto.randomUUID(),
78
+ threadId: options.threadId || "",
79
+ status: "preparing",
80
+ mcpStatuses: {},
81
+ sourceClientId: options.sourceClientId,
82
+ error: undefined,
83
+ });
84
+ }
85
+ /** 记录预热阶段单个 MCP 服务的启动状态。 */
86
+ updateConversationMcp(name, status, error, failureReason) {
87
+ if (!name || this.conversationState.status !== "preparing")
88
+ return this.conversationStateSnapshot;
89
+ const snapshot = this.updateConversation({
90
+ mcpStatuses: { ...this.conversationState.mcpStatuses, [name]: { status, error, failureReason } },
91
+ });
92
+ return this.conversationInventoryComplete && this.preparedConversationThreadId
93
+ ? this.completeConversationPreparation(this.preparedConversationThreadId)
94
+ : snapshot;
95
+ }
96
+ /** 用 app-server 的完整 MCP 清单补齐未发送逐项通知的服务。 */
97
+ completeConversationMcpInventory(services) {
98
+ if (this.conversationState.status !== "preparing")
99
+ return this.conversationStateSnapshot;
100
+ const mcpStatuses = { ...this.conversationState.mcpStatuses };
101
+ services.filter((item) => item.name).forEach((item) => {
102
+ const current = mcpStatuses[item.name];
103
+ if (current?.status === "failed" || current?.status === "cancelled")
104
+ return;
105
+ mcpStatuses[item.name] = item.authStatus === "notLoggedIn"
106
+ ? { status: "failed", error: "MCP 服务未登录", failureReason: "reauthenticationRequired" }
107
+ : { status: "ready" };
108
+ });
109
+ this.conversationInventoryComplete = true;
110
+ return this.updateConversation({ mcpStatuses });
111
+ }
112
+ /** MCP 清单读取结束后提交线程和最终可发送状态。 */
113
+ completeConversationPreparation(threadId) {
114
+ this.preparedConversationThreadId = threadId;
115
+ const statuses = this.conversationState.mcpStatuses;
116
+ const hasPending = !this.conversationInventoryComplete || Object.values(statuses).some((item) => item.status === "starting");
117
+ const requiredFailure = statuses["infinite-canvas"]?.status !== "ready";
118
+ const hasFailure = Object.values(statuses).some((item) => item.status === "failed" || item.status === "cancelled");
119
+ const requiredFailureDetail = statuses["infinite-canvas"]?.error;
120
+ return this.updateConversation({
121
+ threadId,
122
+ status: hasPending ? "preparing" : requiredFailure ? "failed" : hasFailure ? "warning" : "ready",
123
+ error: requiredFailure ? `画布 MCP 初始化失败${requiredFailureDetail ? `:${requiredFailureDetail}` : ""}` : undefined,
124
+ });
125
+ }
126
+ /** 将创建线程或读取 MCP 清单的失败保存为不可发送状态。 */
127
+ failConversationPreparation(error) {
128
+ this.conversationInventoryComplete = false;
129
+ this.preparedConversationThreadId = "";
130
+ return this.updateConversation({ status: "failed", error });
131
+ }
132
+ /** 切换到无需重新预热的既有状态,例如删除当前对话后的空状态。 */
133
+ activateConversation(threadId, sourceClientId) {
134
+ this.conversationInventoryComplete = false;
135
+ this.preparedConversationThreadId = "";
136
+ return this.updateConversation({
137
+ conversationId: threadId || crypto.randomUUID(),
138
+ threadId,
139
+ status: threadId ? "ready" : "idle",
140
+ mcpStatuses: {},
141
+ sourceClientId,
142
+ error: undefined,
143
+ });
144
+ }
145
+ markConversationRunning(threadId) {
146
+ if (!threadId || threadId !== this.conversationState.threadId || !["ready", "warning"].includes(this.conversationState.status))
147
+ return this.conversationStateSnapshot;
148
+ return this.updateConversation({ status: "running" });
149
+ }
150
+ finishConversationRun(threadId) {
151
+ if (!threadId || threadId !== this.conversationState.threadId)
152
+ return this.conversationStateSnapshot;
153
+ const hasFailure = Object.values(this.conversationState.mcpStatuses).some((item) => item.status === "failed" || item.status === "cancelled");
154
+ return this.updateConversation({ status: hasFailure ? "warning" : "ready" });
155
+ }
156
+ /** 判断网页客户端是否仍连接到当前 Agent。 */
157
+ hasClient(clientId) {
158
+ return this.clients.has(clientId);
159
+ }
160
+ /** 读取指定网页上报的画布,避免提炼时受最近焦点或其他标签页影响。 */
161
+ canvasStateForClient(clientId) {
162
+ return this.clients.has(clientId) ? this.canvasStates.get(clientId) || null : null;
163
+ }
164
+ /** 原子取得 Codex 写操作权限,避免多个网页并发切换或修改会话。 */
165
+ beginCodexMutation() {
166
+ if (this.codexState.busy || this.codexMutationBusy)
167
+ return false;
168
+ this.codexMutationBusy = true;
169
+ return true;
170
+ }
171
+ /** 释放 Codex 写操作权限。 */
172
+ endCodexMutation() {
173
+ this.codexMutationBusy = false;
174
+ }
175
+ /** 返回当前 Codex turn 的线程、turn 和发起网页。 */
176
+ get codexEventScope() {
177
+ return {
178
+ threadId: this.codexState.threadId,
179
+ turnId: this.codexState.busy ? this.codexState.turnId : "",
180
+ sourceClientId: this.codexState.busy ? this.boundClientId : "",
181
+ };
182
+ }
183
+ /** 返回刷新后仍需展示的 Codex 权限请求。 */
184
+ get codexPendingApprovals() {
185
+ return [...this.pendingApprovals.values()];
186
+ }
187
+ /** 跟踪需要跨页面重连恢复的 Codex 权限请求。 */
188
+ trackCodexEvent(type, payload) {
189
+ const requestId = String(payload.requestId || "");
190
+ if (type === "codex_approval" && requestId)
191
+ this.pendingApprovals.set(requestId, payload);
192
+ if (type === "codex_approval_resolved" && requestId)
193
+ this.pendingApprovals.delete(requestId);
194
+ if (type === "agent_error")
195
+ this.pendingApprovals.clear();
196
+ }
197
+ /** 更新并广播 Codex 运行状态;静默后台活动可保留上一 turn 的断线重放。 */
198
+ setCodexState(patch, options = {}) {
199
+ const next = { ...this.codexState, ...patch };
200
+ const threadChanged = next.threadId !== this.codexState.threadId;
201
+ const turnChanged = Boolean(this.codexState.turnId && next.turnId && next.turnId !== this.codexState.turnId);
202
+ const nextTurnStarted = !this.codexState.busy && next.busy;
203
+ if (!options.preserveReplay && (threadChanged || turnChanged || nextTurnStarted)) {
204
+ this.codexReplayEvents.clear();
205
+ this.codexReplayActiveItems.clear();
206
+ }
207
+ if (!next.busy) {
208
+ if (this.boundClientId && !this.clients.has(this.boundClientId))
209
+ this.boundClientId = "";
210
+ }
211
+ if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId)
212
+ return;
213
+ this.codexState = next;
214
+ logger.debug("Codex state changed", this.codexState);
215
+ this.emitAll("codex_state", this.codexState);
216
+ }
217
+ /** 权威历史已覆盖指定 turn 后,清理其断线重放事件。 */
218
+ acknowledgeCodexHistory(threadId, turnIds) {
219
+ const acknowledged = new Set(turnIds.filter(Boolean));
220
+ if (!threadId || !acknowledged.size)
221
+ return;
222
+ this.codexReplayEvents.forEach((event, key) => {
223
+ const eventThreadId = String(event.payload.threadId || event.payload.thread_id || "");
224
+ const eventTurnId = String(event.payload.turnId || event.payload.turn_id || "");
225
+ if (eventThreadId === threadId && acknowledged.has(eventTurnId)) {
226
+ this.codexReplayEvents.delete(key);
227
+ this.codexReplayActiveItems.delete(key);
228
+ }
229
+ });
230
+ }
231
+ /** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
232
+ openEvents(url, res, activeThreadId = "") {
233
+ const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
234
+ const statusOnly = url.searchParams.get("role") === "status";
235
+ logger.info("SSE client connected", { clientId, statusOnly });
236
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
237
+ if (!statusOnly) {
238
+ this.clients.set(clientId, res);
239
+ if (!this.clientFocusOrder.has(clientId))
240
+ this.clientFocusOrder.set(clientId, 0);
241
+ if (!this.activeClientId) {
242
+ this.activeClientId = clientId;
243
+ this.clientFocusOrder.set(clientId, ++this.focusSequence);
244
+ }
245
+ }
246
+ sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, conversation: this.conversationStateSnapshot, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
247
+ if (!statusOnly && activeThreadId && this.codexState.threadId === activeThreadId)
248
+ this.codexReplayEvents.forEach((event) => sendEvent(res, event.type, event.payload));
249
+ const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
250
+ res.on("close", () => {
251
+ clearInterval(timer);
252
+ logger.info("SSE client disconnected", { clientId, statusOnly });
253
+ if (statusOnly || this.clients.get(clientId) !== res)
254
+ return;
255
+ this.clients.delete(clientId);
256
+ this.clientFocusOrder.delete(clientId);
257
+ this.canvasStates.delete(clientId);
258
+ this.pending.forEach((item, requestId) => {
259
+ if (item.clientId !== clientId)
260
+ return;
261
+ this.pending.delete(requestId);
262
+ item.reject(new Error("请求页面已断开"));
263
+ });
264
+ if (this.activeClientId === clientId)
265
+ this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
266
+ });
267
+ }
268
+ updateConversation(patch) {
269
+ this.conversationState = { ...this.conversationState, ...patch, revision: this.conversationState.revision + 1 };
270
+ const snapshot = this.conversationStateSnapshot;
271
+ this.emitAll("conversation_changed", snapshot);
272
+ return snapshot;
273
+ }
274
+ /** 保存指定网页上报的最新画布快照。 */
275
+ updateState(body, clientId) {
276
+ const targetClientId = clientId || this.activeClientId;
277
+ if (!targetClientId || !this.clients.has(targetClientId))
278
+ return;
279
+ const state = { ...(body && typeof body === "object" && !Array.isArray(body) ? body : {}), clientId: targetClientId };
280
+ this.canvasStates.set(targetClientId, state);
281
+ logger.debug("Canvas state updated", { clientId: targetClientId, nodes: state.nodes?.length || 0, connections: state.connections?.length || 0 });
282
+ }
283
+ /** 将指定网页设为最近激活的工具目标。 */
284
+ activateClient(clientId) {
285
+ if (!this.clients.has(clientId))
286
+ throw new Error("当前网页未连接");
287
+ this.activeClientId = clientId;
288
+ this.clientFocusOrder.set(clientId, ++this.focusSequence);
289
+ logger.debug("Canvas client activated", { clientId });
290
+ }
291
+ /** 将当前 Agent turn 固定绑定到指定网页。 */
292
+ bindClient(clientId) {
293
+ if (!this.clients.has(clientId))
294
+ throw new Error("当前网页未连接");
295
+ this.boundClientId = clientId;
296
+ logger.debug("Canvas client bound to turn", { clientId });
297
+ }
298
+ /** 解除当前 Agent turn 的网页绑定。 */
299
+ releaseClient(clientId) {
300
+ if (this.boundClientId === clientId)
301
+ this.boundClientId = "";
302
+ logger.debug("Canvas client released from turn", { clientId });
303
+ }
304
+ /** 保存当前 turn 可用的图片附件并返回安全引用。 */
305
+ setTurnAttachments(clientId, attachments) {
306
+ this.turnAttachments.clear();
307
+ return attachments.flatMap((item, index) => {
308
+ if (!item.dataUrl?.startsWith("data:image/"))
309
+ return [];
310
+ const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
311
+ const attachment = {
312
+ clientId,
313
+ id,
314
+ name: item.name?.trim() || `图片 ${index + 1}`,
315
+ type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
316
+ size: positiveNumber(item.size, 0),
317
+ width: positiveNumber(item.width, 1024),
318
+ height: positiveNumber(item.height, 1024),
319
+ dataUrl: item.dataUrl,
320
+ };
321
+ this.turnAttachments.set(id, attachment);
322
+ return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
323
+ });
324
+ }
325
+ /** 清理指定网页或全部 turn 附件。 */
326
+ clearTurnAttachments(clientId) {
327
+ this.turnAttachments.forEach((item, id) => {
328
+ if (!clientId || item.clientId === clientId)
329
+ this.turnAttachments.delete(id);
330
+ });
331
+ }
332
+ /** 获取属于指定网页 turn 的图片附件。 */
333
+ getTurnAttachment(clientId, attachmentId) {
334
+ const attachment = this.turnAttachments.get(attachmentId);
335
+ if (!attachment)
336
+ throw new Error(`找不到本轮图片附件:${attachmentId}`);
337
+ if (attachment.clientId !== clientId)
338
+ throw new Error("图片附件不属于当前 turn 的发起标签页");
339
+ return attachment;
340
+ }
341
+ /** 接收网页返回的工具调用结果。 */
342
+ resolveResult(clientId, body) {
343
+ const item = body.requestId ? this.pending.get(body.requestId) : null;
344
+ if (!item || !body.requestId || item.clientId !== clientId)
345
+ return false;
346
+ this.pending.delete(body.requestId);
347
+ logger.debug("Canvas tool result received", { clientId, requestId: body.requestId, error: body.error, result: body.result });
348
+ body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
349
+ return true;
350
+ }
351
+ /** 向全部已连接网页广播事件。 */
352
+ emitAll(type, payload) {
353
+ this.clients.forEach((client) => sendEvent(client, type, payload));
354
+ }
355
+ /** 向全部网页广播带线程归属的事件。 */
356
+ emitThread(type, threadId, payload = {}) {
357
+ const data = { ...payload, threadId };
358
+ const replayKey = codexReplayKey(type, data);
359
+ const eventTurnId = String(data.turnId || data.turn_id || "");
360
+ const currentScope = threadId === this.codexState.threadId && (!this.codexState.turnId || !eventTurnId || eventTurnId === this.codexState.turnId);
361
+ if (this.codexState.busy && currentScope && replayKey) {
362
+ const item = recordValue(data.item);
363
+ const eventType = String(data.type || "");
364
+ if (type === "agent_event" && item.id && (eventType === "item.started" || eventType === "item.updated"))
365
+ this.codexReplayActiveItems.add(replayKey);
366
+ if (type === "agent_event" && item.id && eventType === "item.completed")
367
+ this.codexReplayActiveItems.delete(replayKey);
368
+ if (type === "agent_event" && (eventType === "turn.completed" || eventType === "error"))
369
+ this.clearReplayActiveTurn(threadId, eventTurnId);
370
+ const replayData = this.replaySnapshot(replayKey, data);
371
+ this.codexReplayEvents.set(replayKey, { type, payload: { ...replayData, replayed: true } });
372
+ while (this.codexReplayEvents.size > 240) {
373
+ const evictable = [...this.codexReplayEvents.keys()].find((key) => !this.codexReplayActiveItems.has(key));
374
+ if (!evictable)
375
+ break;
376
+ this.codexReplayEvents.delete(evictable);
377
+ }
378
+ }
379
+ this.emitAll(type, data);
380
+ }
381
+ /** 为断线重连保存完整的最新文本快照,实时连接仍只接收增量。 */
382
+ replaySnapshot(replayKey, data) {
383
+ if (data.type !== "item.updated" && data.type !== "item.completed")
384
+ return data;
385
+ const item = recordValue(data.item);
386
+ if (!item.id)
387
+ return data;
388
+ const previous = recordValue(recordValue(this.codexReplayEvents.get(replayKey)?.payload).item);
389
+ const delta = String(item.delta || "");
390
+ if (!delta)
391
+ return data;
392
+ const previousText = String(previous.text || "");
393
+ const { delta: _delta, ...snapshotItem } = item;
394
+ return { ...data, item: { ...previous, ...snapshotItem, text: `${previousText}${delta}` } };
395
+ }
396
+ clearReplayActiveTurn(threadId, turnId) {
397
+ const prefix = `item:${turnId}:`;
398
+ this.codexReplayActiveItems.forEach((key) => {
399
+ if (key.startsWith(prefix))
400
+ this.codexReplayActiveItems.delete(key);
401
+ });
402
+ if (!turnId)
403
+ return;
404
+ this.codexReplayActiveItems.forEach((key) => {
405
+ const event = this.codexReplayEvents.get(key);
406
+ const eventThreadId = String(event?.payload.threadId || event?.payload.thread_id || "");
407
+ const eventTurnId = String(event?.payload.turnId || event?.payload.turn_id || "");
408
+ if (eventThreadId === threadId && eventTurnId === turnId)
409
+ this.codexReplayActiveItems.delete(key);
410
+ });
411
+ }
412
+ /** 校验工具参数并将调用分派到当前目标网页。 */
413
+ async callTool(name, rawInput) {
414
+ if (!isToolName(name))
415
+ throw new Error(`未知工具:${String(name)}`);
416
+ logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
417
+ const input = parseToolInput(name, rawInput);
418
+ if (SITE_TOOLS.has(name)) {
419
+ if (!this.clients.size)
420
+ throw new Error("当前没有已连接网页");
421
+ return await this.requestCanvasTool(name, input);
422
+ }
423
+ const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(name);
424
+ if (readTool && (!this.clients.size || !this.canvasState))
425
+ throw new Error("当前没有已连接画布");
426
+ if (name === "canvas_get_state" || name === "canvas_export_snapshot")
427
+ return compactCanvasState(this.canvasState);
428
+ if (name === "canvas_get_selection") {
429
+ const ids = new Set(this.canvasState?.selectedNodeIds || []);
430
+ return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
431
+ }
432
+ if (name === "canvas_create_attachment_nodes")
433
+ return await this.createAttachmentNodes(input);
434
+ if (!this.clients.size)
435
+ throw new Error("当前没有已连接画布");
436
+ const request = buildCanvasToolRequest(name, input, this.canvasState);
437
+ return await this.requestCanvasTool(request.name, request.input);
438
+ }
439
+ /** 将当前 turn 的附件转换为画布图片节点。 */
440
+ async createAttachmentNodes(input) {
441
+ const clientId = this.targetClientId;
442
+ if (!this.clients.has(clientId))
443
+ throw new Error("当前没有已连接画布");
444
+ const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
445
+ const x = Number(input.x ?? nextCanvasX(this.canvasState));
446
+ const y = Number(input.y ?? 0);
447
+ const gap = Number(input.gap ?? 40);
448
+ const direction = input.direction || "row";
449
+ let offset = 0;
450
+ const nodes = attachments.map((attachment) => {
451
+ const size = fitAttachmentNodeSize(attachment.width, attachment.height);
452
+ const node = {
453
+ id: `image-${crypto.randomUUID()}`,
454
+ attachmentId: attachment.id,
455
+ title: attachment.name,
456
+ position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
457
+ width: size.width,
458
+ height: size.height,
459
+ };
460
+ offset += (direction === "row" ? size.width : size.height) + gap;
461
+ return node;
462
+ });
463
+ await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
464
+ return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
465
+ }
466
+ /** 向目标网页发送工具请求并等待调用结果。 */
467
+ async requestCanvasTool(name, input) {
468
+ const requestId = crypto.randomUUID();
469
+ const clientId = this.targetClientId;
470
+ const client = this.clients.get(clientId);
471
+ if (!client)
472
+ throw new Error("当前没有已连接画布");
473
+ sendEvent(client, "tool_call", { requestId, name, input });
474
+ logger.debug("Canvas tool request sent", { requestId, name, input, clientId });
475
+ return await new Promise((resolve, reject) => {
476
+ const timer = setTimeout(() => {
477
+ this.pending.delete(requestId);
478
+ logger.warn("Canvas tool request timed out", { requestId, name, clientId });
479
+ reject(new Error("画布操作超时"));
480
+ }, 30000);
481
+ this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
482
+ });
483
+ }
484
+ }
485
+ /** 为运行中 turn 的可重放事件生成稳定键。 */
486
+ function codexReplayKey(type, payload) {
487
+ const turnId = String(payload.turnId || payload.turn_id || "");
488
+ if (type === "chat_message") {
489
+ const message = recordValue(payload.message);
490
+ const clientMessageId = String(message.clientMessageId || "");
491
+ if (clientMessageId)
492
+ return `chat:${clientMessageId}`;
493
+ const messageId = String(message.itemId || message.id || "");
494
+ return messageId ? `chat:${turnId}:${messageId}` : "";
495
+ }
496
+ if (type === "agent_error")
497
+ return `error:${turnId}`;
498
+ if (type !== "agent_event")
499
+ return "";
500
+ const item = recordValue(payload.item);
501
+ if (item.id)
502
+ return `item:${turnId}:${String(item.id)}`;
503
+ const eventType = String(payload.type || "");
504
+ if (eventType === "plan.updated")
505
+ return `plan:${turnId}`;
506
+ if (eventType === "usage.updated")
507
+ return `usage:${turnId}`;
508
+ if (eventType === "turn.completed" || eventType === "error")
509
+ return `${eventType}:${turnId}`;
510
+ return "";
511
+ }
512
+ function recordValue(value) {
513
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
514
+ }
515
+ /** 向 SSE 连接写入一个事件。 */
516
+ function sendEvent(res, type, payload) {
517
+ res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
518
+ }
519
+ /** 将未知数值转换为正数,否则使用默认值。 */
520
+ function positiveNumber(value, fallback) {
521
+ const number = Number(value);
522
+ return Number.isFinite(number) && number > 0 ? number : fallback;
523
+ }