@ninkch/dsh-infinite-canvas 0.17.6 → 0.17.8

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,15 @@ window.__ModuleLoader__.load({
349
356
  'dataRootOpen': '打开目录',
350
357
  'dataRootMigrate': '迁移历史资产',
351
358
  'dataRootMigrated': '已迁移 {n} 个资产到数据根目录',
359
+ 'collapse': '折叠',
360
+ 'foldOpen': '展开',
361
+ 'craftReadyCount': '{r}/{n} 就绪',
362
+ 'craftDirLabel': '当前项目后期目录',
363
+ 'craftDataRootNeed': '请先在上方设置画布数据根目录:Craft 后期文件将存放于 数据根目录\\projects\\<项目>\\craft',
364
+ 'craftRootMismatch': 'ArtCraft 自身数据目录与画布数据根目录不一致:请在 ArtCraft 设置里把数据目录改成同一根目录,否则后期文件会落到两处',
365
+ 'dataRootImportBtn': '从数据目录导入',
366
+ 'dataRootImported': '导入完成:合并 {n} 个项目、补入 {a} 条资产元数据',
367
+ 'dataRootImportHint': '换电脑迁移:把整个数据目录拷到新机器 → 设置为数据根目录 → 点"从数据目录导入"即可恢复项目/节点/设定集/资产',
352
368
  'dataRootMigratedDone': '已迁移',
353
369
  'dataRootMigrating': '迁移中',
354
370
  'dataRootUnsupported': '当前插件后台版本较旧(不支持数据根目录):重启 Harness 后可用',
@@ -677,6 +693,15 @@ window.__ModuleLoader__.load({
677
693
  'dataRootOpen': 'Open folder',
678
694
  'dataRootMigrate': 'Migrate existing assets',
679
695
  'dataRootMigrated': 'Migrated {n} assets to the data root',
696
+ 'collapse': 'Collapse',
697
+ 'foldOpen': 'Expand',
698
+ 'craftReadyCount': '{r}/{n} ready',
699
+ 'craftDirLabel': 'Project craft dir',
700
+ 'craftDataRootNeed': 'Set the canvas data root above first: craft files go to <root>\\projects\\<project>\\craft',
701
+ 'craftRootMismatch': "ArtCraft's own data root differs from the canvas data root — point ArtCraft at the same root or craft files will land in two places",
702
+ 'dataRootImportBtn': 'Import from data root',
703
+ 'dataRootImported': 'Imported: {n} projects merged, {a} asset records added',
704
+ '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
705
  'dataRootMigratedDone': 'Migrated',
681
706
  'dataRootMigrating': 'Migrating',
682
707
  'dataRootUnsupported': 'The plugin backend is older and lacks data-root support — restart Harness to enable',
@@ -1079,8 +1104,8 @@ window.__ModuleLoader__.load({
1079
1104
  try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 忽略配额错误 */ }
1080
1105
  }
1081
1106
  const DEFAULT_SETTINGS = {
1082
- // 生成参数一律"留空即不发送",由 Harness 适配器/模型用已配置的默认值(不在代码里写死)
1083
- textLlm: { provider: '', model: '', maxTokens: '', temperature: '' },
1107
+ // 文本模型全部用 Harness 的当前模型与默认参数:画布只选择模型,不单独配置生成参数
1108
+ textLlm: { provider: '', model: '' },
1084
1109
  imageApis: [], // [{id, name, baseUrl, apiKey, model, mode, size?, responseFormat?}]
1085
1110
  videoApis: [], // [{id, name, url, apiKey, model, protocol?, watermark?, generateAudio?}]
1086
1111
  ttsApis: [], // [{id, name, baseUrl, apiKey, model, voice}]
@@ -1094,7 +1119,7 @@ window.__ModuleLoader__.load({
1094
1119
  function loadSettings() {
1095
1120
  const s = lsGet(LS_SETTINGS, {}) || {};
1096
1121
  const out = {
1097
- textLlm: { ...DEFAULT_SETTINGS.textLlm, ...(s.textLlm || {}) },
1122
+ textLlm: { provider: (s.textLlm && s.textLlm.provider) || '', model: (s.textLlm && s.textLlm.model) || '' },
1098
1123
  imageApis: Array.isArray(s.imageApis) ? s.imageApis : [],
1099
1124
  videoApis: Array.isArray(s.videoApis) ? s.videoApis : [],
1100
1125
  ttsApis: Array.isArray(s.ttsApis) ? s.ttsApis : [],
@@ -1292,12 +1317,16 @@ window.__ModuleLoader__.load({
1292
1317
  entLines.length ? (zh ? '设定集:' : 'Series bible: ') + entLines.join(';') : '',
1293
1318
  ].filter(Boolean).join('\n');
1294
1319
  }
1295
- /** 资产命名:优先节点名(如「角色-张山 多视图」),否则用命中实体,最后兜底。 */
1320
+ /** 资产命名时间戳(MM-DD HH:MM):同节点多次生成靠它区分。 */
1321
+ function nowStamp() {
1322
+ return new Date().toISOString().slice(5, 16).replace('T', ' ');
1323
+ }
1324
+ /** 资产命名:**节点名 + 时间**(用户规则);无节点名退回实体名/类型前缀 + 时间。 */
1296
1325
  function assetNameFor(node, entities, fallbackKind) {
1297
1326
  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', ' ');
1327
+ if (label) return label + ' ' + nowStamp();
1328
+ if (entities && entities.length) return entityLabelOf(entities[0].kind) + '-' + entities[0].name + ' ' + nowStamp();
1329
+ return (fallbackKind === 'video' ? '视频' : '图片') + '-' + nowStamp();
1301
1330
  }
1302
1331
  // <<<MENTION_PURE_END>>>
1303
1332
 
@@ -1509,21 +1538,72 @@ window.__ModuleLoader__.load({
1509
1538
  }
1510
1539
  await idbPut(rec);
1511
1540
  assetNameCache.set(rec.id, rec.name);
1541
+ mirrorAssetIndex(rec); // 资产元数据镜像进数据目录 assets.json(迁移便利,失败静默)
1512
1542
  return rec;
1513
1543
  }
1544
+ /** 资产元数据索引记录(去 blob;文本资产保留 text 以便导入端免读文件即可预览)。 */
1545
+ function assetIndexRecord(rec) {
1546
+ const out = {
1547
+ id: rec.id, projectId: rec.projectId, kind: rec.kind, mime: rec.mime,
1548
+ name: rec.name, createdAt: rec.createdAt, prompt: rec.prompt || '',
1549
+ source: rec.source || '', category: rec.category || 'general',
1550
+ };
1551
+ if (rec.rel) { out.rel = rec.rel; out.size = rec.size || 0; }
1552
+ if (rec.entity) out.entity = rec.entity;
1553
+ if (Array.isArray(rec.entities) && rec.entities.length) out.entities = rec.entities;
1554
+ if (rec.nodeLabel) out.nodeLabel = rec.nodeLabel;
1555
+ if (rec.kind === 'text' && typeof rec.text === 'string') out.text = rec.text;
1556
+ return out;
1557
+ }
1558
+ /** 把资产元数据镜像进数据目录的 assets.json(失败静默:索引是迁移便利,不是主存储)。 */
1559
+ function mirrorAssetIndex(rec) {
1560
+ if (!S.dataRoot || !rec || !rec.id) return;
1561
+ const proj = S.projects.find((p) => p.id === rec.projectId);
1562
+ hostRpc('assetIndexPut', {
1563
+ projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
1564
+ }).catch(() => { /* 索引失败不影响资产本身 */ });
1565
+ }
1566
+ /** 启动/设置数据目录后:一次性把 IndexedDB 里的全部资产元数据同步进 assets.json(补齐历史资产索引)。
1567
+ * 全量**替换**语义(含空项目占位):IndexedDB 是资产库唯一事实源,借此自愈删除后残留的幽灵条目。 */
1568
+ let assetIndexSynced = false;
1569
+ async function syncAssetIndexToHost() {
1570
+ if (assetIndexSynced || !S.dataRoot) return;
1571
+ assetIndexSynced = true;
1572
+ try {
1573
+ const all = await idbAll();
1574
+ const items = [];
1575
+ const seen = new Set();
1576
+ const projName = new Map(S.projects.map((p) => [p.id, p.name]));
1577
+ for (const rec of all) {
1578
+ if (!rec || !rec.id || !rec.projectId) continue;
1579
+ seen.add(rec.projectId);
1580
+ items.push({ projectId: rec.projectId, projectName: projName.get(rec.projectId) || rec.projectId, asset: assetIndexRecord(rec) });
1581
+ }
1582
+ // 本机已知但没有任何资产的项目也要发占位:assets.json 清空对齐(否则删除后会残留幽灵条目)
1583
+ for (const p of S.projects) {
1584
+ if (!seen.has(p.id)) items.push({ projectId: p.id, projectName: p.name, asset: null });
1585
+ }
1586
+ if (items.length) await hostRpc('assetIndexBulkPut', { items });
1587
+ } catch { /* 索引同步失败不影响 */ }
1588
+ }
1514
1589
  /** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
1515
1590
  async function migrateAssetsToDisk(onProgress) {
1516
1591
  if (!S.dataRoot) throw new Error('请先设置数据根目录');
1517
1592
  const all = await idbAll();
1518
- const targets = all.filter((r) => r.blob && !r.rel);
1593
+ // 二进制资产(有 blob)与文本资产(text,此前从不落盘)都要迁移
1594
+ const targets = all.filter((r) => (r.blob && !r.rel) || (r.kind === 'text' && !r.rel && typeof r.text === 'string' && r.text.length));
1519
1595
  let done = 0;
1520
1596
  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 });
1597
+ const isText = rec.kind === 'text' && !rec.blob;
1598
+ const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${isText ? 'md' : extFromMime(rec.mime)}`;
1599
+ const blob = isText ? new Blob([rec.text || ''], { type: 'text/markdown' }) : rec.blob;
1600
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
1523
1601
  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;
1602
+ const next = { ...rec, rel, size: blob.size, stored: 'disk' };
1603
+ if (isText) next.text = rec.text; // 文本保留在元数据里:预览/下载不必回读文件
1604
+ else delete next.blob;
1526
1605
  await idbPut(next);
1606
+ mirrorAssetIndex(next); // 迁移后刷新数据目录 assets.json(补 rel/size)
1527
1607
  assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
1528
1608
  done++;
1529
1609
  if (onProgress) onProgress(done, targets.length);
@@ -1648,11 +1728,30 @@ window.__ModuleLoader__.load({
1648
1728
  return new Blob(chunks, { type: mime.split(';')[0] });
1649
1729
  }
1650
1730
 
1731
+ /** 外部 API 请求统一经 Host 代理(图片/视频/音频网关都走这里):浏览器直连第三方接口会被
1732
+ * CORS 拦截(带 Authorization 的 POST 触发预检,网关不返回 CORS 头 → TypeError: Failed to fetch)。
1733
+ * 返回 { ok, status, contentType, text, blob }。 */
1734
+ async function fetchViaProxy(url, { method = 'POST', headers = {}, body = null, bodyRaw = null, bodyContentType = '', timeoutMs = 120000, label = '请求' } = {}) {
1735
+ const payload = { url, method, headers, timeoutMs };
1736
+ if (bodyRaw != null) { payload.bodyRaw = bodyRaw; payload.bodyContentType = bodyContentType; }
1737
+ else if (body != null) payload.body = body;
1738
+ // 客户端→Host 的预算比上游超时多 15s:让上游超时(504/带原因)先到,而不是客户端先断
1739
+ const res = await fetchT('/' + NS + '/api/fetch-proxy', {
1740
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
1741
+ }, Number(timeoutMs) + 15000, label + '(代理)');
1742
+ const ct = res.headers.get('content-type') || '';
1743
+ const buf = await res.arrayBuffer();
1744
+ const blob = new Blob([buf], { type: ct || 'application/octet-stream' });
1745
+ let text = '';
1746
+ if (!ct || /json|text|plain/.test(ct)) { try { text = new TextDecoder().decode(buf); } catch { text = ''; } }
1747
+ return { ok: res.ok, status: res.status, contentType: ct, text, blob };
1748
+ }
1651
1749
  async function apiImage(prompt, refBlobs, s, opts) {
1652
1750
  if (!s.baseUrl) throw new Error('未配置图片 API');
1653
1751
  const base = s.baseUrl.replace(/\/+$/, '');
1654
1752
  const refs = refBlobs || [];
1655
1753
  const auth = s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {};
1754
+ const upstreamTimeout = Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000; // 商汤等同步接口 20s+,默认 120s
1656
1755
  // 参考图编辑模式:multipart 上传到 /images/edits(OpenAI gpt-image-1 兼容)
1657
1756
  if (refs.length && s.mode === 'edits') {
1658
1757
  const fd = new FormData();
@@ -1661,15 +1760,20 @@ window.__ModuleLoader__.load({
1661
1760
  fd.append('n', '1');
1662
1761
  if (s.size) fd.append('size', s.size);
1663
1762
  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();
1763
+ // FormData 经 Response 序列化为 multipart 字节(含 boundary)→ base64 经代理原样转发
1764
+ const serialized = new Response(fd);
1765
+ const bodyRaw = arrayBufferToBase64(await serialized.arrayBuffer());
1766
+ const bodyContentType = serialized.headers.get('content-type') || 'multipart/form-data';
1767
+ const res = await fetchViaProxy(base + '/images/edits', { headers: auth, bodyRaw, bodyContentType, timeoutMs: upstreamTimeout, label: '图片编辑 API' });
1768
+ if (!res.ok) throw new Error('图片编辑 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
1769
+ let j = null;
1770
+ try { j = JSON.parse(res.text); } catch { j = null; }
1667
1771
  const d = j && j.data && j.data[0];
1668
1772
  if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
1669
1773
  if (d && d.url) {
1670
- const r2 = await fetchT(d.url, null, 60000, '下载图片');
1774
+ const r2 = await fetchViaProxy(d.url, { method: 'GET', timeoutMs: 120000, label: '下载图片' });
1671
1775
  if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
1672
- return await r2.blob();
1776
+ return r2.blob;
1673
1777
  }
1674
1778
  throw new Error('图片编辑 API 响应中没有 data[0].b64_json / url');
1675
1779
  }
@@ -1683,22 +1787,19 @@ window.__ModuleLoader__.load({
1683
1787
  const sizeOverride = String(s.size || '').trim();
1684
1788
  if (sizeOverride) body.size = sizeOverride;
1685
1789
  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',
1790
+ const res = await fetchViaProxy(base + '/images/generations', {
1691
1791
  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();
1792
+ body, timeoutMs: upstreamTimeout, label: '图片 API',
1793
+ });
1794
+ if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
1795
+ let j = null;
1796
+ try { j = JSON.parse(res.text); } catch { j = null; }
1696
1797
  const d = j && j.data && j.data[0];
1697
1798
  if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
1698
1799
  if (d && d.url) {
1699
- const r2 = await fetchT(d.url, null, 60000, '下载图片');
1800
+ const r2 = await fetchViaProxy(d.url, { method: 'GET', timeoutMs: 120000, label: '下载图片' });
1700
1801
  if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
1701
- return await r2.blob();
1802
+ return r2.blob;
1702
1803
  }
1703
1804
  throw new Error('图片 API 响应中没有 data[0].b64_json / url');
1704
1805
  }
@@ -1734,9 +1835,10 @@ window.__ModuleLoader__.load({
1734
1835
  ?? (j.output && (j.output.task_id ?? j.output.id));
1735
1836
  }
1736
1837
  async function downloadVideo(url, headers) {
1737
- const r = await fetchT(url, { headers }, 300000, '下载视频');
1838
+ // 经 Host 代理:签名下载直连同样会被 CORS 拦
1839
+ const r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
1738
1840
  if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
1739
- return await r.blob();
1841
+ return r.blob;
1740
1842
  }
1741
1843
  /** 火山方舟 Seedance 官方协议:创建任务 → 轮询 → 下载(支持首帧 + 最多 4 张参考图)。 */
1742
1844
  async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
@@ -1762,17 +1864,17 @@ window.__ModuleLoader__.load({
1762
1864
  generate_audio: s.generateAudio !== false,
1763
1865
  };
1764
1866
  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();
1867
+ const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: 'Seedance 视频 API' });
1868
+ if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + res.text.slice(0, 300));
1869
+ const j = JSON.parse(res.text || '{}');
1768
1870
  const taskId = j && j.id;
1769
1871
  if (!taskId) throw new Error('Seedance 响应中没有任务 id');
1770
1872
  const deadline = Date.now() + 15 * 60 * 1000;
1771
1873
  while (Date.now() < deadline) {
1772
1874
  await sleep(10000);
1773
- const r2 = await fetchT(base + '/' + taskId, { headers }, 60000, '查询视频任务');
1875
+ const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' });
1774
1876
  if (!r2.ok) throw new Error('查询 Seedance 任务失败 HTTP ' + r2.status);
1775
- const st = await r2.json();
1877
+ const st = JSON.parse(r2.text || '{}');
1776
1878
  if (st.status === 'succeeded') {
1777
1879
  const u = st.content && st.content.video_url;
1778
1880
  if (!u) throw new Error('Seedance 任务成功但未返回视频地址');
@@ -1805,10 +1907,10 @@ window.__ModuleLoader__.load({
1805
1907
  if (frameBlob) {
1806
1908
  try { body.image = await blobToDataUrl(frameBlob); } catch { /* 忽略首帧编码失败 */ }
1807
1909
  }
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));
1910
+ const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: '视频 API' });
1911
+ if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
1810
1912
  let j = null;
1811
- try { j = await res.json(); } catch { /* 非 JSON 响应 */ }
1913
+ try { j = JSON.parse(res.text); } catch { /* 非 JSON 响应 */ }
1812
1914
  const direct = extractVideoUrl(j);
1813
1915
  if (direct) return await downloadVideo(direct, {});
1814
1916
  const taskId = extractTaskId(j);
@@ -1816,10 +1918,10 @@ window.__ModuleLoader__.load({
1816
1918
  const deadline = Date.now() + 15 * 60 * 1000;
1817
1919
  while (Date.now() < deadline) {
1818
1920
  await sleep(3000);
1819
- const r2 = await fetchT(base + '/' + taskId, { headers }, 60000, '查询视频任务');
1921
+ const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
1820
1922
  if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
1821
1923
  let st = null;
1822
- try { st = await r2.json(); } catch { /* 忽略 */ }
1924
+ try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
1823
1925
  const u = extractVideoUrl(st);
1824
1926
  if (u) return await downloadVideo(u, {});
1825
1927
  // 失败判定必须看状态字段,不能拿整串 JSON 做子串匹配:
@@ -2212,7 +2314,9 @@ window.__ModuleLoader__.load({
2212
2314
  const pid = S.activeId;
2213
2315
  const map = craftMapLoad(pid);
2214
2316
  if (!opts.force && map[rec.id]) return map[rec.id];
2215
- const rel = craftProjectSlug() + '/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
2317
+ // Craft 后期数据统一放在**画布数据根目录**的项目目录下:projects/<项目>/<slug 前缀去掉>/craft/…
2318
+ // (ArtCraft 数据根目录 = 画布数据根目录时,即落在 <root>/projects/<项目>/craft/media/)
2319
+ const rel = 'projects/' + craftProjectSlug() + '/craft/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
2216
2320
  const blob = rec.blob || await assetBlob(rec.id);
2217
2321
  if (!blob) throw new Error('资产内容不可用: ' + rec.id);
2218
2322
  await craft.write(rel, blob);
@@ -3012,8 +3116,25 @@ window.__ModuleLoader__.load({
3012
3116
  source: (meta && meta.source) || 'ai',
3013
3117
  category: (meta && meta.category) || 'script',
3014
3118
  };
3119
+ // 文本资产同样落盘到数据根目录(<root>/projects/<项目>/text/<名>.md),
3120
+ // IndexedDB 保留元数据与 text 字段(文本很小,预览/下载不必回读文件)。
3121
+ // 落盘失败降级为仅 IndexedDB(绝不阻塞生成主流程),控制台留痕。
3122
+ if (S.dataRoot) {
3123
+ try {
3124
+ const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
3125
+ const blob = new Blob([rec.text], { type: 'text/markdown' });
3126
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
3127
+ if (!r.ok) throw new Error(await r.text());
3128
+ rec.rel = rel;
3129
+ rec.size = blob.size;
3130
+ rec.stored = 'disk';
3131
+ } catch (err) {
3132
+ console.warn('[infinite-canvas] 文本资产落盘失败(降级为浏览器存储):', String((err && err.message) || err));
3133
+ }
3134
+ }
3015
3135
  await idbPut(rec);
3016
3136
  assetNameCache.set(rec.id, rec.name);
3137
+ mirrorAssetIndex(rec); // 文本资产元数据同样镜像进 assets.json
3017
3138
  return rec;
3018
3139
  }
3019
3140
 
@@ -3541,7 +3662,7 @@ window.__ModuleLoader__.load({
3541
3662
  if (!String(text || '').trim()) throw new Error(T('aiEmptyResult'));
3542
3663
  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
3664
  await saveTextAssetRecord(S.activeId, {
3544
- name: T('scriptAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
3665
+ name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(),
3545
3666
  text, category: 'script', prompt: n.data.idea,
3546
3667
  });
3547
3668
  update((s) => { s.assetsTick++; });
@@ -3561,6 +3682,11 @@ window.__ModuleLoader__.load({
3561
3682
  }
3562
3683
  }
3563
3684
 
3685
+ /** 分镜节点的资产文本(自动入库与手动"存为资产"共用,避免两处格式漂移)。 */
3686
+ function storyboardAssetText(shots) {
3687
+ 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');
3688
+ }
3689
+
3564
3690
  /** 分镜节点:剧本 → 镜头列表(含一致性检查)。 */
3565
3691
  async function genStoryboardNode(n) {
3566
3692
  const st = storyboardScriptText(n);
@@ -3579,8 +3705,8 @@ window.__ModuleLoader__.load({
3579
3705
  // 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
3580
3706
  try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
3581
3707
  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'),
3708
+ name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
3709
+ text: storyboardAssetText(shots),
3584
3710
  category: 'storyboard', prompt: st.slice(0, 200),
3585
3711
  });
3586
3712
  update((s) => { s.assetsTick++; });
@@ -3635,7 +3761,7 @@ window.__ModuleLoader__.load({
3635
3761
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.progress = Math.round(frac * 100) + '% · ' + name; });
3636
3762
  });
3637
3763
  const rec = await saveAssetRecord(S.activeId, 'video', blob, {
3638
- name: T('finalCutDone') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
3764
+ name: (String(n.data.label || '').trim() || T('finalCutDone')) + ' ' + nowStamp(),
3639
3765
  prompt: metas.length + ' segments', source: 'finalcut', category: 'finalcut',
3640
3766
  });
3641
3767
  metas.forEach((m) => URL.revokeObjectURL(m.url));
@@ -4976,7 +5102,7 @@ window.__ModuleLoader__.load({
4976
5102
  type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
4977
5103
  onPointerDown: (e) => e.stopPropagation(),
4978
5104
  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 });
5105
+ await saveTextAssetRecord(S.activeId, { name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(), text: n.data.output, category: 'script', prompt: n.data.idea });
4980
5106
  update((s) => { s.assetsTick++; });
4981
5107
  toast(T('savedOk'));
4982
5108
  },
@@ -5058,8 +5184,26 @@ window.__ModuleLoader__.load({
5058
5184
  )
5059
5185
  : null,
5060
5186
  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})`)
5187
+ ? h('div', { key: 'acts', className: 'dshic-node-row', style: { alignSelf: 'flex-start' } },
5188
+ // 手动"存为资产":AI 生成已自动入库(category=storyboard),这里用于留存当前分镜的一版快照
5189
+ h('button', {
5190
+ key: 'save', type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
5191
+ onPointerDown: (e) => e.stopPropagation(),
5192
+ onClick: async () => {
5193
+ try {
5194
+ await saveTextAssetRecord(S.activeId, {
5195
+ name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
5196
+ text: storyboardAssetText(shots), category: 'storyboard',
5197
+ prompt: String(n.data.script || '').slice(0, 200),
5198
+ });
5199
+ update((s) => { s.assetsTick++; });
5200
+ toast(T('savedOk'));
5201
+ } catch (err) { toast(String((err && err.message) || err)); }
5202
+ },
5203
+ }, Ic('download', 11), T('saveTextAsset')),
5204
+ h('button', { key: 'expand', type: 'button', className: 'dshic-mini', onClick: () => expandStoryboard(n), onPointerDown: (e) => e.stopPropagation() },
5205
+ Ic('expandAll', 11), T('expand') + ` (${shots.length})`),
5206
+ )
5063
5207
  : null,
5064
5208
  ];
5065
5209
  }
@@ -5156,14 +5300,15 @@ window.__ModuleLoader__.load({
5156
5300
  if (!text) { toast(T('subPlaceholder')); return; }
5157
5301
  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
5302
  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' });
5303
+ // TTS 也走 Host 代理:浏览器直连 TTS 网关同样会被 CORS 拦
5304
+ const res = await fetchViaProxy((cfg.baseUrl || '').replace(/\/+$/, '') + '/audio/speech', {
5305
+ headers: { ...(cfg.apiKey ? { Authorization: 'Bearer ' + cfg.apiKey } : {}) },
5306
+ body: { ...(cfg.model ? { model: cfg.model } : {}), input: text, voice: cfg.voice || 'alloy', response_format: 'mp3' },
5307
+ timeoutMs: Number(cfg.timeoutMs) > 0 ? Number(cfg.timeoutMs) : 120000,
5308
+ label: 'TTS API',
5309
+ });
5310
+ if (!res.ok) throw new Error('TTS HTTP ' + res.status + ':' + res.text.slice(0, 200));
5311
+ const blob = /^audio\//.test(res.contentType) ? res.blob : new Blob([res.blob], { type: 'audio/mpeg' });
5167
5312
  const rec = await saveAssetRecord(S.activeId, 'audio', blob, { name: 'TTS-' + text.slice(0, 12), prompt: text, source: 'api', category: 'audio' });
5168
5313
  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
5314
  } catch (e) {
@@ -5200,12 +5345,12 @@ window.__ModuleLoader__.load({
5200
5345
  width: dims.w, height: dims.h,
5201
5346
  });
5202
5347
  try {
5203
- await craft.writeText(slug + '/craft/timeline.xml', xml);
5204
- await craft.writeText(slug + '/craft/subs.srt', buildSrt(items));
5348
+ await craft.writeText('projects/' + slug + '/craft/timeline.xml', xml);
5349
+ await craft.writeText('projects/' + slug + '/craft/subs.srt', buildSrt(items));
5205
5350
  } catch (e) { return; /* craftFetch 已提示 */ }
5206
5351
  setFcDialog({
5207
- args: JSON.stringify(['--project', slug + '/craft/drama.fcproj', '--import', slug + '/craft/timeline.xml', '--export', slug + '/out/final.mp4'], null, 1),
5208
- outRel: slug + '/out/final.mp4',
5352
+ args: JSON.stringify(['--project', 'projects/' + slug + '/craft/drama.fcproj', '--import', 'projects/' + slug + '/craft/timeline.xml', '--export', 'projects/' + slug + '/craft/out/final.mp4'], null, 1),
5353
+ outRel: 'projects/' + slug + '/craft/out/final.mp4',
5209
5354
  });
5210
5355
  };
5211
5356
  const submitFc = (args, outRel) => {
@@ -5540,7 +5685,9 @@ window.__ModuleLoader__.load({
5540
5685
  const confirmDel = async () => {
5541
5686
  const pid = delTarget && delTarget.id;
5542
5687
  if (!pid) return;
5543
- flushSave();
5688
+ // 只刷"非待删项目"的待存文档:flushSave 推的是 S.activeId 的全量文档,
5689
+ // 若推的正是待删项目,会与 projectsDelete 竞速把项目原地复活(资产/设定集已清的残废形态)
5690
+ if (pid !== S.activeId) flushSave();
5544
5691
  update((s) => {
5545
5692
  s.projects = s.projects.filter((p) => p.id !== pid);
5546
5693
  try { localStorage.removeItem(LS_GRAPH + pid); } catch { /* 忽略 */ }
@@ -5755,7 +5902,11 @@ window.__ModuleLoader__.load({
5755
5902
  if (files.length) setTick((x) => x + 1);
5756
5903
  };
5757
5904
  const del = async (a) => {
5758
- if (a.rel) { try { await hostRpc('assetDelete', { rel: a.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
5905
+ // 数据目录同步:删文件(逻辑路径由 Host 映射到项目名文件夹)+ 清 assets.json 索引
5906
+ const proj = S.projects.find((p) => p.id === (a.projectId || S.activeId));
5907
+ try {
5908
+ await hostRpc('assetDelete', { projectId: a.projectId || S.activeId, projectName: (proj && proj.name) || '', id: a.id, rel: a.rel || '' });
5909
+ } catch { /* 磁盘/索引清理失败不阻塞 */ }
5759
5910
  await idbDelete(a.id);
5760
5911
  update((s) => {
5761
5912
  for (const nd of Object.values(s.graph.nodes)) {
@@ -5994,13 +6145,12 @@ window.__ModuleLoader__.load({
5994
6145
  // 默认项 = 内置专家 persona + 内置创作方法论 canvas-craft(自动叠加)
5995
6146
  const options = [{ value: '', label: T('builtinExpert') }].concat((data.skills || []).map((x) => ({ value: x.name, label: x.name })));
5996
6147
  return h('div', { className: 'dshic-field' },
5997
- h('span', { className: 'dshic-field-label' }, label),
6148
+ h('span', { className: 'dshic-field-label' }, label, InfoTip(T('skillHint'))),
5998
6149
  h('select', {
5999
6150
  className: 'dshic-select', value: value || '',
6000
6151
  onChange: (e) => onChange(e.target.value),
6001
6152
  onPointerDown: (e) => e.stopPropagation(),
6002
- }, options.map((o) => h('option', { key: o.value, value: o.value }, o.label))),
6003
- h('div', { className: 'dshic-hint' }, T('skillHint')));
6153
+ }, options.map((o) => h('option', { key: o.value, value: o.value }, o.label))));
6004
6154
  }
6005
6155
 
6006
6156
  function LlmModelSelect() {
@@ -6051,7 +6201,6 @@ window.__ModuleLoader__.load({
6051
6201
  },
6052
6202
  onPointerDown: (e) => e.stopPropagation(),
6053
6203
  }),
6054
- h('div', { className: 'dshic-hint' }, T('llmHint')),
6055
6204
  );
6056
6205
  }
6057
6206
 
@@ -6065,6 +6214,13 @@ window.__ModuleLoader__.load({
6065
6214
  onPointerDown: (e) => e.stopPropagation(),
6066
6215
  }));
6067
6216
  }
6217
+ /** ⓘ 悬停说明:界面简洁化——长解释一律收进悬停提示(原生 title),不再平铺文字。 */
6218
+ function InfoTip(text) {
6219
+ return h('span', {
6220
+ title: String(text || ''), 'aria-label': String(text || ''),
6221
+ style: { flex: 'none', width: 14, height: 14, lineHeight: '13px', textAlign: 'center', fontSize: 10, borderRadius: '50%', border: '1px solid var(--dsw-alias-border-secondary, #777)', color: 'var(--dsw-alias-label-secondary, #999)', cursor: 'help', userSelect: 'none', opacity: 0.85 },
6222
+ }, 'i');
6223
+ }
6068
6224
  function ApiListEditor({ kind }) {
6069
6225
  const [s, setS] = useState(loadSettings);
6070
6226
  const [openId, setOpenId] = useState(null);
@@ -6214,10 +6370,19 @@ window.__ModuleLoader__.load({
6214
6370
  /** T2:设置面板 Craft 后期卡片(连接状态 / 工具灯 / 数据目录)。 */
6215
6371
  function CraftCard() {
6216
6372
  const [, force] = useState(0);
6373
+ const [open, setOpen] = useState(false); // 默认折叠:界面简洁,摘要常显
6217
6374
  useEffect(() => { probeCraft().then(() => force((x) => x + 1)); }, []);
6218
6375
  const cs = S.craftStatus;
6219
6376
  const data = cs && cs.data;
6220
6377
  const slug = craftProjectSlug();
6378
+ // Craft 后期目录 = **画布数据根目录** + 项目目录 + \craft(不再有 ArtCraft 独立数据目录的 second 口径)
6379
+ const craftDir = S.dataRoot ? (S.dataRoot.replace(/[\\/]+$/, '') + '\\projects\\' + slug + '\\craft') : '';
6380
+ // ArtCraft 自身的数据目录若与画布数据根目录不一致 → 后期文件会落到两处,必须提醒
6381
+ const artRoot = data && data.dataRoot ? data.dataRoot.path : '';
6382
+ const rootMismatch = !!(S.dataRoot && artRoot && artRoot.replace(/[\\/]+$/, '') !== S.dataRoot.replace(/[\\/]+$/, ''));
6383
+ // Craft 家族全量动态渲染:ArtCraft /status 返回几款 bins 就渲染几款(当前 12 款),不再写死
6384
+ const binKeys = (data && data.bins ? Object.keys(data.bins) : []).sort();
6385
+ const readyCount = binKeys.filter((k) => data.bins[k] && data.bins[k].found).length;
6221
6386
  const binRow = (key) => {
6222
6387
  const info = data && data.bins ? data.bins[key] : null;
6223
6388
  const ok = !!(info && info.found);
@@ -6229,18 +6394,22 @@ window.__ModuleLoader__.load({
6229
6394
  );
6230
6395
  };
6231
6396
  return h('div', { className: 'dshic-card' },
6232
- h('div', { className: 'dshic-row' },
6397
+ h('div', {
6398
+ className: 'dshic-row', style: { cursor: 'pointer' },
6399
+ onPointerDown: (e) => e.stopPropagation(), onClick: () => setOpen((x) => !x),
6400
+ title: open ? T('collapse') : T('foldOpen'),
6401
+ },
6233
6402
  Ic('film', 12),
6234
6403
  h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0 } }, T('craftCard')),
6235
- h('button', { type: 'button', className: 'dshic-mini is-ghost', onPointerDown: (e) => e.stopPropagation(), onClick: () => { probeCraft().then(() => force((x) => x + 1)); } }, '↻'),
6404
+ data ? h('span', { className: 'dshic-hint', style: { flex: 'none' } }, T('craftReadyCount').replace('{r}', String(readyCount)).replace('{n}', String(binKeys.length))) : null,
6405
+ h('button', { type: 'button', className: 'dshic-mini is-ghost', onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); probeCraft().then(() => force((x) => x + 1)); } }, '↻'),
6406
+ h('span', { style: { flex: 'none', fontSize: 10, transition: 'transform .15s', transform: open ? 'rotate(90deg)' : 'rotate(0deg)' } }, '▶'),
6236
6407
  ),
6237
- data
6238
- ? h('div', { className: 'dshic-hint' }, T('craftDataRoot') + ':' + ((data.dataRoot && data.dataRoot.path) || '—'))
6239
- : h('div', { className: 'dshic-err' }, T('craftUnreachable')),
6240
- data ? binRow('photocraft') : null,
6241
- data ? binRow('filmcraft') : null,
6242
- data ? binRow('effectcraft') : null,
6243
- data ? h('div', { className: 'dshic-hint' }, T('craftProjectDir') + ':' + slug + '/') : null,
6408
+ open ? [
6409
+ craftDir ? h('div', { className: 'dshic-hint' }, T('craftDirLabel') + ':' + craftDir) : h('div', { className: 'dshic-err' }, T('craftDataRootNeed')),
6410
+ rootMismatch ? h('div', { className: 'dshic-err' }, T('craftRootMismatch')) : null,
6411
+ ...(data ? binKeys.map((k) => binRow(k)) : []),
6412
+ ] : null,
6244
6413
  );
6245
6414
  }
6246
6415
 
@@ -6269,7 +6438,7 @@ window.__ModuleLoader__.load({
6269
6438
  setRunning(true); setErr(null); setProgress('…');
6270
6439
  try {
6271
6440
  const mediaRel = await ensureAssetOnDisk(rec);
6272
- const outRel = craftProjectSlug() + '/out/graded/' + rec.id + '-graded' + extForMime(rec.mime, rec.name);
6441
+ const outRel = 'projects/' + craftProjectSlug() + '/craft/out/graded/' + rec.id + '-graded' + extForMime(rec.mime, rec.name);
6273
6442
  const job = await craft.jobStart({
6274
6443
  bin: 'photocraft',
6275
6444
  args: ['run', mediaRel, '--cmd', cmd, '--params', JSON.stringify(paramsObj), '--out', outRel],
@@ -6386,6 +6555,8 @@ window.__ModuleLoader__.load({
6386
6555
  await hostRpc('dataRootSet', { root: val.trim() });
6387
6556
  await refreshDataRoot();
6388
6557
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
6558
+ assetIndexSynced = false; // 换了目录:索引要往新目录重建
6559
+ syncAssetIndexToHost();
6389
6560
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
6390
6561
  setBusy(false);
6391
6562
  };
@@ -6394,6 +6565,8 @@ window.__ModuleLoader__.load({
6394
6565
  await refreshDataRoot();
6395
6566
  setVal(S.dataRoot || root);
6396
6567
  toast(T('dataRootSaved') + ':' + S.dataRoot);
6568
+ assetIndexSynced = false; // 换了目录:索引要往新目录重建
6569
+ syncAssetIndexToHost();
6397
6570
  };
6398
6571
  const browse = async () => {
6399
6572
  if (busy || picking) return;
@@ -6410,6 +6583,31 @@ window.__ModuleLoader__.load({
6410
6583
  setPicking(false); setBusy(false);
6411
6584
  };
6412
6585
  const openDir = async () => { try { await hostRpc('dataRootReveal'); } catch (e) { toast((e && e.message) || e); } };
6586
+ const importFromRoot = async () => {
6587
+ if (busy || picking) return;
6588
+ setBusy(true);
6589
+ try {
6590
+ const r = await hostRpc('dataRootImport');
6591
+ // 资产元数据 → IndexedDB(本机已存在的同 id 资产跳过,绝不覆盖)
6592
+ let assets = 0;
6593
+ const byProject = r.assets || {};
6594
+ for (const pid of Object.keys(byProject)) {
6595
+ for (const rec of byProject[pid] || []) {
6596
+ if (!rec || !rec.id) continue;
6597
+ const cur = await idbGet(rec.id).catch(() => null);
6598
+ if (cur && (cur.rel || cur.blob)) continue;
6599
+ await idbPut({ ...rec, projectId: pid, stored: rec.rel ? 'disk' : 'idb' });
6600
+ assets++;
6601
+ }
6602
+ }
6603
+ if (assets) update((s) => { s.assetsTick++; });
6604
+ toast(T('dataRootImported').replace('{n}', String((r.imported || []).length)).replace('{a}', String(assets)));
6605
+ // 项目文档 Host 侧已合并:立即拉一轮同步,把新项目带进画布
6606
+ try { await hostSyncTick(); } catch { /* 下一轮 3s 轮询会带 */ }
6607
+ await refreshDataRoot();
6608
+ } catch (e) { toast('导入失败:' + ((e && e.message) || e)); }
6609
+ setBusy(false);
6610
+ };
6413
6611
  const migrate = async () => {
6414
6612
  if (busy || picking || mig) return;
6415
6613
  setBusy(true); setMig({ done: 0, total: 0 });
@@ -6421,7 +6619,11 @@ window.__ModuleLoader__.load({
6421
6619
  setBusy(false);
6422
6620
  };
6423
6621
  return h('div', { key: 'dataroot', className: 'dshic-card' },
6424
- h('div', { className: 'dshic-row' }, Ic('image', 12), h('strong', { style: { fontSize: 12 } }, T('dataRootLabel'))),
6622
+ h('div', { className: 'dshic-row' },
6623
+ Ic('image', 12),
6624
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0 } }, T('dataRootLabel')),
6625
+ InfoTip(T('dataRootHint')),
6626
+ ),
6425
6627
  S.dataRootSupported === false
6426
6628
  ? h('div', { className: 'dshic-hint' }, T('dataRootUnsupported'))
6427
6629
  : [
@@ -6434,12 +6636,16 @@ window.__ModuleLoader__.load({
6434
6636
  h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: save }, T('dataRootSave')),
6435
6637
  ),
6436
6638
  picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
6437
- h('div', { className: 'dshic-hint', key: 'hint' }, T('dataRootHint')),
6438
6639
  h('div', { className: 'dshic-row', key: 'row2' },
6439
6640
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
6440
6641
  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
6642
  (mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
6442
6643
  ),
6644
+ h('div', { className: 'dshic-row', key: 'row3' },
6645
+ 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,
6646
+ S.dataRoot ? InfoTip(T('dataRootImportHint')) : null,
6647
+ h('div', { className: 'dshic-flex' }),
6648
+ ),
6443
6649
  ],
6444
6650
  );
6445
6651
  }
@@ -6455,8 +6661,6 @@ window.__ModuleLoader__.load({
6455
6661
  };
6456
6662
  const field = (label, value, onChange, placeholder, password) => h(Field, { label, value, onChange, placeholder, password });
6457
6663
  return [
6458
- h(CraftCard, { key: 'craft' }),
6459
- h(DataRootCard, { key: 'dataroot' }),
6460
6664
  h('div', { key: 'lang', className: 'dshic-card' },
6461
6665
  h('div', { className: 'dshic-row' }, Ic('text', 12), h('strong', { style: { fontSize: 12 } }, T('langLabel'))),
6462
6666
  h('select', {
@@ -6472,24 +6676,27 @@ window.__ModuleLoader__.load({
6472
6676
  h('option', { value: 'zh' }, T('langZh')),
6473
6677
  h('option', { value: 'en' }, T('langEn'))),
6474
6678
  ),
6475
- h('div', { key: 'llm', className: 'dshic-card' },
6476
- h('div', { className: 'dshic-row' }, Ic('spark', 12), h('strong', { style: { fontSize: 12 } }, T('llmLabel'))),
6477
- h(LlmModelSelect),
6679
+ h(DataRootCard, { key: 'dataroot' }),
6680
+ h(CraftCard, { key: 'craft' }),
6681
+ // 专家与技能卡:独立于模型选择
6682
+ h('div', { key: 'skills', className: 'dshic-card' },
6478
6683
  h('div', { className: 'dshic-row' },
6479
- h(Field, {
6480
- label: T('llmMaxTokens'), value: s.textLlm.maxTokens || '', placeholder: T('llmParamAuto'),
6481
- onChange: (v) => apply((d) => { d.textLlm.maxTokens = v; }),
6482
- }),
6483
- h(Field, {
6484
- label: T('llmTemperature'), value: s.textLlm.temperature || '', placeholder: T('llmParamAuto'),
6485
- onChange: (v) => apply((d) => { d.textLlm.temperature = v; }),
6486
- }),
6684
+ Ic('spark', 12),
6685
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0 } }, T('skillEnhance')),
6686
+ InfoTip(T('skillHint')),
6487
6687
  ),
6488
- h('div', { className: 'dshic-hint' }, T('llmParamHint')),
6489
- h('div', { className: 'dshic-hint' }, T('skillEnhance')),
6490
6688
  h(SkillSelect, { label: T('skillScript'), value: s.skillScript, onChange: (v) => apply((d) => { d.skillScript = v; }) }),
6491
6689
  h(SkillSelect, { label: T('skillStoryboard'), value: s.skillStoryboard, onChange: (v) => apply((d) => { d.skillStoryboard = v; }) }),
6492
6690
  ),
6691
+ // 文本模型卡:只选模型,参数全用 Harness 默认;紧挨图片/视频/TTS 模型卡片
6692
+ h('div', { key: 'llm', className: 'dshic-card' },
6693
+ h('div', { className: 'dshic-row' },
6694
+ Ic('spark', 12),
6695
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0 } }, T('llmLabel')),
6696
+ InfoTip(T('llmHint')),
6697
+ ),
6698
+ h(LlmModelSelect),
6699
+ ),
6493
6700
  h(ApiListEditor, { key: 'imglist', kind: 'image' }),
6494
6701
  h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
6495
6702
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
@@ -7043,8 +7250,8 @@ window.__ModuleLoader__.load({
7043
7250
  probeCraft();
7044
7251
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
7045
7252
  checkUpdate(true);
7046
- // 数据根目录(Host 旧版静默降级为 IndexedDB 模式)
7047
- refreshDataRoot();
7253
+ // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把 IDB 资产元数据补齐进数据目录索引
7254
+ refreshDataRoot().then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
7048
7255
  // 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
7049
7256
  hostSyncTick().then(() => pushAllProjectsToHost());
7050
7257
  const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);