@ninkch/dsh-infinite-canvas 0.17.6 → 0.17.7
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/client.js +227 -56
- package/docs/release.md +2 -0
- package/index.js +420 -23
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -91,6 +91,13 @@ window.__ModuleLoader__.load({
|
|
|
91
91
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
92
92
|
return new Blob([arr], { type: mime || 'application/octet-stream' });
|
|
93
93
|
}
|
|
94
|
+
function arrayBufferToBase64(buf) {
|
|
95
|
+
const bytes = new Uint8Array(buf);
|
|
96
|
+
let bin = '';
|
|
97
|
+
const CH = 0x8000;
|
|
98
|
+
for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
|
|
99
|
+
return btoa(bin);
|
|
100
|
+
}
|
|
94
101
|
function blobToDataUrl(blob) {
|
|
95
102
|
return new Promise((res, rej) => {
|
|
96
103
|
const r = new FileReader();
|
|
@@ -349,6 +356,9 @@ window.__ModuleLoader__.load({
|
|
|
349
356
|
'dataRootOpen': '打开目录',
|
|
350
357
|
'dataRootMigrate': '迁移历史资产',
|
|
351
358
|
'dataRootMigrated': '已迁移 {n} 个资产到数据根目录',
|
|
359
|
+
'dataRootImportBtn': '从数据目录导入',
|
|
360
|
+
'dataRootImported': '导入完成:合并 {n} 个项目、补入 {a} 条资产元数据',
|
|
361
|
+
'dataRootImportHint': '换电脑迁移:把整个数据目录拷到新机器 → 设置为数据根目录 → 点"从数据目录导入"即可恢复项目/节点/设定集/资产',
|
|
352
362
|
'dataRootMigratedDone': '已迁移',
|
|
353
363
|
'dataRootMigrating': '迁移中',
|
|
354
364
|
'dataRootUnsupported': '当前插件后台版本较旧(不支持数据根目录):重启 Harness 后可用',
|
|
@@ -677,6 +687,9 @@ window.__ModuleLoader__.load({
|
|
|
677
687
|
'dataRootOpen': 'Open folder',
|
|
678
688
|
'dataRootMigrate': 'Migrate existing assets',
|
|
679
689
|
'dataRootMigrated': 'Migrated {n} assets to the data root',
|
|
690
|
+
'dataRootImportBtn': 'Import from data root',
|
|
691
|
+
'dataRootImported': 'Imported: {n} projects merged, {a} asset records added',
|
|
692
|
+
'dataRootImportHint': 'Migration: copy the whole data folder to the new machine, set it as data root, then click "Import from data root" to restore projects/nodes/bible/assets',
|
|
680
693
|
'dataRootMigratedDone': 'Migrated',
|
|
681
694
|
'dataRootMigrating': 'Migrating',
|
|
682
695
|
'dataRootUnsupported': 'The plugin backend is older and lacks data-root support — restart Harness to enable',
|
|
@@ -1292,12 +1305,16 @@ window.__ModuleLoader__.load({
|
|
|
1292
1305
|
entLines.length ? (zh ? '设定集:' : 'Series bible: ') + entLines.join(';') : '',
|
|
1293
1306
|
].filter(Boolean).join('\n');
|
|
1294
1307
|
}
|
|
1295
|
-
/**
|
|
1308
|
+
/** 资产命名时间戳(MM-DD HH:MM):同节点多次生成靠它区分。 */
|
|
1309
|
+
function nowStamp() {
|
|
1310
|
+
return new Date().toISOString().slice(5, 16).replace('T', ' ');
|
|
1311
|
+
}
|
|
1312
|
+
/** 资产命名:**节点名 + 时间**(用户规则);无节点名退回实体名/类型前缀 + 时间。 */
|
|
1296
1313
|
function assetNameFor(node, entities, fallbackKind) {
|
|
1297
1314
|
const label = String((node && node.data && node.data.label) || '').trim();
|
|
1298
|
-
if (label) return label;
|
|
1299
|
-
if (entities && entities.length) return entityLabelOf(entities[0].kind) + '-' + entities[0].name;
|
|
1300
|
-
return (fallbackKind === 'video' ? '视频' : '图片') + '-' +
|
|
1315
|
+
if (label) return label + ' ' + nowStamp();
|
|
1316
|
+
if (entities && entities.length) return entityLabelOf(entities[0].kind) + '-' + entities[0].name + ' ' + nowStamp();
|
|
1317
|
+
return (fallbackKind === 'video' ? '视频' : '图片') + '-' + nowStamp();
|
|
1301
1318
|
}
|
|
1302
1319
|
// <<<MENTION_PURE_END>>>
|
|
1303
1320
|
|
|
@@ -1509,21 +1526,72 @@ window.__ModuleLoader__.load({
|
|
|
1509
1526
|
}
|
|
1510
1527
|
await idbPut(rec);
|
|
1511
1528
|
assetNameCache.set(rec.id, rec.name);
|
|
1529
|
+
mirrorAssetIndex(rec); // 资产元数据镜像进数据目录 assets.json(迁移便利,失败静默)
|
|
1512
1530
|
return rec;
|
|
1513
1531
|
}
|
|
1532
|
+
/** 资产元数据索引记录(去 blob;文本资产保留 text 以便导入端免读文件即可预览)。 */
|
|
1533
|
+
function assetIndexRecord(rec) {
|
|
1534
|
+
const out = {
|
|
1535
|
+
id: rec.id, projectId: rec.projectId, kind: rec.kind, mime: rec.mime,
|
|
1536
|
+
name: rec.name, createdAt: rec.createdAt, prompt: rec.prompt || '',
|
|
1537
|
+
source: rec.source || '', category: rec.category || 'general',
|
|
1538
|
+
};
|
|
1539
|
+
if (rec.rel) { out.rel = rec.rel; out.size = rec.size || 0; }
|
|
1540
|
+
if (rec.entity) out.entity = rec.entity;
|
|
1541
|
+
if (Array.isArray(rec.entities) && rec.entities.length) out.entities = rec.entities;
|
|
1542
|
+
if (rec.nodeLabel) out.nodeLabel = rec.nodeLabel;
|
|
1543
|
+
if (rec.kind === 'text' && typeof rec.text === 'string') out.text = rec.text;
|
|
1544
|
+
return out;
|
|
1545
|
+
}
|
|
1546
|
+
/** 把资产元数据镜像进数据目录的 assets.json(失败静默:索引是迁移便利,不是主存储)。 */
|
|
1547
|
+
function mirrorAssetIndex(rec) {
|
|
1548
|
+
if (!S.dataRoot || !rec || !rec.id) return;
|
|
1549
|
+
const proj = S.projects.find((p) => p.id === rec.projectId);
|
|
1550
|
+
hostRpc('assetIndexPut', {
|
|
1551
|
+
projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
|
|
1552
|
+
}).catch(() => { /* 索引失败不影响资产本身 */ });
|
|
1553
|
+
}
|
|
1554
|
+
/** 启动/设置数据目录后:一次性把 IndexedDB 里的全部资产元数据同步进 assets.json(补齐历史资产索引)。
|
|
1555
|
+
* 全量**替换**语义(含空项目占位):IndexedDB 是资产库唯一事实源,借此自愈删除后残留的幽灵条目。 */
|
|
1556
|
+
let assetIndexSynced = false;
|
|
1557
|
+
async function syncAssetIndexToHost() {
|
|
1558
|
+
if (assetIndexSynced || !S.dataRoot) return;
|
|
1559
|
+
assetIndexSynced = true;
|
|
1560
|
+
try {
|
|
1561
|
+
const all = await idbAll();
|
|
1562
|
+
const items = [];
|
|
1563
|
+
const seen = new Set();
|
|
1564
|
+
const projName = new Map(S.projects.map((p) => [p.id, p.name]));
|
|
1565
|
+
for (const rec of all) {
|
|
1566
|
+
if (!rec || !rec.id || !rec.projectId) continue;
|
|
1567
|
+
seen.add(rec.projectId);
|
|
1568
|
+
items.push({ projectId: rec.projectId, projectName: projName.get(rec.projectId) || rec.projectId, asset: assetIndexRecord(rec) });
|
|
1569
|
+
}
|
|
1570
|
+
// 本机已知但没有任何资产的项目也要发占位:assets.json 清空对齐(否则删除后会残留幽灵条目)
|
|
1571
|
+
for (const p of S.projects) {
|
|
1572
|
+
if (!seen.has(p.id)) items.push({ projectId: p.id, projectName: p.name, asset: null });
|
|
1573
|
+
}
|
|
1574
|
+
if (items.length) await hostRpc('assetIndexBulkPut', { items });
|
|
1575
|
+
} catch { /* 索引同步失败不影响 */ }
|
|
1576
|
+
}
|
|
1514
1577
|
/** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
|
|
1515
1578
|
async function migrateAssetsToDisk(onProgress) {
|
|
1516
1579
|
if (!S.dataRoot) throw new Error('请先设置数据根目录');
|
|
1517
1580
|
const all = await idbAll();
|
|
1518
|
-
|
|
1581
|
+
// 二进制资产(有 blob)与文本资产(text,此前从不落盘)都要迁移
|
|
1582
|
+
const targets = all.filter((r) => (r.blob && !r.rel) || (r.kind === 'text' && !r.rel && typeof r.text === 'string' && r.text.length));
|
|
1519
1583
|
let done = 0;
|
|
1520
1584
|
for (const rec of targets) {
|
|
1521
|
-
const
|
|
1522
|
-
const
|
|
1585
|
+
const isText = rec.kind === 'text' && !rec.blob;
|
|
1586
|
+
const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${isText ? 'md' : extFromMime(rec.mime)}`;
|
|
1587
|
+
const blob = isText ? new Blob([rec.text || ''], { type: 'text/markdown' }) : rec.blob;
|
|
1588
|
+
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
|
|
1523
1589
|
if (!r.ok) throw new Error('迁移失败: ' + rel + ' — ' + (await r.text()));
|
|
1524
|
-
const next = { ...rec, rel, size:
|
|
1525
|
-
|
|
1590
|
+
const next = { ...rec, rel, size: blob.size, stored: 'disk' };
|
|
1591
|
+
if (isText) next.text = rec.text; // 文本保留在元数据里:预览/下载不必回读文件
|
|
1592
|
+
else delete next.blob;
|
|
1526
1593
|
await idbPut(next);
|
|
1594
|
+
mirrorAssetIndex(next); // 迁移后刷新数据目录 assets.json(补 rel/size)
|
|
1527
1595
|
assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
|
|
1528
1596
|
done++;
|
|
1529
1597
|
if (onProgress) onProgress(done, targets.length);
|
|
@@ -1648,11 +1716,30 @@ window.__ModuleLoader__.load({
|
|
|
1648
1716
|
return new Blob(chunks, { type: mime.split(';')[0] });
|
|
1649
1717
|
}
|
|
1650
1718
|
|
|
1719
|
+
/** 外部 API 请求统一经 Host 代理(图片/视频/音频网关都走这里):浏览器直连第三方接口会被
|
|
1720
|
+
* CORS 拦截(带 Authorization 的 POST 触发预检,网关不返回 CORS 头 → TypeError: Failed to fetch)。
|
|
1721
|
+
* 返回 { ok, status, contentType, text, blob }。 */
|
|
1722
|
+
async function fetchViaProxy(url, { method = 'POST', headers = {}, body = null, bodyRaw = null, bodyContentType = '', timeoutMs = 120000, label = '请求' } = {}) {
|
|
1723
|
+
const payload = { url, method, headers, timeoutMs };
|
|
1724
|
+
if (bodyRaw != null) { payload.bodyRaw = bodyRaw; payload.bodyContentType = bodyContentType; }
|
|
1725
|
+
else if (body != null) payload.body = body;
|
|
1726
|
+
// 客户端→Host 的预算比上游超时多 15s:让上游超时(504/带原因)先到,而不是客户端先断
|
|
1727
|
+
const res = await fetchT('/' + NS + '/api/fetch-proxy', {
|
|
1728
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
|
1729
|
+
}, Number(timeoutMs) + 15000, label + '(代理)');
|
|
1730
|
+
const ct = res.headers.get('content-type') || '';
|
|
1731
|
+
const buf = await res.arrayBuffer();
|
|
1732
|
+
const blob = new Blob([buf], { type: ct || 'application/octet-stream' });
|
|
1733
|
+
let text = '';
|
|
1734
|
+
if (!ct || /json|text|plain/.test(ct)) { try { text = new TextDecoder().decode(buf); } catch { text = ''; } }
|
|
1735
|
+
return { ok: res.ok, status: res.status, contentType: ct, text, blob };
|
|
1736
|
+
}
|
|
1651
1737
|
async function apiImage(prompt, refBlobs, s, opts) {
|
|
1652
1738
|
if (!s.baseUrl) throw new Error('未配置图片 API');
|
|
1653
1739
|
const base = s.baseUrl.replace(/\/+$/, '');
|
|
1654
1740
|
const refs = refBlobs || [];
|
|
1655
1741
|
const auth = s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {};
|
|
1742
|
+
const upstreamTimeout = Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000; // 商汤等同步接口 20s+,默认 120s
|
|
1656
1743
|
// 参考图编辑模式:multipart 上传到 /images/edits(OpenAI gpt-image-1 兼容)
|
|
1657
1744
|
if (refs.length && s.mode === 'edits') {
|
|
1658
1745
|
const fd = new FormData();
|
|
@@ -1661,15 +1748,20 @@ window.__ModuleLoader__.load({
|
|
|
1661
1748
|
fd.append('n', '1');
|
|
1662
1749
|
if (s.size) fd.append('size', s.size);
|
|
1663
1750
|
refs.forEach((b, i) => fd.append('image[]', b, 'ref-' + i + '.png'));
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
const
|
|
1751
|
+
// FormData 经 Response 序列化为 multipart 字节(含 boundary)→ base64 经代理原样转发
|
|
1752
|
+
const serialized = new Response(fd);
|
|
1753
|
+
const bodyRaw = arrayBufferToBase64(await serialized.arrayBuffer());
|
|
1754
|
+
const bodyContentType = serialized.headers.get('content-type') || 'multipart/form-data';
|
|
1755
|
+
const res = await fetchViaProxy(base + '/images/edits', { headers: auth, bodyRaw, bodyContentType, timeoutMs: upstreamTimeout, label: '图片编辑 API' });
|
|
1756
|
+
if (!res.ok) throw new Error('图片编辑 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1757
|
+
let j = null;
|
|
1758
|
+
try { j = JSON.parse(res.text); } catch { j = null; }
|
|
1667
1759
|
const d = j && j.data && j.data[0];
|
|
1668
1760
|
if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
|
|
1669
1761
|
if (d && d.url) {
|
|
1670
|
-
const r2 = await
|
|
1762
|
+
const r2 = await fetchViaProxy(d.url, { method: 'GET', timeoutMs: 120000, label: '下载图片' });
|
|
1671
1763
|
if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
|
|
1672
|
-
return
|
|
1764
|
+
return r2.blob;
|
|
1673
1765
|
}
|
|
1674
1766
|
throw new Error('图片编辑 API 响应中没有 data[0].b64_json / url');
|
|
1675
1767
|
}
|
|
@@ -1683,22 +1775,19 @@ window.__ModuleLoader__.load({
|
|
|
1683
1775
|
const sizeOverride = String(s.size || '').trim();
|
|
1684
1776
|
if (sizeOverride) body.size = sizeOverride;
|
|
1685
1777
|
else { const dims = imageDimsFor(opts); body.size = `${dims.w}x${dims.h}`; }
|
|
1686
|
-
|
|
1687
|
-
// 否则会把大体积 base64 数组发给不识别的网关 → HTTP 400,用户看到的却是"没反应"
|
|
1688
|
-
if (refs.length && (s.mode === 'edits')) body.images = await Promise.all(refs.map((b) => blobToDataUrl(b)));
|
|
1689
|
-
const res = await fetchT(base + '/images/generations', {
|
|
1690
|
-
method: 'POST',
|
|
1778
|
+
const res = await fetchViaProxy(base + '/images/generations', {
|
|
1691
1779
|
headers: { 'Content-Type': 'application/json', ...auth },
|
|
1692
|
-
body:
|
|
1693
|
-
}
|
|
1694
|
-
if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' +
|
|
1695
|
-
|
|
1780
|
+
body, timeoutMs: upstreamTimeout, label: '图片 API',
|
|
1781
|
+
});
|
|
1782
|
+
if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1783
|
+
let j = null;
|
|
1784
|
+
try { j = JSON.parse(res.text); } catch { j = null; }
|
|
1696
1785
|
const d = j && j.data && j.data[0];
|
|
1697
1786
|
if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
|
|
1698
1787
|
if (d && d.url) {
|
|
1699
|
-
const r2 = await
|
|
1788
|
+
const r2 = await fetchViaProxy(d.url, { method: 'GET', timeoutMs: 120000, label: '下载图片' });
|
|
1700
1789
|
if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
|
|
1701
|
-
return
|
|
1790
|
+
return r2.blob;
|
|
1702
1791
|
}
|
|
1703
1792
|
throw new Error('图片 API 响应中没有 data[0].b64_json / url');
|
|
1704
1793
|
}
|
|
@@ -1734,9 +1823,10 @@ window.__ModuleLoader__.load({
|
|
|
1734
1823
|
?? (j.output && (j.output.task_id ?? j.output.id));
|
|
1735
1824
|
}
|
|
1736
1825
|
async function downloadVideo(url, headers) {
|
|
1737
|
-
|
|
1826
|
+
// 经 Host 代理:签名下载直连同样会被 CORS 拦
|
|
1827
|
+
const r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
|
|
1738
1828
|
if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
|
|
1739
|
-
return
|
|
1829
|
+
return r.blob;
|
|
1740
1830
|
}
|
|
1741
1831
|
/** 火山方舟 Seedance 官方协议:创建任务 → 轮询 → 下载(支持首帧 + 最多 4 张参考图)。 */
|
|
1742
1832
|
async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
|
|
@@ -1762,17 +1852,17 @@ window.__ModuleLoader__.load({
|
|
|
1762
1852
|
generate_audio: s.generateAudio !== false,
|
|
1763
1853
|
};
|
|
1764
1854
|
body.ratio = frameBlob ? 'adaptive' : (o.ratio || '16:9');
|
|
1765
|
-
const res = await
|
|
1766
|
-
if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' +
|
|
1767
|
-
const j =
|
|
1855
|
+
const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: 'Seedance 视频 API' });
|
|
1856
|
+
if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1857
|
+
const j = JSON.parse(res.text || '{}');
|
|
1768
1858
|
const taskId = j && j.id;
|
|
1769
1859
|
if (!taskId) throw new Error('Seedance 响应中没有任务 id');
|
|
1770
1860
|
const deadline = Date.now() + 15 * 60 * 1000;
|
|
1771
1861
|
while (Date.now() < deadline) {
|
|
1772
1862
|
await sleep(10000);
|
|
1773
|
-
const r2 = await
|
|
1863
|
+
const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' });
|
|
1774
1864
|
if (!r2.ok) throw new Error('查询 Seedance 任务失败 HTTP ' + r2.status);
|
|
1775
|
-
const st =
|
|
1865
|
+
const st = JSON.parse(r2.text || '{}');
|
|
1776
1866
|
if (st.status === 'succeeded') {
|
|
1777
1867
|
const u = st.content && st.content.video_url;
|
|
1778
1868
|
if (!u) throw new Error('Seedance 任务成功但未返回视频地址');
|
|
@@ -1805,10 +1895,10 @@ window.__ModuleLoader__.load({
|
|
|
1805
1895
|
if (frameBlob) {
|
|
1806
1896
|
try { body.image = await blobToDataUrl(frameBlob); } catch { /* 忽略首帧编码失败 */ }
|
|
1807
1897
|
}
|
|
1808
|
-
const res = await
|
|
1809
|
-
if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' +
|
|
1898
|
+
const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: '视频 API' });
|
|
1899
|
+
if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1810
1900
|
let j = null;
|
|
1811
|
-
try { j =
|
|
1901
|
+
try { j = JSON.parse(res.text); } catch { /* 非 JSON 响应 */ }
|
|
1812
1902
|
const direct = extractVideoUrl(j);
|
|
1813
1903
|
if (direct) return await downloadVideo(direct, {});
|
|
1814
1904
|
const taskId = extractTaskId(j);
|
|
@@ -1816,10 +1906,10 @@ window.__ModuleLoader__.load({
|
|
|
1816
1906
|
const deadline = Date.now() + 15 * 60 * 1000;
|
|
1817
1907
|
while (Date.now() < deadline) {
|
|
1818
1908
|
await sleep(3000);
|
|
1819
|
-
const r2 = await
|
|
1909
|
+
const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
|
|
1820
1910
|
if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
|
|
1821
1911
|
let st = null;
|
|
1822
|
-
try { st =
|
|
1912
|
+
try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
|
|
1823
1913
|
const u = extractVideoUrl(st);
|
|
1824
1914
|
if (u) return await downloadVideo(u, {});
|
|
1825
1915
|
// 失败判定必须看状态字段,不能拿整串 JSON 做子串匹配:
|
|
@@ -3012,8 +3102,25 @@ window.__ModuleLoader__.load({
|
|
|
3012
3102
|
source: (meta && meta.source) || 'ai',
|
|
3013
3103
|
category: (meta && meta.category) || 'script',
|
|
3014
3104
|
};
|
|
3105
|
+
// 文本资产同样落盘到数据根目录(<root>/projects/<项目>/text/<名>.md),
|
|
3106
|
+
// IndexedDB 保留元数据与 text 字段(文本很小,预览/下载不必回读文件)。
|
|
3107
|
+
// 落盘失败降级为仅 IndexedDB(绝不阻塞生成主流程),控制台留痕。
|
|
3108
|
+
if (S.dataRoot) {
|
|
3109
|
+
try {
|
|
3110
|
+
const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
|
|
3111
|
+
const blob = new Blob([rec.text], { type: 'text/markdown' });
|
|
3112
|
+
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
|
|
3113
|
+
if (!r.ok) throw new Error(await r.text());
|
|
3114
|
+
rec.rel = rel;
|
|
3115
|
+
rec.size = blob.size;
|
|
3116
|
+
rec.stored = 'disk';
|
|
3117
|
+
} catch (err) {
|
|
3118
|
+
console.warn('[infinite-canvas] 文本资产落盘失败(降级为浏览器存储):', String((err && err.message) || err));
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3015
3121
|
await idbPut(rec);
|
|
3016
3122
|
assetNameCache.set(rec.id, rec.name);
|
|
3123
|
+
mirrorAssetIndex(rec); // 文本资产元数据同样镜像进 assets.json
|
|
3017
3124
|
return rec;
|
|
3018
3125
|
}
|
|
3019
3126
|
|
|
@@ -3541,7 +3648,7 @@ window.__ModuleLoader__.load({
|
|
|
3541
3648
|
if (!String(text || '').trim()) throw new Error(T('aiEmptyResult'));
|
|
3542
3649
|
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.output = text; nd.data.prevOutput = null; nd.data.aiStatus = 'idle'; nd.data.status = 'done'; nd.data.error = null; } s.assetsTick++; });
|
|
3543
3650
|
await saveTextAssetRecord(S.activeId, {
|
|
3544
|
-
name:
|
|
3651
|
+
name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(),
|
|
3545
3652
|
text, category: 'script', prompt: n.data.idea,
|
|
3546
3653
|
});
|
|
3547
3654
|
update((s) => { s.assetsTick++; });
|
|
@@ -3561,6 +3668,11 @@ window.__ModuleLoader__.load({
|
|
|
3561
3668
|
}
|
|
3562
3669
|
}
|
|
3563
3670
|
|
|
3671
|
+
/** 分镜节点的资产文本(自动入库与手动"存为资产"共用,避免两处格式漂移)。 */
|
|
3672
|
+
function storyboardAssetText(shots) {
|
|
3673
|
+
return (shots || []).map((x) => `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}\n角色:${(x.characters || []).join('、') || '-'}|场景:${x.scene || '-'}|道具:${(x.props || []).join('、') || '-'}`).join('\n\n');
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3564
3676
|
/** 分镜节点:剧本 → 镜头列表(含一致性检查)。 */
|
|
3565
3677
|
async function genStoryboardNode(n) {
|
|
3566
3678
|
const st = storyboardScriptText(n);
|
|
@@ -3579,8 +3691,8 @@ window.__ModuleLoader__.load({
|
|
|
3579
3691
|
// 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
|
|
3580
3692
|
try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
|
|
3581
3693
|
await saveTextAssetRecord(S.activeId, {
|
|
3582
|
-
name:
|
|
3583
|
-
text: shots
|
|
3694
|
+
name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
|
|
3695
|
+
text: storyboardAssetText(shots),
|
|
3584
3696
|
category: 'storyboard', prompt: st.slice(0, 200),
|
|
3585
3697
|
});
|
|
3586
3698
|
update((s) => { s.assetsTick++; });
|
|
@@ -3635,7 +3747,7 @@ window.__ModuleLoader__.load({
|
|
|
3635
3747
|
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.progress = Math.round(frac * 100) + '% · ' + name; });
|
|
3636
3748
|
});
|
|
3637
3749
|
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
3638
|
-
name:
|
|
3750
|
+
name: (String(n.data.label || '').trim() || T('finalCutDone')) + ' ' + nowStamp(),
|
|
3639
3751
|
prompt: metas.length + ' segments', source: 'finalcut', category: 'finalcut',
|
|
3640
3752
|
});
|
|
3641
3753
|
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
@@ -4976,7 +5088,7 @@ window.__ModuleLoader__.load({
|
|
|
4976
5088
|
type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
|
|
4977
5089
|
onPointerDown: (e) => e.stopPropagation(),
|
|
4978
5090
|
onClick: async () => {
|
|
4979
|
-
await saveTextAssetRecord(S.activeId, { name:
|
|
5091
|
+
await saveTextAssetRecord(S.activeId, { name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(), text: n.data.output, category: 'script', prompt: n.data.idea });
|
|
4980
5092
|
update((s) => { s.assetsTick++; });
|
|
4981
5093
|
toast(T('savedOk'));
|
|
4982
5094
|
},
|
|
@@ -5058,8 +5170,26 @@ window.__ModuleLoader__.load({
|
|
|
5058
5170
|
)
|
|
5059
5171
|
: null,
|
|
5060
5172
|
shots.length
|
|
5061
|
-
? h('
|
|
5062
|
-
|
|
5173
|
+
? h('div', { key: 'acts', className: 'dshic-node-row', style: { alignSelf: 'flex-start' } },
|
|
5174
|
+
// 手动"存为资产":AI 生成已自动入库(category=storyboard),这里用于留存当前分镜的一版快照
|
|
5175
|
+
h('button', {
|
|
5176
|
+
key: 'save', type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
|
|
5177
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5178
|
+
onClick: async () => {
|
|
5179
|
+
try {
|
|
5180
|
+
await saveTextAssetRecord(S.activeId, {
|
|
5181
|
+
name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
|
|
5182
|
+
text: storyboardAssetText(shots), category: 'storyboard',
|
|
5183
|
+
prompt: String(n.data.script || '').slice(0, 200),
|
|
5184
|
+
});
|
|
5185
|
+
update((s) => { s.assetsTick++; });
|
|
5186
|
+
toast(T('savedOk'));
|
|
5187
|
+
} catch (err) { toast(String((err && err.message) || err)); }
|
|
5188
|
+
},
|
|
5189
|
+
}, Ic('download', 11), T('saveTextAsset')),
|
|
5190
|
+
h('button', { key: 'expand', type: 'button', className: 'dshic-mini', onClick: () => expandStoryboard(n), onPointerDown: (e) => e.stopPropagation() },
|
|
5191
|
+
Ic('expandAll', 11), T('expand') + ` (${shots.length})`),
|
|
5192
|
+
)
|
|
5063
5193
|
: null,
|
|
5064
5194
|
];
|
|
5065
5195
|
}
|
|
@@ -5156,14 +5286,15 @@ window.__ModuleLoader__.load({
|
|
|
5156
5286
|
if (!text) { toast(T('subPlaceholder')); return; }
|
|
5157
5287
|
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.tts = nd.data.tts || {}; nd.data.tts[x.e.id] = { status: 'running' }; } });
|
|
5158
5288
|
try {
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
headers: {
|
|
5162
|
-
body:
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5289
|
+
// TTS 也走 Host 代理:浏览器直连 TTS 网关同样会被 CORS 拦
|
|
5290
|
+
const res = await fetchViaProxy((cfg.baseUrl || '').replace(/\/+$/, '') + '/audio/speech', {
|
|
5291
|
+
headers: { ...(cfg.apiKey ? { Authorization: 'Bearer ' + cfg.apiKey } : {}) },
|
|
5292
|
+
body: { ...(cfg.model ? { model: cfg.model } : {}), input: text, voice: cfg.voice || 'alloy', response_format: 'mp3' },
|
|
5293
|
+
timeoutMs: Number(cfg.timeoutMs) > 0 ? Number(cfg.timeoutMs) : 120000,
|
|
5294
|
+
label: 'TTS API',
|
|
5295
|
+
});
|
|
5296
|
+
if (!res.ok) throw new Error('TTS HTTP ' + res.status + ':' + res.text.slice(0, 200));
|
|
5297
|
+
const blob = /^audio\//.test(res.contentType) ? res.blob : new Blob([res.blob], { type: 'audio/mpeg' });
|
|
5167
5298
|
const rec = await saveAssetRecord(S.activeId, 'audio', blob, { name: 'TTS-' + text.slice(0, 12), prompt: text, source: 'api', category: 'audio' });
|
|
5168
5299
|
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.tts = nd.data.tts || {}; nd.data.tts[x.e.id] = { status: 'done', assetId: rec.id }; } s.assetsTick++; });
|
|
5169
5300
|
} catch (e) {
|
|
@@ -5540,7 +5671,9 @@ window.__ModuleLoader__.load({
|
|
|
5540
5671
|
const confirmDel = async () => {
|
|
5541
5672
|
const pid = delTarget && delTarget.id;
|
|
5542
5673
|
if (!pid) return;
|
|
5543
|
-
flushSave
|
|
5674
|
+
// 只刷"非待删项目"的待存文档:flushSave 推的是 S.activeId 的全量文档,
|
|
5675
|
+
// 若推的正是待删项目,会与 projectsDelete 竞速把项目原地复活(资产/设定集已清的残废形态)
|
|
5676
|
+
if (pid !== S.activeId) flushSave();
|
|
5544
5677
|
update((s) => {
|
|
5545
5678
|
s.projects = s.projects.filter((p) => p.id !== pid);
|
|
5546
5679
|
try { localStorage.removeItem(LS_GRAPH + pid); } catch { /* 忽略 */ }
|
|
@@ -5755,7 +5888,11 @@ window.__ModuleLoader__.load({
|
|
|
5755
5888
|
if (files.length) setTick((x) => x + 1);
|
|
5756
5889
|
};
|
|
5757
5890
|
const del = async (a) => {
|
|
5758
|
-
|
|
5891
|
+
// 数据目录同步:删文件(逻辑路径由 Host 映射到项目名文件夹)+ 清 assets.json 索引
|
|
5892
|
+
const proj = S.projects.find((p) => p.id === (a.projectId || S.activeId));
|
|
5893
|
+
try {
|
|
5894
|
+
await hostRpc('assetDelete', { projectId: a.projectId || S.activeId, projectName: (proj && proj.name) || '', id: a.id, rel: a.rel || '' });
|
|
5895
|
+
} catch { /* 磁盘/索引清理失败不阻塞 */ }
|
|
5759
5896
|
await idbDelete(a.id);
|
|
5760
5897
|
update((s) => {
|
|
5761
5898
|
for (const nd of Object.values(s.graph.nodes)) {
|
|
@@ -6386,6 +6523,8 @@ window.__ModuleLoader__.load({
|
|
|
6386
6523
|
await hostRpc('dataRootSet', { root: val.trim() });
|
|
6387
6524
|
await refreshDataRoot();
|
|
6388
6525
|
toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
|
|
6526
|
+
assetIndexSynced = false; // 换了目录:索引要往新目录重建
|
|
6527
|
+
syncAssetIndexToHost();
|
|
6389
6528
|
} catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
|
|
6390
6529
|
setBusy(false);
|
|
6391
6530
|
};
|
|
@@ -6394,6 +6533,8 @@ window.__ModuleLoader__.load({
|
|
|
6394
6533
|
await refreshDataRoot();
|
|
6395
6534
|
setVal(S.dataRoot || root);
|
|
6396
6535
|
toast(T('dataRootSaved') + ':' + S.dataRoot);
|
|
6536
|
+
assetIndexSynced = false; // 换了目录:索引要往新目录重建
|
|
6537
|
+
syncAssetIndexToHost();
|
|
6397
6538
|
};
|
|
6398
6539
|
const browse = async () => {
|
|
6399
6540
|
if (busy || picking) return;
|
|
@@ -6410,6 +6551,31 @@ window.__ModuleLoader__.load({
|
|
|
6410
6551
|
setPicking(false); setBusy(false);
|
|
6411
6552
|
};
|
|
6412
6553
|
const openDir = async () => { try { await hostRpc('dataRootReveal'); } catch (e) { toast((e && e.message) || e); } };
|
|
6554
|
+
const importFromRoot = async () => {
|
|
6555
|
+
if (busy || picking) return;
|
|
6556
|
+
setBusy(true);
|
|
6557
|
+
try {
|
|
6558
|
+
const r = await hostRpc('dataRootImport');
|
|
6559
|
+
// 资产元数据 → IndexedDB(本机已存在的同 id 资产跳过,绝不覆盖)
|
|
6560
|
+
let assets = 0;
|
|
6561
|
+
const byProject = r.assets || {};
|
|
6562
|
+
for (const pid of Object.keys(byProject)) {
|
|
6563
|
+
for (const rec of byProject[pid] || []) {
|
|
6564
|
+
if (!rec || !rec.id) continue;
|
|
6565
|
+
const cur = await idbGet(rec.id).catch(() => null);
|
|
6566
|
+
if (cur && (cur.rel || cur.blob)) continue;
|
|
6567
|
+
await idbPut({ ...rec, projectId: pid, stored: rec.rel ? 'disk' : 'idb' });
|
|
6568
|
+
assets++;
|
|
6569
|
+
}
|
|
6570
|
+
}
|
|
6571
|
+
if (assets) update((s) => { s.assetsTick++; });
|
|
6572
|
+
toast(T('dataRootImported').replace('{n}', String((r.imported || []).length)).replace('{a}', String(assets)));
|
|
6573
|
+
// 项目文档 Host 侧已合并:立即拉一轮同步,把新项目带进画布
|
|
6574
|
+
try { await hostSyncTick(); } catch { /* 下一轮 3s 轮询会带 */ }
|
|
6575
|
+
await refreshDataRoot();
|
|
6576
|
+
} catch (e) { toast('导入失败:' + ((e && e.message) || e)); }
|
|
6577
|
+
setBusy(false);
|
|
6578
|
+
};
|
|
6413
6579
|
const migrate = async () => {
|
|
6414
6580
|
if (busy || picking || mig) return;
|
|
6415
6581
|
setBusy(true); setMig({ done: 0, total: 0 });
|
|
@@ -6440,6 +6606,11 @@ window.__ModuleLoader__.load({
|
|
|
6440
6606
|
S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking || !!mig, onPointerDown: (e) => e.stopPropagation(), onClick: migrate }, mig === 'done' ? T('dataRootMigratedDone') : T('dataRootMigrate')) : null,
|
|
6441
6607
|
(mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
|
|
6442
6608
|
),
|
|
6609
|
+
h('div', { className: 'dshic-row', key: 'row3' },
|
|
6610
|
+
S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')) : null,
|
|
6611
|
+
h('div', { className: 'dshic-flex' }),
|
|
6612
|
+
),
|
|
6613
|
+
S.dataRoot ? h('div', { className: 'dshic-hint', key: 'importHint' }, T('dataRootImportHint')) : null,
|
|
6443
6614
|
],
|
|
6444
6615
|
);
|
|
6445
6616
|
}
|
|
@@ -7043,8 +7214,8 @@ window.__ModuleLoader__.load({
|
|
|
7043
7214
|
probeCraft();
|
|
7044
7215
|
// 检查更新(Host RPC;Host 旧版未带此方法时静默)
|
|
7045
7216
|
checkUpdate(true);
|
|
7046
|
-
// 数据根目录(Host 旧版静默降级为 IndexedDB
|
|
7047
|
-
refreshDataRoot();
|
|
7217
|
+
// 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把 IDB 资产元数据补齐进数据目录索引
|
|
7218
|
+
refreshDataRoot().then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
|
|
7048
7219
|
// 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
|
|
7049
7220
|
hostSyncTick().then(() => pushAllProjectsToHost());
|
|
7050
7221
|
const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);
|
package/docs/release.md
CHANGED
|
@@ -60,6 +60,8 @@ node scripts/test-host-hardening.mjs # Host 加固回归(读流错误接管/
|
|
|
60
60
|
node scripts/test-llm-stream.mjs # LLM 流式与推理模型重试(思考实时推送/按配置上限重试)
|
|
61
61
|
node scripts/test-sync-bible.mjs # 同步合并回归(设定集拉回/边墓碑防复活)
|
|
62
62
|
node scripts/test-user-scenarios.mjs # 用户实测场景定向复验(轮询无损/自动连线/设定集读写)
|
|
63
|
+
node scripts/test-dataroot.mjs # 数据目录回归(项目名文件夹/文档镜像/资产索引/一键导入)
|
|
64
|
+
node scripts/test-image-proxy.mjs # 图片 API 代理回归(CORS 规避:JSON/二进制/状态码透传/超时)
|
|
63
65
|
```
|
|
64
66
|
|
|
65
67
|
`check-pack` 防的三类事故:漏打包(如 locale/icon 没进包)、多打包(`.github`/`scripts` 混入)、元数据漂移(版本/exports/`dsh.bundle.patch`)。
|
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, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, 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';
|
|
@@ -121,6 +121,246 @@ function dataRoot() {
|
|
|
121
121
|
const r = readIcConfig().dataRoot;
|
|
122
122
|
return (typeof r === 'string' && r.trim()) ? path.resolve(r.trim()) : null;
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
// ============================================================
|
|
126
|
+
// 数据目录 = 可迁移的完整数据包(换电脑拷贝整个目录 → 一键导入)
|
|
127
|
+
// <root>/canvas.meta.json 清单:项目 id → 文件夹映射(文件夹按项目名)
|
|
128
|
+
// <root>/projects/<项目名>/
|
|
129
|
+
// project.json 项目完整文档(节点/连线/视图/**设定集(含 assetIds 绑定)**)
|
|
130
|
+
// assets.json 资产元数据索引(id/名称/类型/rel/实体绑定/提示词)
|
|
131
|
+
// image|video|audio|text/... 媒体文件
|
|
132
|
+
// 客户端资产记录里的 rel 保持**逻辑路径**(projects/<pid>/…);
|
|
133
|
+
// Host 在读写边界做 pid→项目名 文件夹映射(physicalRel),旧记录无需重写。
|
|
134
|
+
// ============================================================
|
|
135
|
+
const DATA_META_FILE = 'canvas.meta.json';
|
|
136
|
+
|
|
137
|
+
function dataRootMetaFile() {
|
|
138
|
+
const root = dataRoot();
|
|
139
|
+
return root ? path.join(root, DATA_META_FILE) : null;
|
|
140
|
+
}
|
|
141
|
+
function readDataRootMeta() {
|
|
142
|
+
const f = dataRootMetaFile();
|
|
143
|
+
if (f) {
|
|
144
|
+
try {
|
|
145
|
+
const m = JSON.parse(readFileSync(f, 'utf8'));
|
|
146
|
+
if (m && typeof m === 'object' && Array.isArray(m.projects)) return m;
|
|
147
|
+
} catch { /* 缺失/损坏 → 重建 */ }
|
|
148
|
+
}
|
|
149
|
+
return { version: 1, updatedAt: 0, projects: [] };
|
|
150
|
+
}
|
|
151
|
+
function writeDataRootMeta(meta) {
|
|
152
|
+
const f = dataRootMetaFile();
|
|
153
|
+
if (!f) return;
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(path.dirname(f), { recursive: true });
|
|
156
|
+
meta.updatedAt = Date.now();
|
|
157
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
158
|
+
writeFileSync(tmp, JSON.stringify(meta, null, 1), 'utf8');
|
|
159
|
+
renameSync(tmp, f);
|
|
160
|
+
} catch { /* 清单写失败不影响主存储 */ }
|
|
161
|
+
}
|
|
162
|
+
function sanitizeFolderName(name) {
|
|
163
|
+
const s = String(name || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
164
|
+
return s || '未命名项目';
|
|
165
|
+
}
|
|
166
|
+
/** 确保项目的物理文件夹(按项目名)与清单条目存在;改名时尽力重命名目录(被占用/冲突则保留旧文件夹)。返回文件夹名。 */
|
|
167
|
+
function ensureProjectFolder(pid, name) {
|
|
168
|
+
const root = dataRoot();
|
|
169
|
+
if (!root) return null;
|
|
170
|
+
const meta = readDataRootMeta();
|
|
171
|
+
const want = sanitizeFolderName(name || pid);
|
|
172
|
+
let entry = meta.projects.find((p) => p.id === pid) || null;
|
|
173
|
+
if (!entry) {
|
|
174
|
+
let folder = want;
|
|
175
|
+
let i = 2;
|
|
176
|
+
while (meta.projects.some((p) => p.folder === folder)) folder = want + '-' + i++;
|
|
177
|
+
entry = { id: pid, name: want, folder, createdAt: Date.now() };
|
|
178
|
+
meta.projects.push(entry);
|
|
179
|
+
} else {
|
|
180
|
+
entry.name = want;
|
|
181
|
+
if (entry.folder !== want) {
|
|
182
|
+
let target = want;
|
|
183
|
+
let i = 2;
|
|
184
|
+
while (meta.projects.some((p) => p.folder === target && p.id !== pid)) target = want + '-' + i++;
|
|
185
|
+
const from = path.join(root, 'projects', entry.folder);
|
|
186
|
+
const to = path.join(root, 'projects', target);
|
|
187
|
+
if (!existsSync(to)) {
|
|
188
|
+
try {
|
|
189
|
+
if (existsSync(from)) renameSync(from, to);
|
|
190
|
+
entry.folder = target; // 目录被占用(Windows 文件锁)时保留旧文件夹,仅改清单名
|
|
191
|
+
} catch { /* 保留旧文件夹 */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
writeDataRootMeta(meta);
|
|
196
|
+
return entry.folder;
|
|
197
|
+
}
|
|
198
|
+
/** 逻辑路径(projects/<pid>/…)→ 物理路径(projects/<项目名>/…);无法识别时原样返回。 */
|
|
199
|
+
function physicalRel(logicalRel) {
|
|
200
|
+
const m = /^projects\/([^/]+)(\/.*)?$/.exec(String(logicalRel || '').trim().replace(/\\/g, '/'));
|
|
201
|
+
if (!m) return null;
|
|
202
|
+
const meta = readDataRootMeta();
|
|
203
|
+
const entry = meta.projects.find((p) => p.id === m[1]);
|
|
204
|
+
return 'projects/' + (entry ? entry.folder : m[1]) + (m[2] || '');
|
|
205
|
+
}
|
|
206
|
+
/** 把项目文档镜像到数据目录(project.json,含设定集与 assetIds 绑定);镜像失败不影响主存储。 */
|
|
207
|
+
function mirrorProjectToDataRoot(id, doc) {
|
|
208
|
+
const root = dataRoot();
|
|
209
|
+
if (!root || !id) return;
|
|
210
|
+
try {
|
|
211
|
+
const folder = ensureProjectFolder(id, (doc && doc.name) || id);
|
|
212
|
+
if (!folder) return;
|
|
213
|
+
const dir = path.join(root, 'projects', folder);
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
const out = {
|
|
216
|
+
id, name: (doc && doc.name) || id,
|
|
217
|
+
createdAt: (doc && doc.createdAt) || null,
|
|
218
|
+
updatedAt: (doc && doc.updatedAt) || Date.now(), // 用文档自身的时间戳:导入时"新者胜"比较才有意义
|
|
219
|
+
nodes: (doc && doc.nodes) || {},
|
|
220
|
+
edges: (doc && doc.edges) || [],
|
|
221
|
+
view: (doc && doc.view) || null,
|
|
222
|
+
tombstones: (doc && doc.tombstones) || [],
|
|
223
|
+
edgeTombstones: (doc && doc.edgeTombstones) || [],
|
|
224
|
+
bible: (doc && doc.bible) || null, // 设定集(角色/场景/道具条目含 assetIds 资产绑定)
|
|
225
|
+
bibleUpdatedAt: (doc && doc.bibleUpdatedAt) || 0,
|
|
226
|
+
};
|
|
227
|
+
const tmp = path.join(dir, 'project.json.tmp');
|
|
228
|
+
writeFileSync(tmp, JSON.stringify(out, null, 1), 'utf8');
|
|
229
|
+
renameSync(tmp, path.join(dir, 'project.json'));
|
|
230
|
+
} catch { /* 镜像失败不影响主存储 */ }
|
|
231
|
+
}
|
|
232
|
+
/** 资产元数据索引 upsert:<项目文件夹>/assets.json。
|
|
233
|
+
* opts.replace=true 时整表替换(客户端启动全量同步用:IndexedDB 是资产库的唯一事实源,
|
|
234
|
+
* 替换语义能自愈"删除后残留的幽灵条目");默认按 id 合并(单资产保存用)。 */
|
|
235
|
+
function writeAssetIndex(pid, projectName, assets, opts) {
|
|
236
|
+
const root = dataRoot();
|
|
237
|
+
if (!root) throw new Error('未设置数据根目录');
|
|
238
|
+
const folder = ensureProjectFolder(pid, projectName || pid);
|
|
239
|
+
if (!folder) throw new Error('数据目录不可用');
|
|
240
|
+
const dir = path.join(root, 'projects', folder);
|
|
241
|
+
mkdirSync(dir, { recursive: true });
|
|
242
|
+
const f = path.join(dir, 'assets.json');
|
|
243
|
+
let list = [];
|
|
244
|
+
if (!(opts && opts.replace)) {
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(readFileSync(f, 'utf8'));
|
|
247
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
248
|
+
} catch { /* 首次/损坏 → 重建 */ }
|
|
249
|
+
}
|
|
250
|
+
for (const a of assets || []) {
|
|
251
|
+
if (!a || !a.id) continue;
|
|
252
|
+
const i = list.findIndex((x) => x.id === a.id);
|
|
253
|
+
if (i >= 0) list[i] = a;
|
|
254
|
+
else list.push(a);
|
|
255
|
+
}
|
|
256
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
257
|
+
writeFileSync(tmp, JSON.stringify(list, null, 1), 'utf8');
|
|
258
|
+
renameSync(tmp, f);
|
|
259
|
+
return { ok: true, folder, count: list.length };
|
|
260
|
+
}
|
|
261
|
+
/** 删除项目的数据目录镜像(项目被删除时调用)。 */
|
|
262
|
+
function removeDataRootProject(pid) {
|
|
263
|
+
const root = dataRoot();
|
|
264
|
+
if (!root || !pid) return;
|
|
265
|
+
const meta = readDataRootMeta();
|
|
266
|
+
const entry = meta.projects.find((p) => p.id === pid);
|
|
267
|
+
meta.projects = meta.projects.filter((p) => p.id !== pid);
|
|
268
|
+
writeDataRootMeta(meta);
|
|
269
|
+
if (entry) {
|
|
270
|
+
try { rmSync(path.join(root, 'projects', entry.folder), { recursive: true, force: true }); } catch { /* 占用时残留,无碍 */ }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** 一次性迁移/对齐数据目录布局:旧 pid 文件夹 → 项目名文件夹,并镜像全部项目文档。 */
|
|
274
|
+
function dataRootMigrate() {
|
|
275
|
+
const root = dataRoot();
|
|
276
|
+
if (!root) throw new Error('未设置数据根目录');
|
|
277
|
+
let ids = [];
|
|
278
|
+
try {
|
|
279
|
+
ids = existsSync(PROJECTS_DIR)
|
|
280
|
+
? readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, ''))
|
|
281
|
+
: [];
|
|
282
|
+
} catch { ids = []; }
|
|
283
|
+
let filesMoved = 0;
|
|
284
|
+
const folders = [];
|
|
285
|
+
for (const id of ids) {
|
|
286
|
+
let doc = null;
|
|
287
|
+
try { doc = JSON.parse(readFileSync(projectFile(id), 'utf8')); } catch { continue; }
|
|
288
|
+
const folder = ensureProjectFolder(id, (doc && doc.name) || id);
|
|
289
|
+
folders.push(folder);
|
|
290
|
+
const dir = path.join(root, 'projects', folder);
|
|
291
|
+
mkdirSync(dir, { recursive: true });
|
|
292
|
+
// 旧物理布局(projects/<pid>/<kind>/…)→ 新布局(projects/<项目名>/<kind>/…)
|
|
293
|
+
const oldDir = path.join(root, 'projects', id);
|
|
294
|
+
if (id !== folder && existsSync(oldDir)) {
|
|
295
|
+
try {
|
|
296
|
+
for (const kind of readdirSync(oldDir)) {
|
|
297
|
+
const fromKind = path.join(oldDir, kind);
|
|
298
|
+
const toKind = path.join(dir, kind);
|
|
299
|
+
mkdirSync(toKind, { recursive: true });
|
|
300
|
+
for (const f of readdirSync(fromKind)) {
|
|
301
|
+
const from = path.join(fromKind, f);
|
|
302
|
+
const to = path.join(toKind, f);
|
|
303
|
+
if (!existsSync(to)) { renameSync(from, to); filesMoved++; }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
rmSync(oldDir, { recursive: true, force: true });
|
|
307
|
+
} catch { /* 目录被占用:保留旧目录,下轮再试 */ }
|
|
308
|
+
}
|
|
309
|
+
mirrorProjectToDataRoot(id, doc);
|
|
310
|
+
}
|
|
311
|
+
return { ok: true, projects: folders.filter(Boolean).length, filesMoved, folders };
|
|
312
|
+
}
|
|
313
|
+
/** 从项目的 assets.json 索引里移除一条资产记录(资产被删除时保持数据目录同步)。 */
|
|
314
|
+
function removeAssetIndexEntry(pid, projectName, assetId) {
|
|
315
|
+
const root = dataRoot();
|
|
316
|
+
if (!root || !pid || !assetId) return { ok: true, removed: false };
|
|
317
|
+
let folder = null;
|
|
318
|
+
try { folder = ensureProjectFolder(pid, projectName || pid); } catch { return { ok: true, removed: false }; }
|
|
319
|
+
if (!folder) return { ok: true, removed: false };
|
|
320
|
+
const f = path.join(root, 'projects', folder, 'assets.json');
|
|
321
|
+
let list = [];
|
|
322
|
+
try {
|
|
323
|
+
const parsed = JSON.parse(readFileSync(f, 'utf8'));
|
|
324
|
+
if (Array.isArray(parsed)) list = parsed;
|
|
325
|
+
} catch { return { ok: true, removed: false }; }
|
|
326
|
+
const next = list.filter((x) => x && x.id !== assetId);
|
|
327
|
+
if (next.length === list.length) return { ok: true, removed: false };
|
|
328
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
329
|
+
writeFileSync(tmp, JSON.stringify(next, null, 1), 'utf8');
|
|
330
|
+
renameSync(tmp, f);
|
|
331
|
+
return { ok: true, removed: true };
|
|
332
|
+
}
|
|
333
|
+
/** 一键导入:扫描数据目录 → 合并 project.json 进主存储(updatedAt 新者胜)→ 返回资产元数据供客户端入 IDB。 */
|
|
334
|
+
function dataRootImport() {
|
|
335
|
+
const root = dataRoot();
|
|
336
|
+
if (!root) throw new Error('未设置数据根目录');
|
|
337
|
+
const projectsDir = path.join(root, 'projects');
|
|
338
|
+
const dirs = existsSync(projectsDir)
|
|
339
|
+
? readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
340
|
+
: [];
|
|
341
|
+
const imported = [];
|
|
342
|
+
const assets = {};
|
|
343
|
+
for (const folder of dirs) {
|
|
344
|
+
const dir = path.join(projectsDir, folder);
|
|
345
|
+
let doc = null;
|
|
346
|
+
try { doc = JSON.parse(readFileSync(path.join(dir, 'project.json'), 'utf8')); } catch { continue; }
|
|
347
|
+
const id = String(doc.id || '');
|
|
348
|
+
if (!id) continue;
|
|
349
|
+
let cur = null;
|
|
350
|
+
try { cur = JSON.parse(readFileSync(projectFile(id), 'utf8')); } catch { /* 本机没有该项目 */ }
|
|
351
|
+
if (!cur || (cur.updatedAt || 0) < (doc.updatedAt || 0)) {
|
|
352
|
+
doc.updatedAt = Date.now();
|
|
353
|
+
writeIcDoc(id, doc); // 会同步镜像回数据目录(无害)
|
|
354
|
+
clearDeletedProjectId(id); // 导入恢复:撤掉长效墓碑,后续客户端推送不再被拒收
|
|
355
|
+
imported.push({ id, name: doc.name || id });
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const list = JSON.parse(readFileSync(path.join(dir, 'assets.json'), 'utf8'));
|
|
359
|
+
if (Array.isArray(list)) assets[id] = list;
|
|
360
|
+
} catch { /* 无资产索引 */ }
|
|
361
|
+
}
|
|
362
|
+
return { ok: true, imported, assets };
|
|
363
|
+
}
|
|
124
364
|
/** 设定集字段规范化(世界观/风格 + 角色/场景/道具条目,条目可携带链接资产 assetIds)。 */
|
|
125
365
|
function normalizeBible(raw) {
|
|
126
366
|
const b = (raw && typeof raw === 'object') ? raw : {};
|
|
@@ -268,6 +508,9 @@ function projectsList() {
|
|
|
268
508
|
}
|
|
269
509
|
async function projectsPut(args) {
|
|
270
510
|
const id = String((args && args.id) || '');
|
|
511
|
+
// 项目删除墓碑(长效):删除后迟到的全量推送(450ms 防抖竞速 / 启动 pushAllProjectsToHost 快照)
|
|
512
|
+
// 一律拒收——否则项目原地复活(资产/设定集已清,复活即残废)。客户端不会复用项目 id,拒收永不误伤。
|
|
513
|
+
if (deletedProjectIds().includes(id)) return { ok: true, id, deleted: true };
|
|
271
514
|
const f = projectFile(id);
|
|
272
515
|
const doc = (args && args.doc) || {};
|
|
273
516
|
let prev = {};
|
|
@@ -368,14 +611,85 @@ async function projectsPut(args) {
|
|
|
368
611
|
out.bibleUpdatedAt = prev.bibleUpdatedAt || 0;
|
|
369
612
|
}
|
|
370
613
|
mkdirSync(PROJECTS_DIR, { recursive: true });
|
|
371
|
-
|
|
614
|
+
writeIcDoc(id, out); // 统一走原子写 + 数据目录镜像(此前直写绕过了镜像与原子性)
|
|
372
615
|
return { ok: true, id, updatedAt: out.updatedAt };
|
|
373
616
|
}
|
|
374
617
|
async function projectsGet(args) {
|
|
375
618
|
return readDoc(String((args && args.id) || ''));
|
|
376
619
|
}
|
|
620
|
+
/** 长效项目删除墓碑(区别于 projectsSync 的一次性 deletedProjects 通知信号):
|
|
621
|
+
* 删除后任何迟到的 projectsPut 都被拒收,直到 dataRootImport 显式恢复该 id。 */
|
|
622
|
+
function deletedProjectIds(cfg) {
|
|
623
|
+
const c = cfg || readIcConfig();
|
|
624
|
+
return (c.ui && Array.isArray(c.ui.deletedProjectIds)) ? c.ui.deletedProjectIds : [];
|
|
625
|
+
}
|
|
626
|
+
function addDeletedProjectId(id) {
|
|
627
|
+
const pid = String(id || '');
|
|
628
|
+
if (!pid) return;
|
|
629
|
+
const cfg = readIcConfig();
|
|
630
|
+
const prev = (cfg.ui && Array.isArray(cfg.ui.deletedProjectIds)) ? cfg.ui.deletedProjectIds : [];
|
|
631
|
+
cfg.ui = { ...(cfg.ui || {}), deletedProjectIds: [...new Set([...prev, pid])].slice(-200) };
|
|
632
|
+
writeIcConfig(cfg);
|
|
633
|
+
}
|
|
634
|
+
function clearDeletedProjectId(id) {
|
|
635
|
+
const pid = String(id || '');
|
|
636
|
+
if (!pid) return;
|
|
637
|
+
const cfg = readIcConfig();
|
|
638
|
+
if (!cfg.ui || !Array.isArray(cfg.ui.deletedProjectIds) || !cfg.ui.deletedProjectIds.includes(pid)) return;
|
|
639
|
+
cfg.ui = { ...cfg.ui, deletedProjectIds: cfg.ui.deletedProjectIds.filter((x) => x !== pid) };
|
|
640
|
+
writeIcConfig(cfg);
|
|
641
|
+
}
|
|
642
|
+
/** 外部 API 代理转发:浏览器直连第三方图片/视频/音频网关会被 CORS 拦截(带 Authorization 头的
|
|
643
|
+
* POST 触发预检,绝大多数网关不返回 CORS 头 → TypeError: Failed to fetch)。
|
|
644
|
+
* 由 Host 服务端(Node fetch,无 CORS)代为请求并原样回传状态码与响应体。
|
|
645
|
+
* req: { url, method?, headers?, body?(对象→JSON 序列化), bodyRaw?(base64 原文,multipart 用), bodyContentType?, timeoutMs? } */
|
|
646
|
+
async function fetchProxyForward(reqBody) {
|
|
647
|
+
const url = String((reqBody && reqBody.url) || '');
|
|
648
|
+
if (!/^https?:\/\//i.test(url)) throw new Error('需要 http(s) 接口 url');
|
|
649
|
+
const method = String((reqBody && reqBody.method) || 'POST').toUpperCase();
|
|
650
|
+
const headers = { ...((reqBody && reqBody.headers && typeof reqBody.headers === 'object') ? reqBody.headers : {}) };
|
|
651
|
+
let body;
|
|
652
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
653
|
+
if (reqBody && reqBody.bodyRaw != null) {
|
|
654
|
+
body = Buffer.from(String(reqBody.bodyRaw), 'base64');
|
|
655
|
+
if (reqBody.bodyContentType) headers['content-type'] = String(reqBody.bodyContentType);
|
|
656
|
+
} else if (reqBody && reqBody.body != null) {
|
|
657
|
+
body = typeof reqBody.body === 'string' ? reqBody.body : JSON.stringify(reqBody.body);
|
|
658
|
+
if (!headers['content-type']) headers['content-type'] = 'application/json';
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const ms = Number(reqBody && reqBody.timeoutMs) > 0 ? Number(reqBody.timeoutMs) : 120000;
|
|
662
|
+
const upstream = await fetch(url, {
|
|
663
|
+
method,
|
|
664
|
+
headers,
|
|
665
|
+
...(body !== undefined ? { body } : {}),
|
|
666
|
+
signal: AbortSignal.timeout(ms),
|
|
667
|
+
});
|
|
668
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
669
|
+
return { status: upstream.status, contentType: upstream.headers.get('content-type') || 'application/json; charset=utf-8', buf };
|
|
670
|
+
}
|
|
671
|
+
/** 设置数据根目录(绝对路径)并立即迁移布局;供 RPC 与测试复用。 */
|
|
672
|
+
function setDataRoot(raw) {
|
|
673
|
+
raw = String(raw ?? '').trim();
|
|
674
|
+
if (!raw) throw new Error('需要数据根目录路径');
|
|
675
|
+
// 必须在 resolve 之前判绝对路径:resolve 之后恒为绝对路径,检查就成了死代码,
|
|
676
|
+
// 相对路径会静默落到应用 cwd(升级即丢数据)
|
|
677
|
+
if (!path.isAbsolute(raw)) throw new Error('需要绝对路径(如 D:\\canvas-data)');
|
|
678
|
+
const root = path.resolve(raw);
|
|
679
|
+
mkdirSync(root, { recursive: true });
|
|
680
|
+
const cfg = readIcConfig();
|
|
681
|
+
cfg.dataRoot = root;
|
|
682
|
+
writeIcConfig(cfg);
|
|
683
|
+
// 首次设置/换目录:立即迁移布局(pid 文件夹 → 项目名文件夹)并镜像全部项目文档
|
|
684
|
+
let migrated = null;
|
|
685
|
+
try { migrated = dataRootMigrate(); } catch (e) { migrated = { error: String((e && e.message) || e) }; }
|
|
686
|
+
return { ok: true, dataRoot: root, migrated };
|
|
687
|
+
}
|
|
377
688
|
async function projectsDelete(args) {
|
|
378
|
-
|
|
689
|
+
const id = String((args && args.id) || '');
|
|
690
|
+
try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
|
|
691
|
+
try { addDeletedProjectId(id); } catch { /* 墓碑写失败不影响删除 */ }
|
|
692
|
+
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
379
693
|
return { ok: true };
|
|
380
694
|
}
|
|
381
695
|
async function projectsSync() {
|
|
@@ -621,6 +935,7 @@ function writeIcDoc(id, doc) {
|
|
|
621
935
|
const tmp = f + '.' + process.pid + '.tmp';
|
|
622
936
|
writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
|
|
623
937
|
renameSync(tmp, f);
|
|
938
|
+
mirrorProjectToDataRoot(id, doc); // 数据目录镜像(含设定集/assetIds 绑定):失败不影响主存储
|
|
624
939
|
}
|
|
625
940
|
/** 读取设定集(对话侧"设定集先行"的前提)。 */
|
|
626
941
|
async function canvasGetBible(args) {
|
|
@@ -641,7 +956,13 @@ async function canvasUpdateBible(args) {
|
|
|
641
956
|
const mergeList = (base, incoming) => {
|
|
642
957
|
if (incoming === undefined) return base;
|
|
643
958
|
const list = (Array.isArray(incoming) ? incoming : [])
|
|
644
|
-
.map((x) => ({
|
|
959
|
+
.map((x) => ({
|
|
960
|
+
name: String((x && x.name) || ''),
|
|
961
|
+
desc: String((x && x.desc) || ''),
|
|
962
|
+
// 资产绑定(assetIds)必须透传:设定集落地到数据目录时要携带"连接的资产信息";
|
|
963
|
+
// 未提供 assetIds 的合并不抹掉既有绑定(spread 只覆盖存在的键)
|
|
964
|
+
...(Array.isArray(x && x.assetIds) ? { assetIds: x.assetIds.map(String).filter(Boolean).slice(0, 20) } : {}),
|
|
965
|
+
}))
|
|
645
966
|
.filter((x) => x.name);
|
|
646
967
|
if ((args && args.mode) === 'replace') return list;
|
|
647
968
|
const out = [...base];
|
|
@@ -746,10 +1067,16 @@ async function canvasDeleteProject(args) {
|
|
|
746
1067
|
let name = id;
|
|
747
1068
|
try { name = JSON.parse(readFileSync(f, 'utf8')).name || id; } catch { /* 本来就不存在 */ }
|
|
748
1069
|
try { unlinkSync(f); } catch { /* 已删除 */ }
|
|
749
|
-
|
|
1070
|
+
try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
|
|
1071
|
+
// 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活 + 长效墓碑:拒收迟到的推送
|
|
750
1072
|
const cfg = readIcConfig();
|
|
751
1073
|
const prev = (cfg.ui && cfg.ui.deletedProjects) || [];
|
|
752
|
-
|
|
1074
|
+
const prevTomb = Array.isArray(cfg.ui.deletedProjectIds) ? cfg.ui.deletedProjectIds : [];
|
|
1075
|
+
cfg.ui = {
|
|
1076
|
+
...(cfg.ui || {}),
|
|
1077
|
+
deletedProjects: [...new Set([...prev, id])].slice(-50),
|
|
1078
|
+
deletedProjectIds: [...new Set([...prevTomb, id])].slice(-200),
|
|
1079
|
+
};
|
|
753
1080
|
writeIcConfig(cfg);
|
|
754
1081
|
return { ok: true, projectId: id, name };
|
|
755
1082
|
}
|
|
@@ -1000,7 +1327,7 @@ async function updateInstall() {
|
|
|
1000
1327
|
export const inject = ['webServer', 'tools'];
|
|
1001
1328
|
|
|
1002
1329
|
// 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
|
|
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 };
|
|
1330
|
+
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, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync };
|
|
1004
1331
|
|
|
1005
1332
|
function resolveModel(providers, want) {
|
|
1006
1333
|
const byProvider = want && want.provider
|
|
@@ -1163,17 +1490,7 @@ export function apply(ctx) {
|
|
|
1163
1490
|
return { dataRoot: root, exists, configPath: CONFIG_PATH };
|
|
1164
1491
|
},
|
|
1165
1492
|
async dataRootSet(args) {
|
|
1166
|
-
|
|
1167
|
-
if (!raw) throw new Error('需要数据根目录路径');
|
|
1168
|
-
// 必须在 resolve 之前判绝对路径:resolve 之后恒为绝对路径,检查就成了死代码,
|
|
1169
|
-
// 相对路径会静默落到应用 cwd(升级即丢数据)
|
|
1170
|
-
if (!path.isAbsolute(raw)) throw new Error('需要绝对路径(如 D:\\canvas-data)');
|
|
1171
|
-
const root = path.resolve(raw);
|
|
1172
|
-
mkdirSync(root, { recursive: true });
|
|
1173
|
-
const cfg = readIcConfig();
|
|
1174
|
-
cfg.dataRoot = root;
|
|
1175
|
-
writeIcConfig(cfg);
|
|
1176
|
-
return { ok: true, dataRoot: root };
|
|
1493
|
+
return setDataRoot((args && args.root) ?? '');
|
|
1177
1494
|
},
|
|
1178
1495
|
async dataRootReveal() {
|
|
1179
1496
|
const root = dataRoot();
|
|
@@ -1188,6 +1505,34 @@ export function apply(ctx) {
|
|
|
1188
1505
|
} catch { /* 同步抛错同样忽略 */ }
|
|
1189
1506
|
return { ok: true, dataRoot: root };
|
|
1190
1507
|
},
|
|
1508
|
+
// 资产元数据索引:客户端把资产记录(无 blob)写入 <项目文件夹>/assets.json(数据目录随拷随用)
|
|
1509
|
+
async assetIndexPut(args) {
|
|
1510
|
+
const projectId = String((args && args.projectId) || '');
|
|
1511
|
+
const asset = (args && args.asset) || null;
|
|
1512
|
+
if (!projectId || !asset || !asset.id) throw new Error('需要 projectId 与 asset');
|
|
1513
|
+
return writeAssetIndex(projectId, String((args && args.projectName) || ''), [asset]);
|
|
1514
|
+
},
|
|
1515
|
+
async assetIndexBulkPut(args) {
|
|
1516
|
+
const items = Array.isArray(args && args.items) ? args.items : [];
|
|
1517
|
+
const byProject = new Map();
|
|
1518
|
+
for (const it of items) {
|
|
1519
|
+
if (!it || !it.projectId) continue;
|
|
1520
|
+
const key = String(it.projectId);
|
|
1521
|
+
if (!byProject.has(key)) byProject.set(key, { name: String(it.projectName || key), list: [] });
|
|
1522
|
+
if (it.asset && it.asset.id) byProject.get(key).list.push(it.asset); // asset=null 的项目占位也入组:assets.json 清空对齐
|
|
1523
|
+
}
|
|
1524
|
+
let indexed = 0;
|
|
1525
|
+
// 整表替换:客户端 IDB 是资产库唯一事实源,启动全量同步借此自愈残留的幽灵条目
|
|
1526
|
+
for (const [pid, { name, list }] of byProject) {
|
|
1527
|
+
writeAssetIndex(pid, name, list, { replace: true });
|
|
1528
|
+
indexed += list.length;
|
|
1529
|
+
}
|
|
1530
|
+
return { ok: true, indexed, projects: byProject.size };
|
|
1531
|
+
},
|
|
1532
|
+
// 一键迁移/对齐:旧 pid 文件夹 → 项目名文件夹 + 全部项目文档镜像
|
|
1533
|
+
async dataRootMigrate() { return dataRootMigrate(); },
|
|
1534
|
+
// 一键导入:换电脑后把数据目录拷过来 → 扫描 project.json/assets.json 合并进本机
|
|
1535
|
+
async dataRootImport() { return dataRootImport(); },
|
|
1191
1536
|
async pickFolder() {
|
|
1192
1537
|
const r = await pickFolderDialog();
|
|
1193
1538
|
return r; // { ok, path } | { ok:false, cancelled } | { ok:false, unsupported }
|
|
@@ -1209,9 +1554,21 @@ export function apply(ctx) {
|
|
|
1209
1554
|
async canvasOpen(args) { return canvasOpen(args); },
|
|
1210
1555
|
async assetDelete(args) {
|
|
1211
1556
|
const root = dataRoot();
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1557
|
+
const rel = String((args && args.rel) || '');
|
|
1558
|
+
// 逻辑路径(pid)→ 物理路径(项目名文件夹):不映射会删不到真实文件
|
|
1559
|
+
if (root && rel) {
|
|
1560
|
+
const mapped = physicalRel(rel) || rel;
|
|
1561
|
+
try {
|
|
1562
|
+
const { abs } = safeJoin(root, mapped);
|
|
1563
|
+
unlinkSync(abs);
|
|
1564
|
+
} catch { /* 已不存在视为成功 */ }
|
|
1565
|
+
}
|
|
1566
|
+
// 同步清掉 assets.json 里的记录,保持数据目录与应用状态一致
|
|
1567
|
+
const pid = String((args && args.projectId) || '');
|
|
1568
|
+
const assetId = String((args && args.id) || '');
|
|
1569
|
+
if (pid && assetId) {
|
|
1570
|
+
try { removeAssetIndexEntry(pid, String((args && args.projectName) || ''), assetId); } catch { /* 索引清理失败不影响 */ }
|
|
1571
|
+
}
|
|
1215
1572
|
return { ok: true };
|
|
1216
1573
|
},
|
|
1217
1574
|
async listSkills() {
|
|
@@ -1550,6 +1907,9 @@ export function apply(ctx) {
|
|
|
1550
1907
|
// 内置技能:把画布操作规程交给 skills 服务(Agent 可加载,作为硬约束)
|
|
1551
1908
|
try { registerCanvasSkill(ctx, toolDisposers); } catch { /* skills 服务不可用时跳过 */ }
|
|
1552
1909
|
|
|
1910
|
+
// 数据根目录已设置时:启动即迁移/对齐布局(pid 文件夹 → 项目名文件夹 + 项目文档镜像),失败静默(下次设置/重启再试)
|
|
1911
|
+
try { if (dataRoot()) dataRootMigrate(); } catch { /* 迁移失败不影响启动 */ }
|
|
1912
|
+
|
|
1553
1913
|
const registrations = [
|
|
1554
1914
|
ctx.webServer.register({
|
|
1555
1915
|
kind: 'exact',
|
|
@@ -1628,7 +1988,9 @@ export function apply(ctx) {
|
|
|
1628
1988
|
const root = dataRoot();
|
|
1629
1989
|
if (!root) throw new Error('未设置数据根目录');
|
|
1630
1990
|
const u = new URL(req.url, 'http://localhost');
|
|
1631
|
-
const
|
|
1991
|
+
const rawRel = u.searchParams.get('rel') || '';
|
|
1992
|
+
const rel = physicalRel(rawRel) || rawRel; // 逻辑路径(pid)→ 物理路径(项目名文件夹)
|
|
1993
|
+
const { abs, clean } = safeJoin(root, rel);
|
|
1632
1994
|
mkdirSync(path.dirname(abs), { recursive: true });
|
|
1633
1995
|
let size = 0;
|
|
1634
1996
|
const MAX_UPLOAD = 512 * 1024 * 1024; // 512MB 上限:避免无界写盘
|
|
@@ -1657,6 +2019,39 @@ export function apply(ctx) {
|
|
|
1657
2019
|
}
|
|
1658
2020
|
},
|
|
1659
2021
|
}),
|
|
2022
|
+
// 外部 API 代理:浏览器直连第三方图片/视频/音频网关会被 CORS 拦(Failed to fetch),由 Host 代为请求
|
|
2023
|
+
ctx.webServer.register({
|
|
2024
|
+
kind: 'exact',
|
|
2025
|
+
path: `/${NAME}/api/fetch-proxy`,
|
|
2026
|
+
async handler(req, res) {
|
|
2027
|
+
try {
|
|
2028
|
+
if (req.method !== 'POST') {
|
|
2029
|
+
res.statusCode = 405;
|
|
2030
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
2031
|
+
res.end(JSON.stringify({ error: '仅接受 POST' }));
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
const chunks = [];
|
|
2035
|
+
let size = 0;
|
|
2036
|
+
for await (const c of req) {
|
|
2037
|
+
size += c.length;
|
|
2038
|
+
if (size > 16e6) { res.statusCode = 413; res.setHeader('content-type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: '请求体过大(上限 16MB)' })); return; }
|
|
2039
|
+
chunks.push(c);
|
|
2040
|
+
}
|
|
2041
|
+
let payload = {};
|
|
2042
|
+
try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { payload = {}; }
|
|
2043
|
+
const out = await fetchProxyForward(payload);
|
|
2044
|
+
res.statusCode = out.status;
|
|
2045
|
+
res.setHeader('content-type', out.contentType);
|
|
2046
|
+
res.end(out.buf);
|
|
2047
|
+
} catch (e) {
|
|
2048
|
+
const timedOut = e && (e.name === 'TimeoutError' || /aborted|timeout/i.test(String((e && e.message) || e)));
|
|
2049
|
+
res.statusCode = timedOut ? 504 : 502;
|
|
2050
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
2051
|
+
res.end(JSON.stringify({ error: (timedOut ? '图片接口代理超时:' : '图片接口代理请求失败:') + String((e && e.message) || e) }));
|
|
2052
|
+
}
|
|
2053
|
+
},
|
|
2054
|
+
}),
|
|
1660
2055
|
ctx.webServer.register({
|
|
1661
2056
|
kind: 'exact',
|
|
1662
2057
|
path: `/${NAME}/api/file`,
|
|
@@ -1666,7 +2061,9 @@ export function apply(ctx) {
|
|
|
1666
2061
|
const root = dataRoot();
|
|
1667
2062
|
if (!root) { res.statusCode = 404; res.end('未设置数据根目录'); return; }
|
|
1668
2063
|
const u = new URL(req.url, 'http://localhost');
|
|
1669
|
-
const
|
|
2064
|
+
const rawRel = u.searchParams.get('rel') || '';
|
|
2065
|
+
const rel = physicalRel(rawRel) || rawRel; // 逻辑路径(pid)→ 物理路径(项目名文件夹)
|
|
2066
|
+
const { abs } = safeJoin(root, rel);
|
|
1670
2067
|
let st;
|
|
1671
2068
|
try { st = statSync(abs); } catch { res.statusCode = 404; res.end('not found'); return; }
|
|
1672
2069
|
if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninkch/dsh-infinite-canvas",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.7",
|
|
4
4
|
"description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|