qcanvas-local-agent 0.1.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 +36 -0
- package/dist/agents.d.ts +90 -0
- package/dist/agents.js +748 -0
- package/dist/canvas-session.d.ts +16 -0
- package/dist/canvas-session.js +154 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +150 -0
- package/dist/generation-flow-compiler.d.ts +67 -0
- package/dist/generation-flow-compiler.js +207 -0
- package/dist/http-server.d.ts +1 -0
- package/dist/http-server.js +275 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +49 -0
- package/dist/schemas.d.ts +257 -0
- package/dist/schemas.js +78 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import type { PendingToolResult, QCanvasSnapshot } from "./types.js";
|
|
3
|
+
export declare class CanvasSession {
|
|
4
|
+
private readonly clients;
|
|
5
|
+
private readonly pending;
|
|
6
|
+
private snapshot;
|
|
7
|
+
health(): Record<string, unknown>;
|
|
8
|
+
openEvents(clientIdInput: string | null, res: ServerResponse): void;
|
|
9
|
+
updateState(body: unknown, clientId: string): QCanvasSnapshot;
|
|
10
|
+
emitAll(type: string, payload: unknown): void;
|
|
11
|
+
resolveResult(body: PendingToolResult): void;
|
|
12
|
+
callTool(rawName: unknown, input: unknown, agent?: string): Promise<unknown>;
|
|
13
|
+
private readFlow;
|
|
14
|
+
private createGenerationFlow;
|
|
15
|
+
private requestBrowserPatch;
|
|
16
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { isLocalAgentToolName, localAgentToolInputSchemas } from "./schemas.js";
|
|
3
|
+
import { compileGenerationFlow, GenerationFlowCompileError, } from "./generation-flow-compiler.js";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function readString(value) {
|
|
8
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
9
|
+
}
|
|
10
|
+
function readStringArray(value) {
|
|
11
|
+
if (!Array.isArray(value))
|
|
12
|
+
return [];
|
|
13
|
+
return Array.from(new Set(value.map((item) => readString(item)).filter((item) => Boolean(item))));
|
|
14
|
+
}
|
|
15
|
+
function readViewport(value) {
|
|
16
|
+
if (!isRecord(value))
|
|
17
|
+
return null;
|
|
18
|
+
const x = Number(value.x);
|
|
19
|
+
const y = Number(value.y);
|
|
20
|
+
const zoom = Number(value.zoom);
|
|
21
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom))
|
|
22
|
+
return null;
|
|
23
|
+
return { x, y, zoom };
|
|
24
|
+
}
|
|
25
|
+
function parseCanvasSnapshot(value, clientId) {
|
|
26
|
+
if (!isRecord(value))
|
|
27
|
+
throw new Error("canvas state must be an object");
|
|
28
|
+
const nodes = Array.isArray(value.nodes) ? value.nodes : null;
|
|
29
|
+
const edges = Array.isArray(value.edges) ? value.edges : null;
|
|
30
|
+
if (!nodes)
|
|
31
|
+
throw new Error("canvas state missing nodes array");
|
|
32
|
+
if (!edges)
|
|
33
|
+
throw new Error("canvas state missing edges array");
|
|
34
|
+
return {
|
|
35
|
+
clientId,
|
|
36
|
+
url: readString(value.url),
|
|
37
|
+
projectId: readString(value.projectId),
|
|
38
|
+
projectName: readString(value.projectName),
|
|
39
|
+
flowId: readString(value.flowId),
|
|
40
|
+
flowName: readString(value.flowName),
|
|
41
|
+
nodes,
|
|
42
|
+
edges,
|
|
43
|
+
selectedNodeIds: readStringArray(value.selectedNodeIds),
|
|
44
|
+
viewport: readViewport(value.viewport),
|
|
45
|
+
updatedAt: readString(value.updatedAt) || new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function writeSse(res, event, data) {
|
|
49
|
+
res.write(`event: ${event}\n`);
|
|
50
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
51
|
+
}
|
|
52
|
+
export class CanvasSession {
|
|
53
|
+
clients = new Map();
|
|
54
|
+
pending = new Map();
|
|
55
|
+
snapshot = null;
|
|
56
|
+
health() {
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
clients: this.clients.size,
|
|
60
|
+
hasCanvas: Boolean(this.snapshot && this.clients.has(this.snapshot.clientId)),
|
|
61
|
+
updatedAt: this.snapshot?.updatedAt || null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
openEvents(clientIdInput, res) {
|
|
65
|
+
const clientId = clientIdInput || crypto.randomUUID();
|
|
66
|
+
res.writeHead(200, {
|
|
67
|
+
"content-type": "text/event-stream",
|
|
68
|
+
"cache-control": "no-cache",
|
|
69
|
+
connection: "keep-alive",
|
|
70
|
+
});
|
|
71
|
+
this.clients.set(clientId, res);
|
|
72
|
+
writeSse(res, "hello", { ok: true, clientId });
|
|
73
|
+
const timer = setInterval(() => writeSse(res, "ping", { at: new Date().toISOString() }), 15000);
|
|
74
|
+
res.on("close", () => {
|
|
75
|
+
clearInterval(timer);
|
|
76
|
+
this.clients.delete(clientId);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
updateState(body, clientId) {
|
|
80
|
+
const snapshot = parseCanvasSnapshot(body, clientId);
|
|
81
|
+
this.snapshot = snapshot;
|
|
82
|
+
return snapshot;
|
|
83
|
+
}
|
|
84
|
+
emitAll(type, payload) {
|
|
85
|
+
this.clients.forEach((client) => writeSse(client, type, payload));
|
|
86
|
+
}
|
|
87
|
+
resolveResult(body) {
|
|
88
|
+
const requestId = readString(body.requestId);
|
|
89
|
+
if (!requestId)
|
|
90
|
+
return;
|
|
91
|
+
const pending = this.pending.get(requestId);
|
|
92
|
+
if (!pending)
|
|
93
|
+
return;
|
|
94
|
+
this.pending.delete(requestId);
|
|
95
|
+
clearTimeout(pending.timer);
|
|
96
|
+
if (body.error) {
|
|
97
|
+
pending.reject(new Error(body.error));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
pending.resolve(body.result);
|
|
101
|
+
}
|
|
102
|
+
async callTool(rawName, input, agent) {
|
|
103
|
+
if (!isLocalAgentToolName(rawName)) {
|
|
104
|
+
throw new Error(`unknown QCanvas local tool: ${String(rawName)}`);
|
|
105
|
+
}
|
|
106
|
+
if (rawName === "tapcanvas_flow_get")
|
|
107
|
+
return this.readFlow();
|
|
108
|
+
if (rawName === "tapcanvas_create_generation_flow")
|
|
109
|
+
return this.createGenerationFlow(input, agent);
|
|
110
|
+
return this.requestBrowserPatch(rawName, input, agent);
|
|
111
|
+
}
|
|
112
|
+
readFlow() {
|
|
113
|
+
if (!this.snapshot || !this.clients.has(this.snapshot.clientId)) {
|
|
114
|
+
throw new Error("no live QCanvas browser tab is connected");
|
|
115
|
+
}
|
|
116
|
+
return this.snapshot;
|
|
117
|
+
}
|
|
118
|
+
async createGenerationFlow(input, agent) {
|
|
119
|
+
const snapshot = this.readFlow();
|
|
120
|
+
const intent = localAgentToolInputSchemas.tapcanvas_create_generation_flow.parse(input);
|
|
121
|
+
let compiled;
|
|
122
|
+
try {
|
|
123
|
+
compiled = compileGenerationFlow(intent, { nodes: snapshot.nodes });
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw error instanceof GenerationFlowCompileError ? new Error(error.message) : error;
|
|
127
|
+
}
|
|
128
|
+
// Reuse the browser's existing tapcanvas_flow_patch executor instead of
|
|
129
|
+
// teaching the browser a second node-creation code path.
|
|
130
|
+
const patchResult = await this.requestBrowserPatch("tapcanvas_flow_patch", compiled.patch, agent);
|
|
131
|
+
return {
|
|
132
|
+
...(patchResult && typeof patchResult === "object" ? patchResult : {}),
|
|
133
|
+
nodeId: compiled.primaryNodeId,
|
|
134
|
+
warnings: compiled.warnings,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async requestBrowserPatch(name, input, agent) {
|
|
138
|
+
if (!this.snapshot || !this.clients.has(this.snapshot.clientId)) {
|
|
139
|
+
throw new Error("no live QCanvas browser tab is connected");
|
|
140
|
+
}
|
|
141
|
+
const client = this.clients.get(this.snapshot.clientId);
|
|
142
|
+
if (!client)
|
|
143
|
+
throw new Error("connected QCanvas browser tab is unavailable");
|
|
144
|
+
const requestId = crypto.randomUUID();
|
|
145
|
+
writeSse(client, "tool_call", { requestId, name, input, ...(agent ? { agent } : {}) });
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
this.pending.delete(requestId);
|
|
149
|
+
reject(new Error("QCanvas browser confirmation timed out"));
|
|
150
|
+
}, 60000);
|
|
151
|
+
this.pending.set(requestId, { resolve, reject, timer });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const VERSION = "0.1.0";
|
|
2
|
+
export declare const CONFIG_DIR: string;
|
|
3
|
+
export declare const CONFIG_FILE: string;
|
|
4
|
+
export declare const AGENT_PROMPT = "\u4F60\u6B63\u5728\u5E2E\u52A9\u7528\u6237\u64CD\u4F5C QCanvas \u7F51\u9875\u753B\u5E03\u3002\u9700\u8981\u6539\u52A8\u753B\u5E03\u65F6\u4F18\u5148\u4F7F\u7528\u5DF2\u914D\u7F6E\u7684 qcanvas MCP \u5DE5\u5177\uFF1A\u5148\u7528 tapcanvas_flow_get \u8BFB\u53D6\u5F53\u524D\u753B\u5E03\u56FE\uFF08\u8282\u70B9 nodes / \u8FDE\u7EBF edges / \u9009\u533A selectedNodeIds\uFF09\uFF0C\u518D\u7528 tapcanvas_flow_patch \u63D0\u4EA4\u6539\u52A8\u3002tapcanvas_flow_patch \u652F\u6301 createNodes\u3001createEdges\u3001patchNodeData\u3001appendNodeArrays\u3001deleteNodeIds\u3001deleteEdgeIds\uFF1B\u8282\u70B9 type \u7528 \"taskNode\" \u6216 \"groupNode\"\uFF0Cdata \u91CC\u653E kind\uFF08image/text/video/audio \u7B49\uFF09\u4E0E\u4E1A\u52A1\u5B57\u6BB5\u3002\u6240\u6709\u5199\u64CD\u4F5C\u90FD\u4F1A\u7531\u6D4F\u89C8\u5668\u4E8C\u6B21\u786E\u8BA4\u540E\u624D\u771F\u6B63\u5E94\u7528\uFF0C\u4E0D\u8981\u6A21\u62DF\u9F20\u6807\u70B9\u51FB\uFF0C\u4E5F\u4E0D\u8981\u8981\u6C42\u7528\u6237\u624B\u52A8\u590D\u5236 JSON\u3002";
|
|
5
|
+
export type CanvasWorkspaceConfig = {
|
|
6
|
+
workspacePath: string;
|
|
7
|
+
/** 每个本机智能体在该画布上的活跃会话/线程 id,按 agentId 索引。 */
|
|
8
|
+
activeThreadIds?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
export type LocalAgentConfig = {
|
|
11
|
+
host: string;
|
|
12
|
+
port: number;
|
|
13
|
+
url: string;
|
|
14
|
+
token: string;
|
|
15
|
+
/** Claude Code CLI 可执行命令;不在 PATH 时可配置完整路径。 */
|
|
16
|
+
claudeCommand: string;
|
|
17
|
+
origins: string[];
|
|
18
|
+
canvases: Record<string, CanvasWorkspaceConfig>;
|
|
19
|
+
};
|
|
20
|
+
export type CanvasWorkspace = {
|
|
21
|
+
canvasId: string;
|
|
22
|
+
workspacePath: string;
|
|
23
|
+
activeThreadIds: Record<string, string>;
|
|
24
|
+
};
|
|
25
|
+
export declare function ensureCanvasWorkspace(config: LocalAgentConfig, canvasId: string): CanvasWorkspace;
|
|
26
|
+
export declare function setCanvasActiveThread(config: LocalAgentConfig, canvasId: string, agentId: string, threadId: string): CanvasWorkspace;
|
|
27
|
+
export declare function saveConfig(config: LocalAgentConfig): void;
|
|
28
|
+
export declare function loadConfig(writeIfMissing: boolean): LocalAgentConfig;
|
|
29
|
+
export declare function rememberOrigin(config: LocalAgentConfig, origin: string | null): LocalAgentConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const VERSION = "0.1.0";
|
|
6
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".qcanvas");
|
|
7
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, "qcanvas-local-agent.json");
|
|
8
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
9
|
+
const DEFAULT_PORT = 17372;
|
|
10
|
+
export const AGENT_PROMPT = "你正在帮助用户操作 QCanvas 网页画布。需要改动画布时优先使用已配置的 qcanvas MCP 工具:先用 tapcanvas_flow_get 读取当前画布图(节点 nodes / 连线 edges / 选区 selectedNodeIds),再用 tapcanvas_flow_patch 提交改动。tapcanvas_flow_patch 支持 createNodes、createEdges、patchNodeData、appendNodeArrays、deleteNodeIds、deleteEdgeIds;节点 type 用 \"taskNode\" 或 \"groupNode\",data 里放 kind(image/text/video/audio 等)与业务字段。所有写操作都会由浏览器二次确认后才真正应用,不要模拟鼠标点击,也不要要求用户手动复制 JSON。";
|
|
11
|
+
function readConfigRecord(value) {
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
13
|
+
return {};
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function readString(value) {
|
|
17
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
18
|
+
}
|
|
19
|
+
function readPort(value) {
|
|
20
|
+
const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
|
21
|
+
if (!Number.isInteger(numeric) || numeric < 1 || numeric > 65535)
|
|
22
|
+
return null;
|
|
23
|
+
return numeric;
|
|
24
|
+
}
|
|
25
|
+
function readOrigins(value) {
|
|
26
|
+
if (!Array.isArray(value))
|
|
27
|
+
return [];
|
|
28
|
+
return Array.from(new Set(value.map((item) => readString(item)).filter((item) => Boolean(item))));
|
|
29
|
+
}
|
|
30
|
+
function readActiveThreadIds(record) {
|
|
31
|
+
const out = {};
|
|
32
|
+
const raw = readConfigRecord(record.activeThreadIds);
|
|
33
|
+
for (const [agentId, threadId] of Object.entries(raw)) {
|
|
34
|
+
const id = readString(threadId);
|
|
35
|
+
if (id)
|
|
36
|
+
out[agentId] = id;
|
|
37
|
+
}
|
|
38
|
+
// 旧版单 agent 字段:迁移为 codex 的活跃线程。
|
|
39
|
+
const legacy = readString(record.activeThreadId);
|
|
40
|
+
if (legacy && !out.codex)
|
|
41
|
+
out.codex = legacy;
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function readCanvases(value) {
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
46
|
+
return {};
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
49
|
+
const record = readConfigRecord(entry);
|
|
50
|
+
const workspacePath = readString(record.workspacePath);
|
|
51
|
+
if (!workspacePath)
|
|
52
|
+
continue;
|
|
53
|
+
const activeThreadIds = readActiveThreadIds(record);
|
|
54
|
+
out[key] = { workspacePath, ...(Object.keys(activeThreadIds).length ? { activeThreadIds } : {}) };
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function createConfig(raw) {
|
|
59
|
+
const host = readString(process.env.QCANVAS_LOCAL_AGENT_HOST) || readString(raw.host) || DEFAULT_HOST;
|
|
60
|
+
const port = readPort(process.env.QCANVAS_LOCAL_AGENT_PORT) || readPort(raw.port) || DEFAULT_PORT;
|
|
61
|
+
const token = readString(process.env.QCANVAS_LOCAL_AGENT_TOKEN) ||
|
|
62
|
+
readString(raw.token) ||
|
|
63
|
+
crypto.randomBytes(24).toString("base64url");
|
|
64
|
+
const claudeCommand = readString(process.env.QCANVAS_CLAUDE_COMMAND) || readString(raw.claudeCommand) || "claude";
|
|
65
|
+
return {
|
|
66
|
+
host,
|
|
67
|
+
port,
|
|
68
|
+
url: `http://${host}:${port}`,
|
|
69
|
+
token,
|
|
70
|
+
claudeCommand,
|
|
71
|
+
origins: readOrigins(raw.origins),
|
|
72
|
+
canvases: readCanvases(raw.canvases),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function resolveWorkspacePath(value) {
|
|
76
|
+
if (value === "~")
|
|
77
|
+
return os.homedir();
|
|
78
|
+
if (value.startsWith("~/"))
|
|
79
|
+
return path.join(os.homedir(), value.slice(2));
|
|
80
|
+
return path.resolve(value);
|
|
81
|
+
}
|
|
82
|
+
function safeSegment(value) {
|
|
83
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "default";
|
|
84
|
+
}
|
|
85
|
+
export function ensureCanvasWorkspace(config, canvasId) {
|
|
86
|
+
const id = safeSegment(canvasId || "default");
|
|
87
|
+
const current = config.canvases[id];
|
|
88
|
+
if (current?.workspacePath) {
|
|
89
|
+
const workspacePath = resolveWorkspacePath(current.workspacePath);
|
|
90
|
+
fs.mkdirSync(workspacePath, { recursive: true });
|
|
91
|
+
return { canvasId: id, workspacePath, activeThreadIds: { ...(current.activeThreadIds || {}) } };
|
|
92
|
+
}
|
|
93
|
+
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id);
|
|
94
|
+
config.canvases[id] = { workspacePath };
|
|
95
|
+
fs.mkdirSync(workspacePath, { recursive: true });
|
|
96
|
+
saveConfig(config);
|
|
97
|
+
return { canvasId: id, workspacePath, activeThreadIds: {} };
|
|
98
|
+
}
|
|
99
|
+
export function setCanvasActiveThread(config, canvasId, agentId, threadId) {
|
|
100
|
+
const current = ensureCanvasWorkspace(config, canvasId);
|
|
101
|
+
const activeThreadIds = { ...current.activeThreadIds };
|
|
102
|
+
if (threadId) {
|
|
103
|
+
activeThreadIds[agentId] = threadId;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
delete activeThreadIds[agentId];
|
|
107
|
+
}
|
|
108
|
+
config.canvases[current.canvasId] = {
|
|
109
|
+
...(config.canvases[current.canvasId] || { workspacePath: current.workspacePath }),
|
|
110
|
+
workspacePath: config.canvases[current.canvasId]?.workspacePath || current.workspacePath,
|
|
111
|
+
...(Object.keys(activeThreadIds).length ? { activeThreadIds } : { activeThreadIds: undefined }),
|
|
112
|
+
};
|
|
113
|
+
saveConfig(config);
|
|
114
|
+
return { ...current, activeThreadIds };
|
|
115
|
+
}
|
|
116
|
+
export function saveConfig(config) {
|
|
117
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
118
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({
|
|
119
|
+
host: config.host,
|
|
120
|
+
port: config.port,
|
|
121
|
+
url: config.url,
|
|
122
|
+
token: config.token,
|
|
123
|
+
claudeCommand: config.claudeCommand,
|
|
124
|
+
origins: config.origins,
|
|
125
|
+
canvases: config.canvases,
|
|
126
|
+
}, null, 2));
|
|
127
|
+
}
|
|
128
|
+
export function loadConfig(writeIfMissing) {
|
|
129
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
130
|
+
const next = createConfig({});
|
|
131
|
+
if (writeIfMissing)
|
|
132
|
+
saveConfig(next);
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
const rawText = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
136
|
+
const parsed = JSON.parse(rawText);
|
|
137
|
+
const config = createConfig(readConfigRecord(parsed));
|
|
138
|
+
if (writeIfMissing)
|
|
139
|
+
saveConfig(config);
|
|
140
|
+
return config;
|
|
141
|
+
}
|
|
142
|
+
export function rememberOrigin(config, origin) {
|
|
143
|
+
if (!origin)
|
|
144
|
+
return config;
|
|
145
|
+
if (config.origins.includes(origin))
|
|
146
|
+
return config;
|
|
147
|
+
const next = { ...config, origins: [...config.origins, origin] };
|
|
148
|
+
saveConfig(next);
|
|
149
|
+
return next;
|
|
150
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level "generation flow" intent -> low-level tapcanvas_flow_patch compiler.
|
|
3
|
+
*
|
|
4
|
+
* This mirrors apps/hono-api/src/modules/task/generation-flow-compiler.ts (the
|
|
5
|
+
* server-side canonical copy used by the main agents bridge). It is duplicated
|
|
6
|
+
* here, rather than imported, because this package is a standalone distributable
|
|
7
|
+
* CLI with no build-time dependency on hono-api (same convention already used by
|
|
8
|
+
* schemas.ts in this package). Keep the handle matrix and supported kinds below
|
|
9
|
+
* in sync with apps/hono-api/src/modules/ai/tool-schemas.ts.
|
|
10
|
+
*/
|
|
11
|
+
export type GenerationFlowIntent = {
|
|
12
|
+
kind: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
position?: {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
};
|
|
19
|
+
referenceNodeIds?: string[];
|
|
20
|
+
referenceImageUrls?: string[];
|
|
21
|
+
model?: string;
|
|
22
|
+
aspect?: string;
|
|
23
|
+
groupId?: string;
|
|
24
|
+
nodeId?: string;
|
|
25
|
+
};
|
|
26
|
+
type TaskNodeData = Record<string, unknown> & {
|
|
27
|
+
kind: string;
|
|
28
|
+
prompt: string;
|
|
29
|
+
};
|
|
30
|
+
export type CompiledTaskNode = {
|
|
31
|
+
id: string;
|
|
32
|
+
type: "taskNode";
|
|
33
|
+
position: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
};
|
|
37
|
+
data: TaskNodeData;
|
|
38
|
+
selected: true;
|
|
39
|
+
parentId?: string;
|
|
40
|
+
};
|
|
41
|
+
export type CompiledEdge = {
|
|
42
|
+
id: string;
|
|
43
|
+
source: string;
|
|
44
|
+
target: string;
|
|
45
|
+
sourceHandle?: string;
|
|
46
|
+
targetHandle?: string;
|
|
47
|
+
};
|
|
48
|
+
export type GenerationFlowCompileResult = {
|
|
49
|
+
patch: {
|
|
50
|
+
createNodes: CompiledTaskNode[];
|
|
51
|
+
createEdges: CompiledEdge[];
|
|
52
|
+
};
|
|
53
|
+
primaryNodeId: string;
|
|
54
|
+
warnings: string[];
|
|
55
|
+
};
|
|
56
|
+
export declare class GenerationFlowCompileError extends Error {
|
|
57
|
+
code: string;
|
|
58
|
+
constructor(message: string, code: string);
|
|
59
|
+
}
|
|
60
|
+
type GraphLike = {
|
|
61
|
+
nodes?: unknown;
|
|
62
|
+
} | null | undefined;
|
|
63
|
+
type CompileOptions = {
|
|
64
|
+
generateId?: () => string;
|
|
65
|
+
};
|
|
66
|
+
export declare function compileGenerationFlow(intent: GenerationFlowIntent, graph: GraphLike, options?: CompileOptions): GenerationFlowCompileResult;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level "generation flow" intent -> low-level tapcanvas_flow_patch compiler.
|
|
3
|
+
*
|
|
4
|
+
* This mirrors apps/hono-api/src/modules/task/generation-flow-compiler.ts (the
|
|
5
|
+
* server-side canonical copy used by the main agents bridge). It is duplicated
|
|
6
|
+
* here, rather than imported, because this package is a standalone distributable
|
|
7
|
+
* CLI with no build-time dependency on hono-api (same convention already used by
|
|
8
|
+
* schemas.ts in this package). Keep the handle matrix and supported kinds below
|
|
9
|
+
* in sync with apps/hono-api/src/modules/ai/tool-schemas.ts.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
const HANDLE_MATRIX = {
|
|
13
|
+
textLikeSources: ["out-text", "out-text-wide"],
|
|
14
|
+
imageLikeSources: ["out-image", "out-image-wide"],
|
|
15
|
+
videoLikeSources: ["out-video", "out-video-wide"],
|
|
16
|
+
imageLikeTargets: ["in-image", "in-image-wide"],
|
|
17
|
+
genericTargets: ["in-any", "in-any-wide"],
|
|
18
|
+
};
|
|
19
|
+
const SUPPORTED_TASK_NODE_KINDS = new Set([
|
|
20
|
+
"text",
|
|
21
|
+
"image",
|
|
22
|
+
"panorama360",
|
|
23
|
+
"video",
|
|
24
|
+
"novelDoc",
|
|
25
|
+
"scriptDoc",
|
|
26
|
+
"storyboardScript",
|
|
27
|
+
"scriptGenerator",
|
|
28
|
+
"cameraRef",
|
|
29
|
+
"workflowInput",
|
|
30
|
+
"workflowOutput",
|
|
31
|
+
"storyboardImage",
|
|
32
|
+
"imageFission",
|
|
33
|
+
"mosaic",
|
|
34
|
+
"composeVideo",
|
|
35
|
+
"audio",
|
|
36
|
+
"subtitle",
|
|
37
|
+
]);
|
|
38
|
+
const DEFAULT_NODE_WIDTH = 360;
|
|
39
|
+
const HORIZONTAL_GAP = 80;
|
|
40
|
+
const IMAGE_LIKE_KINDS = new Set(["image", "panorama360", "storyboardImage", "imageFission", "mosaic"]);
|
|
41
|
+
const VIDEO_LIKE_KINDS = new Set(["video", "composeVideo"]);
|
|
42
|
+
export class GenerationFlowCompileError extends Error {
|
|
43
|
+
code;
|
|
44
|
+
constructor(message, code) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "GenerationFlowCompileError";
|
|
47
|
+
this.code = code;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function toRecord(value) {
|
|
51
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
52
|
+
}
|
|
53
|
+
function readString(value) {
|
|
54
|
+
return typeof value === "string" ? value.trim() : "";
|
|
55
|
+
}
|
|
56
|
+
function nodeCategory(kind) {
|
|
57
|
+
if (IMAGE_LIKE_KINDS.has(kind))
|
|
58
|
+
return "image";
|
|
59
|
+
if (VIDEO_LIKE_KINDS.has(kind))
|
|
60
|
+
return "video";
|
|
61
|
+
return "text";
|
|
62
|
+
}
|
|
63
|
+
function sourceHandleForCategory(category) {
|
|
64
|
+
if (category === "image")
|
|
65
|
+
return HANDLE_MATRIX.imageLikeSources[0];
|
|
66
|
+
if (category === "video")
|
|
67
|
+
return HANDLE_MATRIX.videoLikeSources[0];
|
|
68
|
+
return HANDLE_MATRIX.textLikeSources[0];
|
|
69
|
+
}
|
|
70
|
+
function targetHandleForCategory(category) {
|
|
71
|
+
if (category === "image")
|
|
72
|
+
return HANDLE_MATRIX.imageLikeTargets[0];
|
|
73
|
+
return HANDLE_MATRIX.genericTargets[0];
|
|
74
|
+
}
|
|
75
|
+
function modelFieldForCategory(category) {
|
|
76
|
+
if (category === "image")
|
|
77
|
+
return "imageModel";
|
|
78
|
+
if (category === "video")
|
|
79
|
+
return "videoModel";
|
|
80
|
+
return "modelSelect";
|
|
81
|
+
}
|
|
82
|
+
function graphNodes(graph) {
|
|
83
|
+
const raw = toRecord(graph)?.nodes;
|
|
84
|
+
if (!Array.isArray(raw))
|
|
85
|
+
return [];
|
|
86
|
+
return raw.map(toRecord).filter((node) => Boolean(node));
|
|
87
|
+
}
|
|
88
|
+
function findNodeById(nodes, id) {
|
|
89
|
+
return nodes.find((node) => readString(node.id) === id) ?? null;
|
|
90
|
+
}
|
|
91
|
+
function computeNextPosition(nodes) {
|
|
92
|
+
let maxRight = Number.NEGATIVE_INFINITY;
|
|
93
|
+
for (const node of nodes) {
|
|
94
|
+
if (readString(node.parentId))
|
|
95
|
+
continue;
|
|
96
|
+
const position = toRecord(node.position);
|
|
97
|
+
const x = Number(position?.x);
|
|
98
|
+
if (!Number.isFinite(x))
|
|
99
|
+
continue;
|
|
100
|
+
const data = toRecord(node.data);
|
|
101
|
+
const width = Number(data?.nodeWidth);
|
|
102
|
+
const right = x + (Number.isFinite(width) && width > 0 ? width : DEFAULT_NODE_WIDTH);
|
|
103
|
+
if (right > maxRight)
|
|
104
|
+
maxRight = right;
|
|
105
|
+
}
|
|
106
|
+
return Number.isFinite(maxRight) ? { x: maxRight + HORIZONTAL_GAP, y: 0 } : { x: 0, y: 0 };
|
|
107
|
+
}
|
|
108
|
+
function extractReferenceImageUrl(node) {
|
|
109
|
+
const data = toRecord(node.data);
|
|
110
|
+
if (!data)
|
|
111
|
+
return "";
|
|
112
|
+
const direct = readString(data.imageUrl);
|
|
113
|
+
if (direct)
|
|
114
|
+
return direct;
|
|
115
|
+
const results = Array.isArray(data.imageResults) ? data.imageResults : [];
|
|
116
|
+
const primaryIndex = Number(data.imagePrimaryIndex);
|
|
117
|
+
const ordered = Number.isInteger(primaryIndex) && primaryIndex >= 0 && primaryIndex < results.length
|
|
118
|
+
? [results[primaryIndex], ...results]
|
|
119
|
+
: results;
|
|
120
|
+
for (const item of ordered) {
|
|
121
|
+
const url = readString(toRecord(item)?.url);
|
|
122
|
+
if (url)
|
|
123
|
+
return url;
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
export function compileGenerationFlow(intent, graph, options = {}) {
|
|
128
|
+
const generateId = options.generateId ?? (() => crypto.randomUUID());
|
|
129
|
+
const warnings = [];
|
|
130
|
+
const kind = readString(intent.kind);
|
|
131
|
+
if (!kind)
|
|
132
|
+
throw new GenerationFlowCompileError("kind is required", "kind_required");
|
|
133
|
+
if (!SUPPORTED_TASK_NODE_KINDS.has(kind)) {
|
|
134
|
+
throw new GenerationFlowCompileError(`Unsupported taskNode kind: ${kind}`, "unsupported_kind");
|
|
135
|
+
}
|
|
136
|
+
const prompt = readString(intent.prompt);
|
|
137
|
+
if (!prompt)
|
|
138
|
+
throw new GenerationFlowCompileError("prompt is required", "prompt_required");
|
|
139
|
+
const category = nodeCategory(kind);
|
|
140
|
+
const nodes = graphNodes(graph);
|
|
141
|
+
const nodeId = readString(intent.nodeId) || generateId();
|
|
142
|
+
const position = intent.position && Number.isFinite(intent.position.x) && Number.isFinite(intent.position.y)
|
|
143
|
+
? { x: intent.position.x, y: intent.position.y }
|
|
144
|
+
: computeNextPosition(nodes);
|
|
145
|
+
const assetInputs = [];
|
|
146
|
+
const referenceImages = [];
|
|
147
|
+
const seenUrls = new Set();
|
|
148
|
+
const addReferenceImage = (url, name) => {
|
|
149
|
+
const trimmed = readString(url);
|
|
150
|
+
if (!trimmed || seenUrls.has(trimmed))
|
|
151
|
+
return;
|
|
152
|
+
seenUrls.add(trimmed);
|
|
153
|
+
assetInputs.push({ url: trimmed, role: "reference", ...(name ? { name } : {}) });
|
|
154
|
+
referenceImages.push(trimmed);
|
|
155
|
+
};
|
|
156
|
+
const targetHandle = targetHandleForCategory(category);
|
|
157
|
+
const edges = [];
|
|
158
|
+
const seenSources = new Set();
|
|
159
|
+
for (const rawRefId of intent.referenceNodeIds ?? []) {
|
|
160
|
+
const refId = readString(rawRefId);
|
|
161
|
+
if (!refId || refId === nodeId || seenSources.has(refId))
|
|
162
|
+
continue;
|
|
163
|
+
seenSources.add(refId);
|
|
164
|
+
const refNode = findNodeById(nodes, refId);
|
|
165
|
+
if (!refNode) {
|
|
166
|
+
warnings.push(`referenceNodeId not found in flow: ${refId}`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const refKind = readString(toRecord(refNode.data)?.kind);
|
|
170
|
+
const sourceHandle = sourceHandleForCategory(nodeCategory(refKind));
|
|
171
|
+
edges.push({
|
|
172
|
+
id: `${refId}__${nodeId}`,
|
|
173
|
+
source: refId,
|
|
174
|
+
target: nodeId,
|
|
175
|
+
...(sourceHandle ? { sourceHandle } : {}),
|
|
176
|
+
...(targetHandle ? { targetHandle } : {}),
|
|
177
|
+
});
|
|
178
|
+
addReferenceImage(extractReferenceImageUrl(refNode), readString(toRecord(refNode.data)?.label) || undefined);
|
|
179
|
+
}
|
|
180
|
+
for (const url of intent.referenceImageUrls ?? [])
|
|
181
|
+
addReferenceImage(url);
|
|
182
|
+
const model = readString(intent.model);
|
|
183
|
+
const aspect = readString(intent.aspect);
|
|
184
|
+
const label = readString(intent.label) || undefined;
|
|
185
|
+
const data = {
|
|
186
|
+
kind,
|
|
187
|
+
prompt,
|
|
188
|
+
...(label ? { label } : {}),
|
|
189
|
+
...(model ? { [modelFieldForCategory(category)]: model } : {}),
|
|
190
|
+
...(category === "image" && aspect ? { aspect } : {}),
|
|
191
|
+
...(assetInputs.length ? { assetInputs } : {}),
|
|
192
|
+
...(referenceImages.length ? { referenceImages } : {}),
|
|
193
|
+
};
|
|
194
|
+
const node = {
|
|
195
|
+
id: nodeId,
|
|
196
|
+
type: "taskNode",
|
|
197
|
+
position,
|
|
198
|
+
data,
|
|
199
|
+
selected: true,
|
|
200
|
+
...(readString(intent.groupId) ? { parentId: readString(intent.groupId) } : {}),
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
patch: { createNodes: [node], createEdges: edges },
|
|
204
|
+
primaryNodeId: nodeId,
|
|
205
|
+
warnings,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startHttpServer(): Promise<void>;
|