@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 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' ? '视频' : '图片') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' ');
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
- const targets = all.filter((r) => r.blob && !r.rel);
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 rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${extFromMime(rec.mime)}`;
1522
- const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
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: rec.blob.size, stored: 'disk' };
1525
- delete next.blob;
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
- const res = await fetchT(base + '/images/edits', { method: 'POST', headers: auth, body: fd }, s.timeoutMs, '图片编辑 API');
1665
- if (!res.ok) throw new Error('图片编辑 API HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
1666
- const j = await res.json();
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 fetchT(d.url, null, 60000, '下载图片');
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 await r2.blob();
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
- // 文生图模式(generations)按界面说明**忽略参考图**;参考图只在 edits 模式使用,
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: JSON.stringify(body),
1693
- }, s.timeoutMs, '图片 API');
1694
- if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
1695
- const j = await res.json();
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 fetchT(d.url, null, 60000, '下载图片');
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 await r2.blob();
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
- const r = await fetchT(url, { headers }, 300000, '下载视频');
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 await r.blob();
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 fetchT(base, { method: 'POST', headers, body: JSON.stringify(body) }, s.timeoutMs, 'Seedance 视频 API');
1766
- if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
1767
- const j = await res.json();
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 fetchT(base + '/' + taskId, { headers }, 60000, '查询视频任务');
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 = await r2.json();
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 fetchT(base, { method: 'POST', headers, body: JSON.stringify(body) }, s.timeoutMs, '视频 API');
1809
- if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
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 = await res.json(); } catch { /* 非 JSON 响应 */ }
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 fetchT(base + '/' + taskId, { headers }, 60000, '查询视频任务');
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 = await r2.json(); } catch { /* 忽略 */ }
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: T('scriptAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
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: T('storyboardAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
3583
- text: 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'),
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: T('finalCutDone') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
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: T('scriptAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '), text: n.data.output, category: 'script', prompt: n.data.idea });
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('button', { key: 'expand', type: 'button', className: 'dshic-mini', style: { alignSelf: 'flex-start' }, onClick: () => expandStoryboard(n), onPointerDown: (e) => e.stopPropagation() },
5062
- Ic('expandAll', 11), T('expand') + ` (${shots.length})`)
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
- const res = await fetchT((cfg.baseUrl || '').replace(/\/+$/, '') + '/audio/speech', {
5160
- method: 'POST',
5161
- headers: { 'Content-Type': 'application/json', ...(cfg.apiKey ? { Authorization: 'Bearer ' + cfg.apiKey } : {}) },
5162
- body: JSON.stringify({ ...(cfg.model ? { model: cfg.model } : {}), input: text, voice: cfg.voice || 'alloy', response_format: 'mp3' }),
5163
- }, cfg.timeoutMs, 'TTS API');
5164
- if (!res.ok) throw new Error('TTS HTTP ' + res.status + ':' + (await res.text()).slice(0, 200));
5165
- const buf = await res.arrayBuffer();
5166
- const blob = new Blob([buf], { type: res.headers.get('content-type') || 'audio/mpeg' });
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
- if (a.rel) { try { await hostRpc('assetDelete', { rel: a.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
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
- writeFileSync(f, JSON.stringify(out, null, 1), 'utf8');
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
- try { unlinkSync(projectFile(args && args.id)); } catch { /* 已不存在视为成功 */ }
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) => ({ name: String((x && x.name) || ''), desc: String((x && x.desc) || '') }))
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
- cfg.ui = { ...(cfg.ui || {}), deletedProjects: [...new Set([...prev, id])].slice(-50) };
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
- const raw = String((args && args.root) ?? '').trim();
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
- if (!root) throw new Error('未设置数据根目录');
1213
- const { abs } = safeJoin(root, (args && args.rel) || '');
1214
- try { unlinkSync(abs); } catch { /* 已不存在视为成功 */ }
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 { abs, clean } = safeJoin(root, u.searchParams.get('rel') || '');
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 { abs } = safeJoin(root, u.searchParams.get('rel') || '');
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.6",
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",