@ninkch/dsh-infinite-canvas 0.17.18 → 0.18.1
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 +20 -19
- package/assets/canvas-studio/SKILL.md +5 -6
- package/client.js +387 -135
- package/docs/migration-0.18.md +70 -0
- package/index.js +655 -29
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync,
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
15
17
|
|
|
16
18
|
const NAME = 'infinite-canvas';
|
|
17
19
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -180,6 +182,404 @@ function sanitizeFolderName(name) {
|
|
|
180
182
|
const s = String(name || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
181
183
|
return s || '未命名项目';
|
|
182
184
|
}
|
|
185
|
+
|
|
186
|
+
// ============================================================
|
|
187
|
+
// 存储引擎 v2(0.18.0):SQLite(node:sqlite)+ CAS 对象库
|
|
188
|
+
// <dataRoot>/canvas.db 元数据 + 项目文档 = 唯一事实源
|
|
189
|
+
// <dataRoot>/objects/<sha2>/<sha256> 媒体文件(内容寻址、去重、引用计数)
|
|
190
|
+
// node:sqlite 需要 Node ≥22.5(DSH 捆绑 Node 24 满足);不可用时整站回退 v1 JSON 存储。
|
|
191
|
+
// ============================================================
|
|
192
|
+
const require_ = createRequire(import.meta.url);
|
|
193
|
+
const SQLITE = (() => { try { return require_('node:sqlite'); } catch { return null; } })();
|
|
194
|
+
let _db = null; // DatabaseSync 实例(按 dataRoot 缓存)
|
|
195
|
+
let _dbRoot = null;
|
|
196
|
+
let _rpcMethods = null; // RPC 方法表(apply 时赋值;测试直接调用)
|
|
197
|
+
|
|
198
|
+
function sqlActive() { return !!SQLITE && !!dataRoot(); }
|
|
199
|
+
function dbFileFor(root) { return path.join(root, 'canvas.db'); }
|
|
200
|
+
function objectsDirFor(root) { return path.join(root, 'objects'); }
|
|
201
|
+
function objectAbsFor(root, sha) { return path.join(objectsDirFor(root), String(sha).slice(0, 2), String(sha)); }
|
|
202
|
+
|
|
203
|
+
function closeDb() {
|
|
204
|
+
if (!_db) return;
|
|
205
|
+
try { _db.close(); } catch { /* 已关闭 */ }
|
|
206
|
+
_db = null;
|
|
207
|
+
_dbRoot = null;
|
|
208
|
+
}
|
|
209
|
+
process.on('exit', () => { try { closeDb(); } catch { /* 忽略 */ } });
|
|
210
|
+
|
|
211
|
+
/** 打开(或复用)数据目录的 canvas.db;全新库自动执行一次性 v1 迁移。 */
|
|
212
|
+
function getDb() {
|
|
213
|
+
const root = dataRoot();
|
|
214
|
+
if (!root || !SQLITE) return null;
|
|
215
|
+
if (_db && _dbRoot === root) return _db;
|
|
216
|
+
closeDb();
|
|
217
|
+
mkdirSync(root, { recursive: true });
|
|
218
|
+
const db = new SQLITE.DatabaseSync(dbFileFor(root));
|
|
219
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
|
|
220
|
+
ensureSchema(db, root);
|
|
221
|
+
_db = db;
|
|
222
|
+
_dbRoot = root;
|
|
223
|
+
return _db;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function metaGet(db, k) {
|
|
227
|
+
const r = db.prepare('SELECT value FROM meta WHERE key = ?').get(k);
|
|
228
|
+
return r ? r.value : null;
|
|
229
|
+
}
|
|
230
|
+
function metaSet(db, k, v) {
|
|
231
|
+
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(k, String(v));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ensureSchema(db, root) {
|
|
235
|
+
db.exec(`
|
|
236
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
237
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
238
|
+
id TEXT PRIMARY KEY, name TEXT, created_at INTEGER, updated_at INTEGER,
|
|
239
|
+
bible_updated_at INTEGER DEFAULT 0, summary TEXT, tombstones TEXT, doc TEXT);
|
|
240
|
+
CREATE TABLE IF NOT EXISTS assets (
|
|
241
|
+
id TEXT PRIMARY KEY, project_id TEXT, episode_id TEXT,
|
|
242
|
+
kind TEXT, mime TEXT, name TEXT,
|
|
243
|
+
rel TEXT, sha TEXT, bytes INTEGER,
|
|
244
|
+
width INTEGER, height INTEGER, duration REAL,
|
|
245
|
+
prompt TEXT, category TEXT, entity TEXT, entities TEXT, node_label TEXT,
|
|
246
|
+
text TEXT, source TEXT, created_at INTEGER, updated_at INTEGER);
|
|
247
|
+
CREATE INDEX IF NOT EXISTS idx_assets_proj ON assets(project_id, created_at DESC, id DESC);
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_assets_sha ON assets(sha);
|
|
249
|
+
CREATE TABLE IF NOT EXISTS bindings (asset_id TEXT, entity_id TEXT, PRIMARY KEY (asset_id, entity_id));
|
|
250
|
+
CREATE TABLE IF NOT EXISTS entities (id TEXT PRIMARY KEY, project_id TEXT, kind TEXT, name TEXT);
|
|
251
|
+
CREATE TABLE IF NOT EXISTS episodes (id TEXT PRIMARY KEY, project_id TEXT, season INTEGER, number INTEGER, title TEXT);
|
|
252
|
+
`);
|
|
253
|
+
if (!metaGet(db, 'fts_tokenizer')) {
|
|
254
|
+
try {
|
|
255
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt, tokenize = 'trigram')`);
|
|
256
|
+
metaSet(db, 'fts_tokenizer', 'trigram'); // trigram:中文子串级全文检索(SQLite ≥3.34)
|
|
257
|
+
} catch {
|
|
258
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt)`);
|
|
259
|
+
metaSet(db, 'fts_tokenizer', 'unicode61');
|
|
260
|
+
}
|
|
261
|
+
} else if (metaGet(db, 'fts_tokenizer') === 'unicode61') {
|
|
262
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt)`);
|
|
263
|
+
} else {
|
|
264
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt, tokenize = 'trigram')`);
|
|
265
|
+
}
|
|
266
|
+
if (!metaGet(db, 'schema_version')) {
|
|
267
|
+
// 全新库:从 v1 布局(~/.dsh 项目 JSON + 数据目录 assets.json 镜像)一次性导入
|
|
268
|
+
const counts = migrateV1IntoDb(db, root);
|
|
269
|
+
metaSet(db, 'schema_version', '2');
|
|
270
|
+
metaSet(db, 'migrated_from', 'v1@' + new Date().toISOString());
|
|
271
|
+
metaSet(db, 'migrated_counts', JSON.stringify(counts));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 项目文档摘要(列表页用,避免每次列表都解析全量 doc)。 */
|
|
276
|
+
function docSummary(d) {
|
|
277
|
+
const nodes = Object.values(d.nodes || {}).map((n) => ({
|
|
278
|
+
id: n.id, type: n.type, label: (n.data && n.data.label) || '', status: (n.data && n.data.status) || 'idle',
|
|
279
|
+
}));
|
|
280
|
+
return { nodeIds: Object.keys(d.nodes || {}), nodes, source: d.source || 'app' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** v1 → v2 一次性迁移:文档按 updatedAt 新者胜合并;资产索引照抄(媒体文件原地保留,rel 指针不动——零风险迁移)。 */
|
|
284
|
+
function migrateV1IntoDb(db, root) {
|
|
285
|
+
const counts = { projects: 0, assets: 0 };
|
|
286
|
+
const insProj = db.prepare('INSERT OR REPLACE INTO projects (id, name, created_at, updated_at, bible_updated_at, summary, tombstones, doc) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
287
|
+
const docs = new Map();
|
|
288
|
+
const consider = (d) => {
|
|
289
|
+
if (!d || !d.id) return;
|
|
290
|
+
const cur = docs.get(d.id);
|
|
291
|
+
if (!cur || ((cur.updatedAt || 0) <= (d.updatedAt || 0))) docs.set(d.id, d);
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
for (const f of readdirSync(PROJECTS_DIR)) {
|
|
295
|
+
if (!f.endsWith('.json')) continue;
|
|
296
|
+
try { consider(JSON.parse(readFileSync(path.join(PROJECTS_DIR, f), 'utf8'))); } catch { /* 损坏跳过 */ }
|
|
297
|
+
}
|
|
298
|
+
} catch { /* 无旧目录 */ }
|
|
299
|
+
try {
|
|
300
|
+
const meta = readDataRootMeta();
|
|
301
|
+
for (const entry of meta.projects) {
|
|
302
|
+
try { consider(JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'project.json'), 'utf8'))); } catch { /* 跳过 */ }
|
|
303
|
+
}
|
|
304
|
+
} catch { /* 无镜像 */ }
|
|
305
|
+
for (const d of docs.values()) {
|
|
306
|
+
insProj.run(
|
|
307
|
+
d.id, String(d.name || d.id).slice(0, 80), d.createdAt || 0, d.updatedAt || 0, d.bibleUpdatedAt || 0,
|
|
308
|
+
JSON.stringify(docSummary(d)), JSON.stringify(d.tombstones || []), JSON.stringify(d),
|
|
309
|
+
);
|
|
310
|
+
counts.projects++;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const meta = readDataRootMeta();
|
|
314
|
+
for (const entry of meta.projects) {
|
|
315
|
+
let list = null;
|
|
316
|
+
try { list = JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'assets.json'), 'utf8')); } catch { continue; }
|
|
317
|
+
if (!Array.isArray(list)) continue;
|
|
318
|
+
for (const a of list) {
|
|
319
|
+
if (!a || !a.id) continue;
|
|
320
|
+
upsertAssetRow(db, assetRowFromRecord({ ...a, projectId: a.projectId || entry.id }));
|
|
321
|
+
counts.assets++;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch { /* 无索引 */ }
|
|
325
|
+
return counts;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 客户端资产记录 → SQL 行参数(0.18 统一形状)。 */
|
|
329
|
+
function assetRowFromRecord(a) {
|
|
330
|
+
return {
|
|
331
|
+
id: String(a.id),
|
|
332
|
+
project_id: String(a.projectId || ''),
|
|
333
|
+
episode_id: a.episodeId ? String(a.episodeId) : null,
|
|
334
|
+
kind: String(a.kind || ''),
|
|
335
|
+
mime: String(a.mime || ''),
|
|
336
|
+
name: String(a.name || ''),
|
|
337
|
+
rel: a.rel ? String(a.rel) : null,
|
|
338
|
+
sha: a.sha ? String(a.sha).toLowerCase() : null,
|
|
339
|
+
bytes: Number(a.size || a.bytes || 0) || null,
|
|
340
|
+
width: Number(a.width || 0) || null,
|
|
341
|
+
height: Number(a.height || 0) || null,
|
|
342
|
+
duration: Number(a.duration || 0) || null,
|
|
343
|
+
prompt: String(a.prompt || ''),
|
|
344
|
+
category: String(a.category || 'general'),
|
|
345
|
+
entity: a.entity ? String(a.entity) : null,
|
|
346
|
+
entities: Array.isArray(a.entities) ? JSON.stringify(a.entities) : null,
|
|
347
|
+
node_label: a.nodeLabel ? String(a.nodeLabel) : null,
|
|
348
|
+
text: (a.kind === 'text' && typeof a.text === 'string') ? a.text : null,
|
|
349
|
+
source: String(a.source || ''),
|
|
350
|
+
created_at: Number(a.createdAt) || Date.now(),
|
|
351
|
+
updated_at: Date.now(),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** SQL 行 → 客户端资产记录(v1 形状超集:新增 sha/episodeId)。 */
|
|
356
|
+
function rowToAsset(r) {
|
|
357
|
+
const out = {
|
|
358
|
+
id: r.id, projectId: r.project_id, kind: r.kind, mime: r.mime, name: r.name,
|
|
359
|
+
createdAt: r.created_at, category: r.category || 'general', prompt: r.prompt || '', source: r.source || '',
|
|
360
|
+
};
|
|
361
|
+
if (r.episode_id) out.episodeId = r.episode_id;
|
|
362
|
+
if (r.rel) { out.rel = r.rel; out.size = r.bytes || 0; }
|
|
363
|
+
if (r.sha) { out.sha = r.sha; out.size = r.bytes || 0; }
|
|
364
|
+
if (r.width) out.width = r.width;
|
|
365
|
+
if (r.height) out.height = r.height;
|
|
366
|
+
if (r.duration) out.duration = r.duration;
|
|
367
|
+
if (r.entity) out.entity = r.entity;
|
|
368
|
+
if (r.entities) { try { out.entities = JSON.parse(r.entities); } catch { /* 忽略 */ } }
|
|
369
|
+
if (r.node_label) out.nodeLabel = r.node_label;
|
|
370
|
+
if (r.text != null && r.text !== '') out.text = r.text;
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function syncFts(db, id, name, prompt) {
|
|
375
|
+
db.prepare('DELETE FROM assets_fts WHERE id = ?').run(id);
|
|
376
|
+
db.prepare('INSERT INTO assets_fts (id, name, prompt) VALUES (?, ?, ?)').run(id, String(name || ''), String(prompt || ''));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function upsertAssetRow(db, row) {
|
|
380
|
+
db.prepare(`INSERT INTO assets (id, project_id, episode_id, kind, mime, name, rel, sha, bytes, width, height, duration, prompt, category, entity, entities, node_label, text, source, created_at, updated_at)
|
|
381
|
+
VALUES (@id, @project_id, @episode_id, @kind, @mime, @name, @rel, @sha, @bytes, @width, @height, @duration, @prompt, @category, @entity, @entities, @node_label, @text, @source, @created_at, @updated_at)
|
|
382
|
+
ON CONFLICT(id) DO UPDATE SET project_id=@project_id, episode_id=@episode_id, kind=@kind, mime=@mime, name=@name, rel=@rel, sha=@sha, bytes=@bytes,
|
|
383
|
+
width=@width, height=@height, duration=@duration, prompt=@prompt, category=@category, entity=@entity, entities=@entities,
|
|
384
|
+
node_label=@node_label, text=@text, source=@source, updated_at=@updated_at`).run(row);
|
|
385
|
+
syncFts(db, row.id, row.name, row.prompt);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 对象引用计数:>0 表示仍有资产引用,不可物理删除。 */
|
|
389
|
+
function objectRefCount(db, sha) {
|
|
390
|
+
return db.prepare('SELECT COUNT(*) AS n FROM assets WHERE sha = ?').get(sha).n;
|
|
391
|
+
}
|
|
392
|
+
/** 引用归零后删除 CAS 对象(其它项目还在用就保留——内容寻址天然跨项目共享)。 */
|
|
393
|
+
function gcObjectIfUnreferenced(db, root, sha) {
|
|
394
|
+
try {
|
|
395
|
+
if (objectRefCount(db, sha) > 0) return false;
|
|
396
|
+
const abs = objectAbsFor(root, sha);
|
|
397
|
+
if (existsSync(abs)) unlinkSync(abs);
|
|
398
|
+
return true;
|
|
399
|
+
} catch { return false; }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 资产删除(SQL 模式):行 + FTS + rel 旧文件 / sha 对象按引用计数清理。 */
|
|
403
|
+
function dbAssetDelete(id) {
|
|
404
|
+
const db = getDb();
|
|
405
|
+
const root = dataRoot();
|
|
406
|
+
const row = db.prepare('SELECT * FROM assets WHERE id = ?').get(id);
|
|
407
|
+
if (!row) return { ok: true, deleted: false };
|
|
408
|
+
db.prepare('DELETE FROM assets WHERE id = ?').run(id);
|
|
409
|
+
db.prepare('DELETE FROM assets_fts WHERE id = ?').run(id);
|
|
410
|
+
if (row.sha && root) gcObjectIfUnreferenced(db, root, row.sha);
|
|
411
|
+
if (row.rel && root) {
|
|
412
|
+
try {
|
|
413
|
+
const mapped = physicalRel(row.rel) || row.rel;
|
|
414
|
+
const { abs } = safeJoin(root, mapped);
|
|
415
|
+
unlinkSync(abs);
|
|
416
|
+
} catch { /* 已不存在视为成功 */ }
|
|
417
|
+
}
|
|
418
|
+
return { ok: true, deleted: true };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** 资产分页查询:过滤 + keyset 游标(created_at, id)+ 中文全文检索。 */
|
|
422
|
+
function dbAssetsList(args) {
|
|
423
|
+
const db = getDb();
|
|
424
|
+
const limit = Math.min(200, Math.max(1, Number(args && args.limit) || 50));
|
|
425
|
+
const where = [];
|
|
426
|
+
const params = {};
|
|
427
|
+
const projectId = String((args && args.projectId) || '');
|
|
428
|
+
if (projectId) { where.push('project_id = @projectId'); params.projectId = projectId; }
|
|
429
|
+
const category = String((args && args.category) || '');
|
|
430
|
+
if (category) { where.push('category = @category'); params.category = category; }
|
|
431
|
+
const kind = String((args && args.kind) || '');
|
|
432
|
+
if (kind) { where.push('kind = @kind'); params.kind = kind; }
|
|
433
|
+
const q = String((args && args.q) || '').trim();
|
|
434
|
+
if (q) {
|
|
435
|
+
if (q.length >= 3 && metaGet(db, 'fts_tokenizer') === 'trigram') {
|
|
436
|
+
where.push("id IN (SELECT id FROM assets_fts WHERE assets_fts MATCH @q)");
|
|
437
|
+
params.q = '"' + q.replace(/"/g, '""') + '"';
|
|
438
|
+
} else {
|
|
439
|
+
where.push("(name LIKE @like ESCAPE '\\' OR prompt LIKE @like ESCAPE '\\')");
|
|
440
|
+
params.like = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
let cursor = null;
|
|
444
|
+
if (args && args.cursor) {
|
|
445
|
+
try { cursor = JSON.parse(Buffer.from(String(args.cursor), 'base64').toString('utf8')); } catch { cursor = null; }
|
|
446
|
+
}
|
|
447
|
+
if (Array.isArray(cursor) && cursor.length === 2) {
|
|
448
|
+
where.push('(created_at < @cAt OR (created_at = @cAt AND id < @cId))');
|
|
449
|
+
params.cAt = Number(cursor[0]) || 0;
|
|
450
|
+
params.cId = String(cursor[1] || '');
|
|
451
|
+
}
|
|
452
|
+
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
|
453
|
+
const total = db.prepare('SELECT COUNT(*) AS n FROM assets ' + whereSql).get(params).n;
|
|
454
|
+
const rows = db.prepare('SELECT * FROM assets ' + whereSql + ' ORDER BY created_at DESC, id DESC LIMIT @limit').all({ ...params, limit: limit + 1 });
|
|
455
|
+
let nextCursor = null;
|
|
456
|
+
if (rows.length > limit) {
|
|
457
|
+
rows.length = limit;
|
|
458
|
+
const last = rows[rows.length - 1];
|
|
459
|
+
nextCursor = Buffer.from(JSON.stringify([last.created_at, last.id])).toString('base64');
|
|
460
|
+
}
|
|
461
|
+
return { ok: true, total, items: rows.map(rowToAsset), nextCursor };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function dbAssetsGet(ids) {
|
|
465
|
+
const db = getDb();
|
|
466
|
+
const out = [];
|
|
467
|
+
const get = db.prepare('SELECT * FROM assets WHERE id = ?');
|
|
468
|
+
for (const id of (Array.isArray(ids) ? ids : []).slice(0, 200)) {
|
|
469
|
+
const r = get.get(String(id));
|
|
470
|
+
if (r) out.push(rowToAsset(r));
|
|
471
|
+
}
|
|
472
|
+
return { ok: true, items: out };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function dbAssetNames(projectId) {
|
|
476
|
+
const db = getDb();
|
|
477
|
+
const rows = db.prepare('SELECT id, name FROM assets WHERE project_id = ? ORDER BY created_at DESC').all(String(projectId || ''));
|
|
478
|
+
return { ok: true, names: rows.map((r) => ({ id: r.id, name: r.name })) };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function dbAssetCreate(a) {
|
|
482
|
+
if (!a || !a.id || !a.projectId) throw new Error('需要 asset.id 与 asset.projectId');
|
|
483
|
+
const db = getDb();
|
|
484
|
+
const row = assetRowFromRecord(a);
|
|
485
|
+
upsertAssetRow(db, row);
|
|
486
|
+
return { ok: true, asset: rowToAsset(db.prepare('SELECT * FROM assets WHERE id = ?').get(row.id)) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function dbAssetUpdate(id, patch) {
|
|
490
|
+
const db = getDb();
|
|
491
|
+
const row = db.prepare('SELECT * FROM assets WHERE id = ?').get(String(id || ''));
|
|
492
|
+
if (!row) throw new Error('资产不存在:' + id);
|
|
493
|
+
const cur = rowToAsset(row);
|
|
494
|
+
const merged = { ...cur };
|
|
495
|
+
if (patch && patch.name !== undefined) merged.name = String(patch.name).slice(0, 80);
|
|
496
|
+
if (patch && patch.category !== undefined) merged.category = String(patch.category || 'general');
|
|
497
|
+
if (patch && patch.projectId !== undefined) merged.projectId = String(patch.projectId || '');
|
|
498
|
+
if (patch && patch.prompt !== undefined) merged.prompt = String(patch.prompt || '');
|
|
499
|
+
if (patch && patch.entity !== undefined) merged.entity = patch.entity ? String(patch.entity) : undefined;
|
|
500
|
+
if (patch && patch.entities !== undefined) merged.entities = Array.isArray(patch.entities) ? patch.entities : undefined;
|
|
501
|
+
if (patch && patch.nodeLabel !== undefined) merged.nodeLabel = patch.nodeLabel ? String(patch.nodeLabel) : undefined;
|
|
502
|
+
if (patch && patch.text !== undefined && cur.kind === 'text') merged.text = String(patch.text || '');
|
|
503
|
+
upsertAssetRow(db, assetRowFromRecord({ ...merged, createdAt: cur.createdAt }));
|
|
504
|
+
return { ok: true, asset: rowToAsset(db.prepare('SELECT * FROM assets WHERE id = ?').get(row.id)) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** CAS 对象库 GC:扫描 objects/,删除无任何资产行引用的对象(mtime 超过 24h 才删,避免与并发上传竞态)。 */
|
|
508
|
+
function dbObjectsGc() {
|
|
509
|
+
const root = dataRoot();
|
|
510
|
+
const db = getDb();
|
|
511
|
+
if (!root || !db) throw new Error('SQL 存储不可用');
|
|
512
|
+
const odir = objectsDirFor(root);
|
|
513
|
+
let scanned = 0;
|
|
514
|
+
let removed = 0;
|
|
515
|
+
const grace = Date.now() - 24 * 3600 * 1000;
|
|
516
|
+
try {
|
|
517
|
+
for (const bucket of readdirSync(odir)) {
|
|
518
|
+
const bdir = path.join(odir, bucket);
|
|
519
|
+
let stats = null;
|
|
520
|
+
try { stats = statSync(bdir); } catch { continue; }
|
|
521
|
+
if (!stats.isDirectory()) continue;
|
|
522
|
+
for (const f of readdirSync(bdir)) {
|
|
523
|
+
if (!/^[0-9a-f]{64}$/i.test(f)) continue;
|
|
524
|
+
scanned++;
|
|
525
|
+
const abs = path.join(bdir, f);
|
|
526
|
+
let st = null;
|
|
527
|
+
try { st = statSync(abs); } catch { continue; }
|
|
528
|
+
if (st.mtimeMs > grace) continue; // 近期写入:可能尚未登记资产行
|
|
529
|
+
if (objectRefCount(db, f) === 0) {
|
|
530
|
+
try { unlinkSync(abs); removed++; } catch { /* 忽略 */ }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
} catch { /* 目录不存在 = 无对象 */ }
|
|
535
|
+
return { ok: true, scanned, removed };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function readDocOrNull(id) {
|
|
539
|
+
if (sqlActive()) {
|
|
540
|
+
const db = getDb();
|
|
541
|
+
const row = db.prepare('SELECT doc FROM projects WHERE id = ?').get(id);
|
|
542
|
+
if (!row) return null;
|
|
543
|
+
try { return JSON.parse(row.doc); } catch { return null; }
|
|
544
|
+
}
|
|
545
|
+
try { return JSON.parse(readFileSync(projectFile(id), 'utf8')); } catch { return null; }
|
|
546
|
+
}
|
|
547
|
+
function docExists(id) {
|
|
548
|
+
if (sqlActive()) {
|
|
549
|
+
const db = getDb();
|
|
550
|
+
return !!db.prepare('SELECT id FROM projects WHERE id = ?').get(id);
|
|
551
|
+
}
|
|
552
|
+
return existsSync(projectFile(id));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** JSON 降级模式的资产分页:扫全部 assets.json 平铺 → 过滤 → offset 游标模拟(无检索)。 */
|
|
556
|
+
function jsonAssetsList(args) {
|
|
557
|
+
const root = dataRoot();
|
|
558
|
+
const items = [];
|
|
559
|
+
if (root) {
|
|
560
|
+
try {
|
|
561
|
+
const meta = readDataRootMeta();
|
|
562
|
+
for (const entry of meta.projects) {
|
|
563
|
+
let list = null;
|
|
564
|
+
try { list = JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'assets.json'), 'utf8')); } catch { continue; }
|
|
565
|
+
if (Array.isArray(list)) for (const a of list) { if (a && a.id) items.push({ ...a, projectId: a.projectId || entry.id }); }
|
|
566
|
+
}
|
|
567
|
+
} catch { /* 无目录 */ }
|
|
568
|
+
}
|
|
569
|
+
const projectId = String((args && args.projectId) || '');
|
|
570
|
+
const kind = String((args && args.kind) || '');
|
|
571
|
+
const category = String((args && args.category) || '');
|
|
572
|
+
const rows = items
|
|
573
|
+
.filter((a) => (!projectId || a.projectId === projectId) && (!kind || a.kind === kind) && (!category || (a.category || 'general') === category))
|
|
574
|
+
.sort((x, y) => (y.createdAt || 0) - (x.createdAt || 0));
|
|
575
|
+
const total = rows.length;
|
|
576
|
+
let start = 0;
|
|
577
|
+
if (args && args.cursor) { try { start = Number(Buffer.from(String(args.cursor), 'base64').toString('utf8')) || 0; } catch { start = 0; } }
|
|
578
|
+
const limit = Math.min(200, Math.max(1, Number(args && args.limit) || 50));
|
|
579
|
+
const page = rows.slice(start, start + limit);
|
|
580
|
+
const nextCursor = start + limit < total ? Buffer.from(String(start + limit)).toString('base64') : null;
|
|
581
|
+
return Promise.resolve({ ok: true, total, items: page, nextCursor });
|
|
582
|
+
}
|
|
183
583
|
/** 确保项目的物理文件夹(按项目名)与清单条目存在;改名时尽力重命名目录(被占用/冲突则保留旧文件夹)。返回文件夹名。 */
|
|
184
584
|
function ensureProjectFolder(pid, name) {
|
|
185
585
|
const root = dataRoot();
|
|
@@ -516,6 +916,21 @@ function projectFile(id) {
|
|
|
516
916
|
return path.join(PROJECTS_DIR, String(id) + '.json');
|
|
517
917
|
}
|
|
518
918
|
function projectsList() {
|
|
919
|
+
if (sqlActive()) {
|
|
920
|
+
const db = getDb();
|
|
921
|
+
const rows = db.prepare('SELECT id, name, created_at, updated_at, bible_updated_at, summary, tombstones FROM projects ORDER BY updated_at DESC').all();
|
|
922
|
+
return rows.map((r) => {
|
|
923
|
+
let sum = {};
|
|
924
|
+
try { sum = JSON.parse(r.summary || '{}'); } catch { /* 旧行/损坏摘要 */ }
|
|
925
|
+
let tombs = [];
|
|
926
|
+
try { tombs = JSON.parse(r.tombstones || '[]'); } catch { /* 旧行 */ }
|
|
927
|
+
return {
|
|
928
|
+
id: r.id, name: r.name, createdAt: r.created_at || 0, updatedAt: r.updated_at || 0,
|
|
929
|
+
source: sum.source || 'app', nodeIds: sum.nodeIds || [], nodes: sum.nodes || [],
|
|
930
|
+
tombstones: Array.isArray(tombs) ? tombs : [], bibleUpdatedAt: r.bible_updated_at || 0,
|
|
931
|
+
};
|
|
932
|
+
});
|
|
933
|
+
}
|
|
519
934
|
let files = [];
|
|
520
935
|
try { files = readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
521
936
|
const list = [];
|
|
@@ -546,8 +961,7 @@ async function projectsPut(args) {
|
|
|
546
961
|
if (deletedProjectIds().includes(id)) return { ok: true, id, deleted: true };
|
|
547
962
|
const f = projectFile(id);
|
|
548
963
|
const doc = (args && args.doc) || {};
|
|
549
|
-
let prev = {};
|
|
550
|
-
try { prev = JSON.parse(readFileSync(f, 'utf8')); } catch { /* 新项目 */ }
|
|
964
|
+
let prev = readDocOrNull(id) || {};
|
|
551
965
|
// 墓碑:对话删除的节点 + 客户端本地删除的节点(客户端删除时也会写入 tombstones)。
|
|
552
966
|
// tombstoneAt 记录每个墓碑的创建时刻:只有**推送时间戳(doc.ts)不早于墓碑时刻**的推送
|
|
553
967
|
// 才被认定为"撤销恢复"允许清墓碑;陈旧推送(快照早于删除、无 ts 或 ts 更老)不得借
|
|
@@ -724,7 +1138,7 @@ async function fetchProxyForward(reqBody) {
|
|
|
724
1138
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
725
1139
|
return { status: upstream.status, contentType: upstream.headers.get('content-type') || 'application/json; charset=utf-8', buf };
|
|
726
1140
|
}
|
|
727
|
-
/**
|
|
1141
|
+
/** 设置数据根目录(绝对路径)并挂载数据库;SQL 模式下首次打开自动执行 v1→v2 迁移。 */
|
|
728
1142
|
function setDataRoot(raw) {
|
|
729
1143
|
raw = String(raw ?? '').trim();
|
|
730
1144
|
if (!raw) throw new Error('需要数据根目录路径');
|
|
@@ -736,16 +1150,36 @@ function setDataRoot(raw) {
|
|
|
736
1150
|
const cfg = readIcConfig();
|
|
737
1151
|
cfg.dataRoot = root;
|
|
738
1152
|
writeIcConfig(cfg);
|
|
739
|
-
//
|
|
740
|
-
let
|
|
741
|
-
|
|
742
|
-
|
|
1153
|
+
closeDb(); // 换目录:关闭旧库句柄,下一次访问按新路径重开
|
|
1154
|
+
let opened = null;
|
|
1155
|
+
if (sqlActive()) {
|
|
1156
|
+
try {
|
|
1157
|
+
const db = getDb();
|
|
1158
|
+
opened = { mode: 'sqlite', migrated: JSON.parse(metaGet(db, 'migrated_counts') || '{}') };
|
|
1159
|
+
} catch (e) { opened = { mode: 'sqlite', error: String((e && e.message) || e) }; }
|
|
1160
|
+
} else {
|
|
1161
|
+
// v1 布局迁移(仅 JSON 降级模式);就绪后自动搬存量资产到磁盘由客户端负责
|
|
1162
|
+
let migrated = null;
|
|
1163
|
+
try { migrated = dataRootMigrate(); } catch (e) { migrated = { error: String((e && e.message) || e) }; }
|
|
1164
|
+
opened = { mode: 'json', migrated };
|
|
1165
|
+
}
|
|
1166
|
+
return { ok: true, dataRoot: root, ...opened };
|
|
743
1167
|
}
|
|
744
1168
|
async function projectsDelete(args) {
|
|
745
1169
|
const id = String((args && args.id) || '');
|
|
746
|
-
|
|
1170
|
+
if (sqlActive()) {
|
|
1171
|
+
const db = getDb();
|
|
1172
|
+
const root = dataRoot();
|
|
1173
|
+
// 先收集对象引用:项目资产行删除后,仅本项目引用的 CAS 对象一并回收
|
|
1174
|
+
const shas = db.prepare('SELECT DISTINCT sha FROM assets WHERE project_id = ? AND sha IS NOT NULL').all(id).map((r) => r.sha);
|
|
1175
|
+
db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
|
|
1176
|
+
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
|
1177
|
+
if (root) for (const sha of shas) gcObjectIfUnreferenced(db, root, sha);
|
|
1178
|
+
} else {
|
|
1179
|
+
try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
|
|
1180
|
+
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1181
|
+
}
|
|
747
1182
|
try { addDeletedProjectId(id); } catch { /* 墓碑写失败不影响删除 */ }
|
|
748
|
-
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
749
1183
|
return { ok: true };
|
|
750
1184
|
}
|
|
751
1185
|
async function projectsSync() {
|
|
@@ -1009,6 +1443,11 @@ function canvasWriteNode(args) {
|
|
|
1009
1443
|
}
|
|
1010
1444
|
/** 读取项目文档:把"文件不存在/内容损坏"变成用户和模型都读得懂的报错(不泄漏绝对路径)。 */
|
|
1011
1445
|
function readDoc(id) {
|
|
1446
|
+
if (sqlActive()) {
|
|
1447
|
+
const doc = readDocOrNull(id);
|
|
1448
|
+
if (!doc) throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1449
|
+
return doc;
|
|
1450
|
+
}
|
|
1012
1451
|
const f = projectFile(id);
|
|
1013
1452
|
let raw;
|
|
1014
1453
|
try {
|
|
@@ -1022,8 +1461,21 @@ function readDoc(id) {
|
|
|
1022
1461
|
throw new Error('项目文件损坏,无法解析:' + id + '(可考虑重建该项目)');
|
|
1023
1462
|
}
|
|
1024
1463
|
}
|
|
1025
|
-
/**
|
|
1464
|
+
/** 原子写项目文档:SQL 模式走单行事务;文件模式先写临时文件再改名,避免半写导致项目"消失"。 */
|
|
1026
1465
|
function writeIcDoc(id, doc) {
|
|
1466
|
+
if (sqlActive()) {
|
|
1467
|
+
const db = getDb();
|
|
1468
|
+
db.prepare(`INSERT INTO projects (id, name, created_at, updated_at, bible_updated_at, summary, tombstones, doc)
|
|
1469
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1470
|
+
ON CONFLICT(id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at,
|
|
1471
|
+
bible_updated_at = excluded.bible_updated_at, summary = excluded.summary,
|
|
1472
|
+
tombstones = excluded.tombstones, doc = excluded.doc`)
|
|
1473
|
+
.run(
|
|
1474
|
+
id, String(doc.name || id).slice(0, 80), doc.createdAt || Date.now(), doc.updatedAt || Date.now(),
|
|
1475
|
+
doc.bibleUpdatedAt || 0, JSON.stringify(docSummary(doc)), JSON.stringify(doc.tombstones || []), JSON.stringify(doc),
|
|
1476
|
+
);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1027
1479
|
const f = projectFile(id);
|
|
1028
1480
|
const tmp = f + '.' + process.pid + '.tmp';
|
|
1029
1481
|
writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
|
|
@@ -1237,11 +1689,20 @@ function canvasRelayout(args) {
|
|
|
1237
1689
|
}
|
|
1238
1690
|
async function canvasDeleteProject(args) {
|
|
1239
1691
|
const id = String((args && args.projectId) || '');
|
|
1240
|
-
const f = projectFile(id);
|
|
1241
1692
|
let name = id;
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1693
|
+
const prevDoc = readDocOrNull(id);
|
|
1694
|
+
if (prevDoc && prevDoc.name) name = prevDoc.name;
|
|
1695
|
+
if (sqlActive()) {
|
|
1696
|
+
const db = getDb();
|
|
1697
|
+
const root = dataRoot();
|
|
1698
|
+
const shas = db.prepare('SELECT DISTINCT sha FROM assets WHERE project_id = ? AND sha IS NOT NULL').all(id).map((r) => r.sha);
|
|
1699
|
+
db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
|
|
1700
|
+
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
|
1701
|
+
if (root) for (const sha of shas) gcObjectIfUnreferenced(db, root, sha);
|
|
1702
|
+
} else {
|
|
1703
|
+
try { unlinkSync(projectFile(id)); } catch { /* 已删除 */ }
|
|
1704
|
+
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1705
|
+
}
|
|
1245
1706
|
// 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活 + 长效墓碑:拒收迟到的推送
|
|
1246
1707
|
const cfg = readIcConfig();
|
|
1247
1708
|
const prev = (cfg.ui && cfg.ui.deletedProjects) || [];
|
|
@@ -1326,10 +1787,17 @@ function storyboardConsistencyWarnings(bible, shots, extraNames) {
|
|
|
1326
1787
|
}
|
|
1327
1788
|
return warnings;
|
|
1328
1789
|
}
|
|
1329
|
-
/**
|
|
1790
|
+
/** 取资产名(SQL 模式查 assets 表;JSON 模式读数据目录索引;与客户端"设定集/资产中登记"的判定口径对齐)。 */
|
|
1330
1791
|
function readAssetIndexNames(pid) {
|
|
1792
|
+
if (!pid) return [];
|
|
1793
|
+
if (sqlActive()) {
|
|
1794
|
+
try {
|
|
1795
|
+
const db = getDb();
|
|
1796
|
+
return db.prepare('SELECT name FROM assets WHERE project_id = ?').all(pid).map((r) => r.name).filter(Boolean);
|
|
1797
|
+
} catch { return []; }
|
|
1798
|
+
}
|
|
1331
1799
|
const root = dataRoot();
|
|
1332
|
-
if (!root
|
|
1800
|
+
if (!root) return [];
|
|
1333
1801
|
try {
|
|
1334
1802
|
const entry = readDataRootMeta().projects.find((p) => p.id === pid);
|
|
1335
1803
|
if (!entry) return [];
|
|
@@ -1381,9 +1849,8 @@ async function canvasNodeStatus(args) {
|
|
|
1381
1849
|
async function canvasOpen(args) {
|
|
1382
1850
|
const pid = String((args && args.projectId) || '');
|
|
1383
1851
|
if (pid) {
|
|
1384
|
-
const f = projectFile(pid); // 校验 id 合法性
|
|
1385
1852
|
// 项目必须真实存在:否则工具回"✅ 已打开",而前端因为找不到项目什么都不做(假成功)
|
|
1386
|
-
if (!
|
|
1853
|
+
if (!docExists(pid)) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
|
|
1387
1854
|
}
|
|
1388
1855
|
const cfg = readIcConfig();
|
|
1389
1856
|
cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, at: Date.now() } };
|
|
@@ -1588,7 +2055,7 @@ async function updateInstall() {
|
|
|
1588
2055
|
export const inject = ['webServer', 'tools'];
|
|
1589
2056
|
|
|
1590
2057
|
// 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
|
|
1591
|
-
export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync };
|
|
2058
|
+
export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, dbObjectsGc, objectAbsFor, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods };
|
|
1592
2059
|
|
|
1593
2060
|
function resolveModel(providers, want) {
|
|
1594
2061
|
const byProvider = want && want.provider
|
|
@@ -1748,7 +2215,7 @@ export function apply(ctx) {
|
|
|
1748
2215
|
const root = dataRoot();
|
|
1749
2216
|
let exists = false;
|
|
1750
2217
|
if (root) { try { exists = statSync(root).isDirectory(); } catch { /* 不存在 */ } }
|
|
1751
|
-
return { dataRoot: root, exists, configPath: CONFIG_PATH };
|
|
2218
|
+
return { dataRoot: root, exists, configPath: CONFIG_PATH, sql: !!SQLITE, store: sqlActive() ? 'sqlite' : 'json' };
|
|
1752
2219
|
},
|
|
1753
2220
|
async dataRootSet(args) {
|
|
1754
2221
|
return setDataRoot((args && args.root) ?? '');
|
|
@@ -1766,15 +2233,36 @@ export function apply(ctx) {
|
|
|
1766
2233
|
} catch { /* 同步抛错同样忽略 */ }
|
|
1767
2234
|
return { ok: true, dataRoot: root };
|
|
1768
2235
|
},
|
|
1769
|
-
//
|
|
2236
|
+
// 资产元数据写入(v1 兼容入口,0.18 起客户端走 assetCreate):SQL 模式 upsert 进 assets 表
|
|
1770
2237
|
async assetIndexPut(args) {
|
|
1771
2238
|
const projectId = String((args && args.projectId) || '');
|
|
1772
2239
|
const asset = (args && args.asset) || null;
|
|
1773
2240
|
if (!projectId || !asset || !asset.id) throw new Error('需要 projectId 与 asset');
|
|
2241
|
+
if (sqlActive()) return dbAssetCreate({ ...asset, projectId });
|
|
1774
2242
|
return writeAssetIndex(projectId, String((args && args.projectName) || ''), [asset]);
|
|
1775
2243
|
},
|
|
1776
2244
|
async assetIndexBulkPut(args) {
|
|
1777
2245
|
const items = Array.isArray(args && args.items) ? args.items : [];
|
|
2246
|
+
if (sqlActive()) {
|
|
2247
|
+
// 兼容旧客户端的"启动全量同步":逐条 upsert;空表不覆盖非空项目(防旧浏览器清库)
|
|
2248
|
+
const byProject = new Map();
|
|
2249
|
+
for (const it of items) {
|
|
2250
|
+
if (!it || !it.projectId) continue;
|
|
2251
|
+
const key = String(it.projectId);
|
|
2252
|
+
if (!byProject.has(key)) byProject.set(key, []);
|
|
2253
|
+
if (it.asset && it.asset.id) byProject.get(key).push(it.asset);
|
|
2254
|
+
}
|
|
2255
|
+
const db = getDb();
|
|
2256
|
+
let indexed = 0;
|
|
2257
|
+
for (const [pid, list] of byProject) {
|
|
2258
|
+
if (!list.length) {
|
|
2259
|
+
const n = db.prepare('SELECT COUNT(*) AS n FROM assets WHERE project_id = ?').get(pid).n;
|
|
2260
|
+
if (n > 0) continue; // 空表保护:绝不因旧客户端启动清掉服务端已有资产
|
|
2261
|
+
}
|
|
2262
|
+
for (const a of list) { upsertAssetRow(db, assetRowFromRecord({ ...a, projectId: pid })); indexed++; }
|
|
2263
|
+
}
|
|
2264
|
+
return { ok: true, indexed, projects: byProject.size };
|
|
2265
|
+
}
|
|
1778
2266
|
const byProject = new Map();
|
|
1779
2267
|
for (const it of items) {
|
|
1780
2268
|
if (!it || !it.projectId) continue;
|
|
@@ -1790,10 +2278,54 @@ export function apply(ctx) {
|
|
|
1790
2278
|
}
|
|
1791
2279
|
return { ok: true, indexed, projects: byProject.size };
|
|
1792
2280
|
},
|
|
1793
|
-
//
|
|
1794
|
-
async
|
|
1795
|
-
|
|
1796
|
-
|
|
2281
|
+
// ===== 资产库 v2(0.18):服务端是唯一事实源,任意浏览器/远程访问同一份 =====
|
|
2282
|
+
async assetsList(args) {
|
|
2283
|
+
if (sqlActive()) return dbAssetsList(args);
|
|
2284
|
+
return jsonAssetsList(args);
|
|
2285
|
+
},
|
|
2286
|
+
async assetsGet(args) {
|
|
2287
|
+
if (sqlActive()) return dbAssetsGet(args && args.ids);
|
|
2288
|
+
const ids = new Set((Array.isArray(args && args.ids) ? args.ids : []).map(String));
|
|
2289
|
+
const r = await jsonAssetsList({ limit: 200000 });
|
|
2290
|
+
return { ok: true, items: r.items.filter((a) => ids.has(String(a.id))) };
|
|
2291
|
+
},
|
|
2292
|
+
async assetNames(args) {
|
|
2293
|
+
if (sqlActive()) return dbAssetNames(args && args.projectId);
|
|
2294
|
+
const r = await jsonAssetsList({ projectId: args && args.projectId, limit: 200000 });
|
|
2295
|
+
return { ok: true, names: r.items.map((a) => ({ id: a.id, name: a.name })) };
|
|
2296
|
+
},
|
|
2297
|
+
async assetCreate(args) {
|
|
2298
|
+
const a = (args && args.asset) || null;
|
|
2299
|
+
if (!a || !a.id || !a.projectId) throw new Error('需要 asset.id 与 asset.projectId');
|
|
2300
|
+
if (sqlActive()) return dbAssetCreate(a);
|
|
2301
|
+
return writeAssetIndex(String(a.projectId), String((args && args.projectName) || ''), [a]);
|
|
2302
|
+
},
|
|
2303
|
+
async assetUpdate(args) {
|
|
2304
|
+
const id = String((args && args.id) || '');
|
|
2305
|
+
if (!id) throw new Error('需要 asset.id');
|
|
2306
|
+
if (sqlActive()) return dbAssetUpdate(id, (args && args.patch) || {});
|
|
2307
|
+
// JSON 降级:读旧索引改单条写回
|
|
2308
|
+
const r = await jsonAssetsList({ limit: 200000 });
|
|
2309
|
+
const cur = r.items.find((a) => String(a.id) === id);
|
|
2310
|
+
if (!cur) throw new Error('资产不存在:' + id);
|
|
2311
|
+
const patch = (args && args.patch) || {};
|
|
2312
|
+
const next = { ...cur, ...(patch.name !== undefined ? { name: String(patch.name).slice(0, 80) } : {}), ...(patch.category !== undefined ? { category: String(patch.category || 'general') } : {}) };
|
|
2313
|
+
return writeAssetIndex(String(next.projectId), '', [next]);
|
|
2314
|
+
},
|
|
2315
|
+
async objectsGc() { return dbObjectsGc(); },
|
|
2316
|
+
// 一键迁移/对齐:旧 pid 文件夹 → 项目名文件夹 + 全部项目文档镜像(v1 JSON 模式专用)
|
|
2317
|
+
async dataRootMigrate() {
|
|
2318
|
+
if (sqlActive()) return { ok: true, skipped: 'sqlite(数据目录即主存储,无需布局迁移)' };
|
|
2319
|
+
return dataRootMigrate();
|
|
2320
|
+
},
|
|
2321
|
+
// 一键导入:换电脑后把数据目录拷过来 → SQL 模式指向目录即自动挂载/迁移;JSON 模式扫描合并
|
|
2322
|
+
async dataRootImport() {
|
|
2323
|
+
if (sqlActive()) {
|
|
2324
|
+
const db = getDb();
|
|
2325
|
+
return { ok: true, mode: 'sqlite', migrated: JSON.parse(metaGet(db, 'migrated_counts') || '{}'), note: '数据目录已就绪:指向该目录后打开画布即为全部数据' };
|
|
2326
|
+
}
|
|
2327
|
+
return dataRootImport();
|
|
2328
|
+
},
|
|
1797
2329
|
async pickFolder() {
|
|
1798
2330
|
const r = await pickFolderDialog();
|
|
1799
2331
|
return r; // { ok, path } | { ok:false, cancelled } | { ok:false, unsupported }
|
|
@@ -1814,6 +2346,9 @@ export function apply(ctx) {
|
|
|
1814
2346
|
async canvasNodeStatus(args) { return canvasNodeStatus(args); },
|
|
1815
2347
|
async canvasOpen(args) { return canvasOpen(args); },
|
|
1816
2348
|
async assetDelete(args) {
|
|
2349
|
+
const id = String((args && args.id) || '');
|
|
2350
|
+
if (!id) return { ok: true };
|
|
2351
|
+
if (sqlActive()) return dbAssetDelete(id);
|
|
1817
2352
|
const root = dataRoot();
|
|
1818
2353
|
const rel = String((args && args.rel) || '');
|
|
1819
2354
|
// 逻辑路径(pid)→ 物理路径(项目名文件夹):不映射会删不到真实文件
|
|
@@ -1826,9 +2361,8 @@ export function apply(ctx) {
|
|
|
1826
2361
|
}
|
|
1827
2362
|
// 同步清掉 assets.json 里的记录,保持数据目录与应用状态一致
|
|
1828
2363
|
const pid = String((args && args.projectId) || '');
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
try { removeAssetIndexEntry(pid, String((args && args.projectName) || ''), assetId); } catch { /* 索引清理失败不影响 */ }
|
|
2364
|
+
if (pid) {
|
|
2365
|
+
try { removeAssetIndexEntry(pid, String((args && args.projectName) || ''), id); } catch { /* 索引清理失败不影响 */ }
|
|
1832
2366
|
}
|
|
1833
2367
|
return { ok: true };
|
|
1834
2368
|
},
|
|
@@ -2204,8 +2738,14 @@ export function apply(ctx) {
|
|
|
2204
2738
|
try { registerCanvasSkill(ctx, toolDisposers); } catch { /* skills 服务不可用时跳过 */ }
|
|
2205
2739
|
|
|
2206
2740
|
// 数据根目录已设置时:启动即迁移/对齐布局(pid 文件夹 → 项目名文件夹 + 项目文档镜像),失败静默(下次设置/重启再试)
|
|
2207
|
-
try {
|
|
2741
|
+
try {
|
|
2742
|
+
if (dataRoot()) {
|
|
2743
|
+
if (sqlActive()) getDb(); // 预热 SQLite(必要时触发 v1→v2 一次性迁移)
|
|
2744
|
+
else dataRootMigrate();
|
|
2745
|
+
}
|
|
2746
|
+
} catch { /* 迁移失败不影响启动 */ }
|
|
2208
2747
|
|
|
2748
|
+
_rpcMethods = methods;
|
|
2209
2749
|
const registrations = [
|
|
2210
2750
|
ctx.webServer.register({
|
|
2211
2751
|
kind: 'exact',
|
|
@@ -2392,6 +2932,92 @@ export function apply(ctx) {
|
|
|
2392
2932
|
}
|
|
2393
2933
|
},
|
|
2394
2934
|
}),
|
|
2935
|
+
// CAS 对象库(0.18):POST 上传(内容寻址去重)+ GET 读取(immutable + Range,远程访问与视频拖动友好)
|
|
2936
|
+
ctx.webServer.register({
|
|
2937
|
+
kind: 'exact',
|
|
2938
|
+
path: `/${NAME}/api/object`,
|
|
2939
|
+
async handler(req, res) {
|
|
2940
|
+
try {
|
|
2941
|
+
const root = dataRoot();
|
|
2942
|
+
if (!root) throw new Error('未设置数据根目录');
|
|
2943
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
2944
|
+
const odir = objectsDirFor(root);
|
|
2945
|
+
mkdirSync(odir, { recursive: true });
|
|
2946
|
+
const tmp = path.join(odir, '.tmp-' + process.pid + '-' + Date.now());
|
|
2947
|
+
const hash = createHash('sha256');
|
|
2948
|
+
let size = 0;
|
|
2949
|
+
const MAX_UPLOAD = 512 * 1024 * 1024;
|
|
2950
|
+
await new Promise((resolve, reject) => {
|
|
2951
|
+
const ws = createWriteStream(tmp);
|
|
2952
|
+
const fail = (err) => {
|
|
2953
|
+
try { ws.destroy(); } catch { /* 忽略 */ }
|
|
2954
|
+
try { unlinkSync(tmp); } catch { /* 忽略 */ }
|
|
2955
|
+
reject(err);
|
|
2956
|
+
};
|
|
2957
|
+
req.on('data', (c) => { size += c.length; hash.update(c); if (size > MAX_UPLOAD) fail(new Error('文件过大(上限 512MB)')); });
|
|
2958
|
+
req.on('error', fail);
|
|
2959
|
+
req.on('aborted', () => fail(new Error('上传中断')));
|
|
2960
|
+
ws.on('error', fail);
|
|
2961
|
+
ws.on('finish', resolve);
|
|
2962
|
+
req.pipe(ws);
|
|
2963
|
+
});
|
|
2964
|
+
const sha = hash.digest('hex');
|
|
2965
|
+
const dest = objectAbsFor(root, sha);
|
|
2966
|
+
let deduped = false;
|
|
2967
|
+
if (existsSync(dest)) {
|
|
2968
|
+
try { unlinkSync(tmp); } catch { /* 忽略 */ }
|
|
2969
|
+
deduped = true; // 内容已存在:内容寻址天然去重
|
|
2970
|
+
} else {
|
|
2971
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
2972
|
+
renameSync(tmp, dest);
|
|
2973
|
+
}
|
|
2974
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
2975
|
+
res.end(JSON.stringify({ ok: true, sha, size, deduped }));
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
if (req.method === 'GET') {
|
|
2979
|
+
const u = new URL(req.url, 'http://localhost');
|
|
2980
|
+
const sha = String(u.searchParams.get('sha') || '').toLowerCase();
|
|
2981
|
+
if (!/^[0-9a-f]{64}$/.test(sha)) { res.statusCode = 400; res.end('非法 sha256'); return; }
|
|
2982
|
+
const abs = objectAbsFor(root, sha);
|
|
2983
|
+
let st;
|
|
2984
|
+
try { st = statSync(abs); } catch { res.statusCode = 404; res.end('对象不存在'); return; }
|
|
2985
|
+
const mime = String(u.searchParams.get('mime') || 'application/octet-stream');
|
|
2986
|
+
res.setHeader('content-type', /^[\w.+-]+\/[\w.+-]+$/.test(mime) ? mime : 'application/octet-stream');
|
|
2987
|
+
res.setHeader('accept-ranges', 'bytes');
|
|
2988
|
+
res.setHeader('cache-control', 'public, max-age=31536000, immutable'); // 内容寻址:内容永不变,可永久缓存
|
|
2989
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
|
|
2990
|
+
if (m && (m[1] !== '' || m[2] !== '')) {
|
|
2991
|
+
let start;
|
|
2992
|
+
let end;
|
|
2993
|
+
if (m[1] === '') { start = Math.max(0, st.size - Number(m[2])); end = st.size - 1; }
|
|
2994
|
+
else { start = parseInt(m[1], 10); end = m[2] === '' ? st.size - 1 : Math.min(parseInt(m[2], 10), st.size - 1); }
|
|
2995
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= st.size) {
|
|
2996
|
+
res.statusCode = 416;
|
|
2997
|
+
res.setHeader('content-range', 'bytes */' + st.size);
|
|
2998
|
+
res.end();
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
res.statusCode = 206;
|
|
3002
|
+
res.setHeader('content-range', `bytes ${start}-${end}/${st.size}`);
|
|
3003
|
+
res.setHeader('content-length', String(end - start + 1));
|
|
3004
|
+
pipeFile(abs, { start, end }, res);
|
|
3005
|
+
} else {
|
|
3006
|
+
res.setHeader('content-length', String(st.size));
|
|
3007
|
+
pipeFile(abs, {}, res);
|
|
3008
|
+
}
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
res.statusCode = 405;
|
|
3012
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
3013
|
+
res.end(JSON.stringify({ error: '仅接受 GET/POST/PUT' }));
|
|
3014
|
+
} catch (e) {
|
|
3015
|
+
res.statusCode = 400;
|
|
3016
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
3017
|
+
res.end(JSON.stringify({ error: String((e && e.message) || e) }));
|
|
3018
|
+
}
|
|
3019
|
+
},
|
|
3020
|
+
}),
|
|
2395
3021
|
// 插件自带静态资源(帮助与反馈用的微信群二维码等):白名单 assets/ 目录,拒绝路径穿越
|
|
2396
3022
|
ctx.webServer.register({
|
|
2397
3023
|
kind: 'exact',
|