@ninkch/dsh-infinite-canvas 0.17.4 → 0.17.6
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/assets/canvas-craft/SKILL.md +106 -0
- package/assets/canvas-studio/SKILL.md +169 -0
- package/client.js +1844 -272
- package/docs/release.md +40 -46
- package/index.js +1213 -36
- package/package.json +2 -1
package/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
|
-
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -108,7 +108,7 @@ async function ensureBundleEntry(profileDir, pkg) {
|
|
|
108
108
|
|
|
109
109
|
// ---------- 数据根目录(资产落盘)----------
|
|
110
110
|
// 配置存在 ~/.dsh/infinite-canvas/config.json:跨重装/重启稳定,绝不放进包目录。
|
|
111
|
-
const CONFIG_PATH = path.join(os.homedir(), '.dsh', 'infinite-canvas', 'config.json');
|
|
111
|
+
const CONFIG_PATH = process.env.DSH_IC_CONFIG_PATH || path.join(os.homedir(), '.dsh', 'infinite-canvas', 'config.json');
|
|
112
112
|
|
|
113
113
|
function readIcConfig() {
|
|
114
114
|
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) || {}; } catch { return {}; }
|
|
@@ -121,6 +121,54 @@ function dataRoot() {
|
|
|
121
121
|
const r = readIcConfig().dataRoot;
|
|
122
122
|
return (typeof r === 'string' && r.trim()) ? path.resolve(r.trim()) : null;
|
|
123
123
|
}
|
|
124
|
+
/** 设定集字段规范化(世界观/风格 + 角色/场景/道具条目,条目可携带链接资产 assetIds)。 */
|
|
125
|
+
function normalizeBible(raw) {
|
|
126
|
+
const b = (raw && typeof raw === 'object') ? raw : {};
|
|
127
|
+
const arr = (v) => (Array.isArray(v) ? v : [])
|
|
128
|
+
.map((x) => ({
|
|
129
|
+
name: String((x && x.name) || '').slice(0, 60),
|
|
130
|
+
desc: String((x && x.desc) || '').slice(0, 2000),
|
|
131
|
+
// 链接的资产 id(图片/音频/视频):设定条目与可视化资产的绑定,@引用与参考图解析会用到
|
|
132
|
+
assetIds: Array.isArray(x && x.assetIds)
|
|
133
|
+
? [...new Set(x.assetIds.map((a) => String(a || '').trim()).filter(Boolean))].slice(0, 20)
|
|
134
|
+
: [],
|
|
135
|
+
}))
|
|
136
|
+
.filter((x) => x.name);
|
|
137
|
+
return {
|
|
138
|
+
worldview: String(b.worldview || '').slice(0, 8000),
|
|
139
|
+
style: String(b.style || '').slice(0, 2000),
|
|
140
|
+
characters: arr(b.characters),
|
|
141
|
+
scenes: arr(b.scenes),
|
|
142
|
+
props: arr(b.props),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* 把文件流安全地写进响应。
|
|
147
|
+
* createReadStream 的 open/read 错误是**异步事件**,回到不了路由的 try/catch ——
|
|
148
|
+
* 不接管就会变成宿主进程级 uncaughtException(DSH 会按 fatal 退出),
|
|
149
|
+
* 而这条路径非常日常:前端把资产 URL 交给 <img>/<video>,文件被删/被占用即命中。
|
|
150
|
+
*/
|
|
151
|
+
function pipeFile(abs, opts, res) {
|
|
152
|
+
const rs = createReadStream(abs, opts);
|
|
153
|
+
let headersSent = false;
|
|
154
|
+
const onSent = () => { headersSent = true; };
|
|
155
|
+
try { res.on('finish', onSent); } catch { /* 忽略 */ }
|
|
156
|
+
rs.on('error', (err) => {
|
|
157
|
+
const code = err && err.code;
|
|
158
|
+
try {
|
|
159
|
+
if (!res.headersSent && !headersSent) {
|
|
160
|
+
res.statusCode = code === 'ENOENT' ? 404 : 500;
|
|
161
|
+
res.end(code === 'ENOENT' ? 'not found' : 'read failed');
|
|
162
|
+
} else {
|
|
163
|
+
res.destroy(); // 已发头:只能中断连接,绝不能让异常冒到进程层
|
|
164
|
+
}
|
|
165
|
+
} catch { /* 忽略 */ }
|
|
166
|
+
});
|
|
167
|
+
// 客户端提前断开(切页面/取消播放)时主动关掉读流,避免句柄悬挂
|
|
168
|
+
res.on('close', () => { try { rs.destroy(); } catch { /* 忽略 */ } });
|
|
169
|
+
rs.pipe(res);
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
/** rel 限定在 dataRoot 之内:拒绝绝对路径 / 盘符 / 上级跳转,返回 { abs, clean }。 */
|
|
125
173
|
function safeJoin(root, rel) {
|
|
126
174
|
const clean = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
|
@@ -187,6 +235,650 @@ function pickFolderDialog() {
|
|
|
187
235
|
});
|
|
188
236
|
}
|
|
189
237
|
|
|
238
|
+
// ---------- 项目存储(对话 ↔ 画布同步)----------
|
|
239
|
+
// 客户端是内容权威(编辑后推送全量文档);Agent 经工具在 Host 侧追加节点;客户端轮询合并。
|
|
240
|
+
const PROJECTS_DIR = process.env.DSH_IC_PROJECTS_DIR || path.join(os.homedir(), '.dsh', 'infinite-canvas', 'projects');
|
|
241
|
+
function projectFile(id) {
|
|
242
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(id || ''))) throw new Error('非法项目 id');
|
|
243
|
+
return path.join(PROJECTS_DIR, String(id) + '.json');
|
|
244
|
+
}
|
|
245
|
+
function projectsList() {
|
|
246
|
+
let files = [];
|
|
247
|
+
try { files = readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
248
|
+
const list = [];
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
const id = f.slice(0, -5);
|
|
251
|
+
try {
|
|
252
|
+
let d;
|
|
253
|
+
try {
|
|
254
|
+
d = JSON.parse(readFileSync(path.join(PROJECTS_DIR, f), 'utf8'));
|
|
255
|
+
} catch {
|
|
256
|
+
// 损坏/半写的项目文件:显式标记后仍列出,避免用户看到"项目凭空消失"
|
|
257
|
+
list.push({ id: f.replace(/\.json$/, ''), name: f.replace(/\.json$/, '') + '(文件损坏)', corrupted: true, nodeIds: [], nodes: {}, tombstones: [] });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const nodes = Object.values(d.nodes || {}).map((n) => ({
|
|
261
|
+
id: n.id, type: n.type, label: (n.data && n.data.label) || '',
|
|
262
|
+
status: (n.data && n.data.status) || 'idle',
|
|
263
|
+
}));
|
|
264
|
+
list.push({ id: d.id || id, name: d.name || id, createdAt: d.createdAt || 0, updatedAt: d.updatedAt || 0, source: d.source || 'app', nodeIds: Object.keys(d.nodes || {}), nodes, tombstones: d.tombstones || [], bibleUpdatedAt: d.bibleUpdatedAt || 0 });
|
|
265
|
+
} catch { /* 单文件损坏跳过 */ }
|
|
266
|
+
}
|
|
267
|
+
return list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
268
|
+
}
|
|
269
|
+
async function projectsPut(args) {
|
|
270
|
+
const id = String((args && args.id) || '');
|
|
271
|
+
const f = projectFile(id);
|
|
272
|
+
const doc = (args && args.doc) || {};
|
|
273
|
+
let prev = {};
|
|
274
|
+
try { prev = JSON.parse(readFileSync(f, 'utf8')); } catch { /* 新项目 */ }
|
|
275
|
+
// 墓碑:对话删除的节点 + 客户端本地删除的节点(客户端删除时也会写入 tombstones)。
|
|
276
|
+
// tombstoneAt 记录每个墓碑的创建时刻:只有**推送时间戳(doc.ts)不早于墓碑时刻**的推送
|
|
277
|
+
// 才被认定为"撤销恢复"允许清墓碑;陈旧推送(快照早于删除、无 ts 或 ts 更老)不得借
|
|
278
|
+
// "节点在场"清掉墓碑把已删节点复活。
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const tombstoneAt = { ...((prev.tombstoneAt && typeof prev.tombstoneAt === 'object') ? prev.tombstoneAt : {}) };
|
|
281
|
+
const edgeTombstoneAt = { ...((prev.edgeTombstoneAt && typeof prev.edgeTombstoneAt === 'object') ? prev.edgeTombstoneAt : {}) };
|
|
282
|
+
const tombstones = [];
|
|
283
|
+
for (const t of (prev.tombstones || [])) { // 旧档案迁移:无时刻记录的墓碑按 0 处理(保持旧的宽松语义)
|
|
284
|
+
if (typeof t !== 'string' || !t) continue;
|
|
285
|
+
if (!tombstones.includes(t)) tombstones.push(t);
|
|
286
|
+
if (tombstoneAt[t] === undefined) tombstoneAt[t] = 0;
|
|
287
|
+
}
|
|
288
|
+
for (const t of ((doc && doc.tombstones) || [])) { // 新推送来的墓碑:以 Host 接收时刻为创建时刻
|
|
289
|
+
if (typeof t !== 'string' || !t) continue;
|
|
290
|
+
if (!tombstones.includes(t)) tombstones.push(t);
|
|
291
|
+
if (tombstoneAt[t] === undefined) tombstoneAt[t] = now;
|
|
292
|
+
}
|
|
293
|
+
const edgeTombstones = [];
|
|
294
|
+
for (const t of (prev.edgeTombstones || [])) {
|
|
295
|
+
if (typeof t !== 'string' || !t) continue;
|
|
296
|
+
if (!edgeTombstones.includes(t)) edgeTombstones.push(t);
|
|
297
|
+
if (edgeTombstoneAt[t] === undefined) edgeTombstoneAt[t] = 0;
|
|
298
|
+
}
|
|
299
|
+
for (const t of ((doc && doc.edgeTombstones) || [])) {
|
|
300
|
+
if (typeof t !== 'string' || !t) continue;
|
|
301
|
+
if (!edgeTombstones.includes(t)) edgeTombstones.push(t);
|
|
302
|
+
if (edgeTombstoneAt[t] === undefined) edgeTombstoneAt[t] = now;
|
|
303
|
+
}
|
|
304
|
+
// 撤销恢复:节点/边随推送回来了,且推送发出时刻不早于墓碑创建时刻 → 撤掉对应旧墓碑
|
|
305
|
+
//(否则 Ctrl+Z 恢复的节点会被下面的 dead 过滤永久丢弃,表现为"Ctrl+Z 之后节点又消失")
|
|
306
|
+
const pushTs = Number(doc && doc.ts) || 0;
|
|
307
|
+
if (doc && doc.nodes) {
|
|
308
|
+
for (const nid of Object.keys(doc.nodes)) {
|
|
309
|
+
const i = tombstones.indexOf(nid);
|
|
310
|
+
if (i >= 0 && pushTs >= (tombstoneAt[nid] || 0)) {
|
|
311
|
+
tombstones.splice(i, 1);
|
|
312
|
+
delete tombstoneAt[nid];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (doc && Array.isArray(doc.edges)) {
|
|
317
|
+
for (const e of doc.edges) {
|
|
318
|
+
const eid = e && e.id;
|
|
319
|
+
if (!eid) continue;
|
|
320
|
+
const i = edgeTombstones.indexOf(eid);
|
|
321
|
+
if (i >= 0 && pushTs >= (edgeTombstoneAt[eid] || 0)) {
|
|
322
|
+
edgeTombstones.splice(i, 1);
|
|
323
|
+
delete edgeTombstoneAt[eid];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const dead = new Set(tombstones.slice(-200));
|
|
328
|
+
const deadEdges = new Set(edgeTombstones.slice(-400));
|
|
329
|
+
// 节点合并:客户端推来的为准,但**保留 Host 上客户端还没有的节点**(对话刚建的节点
|
|
330
|
+
// 在客户端下一次轮询拉回之前不能被 450ms 的全量推送抹掉);墓碑标记的节点一律丢弃。
|
|
331
|
+
const merged = {};
|
|
332
|
+
for (const [nid, n] of Object.entries(prev.nodes || {})) {
|
|
333
|
+
if (!dead.has(nid)) merged[nid] = n;
|
|
334
|
+
}
|
|
335
|
+
for (const [nid, n] of Object.entries(doc.nodes || {})) {
|
|
336
|
+
if (!dead.has(nid)) merged[nid] = n;
|
|
337
|
+
else delete merged[nid];
|
|
338
|
+
}
|
|
339
|
+
const edgeOk = (e) => e && !deadEdges.has(e.id) && merged[e.fromNode] && merged[e.toNode];
|
|
340
|
+
const clientEdges = (doc.edges || []).filter(edgeOk);
|
|
341
|
+
const prevEdges = (prev.edges || []).filter(edgeOk);
|
|
342
|
+
const edgeKey = (e) => e.fromNode + '>' + (e.fromPort || '') + '>' + e.toNode + '>' + (e.toPort || '');
|
|
343
|
+
const seen = new Set(clientEdges.map(edgeKey));
|
|
344
|
+
const edges = clientEdges.concat(prevEdges.filter((e) => !seen.has(edgeKey(e))));
|
|
345
|
+
const keptTomb = tombstones.slice(-200);
|
|
346
|
+
const keptEdgeTomb = edgeTombstones.slice(-400);
|
|
347
|
+
const pruneStamp = (m, keep) => { const o = {}; for (const k of keep) { if (m[k] !== undefined) o[k] = m[k]; } return o; };
|
|
348
|
+
const out = {
|
|
349
|
+
id,
|
|
350
|
+
name: String((args && args.name) || prev.name || id).slice(0, 80),
|
|
351
|
+
createdAt: prev.createdAt || (doc && doc.createdAt) || Date.now(),
|
|
352
|
+
updatedAt: Date.now(),
|
|
353
|
+
source: prev.source || 'app',
|
|
354
|
+
nodes: merged,
|
|
355
|
+
edges,
|
|
356
|
+
view: doc.view || prev.view || { x: 60, y: 40, scale: 1 },
|
|
357
|
+
tombstones: keptTomb,
|
|
358
|
+
edgeTombstones: keptEdgeTomb,
|
|
359
|
+
tombstoneAt: pruneStamp(tombstoneAt, keptTomb),
|
|
360
|
+
edgeTombstoneAt: pruneStamp(edgeTombstoneAt, keptEdgeTomb),
|
|
361
|
+
};
|
|
362
|
+
// 设定集(世界观/风格/角色/场景/道具):随文档一起同步,对话侧可读写
|
|
363
|
+
if (doc.bible !== undefined) {
|
|
364
|
+
out.bible = normalizeBible(doc.bible);
|
|
365
|
+
out.bibleUpdatedAt = Date.now();
|
|
366
|
+
} else if (prev.bible !== undefined) {
|
|
367
|
+
out.bible = prev.bible;
|
|
368
|
+
out.bibleUpdatedAt = prev.bibleUpdatedAt || 0;
|
|
369
|
+
}
|
|
370
|
+
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
371
|
+
writeFileSync(f, JSON.stringify(out, null, 1), 'utf8');
|
|
372
|
+
return { ok: true, id, updatedAt: out.updatedAt };
|
|
373
|
+
}
|
|
374
|
+
async function projectsGet(args) {
|
|
375
|
+
return readDoc(String((args && args.id) || ''));
|
|
376
|
+
}
|
|
377
|
+
async function projectsDelete(args) {
|
|
378
|
+
try { unlinkSync(projectFile(args && args.id)); } catch { /* 已不存在视为成功 */ }
|
|
379
|
+
return { ok: true };
|
|
380
|
+
}
|
|
381
|
+
async function projectsSync() {
|
|
382
|
+
const cfg = readIcConfig();
|
|
383
|
+
const openRequest = (cfg.ui && cfg.ui.openRequest) || null;
|
|
384
|
+
const runRequest = (cfg.ui && cfg.ui.runRequest) || null;
|
|
385
|
+
const deletedProjects = (cfg.ui && cfg.ui.deletedProjects) || null;
|
|
386
|
+
const docChanged = (cfg.ui && cfg.ui.docChanged) || null;
|
|
387
|
+
if (openRequest || runRequest || deletedProjects || docChanged) {
|
|
388
|
+
cfg.ui = { ...(cfg.ui || {}), openRequest: null, runRequest: null, deletedProjects: null, docChanged: null };
|
|
389
|
+
writeIcConfig(cfg);
|
|
390
|
+
}
|
|
391
|
+
return { projects: projectsList(), openRequest, runRequest, deletedProjects, docChanged };
|
|
392
|
+
}
|
|
393
|
+
/** 节点默认名:取内容首行前 18 字,保证对话创建的节点可识别。 */
|
|
394
|
+
function defaultLabel(text) {
|
|
395
|
+
const line = String(text || '').trim().split(/\r?\n/)[0] || '';
|
|
396
|
+
return line.length > 18 ? line.slice(0, 18) + '…' : (line || '未命名');
|
|
397
|
+
}
|
|
398
|
+
/** 构造与客户端 newNode() 完全一致的节点(形状不一致会导致渲染异常)。 */
|
|
399
|
+
function agentNode(kind, text, x, y, label) {
|
|
400
|
+
const id = 'na' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
401
|
+
const isText = kind === 'text';
|
|
402
|
+
const data = isText
|
|
403
|
+
? { status: 'idle', error: null, text: String(text ?? '') }
|
|
404
|
+
: { status: 'idle', error: null, idea: String(text ?? ''), output: '', aiStatus: 'idle', aiError: null };
|
|
405
|
+
if (label) data.label = String(label).slice(0, 40);
|
|
406
|
+
return { id, type: isText ? 'text' : 'script', x, y, w: isText ? 260 : 320, h: 100, data };
|
|
407
|
+
}
|
|
408
|
+
function nextNodePos(doc) {
|
|
409
|
+
let maxX = 0;
|
|
410
|
+
let maxY = 40;
|
|
411
|
+
for (const n of Object.values(doc.nodes || {})) { if ((n.x || 0) + (n.w || 240) > maxX) { maxX = (n.x || 0) + (n.w || 240); maxY = n.y || 40; } }
|
|
412
|
+
return { x: maxX + 60, y: maxY };
|
|
413
|
+
}
|
|
414
|
+
// ---------- 内置技能(操作规程 + 创作方法论,约束并提升对话的画布创作)----------
|
|
415
|
+
// canvas-studio=操作硬约束;canvas-craft=剧本/分镜/提示词创作方法论
|
|
416
|
+
const SKILL_NAMES = ['canvas-studio', 'canvas-craft'];
|
|
417
|
+
// = @deepseek-ai/dsh-skill 的 BUNDLED_SKILL_RANK(避免 import DSH 包;registry 要求 rank 必须是有限数字)
|
|
418
|
+
const SKILL_RANK = 600;
|
|
419
|
+
/** 解析 SKILL.md 的 YAML frontmatter(仅需 description 与正文)。 */
|
|
420
|
+
function parseSkillMd(raw) {
|
|
421
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(raw || ''));
|
|
422
|
+
const meta = fm ? fm[1] : '';
|
|
423
|
+
const m = /^description:\s*(.+)$/m.exec(meta);
|
|
424
|
+
const description = m ? m[1].trim() : '';
|
|
425
|
+
const content = fm ? String(raw).slice(fm[0].length).trim() : String(raw || '').trim();
|
|
426
|
+
return { description, content };
|
|
427
|
+
}
|
|
428
|
+
/** 把内置技能注册进 skills 服务(与 dsh-skill-office 同款 provider 形状,零 DSH 包依赖)。 */
|
|
429
|
+
function registerCanvasSkill(ctx, disposers) {
|
|
430
|
+
const skills = ctx.get('skills');
|
|
431
|
+
if (!skills || typeof skills.registerProvider !== 'function') return false;
|
|
432
|
+
const candidates = [];
|
|
433
|
+
for (const name of SKILL_NAMES) {
|
|
434
|
+
const assetPath = path.join(HERE, 'assets', name, 'SKILL.md');
|
|
435
|
+
if (!existsSync(assetPath)) continue;
|
|
436
|
+
const { description } = parseSkillMd(readFileSync(assetPath, 'utf8'));
|
|
437
|
+
if (!description) continue;
|
|
438
|
+
candidates.push({
|
|
439
|
+
name,
|
|
440
|
+
description,
|
|
441
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
442
|
+
provider: 'dsh-infinite-canvas',
|
|
443
|
+
source: 'bundled',
|
|
444
|
+
rank: SKILL_RANK,
|
|
445
|
+
resourceBase: { kind: 'directory', path: path.dirname(assetPath) },
|
|
446
|
+
locator: assetPath,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (!candidates.length) return false;
|
|
450
|
+
const provider = {
|
|
451
|
+
name: 'dsh-infinite-canvas',
|
|
452
|
+
list: () => Promise.resolve(candidates),
|
|
453
|
+
async get(c, options) {
|
|
454
|
+
const { rank: _rank, locator, ...summary } = c;
|
|
455
|
+
const raw = await fs.readFile(locator, { encoding: 'utf8', signal: options && options.signal });
|
|
456
|
+
return { ...summary, content: parseSkillMd(raw).content };
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
try {
|
|
460
|
+
const off = skills.registerProvider(() => provider);
|
|
461
|
+
if (typeof off === 'function') disposers.push(off);
|
|
462
|
+
return true;
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function canvasCreateProject(args) {
|
|
469
|
+
const name = String((args && args.name) || '').trim();
|
|
470
|
+
if (!name) throw new Error('需要项目名称');
|
|
471
|
+
// 幂等保护:同名项目已存在 → 复用,绝不重复创建(Agent 重试或误调用都会走到这里)
|
|
472
|
+
const existing = projectsList().find((p) => String(p.name).trim() === name);
|
|
473
|
+
if (existing) {
|
|
474
|
+
return { ok: true, projectId: existing.id, name: existing.name, reused: true, nodeId: null };
|
|
475
|
+
}
|
|
476
|
+
const id = 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
477
|
+
const doc = { id, name: name.slice(0, 80), createdAt: Date.now(), updatedAt: Date.now(), source: 'agent', nodes: {}, edges: [], view: { x: 60, y: 40, scale: 1 } };
|
|
478
|
+
const st = String((args && args.scriptText) || '');
|
|
479
|
+
const out = String((args && args.scriptOutput) || '');
|
|
480
|
+
if (st.trim() || out.trim()) {
|
|
481
|
+
const pos = nextNodePos(doc);
|
|
482
|
+
const n = agentNode('script', st, pos.x, pos.y, (args && args.nodeName) || defaultLabel(st || out));
|
|
483
|
+
if (out.trim()) n.data.output = out; // 剧本节点:idea 与 output 同属一个节点,一次写全
|
|
484
|
+
doc.nodes[n.id] = n;
|
|
485
|
+
}
|
|
486
|
+
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
487
|
+
writeIcDoc(id, doc);
|
|
488
|
+
markDocChanged(id);
|
|
489
|
+
return { ok: true, projectId: id, name: doc.name, nodeId: Object.keys(doc.nodes)[0] || null };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 写入项目内容。
|
|
493
|
+
* - 带 nodeId:更新该节点(target=output 写成稿,target=idea 写提示词;text 节点写正文)
|
|
494
|
+
* - 不带 nodeId:
|
|
495
|
+
* · text 节点 → 追加新节点
|
|
496
|
+
* · 剧本节点 → 复用规则**严格区分"定位"与"命名"**:
|
|
497
|
+
* 1) 给了 name 且能匹配已有节点名 → 更新该节点(这是"定位")
|
|
498
|
+
* 2) 给了 name 但匹配不到 → **新建节点**(这是"命名"新章节;绝不改旧节点的名,
|
|
499
|
+
* 否则多集/多节写作会一路把旧稿覆盖掉且无任何报错)
|
|
500
|
+
* 3) 没给 name → 只复用"有 idea 没 output"或"output 为空"的剧本节点(补全半成品)
|
|
501
|
+
* 4) 其余情况新建,避免静默覆盖已经写完的成稿
|
|
502
|
+
*/
|
|
503
|
+
function canvasWriteNode(args) {
|
|
504
|
+
const id = String((args && args.projectId) || '');
|
|
505
|
+
const f = projectFile(id);
|
|
506
|
+
const doc = readDoc(id);
|
|
507
|
+
const text = String((args && args.text) || '');
|
|
508
|
+
const target = (args && args.target) === 'output' ? 'output' : 'idea';
|
|
509
|
+
const nodeId = String((args && args.nodeId) || '');
|
|
510
|
+
const name = args && args.name ? String(args.name).slice(0, 40) : '';
|
|
511
|
+
const idea = args && args.idea !== undefined ? String(args.idea) : null;
|
|
512
|
+
const overwrite = args && args.overwrite === true;
|
|
513
|
+
const writeInto = (node) => {
|
|
514
|
+
if (node.type === 'text') node.data.text = text;
|
|
515
|
+
else if (node.type === 'storyboard' && target === 'output') {
|
|
516
|
+
// 分镜节点:text 为 {"shots":[...]} JSON → 写入镜头列表;普通文本 → 作为分镜的剧本来源。
|
|
517
|
+
// 禁止把分镜内容塞进剧本节点(剧本节点只放剧本)。
|
|
518
|
+
let shots = null;
|
|
519
|
+
try { const p = JSON.parse(text); if (p && Array.isArray(p.shots)) shots = p.shots; } catch { /* 非 JSON */ }
|
|
520
|
+
if (!shots) {
|
|
521
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
522
|
+
if (fence) { try { const p2 = JSON.parse(fence[1]); if (p2 && Array.isArray(p2.shots)) shots = p2.shots; } catch { /* 非 JSON */ } }
|
|
523
|
+
}
|
|
524
|
+
if (shots) {
|
|
525
|
+
node.data.shots = shots.slice(0, 24).map((s, i) => ({
|
|
526
|
+
title: String((s && s.title) || ('镜头' + (i + 1))).slice(0, 24),
|
|
527
|
+
visual: String((s && s.visual) || ''),
|
|
528
|
+
motion: String((s && s.motion) || ''),
|
|
529
|
+
characters: Array.isArray(s && s.characters) ? s.characters.map(String) : [],
|
|
530
|
+
scene: String((s && s.scene) || ''),
|
|
531
|
+
props: Array.isArray(s && s.props) ? s.props.map(String) : [],
|
|
532
|
+
duration: Number(s && s.duration) || 5,
|
|
533
|
+
}));
|
|
534
|
+
node.data.aiStatus = 'idle';
|
|
535
|
+
node.data.aiError = null;
|
|
536
|
+
} else {
|
|
537
|
+
// 二选一强约束(不可用 overwrite 绕过):已连线到上游剧本的分镜表,内容只从连线取——
|
|
538
|
+
// 两套来源并存会让生成结果不可预期。如需改用粘贴,请先删除该连线或重建分镜表节点。
|
|
539
|
+
const wired = (doc.edges || []).some((e) => e.toNode === node.id && e.toPort === 'script');
|
|
540
|
+
if (wired) {
|
|
541
|
+
throw new Error('该分镜表已连线到上游剧本节点,内容将从连线读取(连线与粘贴二选一,不可并用);请编辑上游剧本节点。');
|
|
542
|
+
}
|
|
543
|
+
node.data.script = text;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else if (target === 'output') { node.data.output = text; node.data.aiStatus = 'idle'; node.data.aiError = null; }
|
|
547
|
+
else node.data.idea = text;
|
|
548
|
+
if (idea !== null && node.type === 'script') node.data.idea = idea;
|
|
549
|
+
if (name) node.data.label = name;
|
|
550
|
+
};
|
|
551
|
+
const save = () => { doc.updatedAt = Date.now(); writeIcDoc(id, doc); markDocChanged(id); };
|
|
552
|
+
/** 该节点是否已有会被 target=output 覆盖的内容(剧本=成稿;分镜=镜头列表;文本=正文)。 */
|
|
553
|
+
const hasExisting = (node) => {
|
|
554
|
+
if (!node || !node.data) return false;
|
|
555
|
+
if (node.type === 'storyboard') return Array.isArray(node.data.shots) && node.data.shots.length > 0;
|
|
556
|
+
return String(node.data.output ?? node.data.text ?? '').trim() !== '';
|
|
557
|
+
};
|
|
558
|
+
const guardOverwrite = (node) => {
|
|
559
|
+
if (target === 'output' && !overwrite && hasExisting(node)) {
|
|
560
|
+
throw new Error('节点「' + ((node.data && node.data.label) || node.id) + '」已有内容,覆盖会丢失原文;确认要覆盖请传 overwrite:true,或换个 name 新建节点。');
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
if (nodeId) {
|
|
564
|
+
const node = doc.nodes[nodeId];
|
|
565
|
+
if (!node) throw new Error('节点不存在: ' + nodeId);
|
|
566
|
+
guardOverwrite(node);
|
|
567
|
+
writeInto(node);
|
|
568
|
+
save();
|
|
569
|
+
return { ok: true, projectId: id, nodeId, updated: true, label: node.data.label || '' };
|
|
570
|
+
}
|
|
571
|
+
if ((args && args.kind) === 'text') {
|
|
572
|
+
const pos = nextNodePos(doc);
|
|
573
|
+
const n = agentNode('text', text, pos.x, pos.y, name || defaultLabel(text));
|
|
574
|
+
doc.nodes[n.id] = n;
|
|
575
|
+
save();
|
|
576
|
+
return { ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' };
|
|
577
|
+
}
|
|
578
|
+
const scripts = Object.values(doc.nodes || {}).filter((n) => n.type === 'script');
|
|
579
|
+
let reuse = null;
|
|
580
|
+
if (name) {
|
|
581
|
+
reuse = scripts.find((n) => String((n.data && n.data.label) || '').trim() === name) || null; // 仅"定位"
|
|
582
|
+
} else {
|
|
583
|
+
reuse = scripts.find((n) => String((n.data && n.data.idea) || '').trim() && !String((n.data && n.data.output) || '').trim())
|
|
584
|
+
|| scripts.find((n) => !String((n.data && n.data.output) || '').trim())
|
|
585
|
+
|| null;
|
|
586
|
+
}
|
|
587
|
+
if (reuse) {
|
|
588
|
+
if (target === 'output' && !overwrite && hasExisting(reuse)) {
|
|
589
|
+
throw new Error('节点「' + ((reuse.data && reuse.data.label) || reuse.id) + '」已有内容,覆盖会丢失原文;确认要覆盖请传 overwrite:true,或换个 name 新建节点。');
|
|
590
|
+
}
|
|
591
|
+
writeInto(reuse);
|
|
592
|
+
save();
|
|
593
|
+
return { ok: true, projectId: id, nodeId: reuse.id, updated: true, reused: true, label: reuse.data.label || '' };
|
|
594
|
+
}
|
|
595
|
+
const pos = nextNodePos(doc);
|
|
596
|
+
const n = agentNode('script', target === 'output' ? '' : text, pos.x, pos.y, name || defaultLabel(text));
|
|
597
|
+
if (target === 'output') n.data.output = text;
|
|
598
|
+
if (idea !== null) n.data.idea = idea;
|
|
599
|
+
doc.nodes[n.id] = n;
|
|
600
|
+
save();
|
|
601
|
+
return { ok: true, projectId: id, nodeId: n.id, label: n.data.label || '' };
|
|
602
|
+
}
|
|
603
|
+
/** 读取项目文档:把"文件不存在/内容损坏"变成用户和模型都读得懂的报错(不泄漏绝对路径)。 */
|
|
604
|
+
function readDoc(id) {
|
|
605
|
+
const f = projectFile(id);
|
|
606
|
+
let raw;
|
|
607
|
+
try {
|
|
608
|
+
raw = readFileSync(f, 'utf8');
|
|
609
|
+
} catch {
|
|
610
|
+
throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
return JSON.parse(raw);
|
|
614
|
+
} catch {
|
|
615
|
+
throw new Error('项目文件损坏,无法解析:' + id + '(可考虑重建该项目)');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** 原子写项目文档:先写临时文件再改名,避免半写导致项目"消失"。 */
|
|
619
|
+
function writeIcDoc(id, doc) {
|
|
620
|
+
const f = projectFile(id);
|
|
621
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
622
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
|
|
623
|
+
renameSync(tmp, f);
|
|
624
|
+
}
|
|
625
|
+
/** 读取设定集(对话侧"设定集先行"的前提)。 */
|
|
626
|
+
async function canvasGetBible(args) {
|
|
627
|
+
const id = String((args && args.projectId) || '');
|
|
628
|
+
const doc = readDoc(id);
|
|
629
|
+
return { projectId: id, name: doc.name || id, bible: normalizeBible(doc.bible || {}), bibleUpdatedAt: doc.bibleUpdatedAt || 0 };
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* 写入设定集:世界观/风格整体替换,角色/场景/道具默认按名称合并(同名更新、不存在则追加);
|
|
633
|
+
* mode='replace' 时条目整体替换。
|
|
634
|
+
*/
|
|
635
|
+
async function canvasUpdateBible(args) {
|
|
636
|
+
const id = String((args && args.projectId) || '');
|
|
637
|
+
const f = projectFile(id);
|
|
638
|
+
const doc = readDoc(id);
|
|
639
|
+
const cur = normalizeBible(doc.bible || {});
|
|
640
|
+
const inc = (args && args.bible) || {};
|
|
641
|
+
const mergeList = (base, incoming) => {
|
|
642
|
+
if (incoming === undefined) return base;
|
|
643
|
+
const list = (Array.isArray(incoming) ? incoming : [])
|
|
644
|
+
.map((x) => ({ name: String((x && x.name) || ''), desc: String((x && x.desc) || '') }))
|
|
645
|
+
.filter((x) => x.name);
|
|
646
|
+
if ((args && args.mode) === 'replace') return list;
|
|
647
|
+
const out = [...base];
|
|
648
|
+
for (const item of list) {
|
|
649
|
+
const i = out.findIndex((x) => String(x.name).trim() === item.name.trim());
|
|
650
|
+
if (i >= 0) out[i] = { ...out[i], ...item };
|
|
651
|
+
else out.push(item);
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
};
|
|
655
|
+
const next = {
|
|
656
|
+
worldview: inc.worldview !== undefined ? String(inc.worldview) : cur.worldview,
|
|
657
|
+
style: inc.style !== undefined ? String(inc.style) : cur.style,
|
|
658
|
+
characters: mergeList(cur.characters, inc.characters),
|
|
659
|
+
scenes: mergeList(cur.scenes, inc.scenes),
|
|
660
|
+
props: mergeList(cur.props, inc.props),
|
|
661
|
+
};
|
|
662
|
+
doc.bible = normalizeBible(next);
|
|
663
|
+
doc.bibleUpdatedAt = Date.now();
|
|
664
|
+
doc.updatedAt = Date.now();
|
|
665
|
+
writeIcDoc(id, doc);
|
|
666
|
+
markDocChanged(id);
|
|
667
|
+
return { projectId: id, bible: doc.bible, bibleUpdatedAt: doc.bibleUpdatedAt };
|
|
668
|
+
}
|
|
669
|
+
/** 通用建节点(供对话建角色/场景/道具参考图等);支持 script/text/image-gen/video-gen/storyboard/finalcut。 */
|
|
670
|
+
function canvasCreateNode(args) {
|
|
671
|
+
const id = String((args && args.projectId) || '');
|
|
672
|
+
const f = projectFile(id);
|
|
673
|
+
const doc = readDoc(id);
|
|
674
|
+
const type = String((args && args.type) || 'text');
|
|
675
|
+
if (!['script', 'text', 'image-gen', 'video-gen', 'storyboard', 'finalcut'].includes(type)) throw new Error('不支持的节点类型: ' + type);
|
|
676
|
+
const label = args && args.name ? String(args.name).slice(0, 40) : '';
|
|
677
|
+
const prompt = String((args && args.prompt) || '');
|
|
678
|
+
const pos = nextNodePos(doc);
|
|
679
|
+
let connected = null;
|
|
680
|
+
let autoConnected = null;
|
|
681
|
+
const connectFrom = String((args && args.connectFrom) || '');
|
|
682
|
+
let node;
|
|
683
|
+
if (type === 'image-gen') {
|
|
684
|
+
node = agentNode('text', '', pos.x, pos.y, label || '图片生成');
|
|
685
|
+
node.type = 'image-gen';
|
|
686
|
+
node.w = 280;
|
|
687
|
+
node.data = { ...node.data, status: 'idle', error: null, prompt, assetId: null, demo: false, size: (args && args.size) || '2K', ratio: (args && args.ratio) || '16:9' };
|
|
688
|
+
} else if (type === 'video-gen') {
|
|
689
|
+
node = agentNode('text', '', pos.x, pos.y, label || '视频生成');
|
|
690
|
+
node.type = 'video-gen';
|
|
691
|
+
node.w = 280;
|
|
692
|
+
// 多镜头工作流:未写 prompt 且项目里已有分镜表 → 自动连到最新分镜表
|
|
693
|
+
//(多镜头提示词从分镜表组合:全部镜头按顺序+时长合成一条提示词,一次性生成整段视频)
|
|
694
|
+
const sbs = Object.values(doc.nodes || {}).filter((n) => n.type === 'storyboard');
|
|
695
|
+
const autoSb = sbs.filter((n) => Array.isArray(n.data && n.data.shots) && n.data.shots.length).pop() || sbs[sbs.length - 1] || null;
|
|
696
|
+
const hasPrompt = Boolean(String(prompt || '').trim());
|
|
697
|
+
const autoConnectId = (!hasPrompt && autoSb) ? autoSb.id : '';
|
|
698
|
+
node.data = { ...node.data, status: 'idle', error: null, prompt: autoConnectId ? '' : prompt, assetId: null, demo: false, duration: Number(args && args.duration) || 5, resolution: (args && args.resolution) || '720P', ratio: (args && args.ratio) || '16:9' };
|
|
699
|
+
autoConnected = autoConnectId ? { from: autoConnectId, ignoredPrompt: false } : null;
|
|
700
|
+
} else if (type === 'storyboard') {
|
|
701
|
+
node = agentNode('text', '', pos.x, pos.y, label || '分镜表');
|
|
702
|
+
node.type = 'storyboard';
|
|
703
|
+
node.w = 360;
|
|
704
|
+
// 连线与提示词**二选一**:创建时项目里已有剧本节点(无论是否已生成完)→ 自动连线到最新剧本节点,
|
|
705
|
+
// 此时 prompt 被忽略(内容从连线取,最多用 requirement 加额外要求)。
|
|
706
|
+
// 工作流=先规划后执行:先把节点和连线建好,再逐步 canvas_run_node。
|
|
707
|
+
const scripts = Object.values(doc.nodes || {}).filter((n) => n.type === 'script');
|
|
708
|
+
const autoScript = scripts.filter((n) => String((n.data && n.data.output) || '').trim()).pop() || scripts[scripts.length - 1] || null;
|
|
709
|
+
const autoConnectId = connectFrom || (autoScript ? autoScript.id : '');
|
|
710
|
+
node.data = { ...node.data, status: 'idle', error: null, script: autoConnectId ? '' : prompt, requirement: String((args && args.requirement) || ''), count: Number(args && args.count) || 6, shots: [], aiStatus: 'idle', aiError: null, warnings: [] };
|
|
711
|
+
autoConnected = autoConnectId ? { from: autoConnectId, ignoredPrompt: Boolean(prompt && autoConnectId) } : null;
|
|
712
|
+
} else if (type === 'finalcut') {
|
|
713
|
+
node = agentNode('text', '', pos.x, pos.y, label || '成片');
|
|
714
|
+
node.type = 'finalcut';
|
|
715
|
+
node.w = 300;
|
|
716
|
+
node.data = { ...node.data, status: 'idle', error: null, resolution: (args && args.resolution) || '720P', ratio: (args && args.ratio) || '16:9', transition: 'none', progress: null, assetId: null, subs: {}, keepAudio: true };
|
|
717
|
+
} else if (type === 'script') {
|
|
718
|
+
node = agentNode('script', prompt, pos.x, pos.y, label || defaultLabel(prompt));
|
|
719
|
+
} else {
|
|
720
|
+
node = agentNode('text', prompt, pos.x, pos.y, label || defaultLabel(prompt));
|
|
721
|
+
}
|
|
722
|
+
doc.nodes[node.id] = node;
|
|
723
|
+
// connectFrom:创建即连线(如分镜表连到上游剧本、image-gen 连到 shot/文本),一次调用完成建点+连线
|
|
724
|
+
const explicitConnect = Boolean(connectFrom);
|
|
725
|
+
if (connectFrom) {
|
|
726
|
+
if (!doc.nodes[connectFrom]) throw new Error('connectFrom 指向的节点不存在: ' + connectFrom);
|
|
727
|
+
const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, args && args.connectToPort);
|
|
728
|
+
connected = edge.id;
|
|
729
|
+
} else if (autoConnected && (type === 'storyboard' || type === 'video-gen')) {
|
|
730
|
+
// 未指定连线但上游已存在(分镜表→剧本 / 视频生成→分镜表)→ 默认自动连线(工作流的默认形态,不依赖 Agent 记得)
|
|
731
|
+
const edge = connectEdge(doc, autoConnected.from, node.id);
|
|
732
|
+
connected = edge.id;
|
|
733
|
+
}
|
|
734
|
+
doc.updatedAt = Date.now();
|
|
735
|
+
writeIcDoc(id, doc);
|
|
736
|
+
markDocChanged(id);
|
|
737
|
+
const note = autoConnected && autoConnected.ignoredPrompt
|
|
738
|
+
? '已自动连线到上游剧本节点,传入的 prompt 被忽略(内容从连线取)。'
|
|
739
|
+
: '';
|
|
740
|
+
return { ok: true, projectId: id, nodeId: node.id, type: node.type, label: (node.data && node.data.label) || '', edgeId: connected || undefined, note: note || undefined };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function canvasDeleteProject(args) {
|
|
744
|
+
const id = String((args && args.projectId) || '');
|
|
745
|
+
const f = projectFile(id);
|
|
746
|
+
let name = id;
|
|
747
|
+
try { name = JSON.parse(readFileSync(f, 'utf8')).name || id; } catch { /* 本来就不存在 */ }
|
|
748
|
+
try { unlinkSync(f); } catch { /* 已删除 */ }
|
|
749
|
+
// 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活
|
|
750
|
+
const cfg = readIcConfig();
|
|
751
|
+
const prev = (cfg.ui && cfg.ui.deletedProjects) || [];
|
|
752
|
+
cfg.ui = { ...(cfg.ui || {}), deletedProjects: [...new Set([...prev, id])].slice(-50) };
|
|
753
|
+
writeIcConfig(cfg);
|
|
754
|
+
return { ok: true, projectId: id, name };
|
|
755
|
+
}
|
|
756
|
+
async function canvasDeleteNode(args) {
|
|
757
|
+
const id = String((args && args.projectId) || '');
|
|
758
|
+
const nodeId = String((args && args.nodeId) || '');
|
|
759
|
+
const f = projectFile(id);
|
|
760
|
+
const doc = readDoc(id);
|
|
761
|
+
if (!doc.nodes[nodeId]) throw new Error('节点不存在: ' + nodeId);
|
|
762
|
+
delete doc.nodes[nodeId];
|
|
763
|
+
doc.edges = (doc.edges || []).filter((e) => e.fromNode !== nodeId && e.toNode !== nodeId);
|
|
764
|
+
doc.tombstones = [...new Set([...(doc.tombstones || []), nodeId])].slice(-100);
|
|
765
|
+
doc.tombstoneAt = { ...(doc.tombstoneAt || {}), [nodeId]: Date.now() }; // 记录墓碑创建时刻:撤销恢复的推送(ts 更新)才允许清墓碑
|
|
766
|
+
doc.updatedAt = Date.now();
|
|
767
|
+
writeIcDoc(id, doc);
|
|
768
|
+
markDocChanged(id);
|
|
769
|
+
return { ok: true, projectId: id, nodeId };
|
|
770
|
+
}
|
|
771
|
+
/** 运行节点:写一次性请求,画布客户端轮询消费后用画布内配置的模型/接口真实执行。 */
|
|
772
|
+
async function canvasRunNode(args) {
|
|
773
|
+
const pid = String((args && args.projectId) || '');
|
|
774
|
+
const nodeId = String((args && args.nodeId) || '');
|
|
775
|
+
const doc = readDoc(pid);
|
|
776
|
+
const node = doc.nodes[nodeId];
|
|
777
|
+
if (!node) throw new Error('节点不存在: ' + nodeId);
|
|
778
|
+
const cfg = readIcConfig();
|
|
779
|
+
cfg.ui = { ...(cfg.ui || {}), runRequest: { projectId: pid, nodeId, at: Date.now() } };
|
|
780
|
+
writeIcConfig(cfg);
|
|
781
|
+
return { ok: true, projectId: pid, nodeId, type: node.type, label: (node.data && node.data.label) || '' };
|
|
782
|
+
}
|
|
783
|
+
/** 读取节点运行状态与产物(画布执行完成后会把结果推回 Host 文档)。 */
|
|
784
|
+
async function canvasNodeStatus(args) {
|
|
785
|
+
const pid = String((args && args.projectId) || '');
|
|
786
|
+
const nodeId = String((args && args.nodeId) || '');
|
|
787
|
+
const doc = readDoc(pid);
|
|
788
|
+
const n = doc.nodes[nodeId];
|
|
789
|
+
if (!n) throw new Error('节点不存在: ' + nodeId);
|
|
790
|
+
return {
|
|
791
|
+
projectId: pid, nodeId, type: n.type, label: (n.data && n.data.label) || '',
|
|
792
|
+
status: (n.data && n.data.status) || 'idle',
|
|
793
|
+
aiStatus: (n.data && n.data.aiStatus) || 'idle',
|
|
794
|
+
error: (n.data && (n.data.error || n.data.aiError)) || null,
|
|
795
|
+
output: String((n.data && n.data.output) || '').slice(0, 6000),
|
|
796
|
+
idea: String((n.data && n.data.idea) || '').slice(0, 2000),
|
|
797
|
+
assetId: (n.data && n.data.assetId) || null,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
async function canvasOpen(args) {
|
|
801
|
+
const pid = String((args && args.projectId) || '');
|
|
802
|
+
if (pid) {
|
|
803
|
+
const f = projectFile(pid); // 校验 id 合法性
|
|
804
|
+
// 项目必须真实存在:否则工具回"✅ 已打开",而前端因为找不到项目什么都不做(假成功)
|
|
805
|
+
if (!existsSync(f)) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
806
|
+
}
|
|
807
|
+
const cfg = readIcConfig();
|
|
808
|
+
cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, at: Date.now() } };
|
|
809
|
+
writeIcConfig(cfg);
|
|
810
|
+
return { ok: true, projectId: pid || null };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** 与客户端一致的端口表(连线校验用)。 */
|
|
814
|
+
const PORT_TABLE = {
|
|
815
|
+
'text': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
816
|
+
'shot': { inputs: [{ id: 'char', kind: 'image' }, { id: 'scene', kind: 'image' }, { id: 'prop', kind: 'image' }], outputs: [{ id: 'visual', kind: 'text' }, { id: 'motion', kind: 'text' }] },
|
|
817
|
+
'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
818
|
+
'storyboard': { inputs: [{ id: 'script', kind: 'text' }], outputs: [{ id: 'out', kind: 'text' }] },
|
|
819
|
+
'image-gen': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
|
|
820
|
+
'video-gen': { inputs: [{ id: 'prompt', kind: 'text' }, { id: 'frame', kind: 'image' }, { id: 'ref', kind: 'image' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }] },
|
|
821
|
+
'image-asset': { inputs: [{ id: 'in', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
|
|
822
|
+
'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }] },
|
|
823
|
+
'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
|
|
824
|
+
'group': { inputs: [], outputs: [] },
|
|
825
|
+
};
|
|
826
|
+
const MULTI_INPUT_PORTS = new Set(['ref', 'char', 'scene', 'prop']);
|
|
827
|
+
/** Agent 改动了项目文档:记一次性信号,让画布在几秒内拉回合并(否则画布上看不到 Agent 的改动)。 */
|
|
828
|
+
function markDocChanged(projectId) {
|
|
829
|
+
try {
|
|
830
|
+
const cfg = readIcConfig();
|
|
831
|
+
cfg.ui = { ...(cfg.ui || {}), docChanged: { projectId, at: Date.now() } };
|
|
832
|
+
writeIcConfig(cfg);
|
|
833
|
+
} catch { /* 配置写失败不影响主流程 */ }
|
|
834
|
+
}
|
|
835
|
+
/** 在两个节点间连一条边(供 canvasConnect 与 canvasCreateNode 的 connectFrom 复用)。返回边对象。 */
|
|
836
|
+
function connectEdge(doc, fromId, toId, fromPort, toPort) {
|
|
837
|
+
const from = doc.nodes[fromId];
|
|
838
|
+
const to = doc.nodes[toId];
|
|
839
|
+
if (!from) throw new Error('源节点不存在: ' + fromId);
|
|
840
|
+
if (!to) throw new Error('目标节点不存在: ' + toId);
|
|
841
|
+
if (fromId === toId) throw new Error('不能把节点连到自己');
|
|
842
|
+
const outs = (PORT_TABLE[from.type] && PORT_TABLE[from.type].outputs) || [];
|
|
843
|
+
const ins = (PORT_TABLE[to.type] && PORT_TABLE[to.type].inputs) || [];
|
|
844
|
+
const fp = String(fromPort || outs[0].id);
|
|
845
|
+
const tp = String(toPort || ins[0].id);
|
|
846
|
+
const outDef = outs.find((p) => p.id === fp);
|
|
847
|
+
const inDef = ins.find((p) => p.id === tp);
|
|
848
|
+
if (!outDef) throw new Error('源节点没有输出端口 ' + fp);
|
|
849
|
+
if (!inDef) throw new Error('目标节点没有输入端口 ' + tp);
|
|
850
|
+
if (outDef.kind !== inDef.kind) throw new Error('端口类型不匹配:' + outDef.kind + ' → ' + inDef.kind + '(剧本/文本输出请连 script/prompt 类输入)');
|
|
851
|
+
const dup = (doc.edges || []).find((e) => e.fromNode === fromId && e.fromPort === fp && e.toNode === toId && e.toPort === tp);
|
|
852
|
+
if (dup) return dup;
|
|
853
|
+
// 单线输入端口(如分镜表的 script、图片的 prompt):新线替换旧线,与画布拖线行为一致
|
|
854
|
+
if (!MULTI_INPUT_PORTS.has(tp)) {
|
|
855
|
+
doc.edges = (doc.edges || []).filter((e) => !(e.toNode === toId && e.toPort === tp));
|
|
856
|
+
}
|
|
857
|
+
const edge = { id: 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), fromNode: fromId, fromPort: fp, toNode: toId, toPort: tp };
|
|
858
|
+
doc.edges.push(edge);
|
|
859
|
+
return edge;
|
|
860
|
+
}
|
|
861
|
+
/** 在两个节点间连线(画布工作流的关键:剧本→分镜→图→视频靠连线组织,不要复制文本)。 */
|
|
862
|
+
function canvasConnect(args) {
|
|
863
|
+
const id = String((args && args.projectId) || '');
|
|
864
|
+
const doc = readDoc(id);
|
|
865
|
+
const fromId = String((args && args.fromNode) || '');
|
|
866
|
+
const toId = String((args && args.toNode) || '');
|
|
867
|
+
const already = (doc.edges || []).some((e) => e.fromNode === fromId && e.toNode === toId);
|
|
868
|
+
const edge = connectEdge(doc, fromId, toId, args && args.fromPort, args && args.toPort);
|
|
869
|
+
const from = doc.nodes[fromId];
|
|
870
|
+
const to = doc.nodes[toId];
|
|
871
|
+
const fromLabel = String((from.data && from.data.label) || fromId);
|
|
872
|
+
const toLabel = String((to.data && to.data.label) || toId);
|
|
873
|
+
doc.updatedAt = Date.now();
|
|
874
|
+
writeIcDoc(id, doc);
|
|
875
|
+
markDocChanged(id);
|
|
876
|
+
return {
|
|
877
|
+
ok: true, projectId: id, edgeId: edge.id, already,
|
|
878
|
+
text: '✅ 已连线:「' + fromLabel + '」(' + edge.fromPort + ') → 「' + toLabel + '」(' + edge.toPort + ')。' + (already ? '(此前已连过,本次未重复建线)' : ''),
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
190
882
|
/** 读取 npm registry 上 latest dist-tag 的版本号。 */
|
|
191
883
|
async function npmLatestVersion() {
|
|
192
884
|
const res = await fetch(NPM_LATEST_URL, {
|
|
@@ -196,25 +888,47 @@ async function npmLatestVersion() {
|
|
|
196
888
|
if (!res.ok) throw new Error('npm registry 返回 HTTP ' + res.status);
|
|
197
889
|
const meta = await res.json();
|
|
198
890
|
if (!meta || typeof meta.version !== 'string') throw new Error('npm registry 响应缺少 version 字段');
|
|
199
|
-
|
|
891
|
+
// 版本号会被拼进安装命令(Windows 下经 shell 执行)→ 必须先校验形态,防止 registry 污染注入
|
|
892
|
+
const v = meta.version.trim();
|
|
893
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v)) throw new Error('npm registry 返回了非法的版本号');
|
|
894
|
+
return v;
|
|
200
895
|
}
|
|
201
896
|
|
|
202
|
-
/**
|
|
897
|
+
/**
|
|
898
|
+
* 语义化版本比较:a<b → -1,a>b → 1,相等 → 0,**无法解析 → null(未知,不能当成"相等")**。
|
|
899
|
+
* prerelease 按 semver 规则逐段比较:数字段按数值(rc.10 > rc.9),数字段优先于字母段。
|
|
900
|
+
*/
|
|
203
901
|
function cmpVer(a, b) {
|
|
204
902
|
const parse = (v) => {
|
|
205
903
|
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(v ?? '').trim());
|
|
206
|
-
return m ? { core: [+m[1], +m[2], +m[3]], pre: m[4]
|
|
904
|
+
return m ? { core: [+m[1], +m[2], +m[3]], pre: m[4] === undefined ? null : m[4].split('.') } : null;
|
|
207
905
|
};
|
|
208
906
|
const A = parse(a);
|
|
209
907
|
const B = parse(b);
|
|
210
|
-
if (!A || !B) return
|
|
908
|
+
if (!A || !B) return null;
|
|
211
909
|
for (let i = 0; i < 3; i++) {
|
|
212
910
|
if (A.core[i] !== B.core[i]) return A.core[i] < B.core[i] ? -1 : 1;
|
|
213
911
|
}
|
|
214
|
-
if (A.pre === B.pre) return 0;
|
|
215
|
-
if (A.pre === null) return 1;
|
|
912
|
+
if (A.pre === null && B.pre === null) return 0;
|
|
913
|
+
if (A.pre === null) return 1; // 正式版 > 预发布版
|
|
216
914
|
if (B.pre === null) return -1;
|
|
217
|
-
|
|
915
|
+
for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
|
|
916
|
+
const x = A.pre[i];
|
|
917
|
+
const y = B.pre[i];
|
|
918
|
+
if (x === undefined) return -1;
|
|
919
|
+
if (y === undefined) return 1;
|
|
920
|
+
const xn = /^\d+$/.test(x);
|
|
921
|
+
const yn = /^\d+$/.test(y);
|
|
922
|
+
if (xn && yn) {
|
|
923
|
+
const d = Number(x) - Number(y);
|
|
924
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
925
|
+
} else if (xn !== yn) {
|
|
926
|
+
return xn ? -1 : 1; // 数字标识符优先级低于字母标识符
|
|
927
|
+
} else if (x !== y) {
|
|
928
|
+
return x < y ? -1 : 1;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return 0;
|
|
218
932
|
}
|
|
219
933
|
|
|
220
934
|
function readLocalVersion() {
|
|
@@ -230,7 +944,12 @@ function readLocalVersion() {
|
|
|
230
944
|
async function updateCheck() {
|
|
231
945
|
const runningVersion = readLocalVersion();
|
|
232
946
|
const remoteVersion = await npmLatestVersion();
|
|
233
|
-
const
|
|
947
|
+
const cmp = cmpVer(runningVersion, remoteVersion);
|
|
948
|
+
if (cmp === null) {
|
|
949
|
+
// 版本号无法解析(本地被改成非规范三段等):如实报"未知",绝不谎称已是最新
|
|
950
|
+
return { source: 'npm', runningVersion, remoteVersion, behind: 0, upToDate: false, unknown: true, note: '版本号无法解析,无法判断是否需要更新' };
|
|
951
|
+
}
|
|
952
|
+
const behind = cmp < 0 ? 1 : 0;
|
|
234
953
|
return { source: 'npm', runningVersion, remoteVersion, behind, upToDate: behind === 0 };
|
|
235
954
|
}
|
|
236
955
|
|
|
@@ -244,7 +963,12 @@ async function updateCheck() {
|
|
|
244
963
|
async function updateInstall() {
|
|
245
964
|
const runningVersion = readLocalVersion();
|
|
246
965
|
const latest = await npmLatestVersion();
|
|
247
|
-
|
|
966
|
+
const cmp = cmpVer(runningVersion, latest);
|
|
967
|
+
if (cmp === null) {
|
|
968
|
+
// 无法比较时不能假装"已是最新"(按钮会显示成功但什么都没装)→ 回退为引导
|
|
969
|
+
return { ok: false, guide: true, version: latest, output: '当前版本号无法解析,无法自动判断;请手动更新到 ' + latest };
|
|
970
|
+
}
|
|
971
|
+
if (cmp >= 0) {
|
|
248
972
|
return { ok: true, changed: false, version: runningVersion };
|
|
249
973
|
}
|
|
250
974
|
if (!PROFILE_DIR) {
|
|
@@ -273,10 +997,10 @@ async function updateInstall() {
|
|
|
273
997
|
return { ok: true, changed: true, version: installed, output, policyRelaxed };
|
|
274
998
|
}
|
|
275
999
|
|
|
276
|
-
export const inject = ['webServer'];
|
|
1000
|
+
export const inject = ['webServer', 'tools'];
|
|
277
1001
|
|
|
278
1002
|
// 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
|
|
279
|
-
export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, safeJoin, mimeOf, getIcConfigPath: () => CONFIG_PATH };
|
|
1003
|
+
export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync };
|
|
280
1004
|
|
|
281
1005
|
function resolveModel(providers, want) {
|
|
282
1006
|
const byProvider = want && want.provider
|
|
@@ -329,28 +1053,101 @@ export function apply(ctx) {
|
|
|
329
1053
|
return { providers: out, resolved: resolveModel(out) };
|
|
330
1054
|
}
|
|
331
1055
|
|
|
332
|
-
|
|
1056
|
+
/**
|
|
1057
|
+
* 调用 Harness 的 LLM 生成文本。
|
|
1058
|
+
* @param args {provider, model, system, user, temperature, maxTokens}
|
|
1059
|
+
* @param hooks 可选:{ onAttempt(info), onReasoning(text), onText(text) } —— 供流式路由把
|
|
1060
|
+
* 思考过程实时推给画布(用户能看到"没卡住")
|
|
1061
|
+
*/
|
|
1062
|
+
async function complete(args, hooks) {
|
|
333
1063
|
const service = llm();
|
|
334
1064
|
if (!service) throw new Error('llm 服务不可用');
|
|
1065
|
+
const emit = (fn, payload) => { try { if (hooks && typeof hooks[fn] === 'function') hooks[fn](payload); } catch { /* 回调异常不影响生成 */ } };
|
|
335
1066
|
const want = args ?? {};
|
|
336
1067
|
const { providers } = await listLlmOptions();
|
|
337
1068
|
const resolved = resolveModel(providers, want);
|
|
338
1069
|
if (!resolved) throw new Error('没有可用模型,请先在 Harness 设置中配置模型提供方');
|
|
339
|
-
|
|
340
|
-
const
|
|
1070
|
+
const system = String(want.system ?? '');
|
|
1071
|
+
const user = String(want.user ?? '');
|
|
1072
|
+
// 只透传调用方显式给出的参数;不给就不发 —— 让适配器/模型用 Harness 里配置的默认值
|
|
1073
|
+
// (此前插件写死 maxTokens 会覆盖用户在 Harness 里的输出上限,推理模型思考一吃就没了)
|
|
1074
|
+
const temperature = typeof want.temperature === 'number' && Number.isFinite(want.temperature) ? want.temperature : undefined;
|
|
1075
|
+
const maxTokens = typeof want.maxTokens === 'number' && Number.isFinite(want.maxTokens) && want.maxTokens > 0 ? want.maxTokens : undefined;
|
|
1076
|
+
const tuned = {
|
|
1077
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
1078
|
+
...(maxTokens === undefined ? {} : { maxTokens }),
|
|
1079
|
+
};
|
|
1080
|
+
const userMessage = { role: 'user', content: [{ type: 'text', text: user }] };
|
|
1081
|
+
/** 跑一次流:统计 chunk 类型,返回文本与失败原因(单次尝试抛错不致命,便于逐级回退)。 */
|
|
1082
|
+
const run = async (options, label) => {
|
|
1083
|
+
const kinds = [];
|
|
1084
|
+
let text = '';
|
|
1085
|
+
let finishKind = null;
|
|
1086
|
+
let finishMsg = null;
|
|
1087
|
+
emit('onAttempt', { label });
|
|
1088
|
+
try {
|
|
1089
|
+
for await (const chunk of service.stream(options)) {
|
|
1090
|
+
const type = chunk && chunk.type;
|
|
1091
|
+
kinds.push(type || 'unknown');
|
|
1092
|
+
if (type === 'reasoning-delta' && typeof chunk.text === 'string') emit('onReasoning', chunk.text);
|
|
1093
|
+
else if (type === 'text-delta' || type === 'text') {
|
|
1094
|
+
if (typeof chunk.text === 'string') { text += chunk.text; emit('onText', chunk.text); }
|
|
1095
|
+
} else if (type === 'finish') {
|
|
1096
|
+
// 适配器的 finish.reason 是对象:{kind:'stop'|'max-tokens'|'error', failure?}
|
|
1097
|
+
const r = chunk.reason;
|
|
1098
|
+
if (r && typeof r === 'object') { finishKind = r.kind || null; finishMsg = (r.failure && r.failure.message) || null; }
|
|
1099
|
+
else if (typeof r === 'string') finishKind = r;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return { text, kinds, finishKind, finishMsg, error: null };
|
|
1103
|
+
} catch (e) {
|
|
1104
|
+
return { text: '', kinds, finishKind, finishMsg, error: String((e && e.message) || e) };
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
const histogram = (kinds) => {
|
|
1108
|
+
if (!kinds.length) return '(没有任何 chunk)';
|
|
1109
|
+
const m = new Map();
|
|
1110
|
+
for (const k of kinds) m.set(k, (m.get(k) || 0) + 1);
|
|
1111
|
+
return [...m.entries()].map(([k, v]) => k + '×' + v).join(', ');
|
|
1112
|
+
};
|
|
1113
|
+
const base = {
|
|
341
1114
|
provider: resolved.provider,
|
|
342
1115
|
model: resolved.model,
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1116
|
+
...(system ? { system } : {}),
|
|
1117
|
+
messages: [userMessage],
|
|
1118
|
+
...tuned,
|
|
1119
|
+
sessionId: 'infinite-canvas',
|
|
1120
|
+
purpose: 'infinite-canvas',
|
|
1121
|
+
};
|
|
1122
|
+
// 1) 规范信封(system 独立字段 + sessionId;参数按调用方配置,不发插件自造的数值)
|
|
1123
|
+
const primary = await run(base, '默认');
|
|
1124
|
+
if (primary.text.trim()) return { text: primary.text, provider: resolved.provider, model: resolved.model, variant: 'system' };
|
|
1125
|
+
// 2) 没拿到正文时的重试 —— **绝不修改任何模型参数**(思考强度/输出上限/温度都不碰):
|
|
1126
|
+
// a) 老式信封(system 放进 messages):只是换一种转写方式
|
|
1127
|
+
// b) 追问一次:"请把最终答案作为正文输出" —— 只追加对话内容,
|
|
1128
|
+
// 适用于"模型把结果写进了思考、正文为空"的情况;思考质量原封不动
|
|
1129
|
+
const nudgeMessage = { role: 'user', content: [{ type: 'text', text: '(继续)请把你的最终答案完整地作为正文输出,不要只输出思考内容。' }] };
|
|
1130
|
+
const attempts = [
|
|
1131
|
+
{ label: '老式信封', options: { provider: resolved.provider, model: resolved.model, messages: system ? [{ role: 'system', content: [{ type: 'text', text: system }] }, userMessage] : [userMessage], ...tuned } },
|
|
1132
|
+
{ label: '追问正文', options: { ...base, messages: [userMessage, nudgeMessage] } },
|
|
1133
|
+
];
|
|
1134
|
+
const tried = [];
|
|
1135
|
+
for (const a of attempts) {
|
|
1136
|
+
const r = await run(a.options, a.label);
|
|
1137
|
+
if (r.text.trim()) return { text: r.text, provider: resolved.provider, model: resolved.model, variant: 'retry', attempt: a.label };
|
|
1138
|
+
tried.push(a.label + ': ' + histogram(r.kinds) + (r.finishKind ? ' finish=' + r.finishKind + (r.finishMsg ? '(' + r.finishMsg + ')' : '') : '') + (r.error ? ' / 错误: ' + r.error : ''));
|
|
352
1139
|
}
|
|
353
|
-
|
|
1140
|
+
const why = primary.finishKind === 'max-tokens'
|
|
1141
|
+
? '输出在预算内被截断(finish=max-tokens)'
|
|
1142
|
+
: primary.finishKind
|
|
1143
|
+
? '模型结束而未输出正文(finish=' + primary.finishKind + (primary.finishMsg ? ':' + primary.finishMsg : '') + ')'
|
|
1144
|
+
: '模型未返回正文';
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
why + '(provider=' + resolved.provider + ' model=' + resolved.model + ')。'
|
|
1147
|
+
+ '插件未修改任何模型参数(思考强度/输出上限/温度都按你的配置);已尝试追问正文仍未得到。'
|
|
1148
|
+
+ '建议:换一个文本模型试试。'
|
|
1149
|
+
+ '(chunk 统计 → 首次: ' + histogram(primary.kinds) + (primary.finishKind ? ' finish=' + primary.finishKind : '') + ';重试: ' + tried.join(' | ') + ')',
|
|
1150
|
+
);
|
|
354
1151
|
}
|
|
355
1152
|
|
|
356
1153
|
const methods = {
|
|
@@ -368,8 +1165,10 @@ export function apply(ctx) {
|
|
|
368
1165
|
async dataRootSet(args) {
|
|
369
1166
|
const raw = String((args && args.root) ?? '').trim();
|
|
370
1167
|
if (!raw) throw new Error('需要数据根目录路径');
|
|
1168
|
+
// 必须在 resolve 之前判绝对路径:resolve 之后恒为绝对路径,检查就成了死代码,
|
|
1169
|
+
// 相对路径会静默落到应用 cwd(升级即丢数据)
|
|
1170
|
+
if (!path.isAbsolute(raw)) throw new Error('需要绝对路径(如 D:\\canvas-data)');
|
|
371
1171
|
const root = path.resolve(raw);
|
|
372
|
-
if (!path.isAbsolute(root)) throw new Error('需要绝对路径(如 D:\\canvas-data)');
|
|
373
1172
|
mkdirSync(root, { recursive: true });
|
|
374
1173
|
const cfg = readIcConfig();
|
|
375
1174
|
cfg.dataRoot = root;
|
|
@@ -381,13 +1180,33 @@ export function apply(ctx) {
|
|
|
381
1180
|
if (!root) throw new Error('未设置数据根目录');
|
|
382
1181
|
mkdirSync(root, { recursive: true });
|
|
383
1182
|
const open = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
384
|
-
|
|
1183
|
+
// spawn 的失败是**异步 error 事件**(ENOENT 等),不挂监听就会冒成进程级未捕获异常
|
|
1184
|
+
try {
|
|
1185
|
+
const child = spawn(open, [root], { detached: true, stdio: 'ignore' });
|
|
1186
|
+
child.on('error', () => { /* 打不开目录不算致命错误,忽略 */ });
|
|
1187
|
+
child.unref();
|
|
1188
|
+
} catch { /* 同步抛错同样忽略 */ }
|
|
385
1189
|
return { ok: true, dataRoot: root };
|
|
386
1190
|
},
|
|
387
1191
|
async pickFolder() {
|
|
388
1192
|
const r = await pickFolderDialog();
|
|
389
1193
|
return r; // { ok, path } | { ok:false, cancelled } | { ok:false, unsupported }
|
|
390
1194
|
},
|
|
1195
|
+
async projectsPut(args) { return projectsPut(args); },
|
|
1196
|
+
async projectsGet(args) { return projectsGet(args); },
|
|
1197
|
+
async projectsList() { return projectsList(); },
|
|
1198
|
+
async projectsDelete(args) { return projectsDelete(args); },
|
|
1199
|
+
async projectsSync() { return projectsSync(); },
|
|
1200
|
+
async canvasCreateProject(args) { return canvasCreateProject(args); },
|
|
1201
|
+
async canvasWriteNode(args) { return canvasWriteNode(args); },
|
|
1202
|
+
async canvasGetBible(args) { return canvasGetBible(args); },
|
|
1203
|
+
async canvasUpdateBible(args) { return canvasUpdateBible(args); },
|
|
1204
|
+
async canvasCreateNode(args) { return canvasCreateNode(args); },
|
|
1205
|
+
async canvasDeleteProject(args) { return canvasDeleteProject(args); },
|
|
1206
|
+
async canvasDeleteNode(args) { return canvasDeleteNode(args); },
|
|
1207
|
+
async canvasRunNode(args) { return canvasRunNode(args); },
|
|
1208
|
+
async canvasNodeStatus(args) { return canvasNodeStatus(args); },
|
|
1209
|
+
async canvasOpen(args) { return canvasOpen(args); },
|
|
391
1210
|
async assetDelete(args) {
|
|
392
1211
|
const root = dataRoot();
|
|
393
1212
|
if (!root) throw new Error('未设置数据根目录');
|
|
@@ -424,6 +1243,313 @@ export function apply(ctx) {
|
|
|
424
1243
|
},
|
|
425
1244
|
};
|
|
426
1245
|
|
|
1246
|
+
const toolDisposers = [];
|
|
1247
|
+
try {
|
|
1248
|
+
if (ctx.tools && typeof ctx.tools.register === 'function') {
|
|
1249
|
+
/** 深度剥掉 undefined 值:DSH 对工具结果做"无损 JSON"校验(snapshotJsonValue),
|
|
1250
|
+
* 任意一层出现 undefined(NaN/Infinity 同理)都会让整个结果被判非法
|
|
1251
|
+
* ——此前 canvas_node_status 在无产物节点上必炸(assetId: undefined)就是这条。 */
|
|
1252
|
+
const stripUndefined = (value) => {
|
|
1253
|
+
if (Array.isArray(value)) return value.map((v) => (v === undefined ? null : stripUndefined(v)));
|
|
1254
|
+
if (value && typeof value === 'object') {
|
|
1255
|
+
const out = {};
|
|
1256
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1257
|
+
if (v === undefined) continue;
|
|
1258
|
+
out[k] = stripUndefined(v);
|
|
1259
|
+
}
|
|
1260
|
+
return out;
|
|
1261
|
+
}
|
|
1262
|
+
return value;
|
|
1263
|
+
};
|
|
1264
|
+
const reg = (def) => {
|
|
1265
|
+
try {
|
|
1266
|
+
const inner = def.execute;
|
|
1267
|
+
def.execute = async (args, exec) => stripUndefined(await inner(args, exec));
|
|
1268
|
+
toolDisposers.push(ctx.tools.register(def));
|
|
1269
|
+
} catch { /* 单个工具失败不阻塞其余 */ }
|
|
1270
|
+
};
|
|
1271
|
+
// 输出 schema:显式声明字段但不用 additionalProperties:false(工具返回值的校验不能比参数更严,
|
|
1272
|
+
// 否则多返回一个字段就会让整轮请求被 API 拒绝)
|
|
1273
|
+
const textOutput = {
|
|
1274
|
+
schema: {
|
|
1275
|
+
type: 'object',
|
|
1276
|
+
properties: {
|
|
1277
|
+
ok: { type: 'boolean' }, text: { type: 'string' },
|
|
1278
|
+
projectId: { type: 'string' }, nodeId: { type: 'string' }, name: { type: 'string' }, label: { type: 'string' },
|
|
1279
|
+
},
|
|
1280
|
+
required: ['ok', 'text'],
|
|
1281
|
+
},
|
|
1282
|
+
render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
|
|
1283
|
+
};
|
|
1284
|
+
reg({
|
|
1285
|
+
name: 'canvas_list_projects',
|
|
1286
|
+
description: '列出无限画布的项目及其节点(节点 id/类型/名称/状态)。用户提到画布项目、想看有哪些项目、或要指定某个项目/节点时使用。',
|
|
1287
|
+
parameters: { type: 'object', properties: { keyword: { type: 'string', description: '按项目名称过滤(可选)' } }, additionalProperties: false },
|
|
1288
|
+
output: {
|
|
1289
|
+
schema: { type: 'object', properties: { count: { type: 'integer' }, text: { type: 'string' } }, required: ['count', 'text'] },
|
|
1290
|
+
render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
|
|
1291
|
+
},
|
|
1292
|
+
timeoutMs: 30000,
|
|
1293
|
+
async execute(args) {
|
|
1294
|
+
const kw = String((args && args.keyword) || '').toLowerCase();
|
|
1295
|
+
const list = projectsList().filter((p) => !kw || String(p.name).toLowerCase().includes(kw));
|
|
1296
|
+
const lines = [];
|
|
1297
|
+
for (const p of list) {
|
|
1298
|
+
lines.push('- 「' + p.name + '」 id=' + p.id + ' · ' + p.nodes.length + ' 节点 · 更新于 ' + new Date(p.updatedAt).toLocaleString());
|
|
1299
|
+
for (const n of p.nodes) lines.push(' · [' + n.type + '] ' + (n.label || '(未命名)') + ' id=' + n.id + ' status=' + n.status);
|
|
1300
|
+
}
|
|
1301
|
+
const text = lines.length ? ('共 ' + list.length + ' 个项目:\n' + lines.join('\n')) : '画布上还没有项目。可调用 canvas_create_project 创建。';
|
|
1302
|
+
return { count: list.length, text };
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
reg({
|
|
1306
|
+
name: 'canvas_create_project',
|
|
1307
|
+
description: '在无限画布上创建一个新项目(可同时写好第一个剧本节点)。【硬约束】调用前必须先 canvas_list_projects 查重;同名项目会被自动复用并提示,绝不要为"重试"或为每个节点重复创建项目。剧本节点必须 idea(创作意图)+ output(剧本成稿)同属一个节点:创作意图填 scriptText,成稿填 scriptOutput,用 nodeName 命名。【注意】世界观/核心设定/风格基调等"创作意图类"内容一律写进设定集(canvas_update_bible),绝不要为它们单独建节点。',
|
|
1308
|
+
parameters: {
|
|
1309
|
+
type: 'object',
|
|
1310
|
+
properties: {
|
|
1311
|
+
name: { type: 'string', description: '项目名称' },
|
|
1312
|
+
scriptText: { type: 'string', description: '可选:创作意图/提示词(写入首个剧本节点的 idea)' },
|
|
1313
|
+
scriptOutput: { type: 'string', description: '可选:剧本完整成稿(写入同一节点的 output)' },
|
|
1314
|
+
nodeName: { type: 'string', description: '可选:首个剧本节点的名字(建议填写,如"第一节剧本")' },
|
|
1315
|
+
},
|
|
1316
|
+
required: ['name'],
|
|
1317
|
+
additionalProperties: false,
|
|
1318
|
+
},
|
|
1319
|
+
output: textOutput,
|
|
1320
|
+
timeoutMs: 30000,
|
|
1321
|
+
async execute(args) {
|
|
1322
|
+
const r = canvasCreateProject(args);
|
|
1323
|
+
if (r.reused) {
|
|
1324
|
+
return { ok: true, projectId: r.projectId, name: r.name, text: '♻️ 项目「' + r.name + '」已存在(id=' + r.projectId + '),已复用,未重复创建。往其中加内容请用 canvas_write_node({ projectId: "' + r.projectId + '", ... })。' };
|
|
1325
|
+
}
|
|
1326
|
+
return { ok: true, projectId: r.projectId, name: r.name, nodeId: r.nodeId || undefined, text: '✅ 已创建项目「' + r.name + '」(id=' + r.projectId + ')。往同一个项目继续加内容请用 canvas_write_node({ projectId: "' + r.projectId + '" }),不要为节点新建项目。' };
|
|
1327
|
+
},
|
|
1328
|
+
});
|
|
1329
|
+
reg({
|
|
1330
|
+
name: 'canvas_write_node',
|
|
1331
|
+
description: '写入无限画布的项目内容。【硬约束】绝不要为了加节点而新建项目——一律指定已有 projectId。世界观/核心设定/风格基调等"创作意图类"内容写进设定集(canvas_update_bible),绝不要为它们建节点。剧本节点的 idea(创作意图)与 output(剧本成稿)必须在同一个节点里;分镜内容只能写进分镜表节点(storyboard):text 为 {"shots":[…]} JSON 时写入镜头列表,普通文本则作为分镜的剧本来源——绝不要用剧本节点装分镜。复用规则:给 name 且能匹配已有节点名 → 更新该节点;给 name 但匹配不到 → **新建节点**(写新一集/新一节时就这样用);不给 name → 只会复用"有意图没成稿"的剧本节点补全。已写成稿的节点默认拒绝覆盖,确认要覆盖请传 overwrite:true(否则报错而不是静默吃掉稿件)。target:idea=提示词(默认),output=成稿。',
|
|
1332
|
+
parameters: {
|
|
1333
|
+
type: 'object',
|
|
1334
|
+
properties: {
|
|
1335
|
+
projectId: { type: 'string', description: '项目 id(来自 canvas_list_projects 或 canvas_create_project)' },
|
|
1336
|
+
text: { type: 'string', description: '要写入的内容(按 target 与节点类型决定写哪里)' },
|
|
1337
|
+
kind: { type: 'string', enum: ['script', 'text'], description: '新节点类型:script=剧本(默认),text=纯文本' },
|
|
1338
|
+
target: { type: 'string', enum: ['idea', 'output'], description: '写哪一部分:idea=提示词/创作意图(默认),output=成稿' },
|
|
1339
|
+
nodeId: { type: 'string', description: '可选:指定要更新的节点(最精确的定位方式)' },
|
|
1340
|
+
name: { type: 'string', description: '可选:节点名字。匹配到同名节点就更新它;匹配不到会新建节点(写新章节用这个)' },
|
|
1341
|
+
idea: { type: 'string', description: '可选:同时设置该剧本节点的创作意图(与 text/target 配合,一次写全意图与成稿)' },
|
|
1342
|
+
overwrite: { type: 'boolean', description: '可选:目标节点已有成稿时是否允许覆盖(默认 false,会报错保护稿件)' },
|
|
1343
|
+
},
|
|
1344
|
+
required: ['projectId', 'text'],
|
|
1345
|
+
additionalProperties: false,
|
|
1346
|
+
},
|
|
1347
|
+
output: textOutput,
|
|
1348
|
+
timeoutMs: 30000,
|
|
1349
|
+
async execute(args) {
|
|
1350
|
+
const r = canvasWriteNode(args);
|
|
1351
|
+
const what = String((args && args.text) || '').trim().slice(0, 24);
|
|
1352
|
+
return { ok: true, projectId: r.projectId, nodeId: r.nodeId, label: r.label || undefined, text: (r.updated ? '✅ 已更新节点' : '✅ 已写入节点') + '「' + (r.label || what) + '」(id=' + r.nodeId + ')。' };
|
|
1353
|
+
},
|
|
1354
|
+
});
|
|
1355
|
+
reg({
|
|
1356
|
+
name: 'canvas_run_node',
|
|
1357
|
+
description: '触发画布用其自身配置的模型/接口真实执行某个节点(剧本生成、分镜拆解、图片生成、视频生成等)。执行在画布侧异步进行,随后可用 canvas_node_status 读取状态与产物。用户说"生成剧本""开始生成""跑一下这个节点"时使用。',
|
|
1358
|
+
parameters: {
|
|
1359
|
+
type: 'object',
|
|
1360
|
+
properties: {
|
|
1361
|
+
projectId: { type: 'string', description: '项目 id' },
|
|
1362
|
+
nodeId: { type: 'string', description: '节点 id(来自 canvas_list_projects)' },
|
|
1363
|
+
},
|
|
1364
|
+
required: ['projectId', 'nodeId'],
|
|
1365
|
+
additionalProperties: false,
|
|
1366
|
+
},
|
|
1367
|
+
output: textOutput,
|
|
1368
|
+
timeoutMs: 30000,
|
|
1369
|
+
async execute(args) {
|
|
1370
|
+
const r = await canvasRunNode(args);
|
|
1371
|
+
return { ok: true, projectId: r.projectId, nodeId: r.nodeId, text: '🚀 已触发执行[' + r.type + ']「' + (r.label || r.nodeId) + '」。画布将在数秒内开始,可用 canvas_node_status 查询进度与结果。' };
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
reg({
|
|
1375
|
+
name: 'canvas_node_status',
|
|
1376
|
+
description: '查询画布节点的运行状态与产物(状态、错误、剧本成稿内容、生成出的资产 id)。用于确认 canvas_run_node 是否完成。',
|
|
1377
|
+
parameters: {
|
|
1378
|
+
type: 'object',
|
|
1379
|
+
properties: {
|
|
1380
|
+
projectId: { type: 'string', description: '项目 id' },
|
|
1381
|
+
nodeId: { type: 'string', description: '节点 id' },
|
|
1382
|
+
},
|
|
1383
|
+
required: ['projectId', 'nodeId'],
|
|
1384
|
+
additionalProperties: false,
|
|
1385
|
+
},
|
|
1386
|
+
output: {
|
|
1387
|
+
schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' }, status: { type: 'string' }, assetId: { type: 'string' } }, required: ['ok', 'text'] },
|
|
1388
|
+
render: (args, value) => [{ type: 'text', text: String(value.text || '') }],
|
|
1389
|
+
},
|
|
1390
|
+
timeoutMs: 30000,
|
|
1391
|
+
async execute(args) {
|
|
1392
|
+
const s = await canvasNodeStatus(args);
|
|
1393
|
+
const lines = ['节点[' + s.type + ']「' + (s.label || s.nodeId) + '」', '状态: ' + s.status + (s.aiStatus && s.aiStatus !== 'idle' ? ' / ' + s.aiStatus : '')];
|
|
1394
|
+
if (s.error) lines.push('错误: ' + s.error);
|
|
1395
|
+
if (s.assetId) lines.push('产物资产: ' + s.assetId);
|
|
1396
|
+
if (s.idea) lines.push('提示词: ' + s.idea.slice(0, 400));
|
|
1397
|
+
if (s.output) lines.push('成稿:\n' + s.output);
|
|
1398
|
+
return { ok: true, status: s.status, assetId: s.assetId || undefined, text: lines.join('\n') };
|
|
1399
|
+
},
|
|
1400
|
+
});
|
|
1401
|
+
reg({
|
|
1402
|
+
name: 'canvas_delete_node',
|
|
1403
|
+
description: '删除无限画布项目中的一个节点(连同它的连线)。用于清理误创建的节点。',
|
|
1404
|
+
parameters: {
|
|
1405
|
+
type: 'object',
|
|
1406
|
+
properties: { projectId: { type: 'string', description: '项目 id' }, nodeId: { type: 'string', description: '节点 id' } },
|
|
1407
|
+
required: ['projectId', 'nodeId'],
|
|
1408
|
+
additionalProperties: false,
|
|
1409
|
+
},
|
|
1410
|
+
output: textOutput,
|
|
1411
|
+
timeoutMs: 30000,
|
|
1412
|
+
async execute(args) {
|
|
1413
|
+
const r = await canvasDeleteNode(args);
|
|
1414
|
+
return { ok: true, projectId: r.projectId, nodeId: r.nodeId, text: '🗑 已删除节点 ' + r.nodeId + '。' };
|
|
1415
|
+
},
|
|
1416
|
+
});
|
|
1417
|
+
reg({
|
|
1418
|
+
name: 'canvas_delete_project',
|
|
1419
|
+
description: '删除无限画布上的一个项目(含其全部节点)。用户明确要求删除项目、或清理误创建的空项目时使用;删除前请先用 canvas_list_projects 确认 id。',
|
|
1420
|
+
parameters: { type: 'object', properties: { projectId: { type: 'string', description: '项目 id' } }, required: ['projectId'], additionalProperties: false },
|
|
1421
|
+
output: textOutput,
|
|
1422
|
+
timeoutMs: 30000,
|
|
1423
|
+
async execute(args) {
|
|
1424
|
+
const r = await canvasDeleteProject(args);
|
|
1425
|
+
return { ok: true, projectId: r.projectId, text: '🗑 已删除项目「' + r.name + '」(id=' + r.projectId + ')。' };
|
|
1426
|
+
},
|
|
1427
|
+
});
|
|
1428
|
+
reg({
|
|
1429
|
+
name: 'canvas_get_bible',
|
|
1430
|
+
description: '读取项目的设定集(世界观/核心设定、风格基调、角色/场景/道具及描述)。【设定集先行】在写剧本或生成角色/场景/道具参考图之前,先读它;若为空,先与用户确认或按用户已给的创意补全设定集,再开始生成剧本。',
|
|
1431
|
+
parameters: {
|
|
1432
|
+
type: 'object',
|
|
1433
|
+
properties: { projectId: { type: 'string', description: '项目 id' } },
|
|
1434
|
+
required: ['projectId'],
|
|
1435
|
+
additionalProperties: false,
|
|
1436
|
+
},
|
|
1437
|
+
output: textOutput,
|
|
1438
|
+
timeoutMs: 30000,
|
|
1439
|
+
async execute(args) {
|
|
1440
|
+
const r = await canvasGetBible(args);
|
|
1441
|
+
const b = r.bible;
|
|
1442
|
+
const lines = ['项目「' + r.name + '」设定集:'];
|
|
1443
|
+
lines.push('世界观/核心设定:' + (b.worldview || '(空)'));
|
|
1444
|
+
lines.push('风格基调:' + (b.style || '(空)'));
|
|
1445
|
+
for (const [label, list] of [['角色', b.characters], ['场景', b.scenes], ['道具', b.props]]) {
|
|
1446
|
+
lines.push(label + '(' + list.length + '):' + (list.length ? list.map((x) => x.name + '=' + (x.desc || '')).join(';') : '(空)'));
|
|
1447
|
+
}
|
|
1448
|
+
return { ok: true, projectId: r.projectId, text: lines.join('\n') };
|
|
1449
|
+
},
|
|
1450
|
+
});
|
|
1451
|
+
reg({
|
|
1452
|
+
name: 'canvas_update_bible',
|
|
1453
|
+
description: '写入项目设定集(世界观/核心设定、风格基调、角色/场景/道具)。默认按名称合并:同名更新、不存在则追加;mode="replace" 时条目整体替换。【设定集先行】先把世界观/风格/角色/场景/道具写进设定集,再生成剧本与参考图,保证跨镜跨集一致。',
|
|
1454
|
+
parameters: {
|
|
1455
|
+
type: 'object',
|
|
1456
|
+
properties: {
|
|
1457
|
+
projectId: { type: 'string', description: '项目 id' },
|
|
1458
|
+
mode: { type: 'string', enum: ['merge', 'replace'], description: '条目合并方式,默认 merge' },
|
|
1459
|
+
bible: {
|
|
1460
|
+
type: 'object',
|
|
1461
|
+
description: '设定集内容(只传要改的字段)',
|
|
1462
|
+
properties: {
|
|
1463
|
+
worldview: { type: 'string', description: '世界观/核心设定(题材、年代、核心规则、主线暗线)' },
|
|
1464
|
+
style: { type: 'string', description: '风格基调(画风、年代、色调、质感)' },
|
|
1465
|
+
characters: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, desc: { type: 'string' } }, required: ['name'] }, description: '角色设定(名称 + 外观/性格描述)' },
|
|
1466
|
+
scenes: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, desc: { type: 'string' } }, required: ['name'] }, description: '场景设定' },
|
|
1467
|
+
props: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, desc: { type: 'string' } }, required: ['name'] }, description: '道具设定' },
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
},
|
|
1471
|
+
required: ['projectId', 'bible'],
|
|
1472
|
+
additionalProperties: false,
|
|
1473
|
+
},
|
|
1474
|
+
output: textOutput,
|
|
1475
|
+
timeoutMs: 30000,
|
|
1476
|
+
async execute(args) {
|
|
1477
|
+
const r = await canvasUpdateBible(args);
|
|
1478
|
+
const b = r.bible;
|
|
1479
|
+
const text = '✅ 设定集已更新(世界观 ' + (b.worldview ? '有' : '空') + '、风格 ' + (b.style ? '有' : '空')
|
|
1480
|
+
+ '、角色 ' + b.characters.length + '、场景 ' + b.scenes.length + '、道具 ' + b.props.length + ')。画布会在数秒内同步显示。';
|
|
1481
|
+
return { ok: true, projectId: r.projectId, text };
|
|
1482
|
+
},
|
|
1483
|
+
});
|
|
1484
|
+
reg({
|
|
1485
|
+
name: 'canvas_create_node',
|
|
1486
|
+
description: '在项目画布上新建指定类型的节点(image-gen 图片生成 / video-gen 视频生成 / storyboard 分镜表 / finalcut 成片 / script 剧本 / text 文本)。【分镜必须用 type:"storyboard" 的分镜表节点,绝不要用剧本节点装分镜】。【工作流铁律】实际生成由节点完成:你只负责写提示词、连线(connectFrom)、canvas_run_node 触发,不要把成稿直接填进 output。分镜表用 connectFrom 连到上游剧本节点(内容来自连线,不要复制剧本全文);image-gen/video-gen 没有上游提示词时才在 prompt 里写内容,有上游时靠连线取。',
|
|
1487
|
+
parameters: {
|
|
1488
|
+
type: 'object',
|
|
1489
|
+
properties: {
|
|
1490
|
+
projectId: { type: 'string', description: '项目 id' },
|
|
1491
|
+
type: { type: 'string', enum: ['script', 'text', 'image-gen', 'video-gen', 'storyboard', 'finalcut'], description: '节点类型' },
|
|
1492
|
+
name: { type: 'string', description: '节点名字(建议填写,如"角色-陈拾 参考图")' },
|
|
1493
|
+
prompt: { type: 'string', description: '提示词(图片/视频用画面提示词;剧本用创作意图;文本用正文)' },
|
|
1494
|
+
size: { type: 'string', description: '可选:图片清晰度 1K/2K/4K' },
|
|
1495
|
+
ratio: { type: 'string', description: '可选:比例,如 16:9 / 9:16 / 1:1' },
|
|
1496
|
+
connectFrom: { type: 'string', description: '可选:创建后立即从该节点连一条线到新节点(如剧本节点→分镜表)' },
|
|
1497
|
+
connectFromPort: { type: 'string', description: '可选:连线源端口,默认源节点第一个输出端口' },
|
|
1498
|
+
connectToPort: { type: 'string', description: '可选:连线目标端口,默认新节点第一个输入端口' },
|
|
1499
|
+
resolution: { type: 'string', description: '可选:视频分辨率 480P/720P/1080P' },
|
|
1500
|
+
duration: { type: 'number', description: '可选:视频时长(秒)' },
|
|
1501
|
+
count: { type: 'number', description: '可选:分镜表镜头数' },
|
|
1502
|
+
requirement: { type: 'string', description: '可选:分镜表额外要求' },
|
|
1503
|
+
},
|
|
1504
|
+
required: ['projectId', 'type'],
|
|
1505
|
+
additionalProperties: false,
|
|
1506
|
+
},
|
|
1507
|
+
output: textOutput,
|
|
1508
|
+
timeoutMs: 30000,
|
|
1509
|
+
async execute(args) {
|
|
1510
|
+
const r = canvasCreateNode(args);
|
|
1511
|
+
return { ok: true, projectId: r.projectId, nodeId: r.nodeId, label: r.label || undefined, text: '✅ 已新建[' + r.type + ']节点「' + (r.label || r.nodeId) + '」(id=' + r.nodeId + ')。可用 canvas_run_node 触发执行。' };
|
|
1512
|
+
},
|
|
1513
|
+
});
|
|
1514
|
+
reg({
|
|
1515
|
+
name: 'canvas_connect',
|
|
1516
|
+
description: '在项目画布上把两个节点连成工作流连线(剧本 → 分镜表 → 图片/视频 → 成片)。【工作流铁律】分镜表的内容来自连线的上游剧本节点,不要把剧本全文复制进分镜表节点;生成类节点(image-gen/video-gen)没有上游提示词时才在 prompt 里写内容,有上游时靠连线取。端口类型必须匹配(text→text、image→image、video→video),不匹配会报错;单线输入端口(script/prompt/frame/in)重复连线会替换旧线,ref 类端口允许多条。',
|
|
1517
|
+
parameters: {
|
|
1518
|
+
type: 'object',
|
|
1519
|
+
properties: {
|
|
1520
|
+
projectId: { type: 'string', description: '项目 id' },
|
|
1521
|
+
fromNode: { type: 'string', description: '源节点 id(连出方)' },
|
|
1522
|
+
fromPort: { type: 'string', description: '可选:源端口,默认该节点第一个输出端口(剧本/文本/图片/视频都是 out)' },
|
|
1523
|
+
toNode: { type: 'string', description: '目标节点 id(连入方)' },
|
|
1524
|
+
toPort: { type: 'string', description: '可选:目标端口,默认该节点第一个输入端口(分镜表=script、图片/视频=prompt、成片=in)' },
|
|
1525
|
+
},
|
|
1526
|
+
required: ['projectId', 'fromNode', 'toNode'],
|
|
1527
|
+
additionalProperties: false,
|
|
1528
|
+
},
|
|
1529
|
+
output: textOutput,
|
|
1530
|
+
timeoutMs: 30000,
|
|
1531
|
+
async execute(args) {
|
|
1532
|
+
const r = canvasConnect(args);
|
|
1533
|
+
return { ok: true, projectId: r.projectId, edgeId: r.edgeId, already: r.already || undefined, text: r.text };
|
|
1534
|
+
},
|
|
1535
|
+
});
|
|
1536
|
+
reg({
|
|
1537
|
+
name: 'canvas_open',
|
|
1538
|
+
description: '打开无限画布页面并跳到指定项目(画布侧会在几秒内自动切换)。用户想查看/编辑画布时使用。',
|
|
1539
|
+
parameters: { type: 'object', properties: { projectId: { type: 'string', description: '要打开的项目 id(缺省只打开画布页)' } }, additionalProperties: false },
|
|
1540
|
+
output: textOutput,
|
|
1541
|
+
timeoutMs: 30000,
|
|
1542
|
+
async execute(args) {
|
|
1543
|
+
const r = await canvasOpen(args);
|
|
1544
|
+
return { ok: true, projectId: r.projectId || undefined, text: r.projectId ? ('✅ 画布即将打开并跳到项目 ' + r.projectId + '。') : '✅ 画布即将打开。' };
|
|
1545
|
+
},
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
} catch { /* tools 服务不可用时跳过注册 */ }
|
|
1549
|
+
|
|
1550
|
+
// 内置技能:把画布操作规程交给 skills 服务(Agent 可加载,作为硬约束)
|
|
1551
|
+
try { registerCanvasSkill(ctx, toolDisposers); } catch { /* skills 服务不可用时跳过 */ }
|
|
1552
|
+
|
|
427
1553
|
const registrations = [
|
|
428
1554
|
ctx.webServer.register({
|
|
429
1555
|
kind: 'exact',
|
|
@@ -439,11 +1565,12 @@ export function apply(ctx) {
|
|
|
439
1565
|
let raw = '';
|
|
440
1566
|
for await (const chunk of req) {
|
|
441
1567
|
raw += chunk;
|
|
442
|
-
if (raw.length >
|
|
1568
|
+
if (raw.length > 8e6) throw new Error('请求过大');
|
|
443
1569
|
}
|
|
444
1570
|
const { method, args } = JSON.parse(raw);
|
|
1571
|
+
if (typeof method !== 'string' || !Object.hasOwn(methods, method)) throw new Error('未知操作: ' + method);
|
|
445
1572
|
const fn = methods[method];
|
|
446
|
-
if (
|
|
1573
|
+
if (typeof fn !== 'function') throw new Error('未知操作: ' + method);
|
|
447
1574
|
res.end(JSON.stringify(await fn(args ?? {})));
|
|
448
1575
|
} catch (e) {
|
|
449
1576
|
res.statusCode = 400;
|
|
@@ -451,6 +1578,42 @@ export function apply(ctx) {
|
|
|
451
1578
|
}
|
|
452
1579
|
},
|
|
453
1580
|
}),
|
|
1581
|
+
ctx.webServer.register({
|
|
1582
|
+
kind: 'exact',
|
|
1583
|
+
path: `/${NAME}/api/llm-stream`,
|
|
1584
|
+
async handler(req, res) {
|
|
1585
|
+
// 流式生成:把「思考中」的内容实时推给画布,让用户看得到过程(推理模型可能要思考很久)
|
|
1586
|
+
try {
|
|
1587
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end('仅接受 POST'); return; }
|
|
1588
|
+
let raw = '';
|
|
1589
|
+
for await (const chunk of req) {
|
|
1590
|
+
raw += chunk;
|
|
1591
|
+
if (raw.length > 4e6) throw new Error('请求过大');
|
|
1592
|
+
}
|
|
1593
|
+
const args = JSON.parse(raw || '{}');
|
|
1594
|
+
res.statusCode = 200;
|
|
1595
|
+
res.setHeader('content-type', 'text/event-stream; charset=utf-8');
|
|
1596
|
+
res.setHeader('cache-control', 'no-cache');
|
|
1597
|
+
res.setHeader('connection', 'keep-alive');
|
|
1598
|
+
const send = (obj) => { try { res.write('data: ' + JSON.stringify(obj) + '\n\n'); } catch { /* 客户端断开 */ } };
|
|
1599
|
+
let closed = false;
|
|
1600
|
+
res.on('close', () => { closed = true; });
|
|
1601
|
+
const text = await complete(args, {
|
|
1602
|
+
onAttempt: (info) => send({ t: 'attempt', d: info }),
|
|
1603
|
+
onReasoning: (d) => { if (!closed) send({ t: 'r', d }); },
|
|
1604
|
+
onText: (d) => { if (!closed) send({ t: 'x', d }); },
|
|
1605
|
+
});
|
|
1606
|
+
send({ t: 'done', d: text });
|
|
1607
|
+
res.end();
|
|
1608
|
+
} catch (e) {
|
|
1609
|
+
const message = String((e && e.message) || e);
|
|
1610
|
+
try {
|
|
1611
|
+
if (res.headersSent) { res.write('data: ' + JSON.stringify({ t: 'fail', d: message }) + '\n\n'); res.end(); }
|
|
1612
|
+
else { res.statusCode = 400; res.end(message); }
|
|
1613
|
+
} catch { /* 忽略 */ }
|
|
1614
|
+
}
|
|
1615
|
+
},
|
|
1616
|
+
}),
|
|
454
1617
|
ctx.webServer.register({
|
|
455
1618
|
kind: 'exact',
|
|
456
1619
|
path: `/${NAME}/api/asset`,
|
|
@@ -468,11 +1631,22 @@ export function apply(ctx) {
|
|
|
468
1631
|
const { abs, clean } = safeJoin(root, u.searchParams.get('rel') || '');
|
|
469
1632
|
mkdirSync(path.dirname(abs), { recursive: true });
|
|
470
1633
|
let size = 0;
|
|
1634
|
+
const MAX_UPLOAD = 512 * 1024 * 1024; // 512MB 上限:避免无界写盘
|
|
471
1635
|
await new Promise((resolve, reject) => {
|
|
472
1636
|
const ws = createWriteStream(abs);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1637
|
+
const fail = (err) => {
|
|
1638
|
+
// 中断/超限:销毁写流并删掉半截文件,避免数据目录累积垃圾与"截断但仍 200"的资产
|
|
1639
|
+
try { ws.destroy(); } catch { /* 忽略 */ }
|
|
1640
|
+
try { unlinkSync(abs); } catch { /* 忽略 */ }
|
|
1641
|
+
reject(err);
|
|
1642
|
+
};
|
|
1643
|
+
req.on('data', (c) => {
|
|
1644
|
+
size += c.length;
|
|
1645
|
+
if (size > MAX_UPLOAD) fail(new Error('文件过大(上限 512MB)'));
|
|
1646
|
+
});
|
|
1647
|
+
req.on('error', fail);
|
|
1648
|
+
req.on('aborted', () => fail(new Error('上传中断')));
|
|
1649
|
+
ws.on('error', fail);
|
|
476
1650
|
ws.on('finish', resolve);
|
|
477
1651
|
req.pipe(ws);
|
|
478
1652
|
});
|
|
@@ -514,10 +1688,10 @@ export function apply(ctx) {
|
|
|
514
1688
|
res.statusCode = 206;
|
|
515
1689
|
res.setHeader('content-range', `bytes ${start}-${end}/${st.size}`);
|
|
516
1690
|
res.setHeader('content-length', String(end - start + 1));
|
|
517
|
-
|
|
1691
|
+
pipeFile(abs, { start, end }, res);
|
|
518
1692
|
} else {
|
|
519
1693
|
res.setHeader('content-length', String(st.size));
|
|
520
|
-
|
|
1694
|
+
pipeFile(abs, {}, res);
|
|
521
1695
|
}
|
|
522
1696
|
} catch (e) {
|
|
523
1697
|
res.statusCode = 400;
|
|
@@ -526,6 +1700,9 @@ export function apply(ctx) {
|
|
|
526
1700
|
},
|
|
527
1701
|
}),
|
|
528
1702
|
];
|
|
529
|
-
return () => {
|
|
1703
|
+
return () => {
|
|
1704
|
+
for (const off of registrations) { try { if (off) off(); } catch { /* 忽略 */ } }
|
|
1705
|
+
for (const d of toolDisposers) { try { d(); } catch { /* 忽略 */ } }
|
|
1706
|
+
};
|
|
530
1707
|
}, `${NAME}: api routes`);
|
|
531
1708
|
}
|