@ninkch/dsh-infinite-canvas 0.17.2 → 0.17.4

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/README.md CHANGED
@@ -76,7 +76,7 @@ Publishing runs through **npm Trusted Publishing** (OIDC) — no npm token exist
76
76
 
77
77
  ## Data & privacy
78
78
 
79
- - Canvas documents, projects, settings and the series bible live in browser `localStorage` (`dsh-ic:*`); assets live in IndexedDB (`dsh-infinite-canvas`).
79
+ - Canvas documents, projects, settings and the series bible live in browser `localStorage` (`dsh-ic:*`); assets live in IndexedDB (`dsh-infinite-canvas`) — **or on disk** once a data root is set in Settings (per-project folders under the configured root, served by the plugin backend with HTTP Range support for video seeking).
80
80
  - AI text generation runs through the Harness host process (your configured models, no extra keys).
81
81
  - Image/video/TTS calls go directly from the browser to the endpoints you configure — mind CORS and key handling.
82
82
  - Nothing is uploaded anywhere by the plugin itself.
package/README.zh-CN.md CHANGED
@@ -76,7 +76,7 @@ flowchart LR
76
76
 
77
77
  ## 数据与隐私
78
78
 
79
- - 画布文档、项目、设置与设定集存于浏览器 `localStorage`(`dsh-ic:*`);资产存于 IndexedDB(`dsh-infinite-canvas`)。
79
+ - 画布文档、项目、设置与设定集存于浏览器 `localStorage`(`dsh-ic:*`);资产存于 IndexedDB(`dsh-infinite-canvas`)——**或在设置中配置数据根目录后落盘**(按项目分目录存放,由插件后台以 HTTP Range 方式服务,支持视频拖动进度条)。
80
80
  - AI 文本经 Harness 宿主进程调用(使用你已配置的模型,无需额外 Key)。
81
81
  - 图片/视频/TTS 请求由浏览器直连你配置的地址——请注意 CORS 与密钥保管。
82
82
  - 插件自身不向任何第三方上传数据。
package/client.js CHANGED
@@ -333,6 +333,25 @@ window.__ModuleLoader__.load({
333
333
  'updateAvailable': '新版本可用',
334
334
  'updateNow': '一键更新',
335
335
  'updateUpToDate': '已是最新版本',
336
+ 'updateInstalling': '正在安装更新…',
337
+ 'updateInstalled': '已安装',
338
+ 'updateInstalledHint': '重启 Harness 后生效(网页部分刷新页面即可)',
339
+ 'updateFailGuide': '自动安装未成功,请在 DSH 插件管理 中升级',
340
+ 'updatePolicySkipped': '(已按你的点击跳过 24 小时新版本冷却期)',
341
+ 'dataRootLabel': '数据根目录',
342
+ 'dataRootPlaceholder': '如 D:\\canvas-data',
343
+ 'dataRootHint': '资产与视频按项目落盘到该目录(不再占用 C 盘 IndexedDB)。留空 = 浏览器 IndexedDB。',
344
+ 'dataRootSave': '设置此目录',
345
+ 'dataRootOpen': '打开目录',
346
+ 'dataRootMigrate': '迁移历史资产',
347
+ 'dataRootMigrated': '已迁移 {n} 个资产到数据根目录',
348
+ 'dataRootMigratedDone': '已迁移',
349
+ 'dataRootMigrating': '迁移中',
350
+ 'dataRootUnsupported': '当前插件后台版本较旧(不支持数据根目录):重启 Harness 后可用',
351
+ 'dataRootSaved': '数据根目录已设置',
352
+ 'dataRootBrowse': '浏览选择目录',
353
+ 'dataRootPicking': '请在弹出的窗口中选择目录(可直接取消)…',
354
+ 'dataRootPickUnsupported': '当前平台不支持目录选择器,请手动填写路径',
336
355
  'updateCurrent': '当前',
337
356
  'updateGo': '去升级',
338
357
  'npmUpgradeHint': '请在 DSH 插件管理 中升级到最新版(本插件绝不自动安装或重启);安装完成后重启 Harness 生效',
@@ -590,6 +609,25 @@ window.__ModuleLoader__.load({
590
609
  'updateAvailable': 'New version available',
591
610
  'updateNow': 'Update now',
592
611
  'updateUpToDate': 'Up to date',
612
+ 'updateInstalling': 'Installing update…',
613
+ 'updateInstalled': 'Installed',
614
+ 'updateInstalledHint': 'restart Harness to take effect (refresh the page for the web part)',
615
+ 'updateFailGuide': 'Auto-install failed — please upgrade via the DSH Plugin Manager',
616
+ 'updatePolicySkipped': '(skipped the 24-hour release cool-down at your click)',
617
+ 'dataRootLabel': 'Data root',
618
+ 'dataRootPlaceholder': 'e.g. D:\\canvas-data',
619
+ 'dataRootHint': 'Assets and videos are stored per project under this folder (no more C: IndexedDB). Empty = browser IndexedDB.',
620
+ 'dataRootSave': 'Set directory',
621
+ 'dataRootOpen': 'Open folder',
622
+ 'dataRootMigrate': 'Migrate existing assets',
623
+ 'dataRootMigrated': 'Migrated {n} assets to the data root',
624
+ 'dataRootMigratedDone': 'Migrated',
625
+ 'dataRootMigrating': 'Migrating',
626
+ 'dataRootUnsupported': 'The plugin backend is older and lacks data-root support — restart Harness to enable',
627
+ 'dataRootSaved': 'Data root set',
628
+ 'dataRootBrowse': 'Browse for folder',
629
+ 'dataRootPicking': 'Pick a folder in the dialog (cancel anytime)…',
630
+ 'dataRootPickUnsupported': 'No folder picker on this platform — type the path instead',
593
631
  'updateCurrent': 'current',
594
632
  'updateGo': 'Upgrade',
595
633
  'npmUpgradeHint': 'Please upgrade via the DSH Plugin Manager (this plugin never auto-installs or restarts); restart Harness after installing',
@@ -1037,30 +1075,81 @@ window.__ModuleLoader__.load({
1037
1075
  }
1038
1076
  const assetUrlCache = new Map();
1039
1077
  const assetNameCache = new Map(); // assetId -> name(连线绑定的同步名称查询)
1078
+ function fileUrlFor(rel) { return '/' + NS + '/api/file?rel=' + encodeURIComponent(rel); }
1079
+ function extFromMime(mime) {
1080
+ const sub = String(mime || '').split(';')[0].split('/')[1] || 'bin';
1081
+ return /^[a-z0-9]{1,8}$/i.test(sub) ? sub : 'bin';
1082
+ }
1040
1083
  async function assetUrl(id) {
1041
1084
  if (!id) return null;
1042
1085
  if (assetUrlCache.has(id)) return assetUrlCache.get(id);
1043
1086
  const rec = await idbGet(id);
1044
- if (!rec || !rec.blob) return null;
1045
- const url = URL.createObjectURL(rec.blob);
1046
- assetUrlCache.set(id, url);
1087
+ if (!rec) return null;
1088
+ let url = null;
1089
+ if (rec.rel) url = fileUrlFor(rec.rel); // 磁盘资产:Host 文件服务(支持 Range)
1090
+ else if (rec.blob) url = URL.createObjectURL(rec.blob); // 旧资产:IndexedDB blob
1091
+ if (url) assetUrlCache.set(id, url);
1047
1092
  return url;
1048
1093
  }
1094
+ /** 取资产 Blob:磁盘资产经 Host 文件服务拉取,旧资产直接读 IndexedDB。 */
1095
+ async function assetBlob(id) {
1096
+ const rec = await idbGet(id);
1097
+ if (!rec) return null;
1098
+ if (rec.blob) return rec.blob;
1099
+ if (rec.rel) {
1100
+ const r = await fetch(fileUrlFor(rec.rel));
1101
+ if (!r.ok) throw new Error('资产读取失败 HTTP ' + r.status);
1102
+ return await r.blob();
1103
+ }
1104
+ return null;
1105
+ }
1049
1106
  async function saveAssetRecord(projectId, kind, blob, meta) {
1107
+ const id = uid('a');
1108
+ const mime = blob.type || (kind === 'image' ? 'image/png' : 'video/webm');
1050
1109
  const rec = {
1051
- id: uid('a'), projectId, kind,
1052
- mime: blob.type || (kind === 'image' ? 'image/png' : 'video/webm'),
1110
+ id, projectId, kind,
1111
+ mime,
1053
1112
  name: (meta && meta.name) || (kind === 'image' ? 'image' : 'video') + '-' + Date.now().toString(36),
1054
- blob,
1055
1113
  createdAt: Date.now(),
1056
1114
  prompt: (meta && meta.prompt) || '',
1057
1115
  source: (meta && meta.source) || 'demo',
1058
1116
  category: (meta && meta.category) || 'general',
1059
1117
  };
1118
+ if (S.dataRoot) {
1119
+ // 数据根目录已配置:落盘到 <root>/projects/<项目>/<类型>/<id>.<ext>,IndexedDB 只存元数据
1120
+ const rel = `projects/${projectId}/${kind}/${id}.${extFromMime(mime)}`;
1121
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
1122
+ if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
1123
+ rec.rel = rel;
1124
+ rec.size = blob.size;
1125
+ rec.stored = 'disk';
1126
+ } else {
1127
+ rec.blob = blob;
1128
+ rec.stored = 'idb';
1129
+ }
1060
1130
  await idbPut(rec);
1061
1131
  assetNameCache.set(rec.id, rec.name);
1062
1132
  return rec;
1063
1133
  }
1134
+ /** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
1135
+ async function migrateAssetsToDisk(onProgress) {
1136
+ if (!S.dataRoot) throw new Error('请先设置数据根目录');
1137
+ const all = await idbAll();
1138
+ const targets = all.filter((r) => r.blob && !r.rel);
1139
+ let done = 0;
1140
+ for (const rec of targets) {
1141
+ const rel = `projects/${rec.projectId}/${rec.kind}/${rec.id}.${extFromMime(rec.mime)}`;
1142
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
1143
+ if (!r.ok) throw new Error('迁移失败: ' + rel + ' — ' + (await r.text()));
1144
+ const next = { ...rec, rel, size: rec.blob.size, stored: 'disk' };
1145
+ delete next.blob;
1146
+ await idbPut(next);
1147
+ assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
1148
+ done++;
1149
+ if (onProgress) onProgress(done, targets.length);
1150
+ }
1151
+ return { migrated: done, total: targets.length };
1152
+ }
1064
1153
  function useAssetUrl(id) {
1065
1154
  const [url, setUrl] = useState(null);
1066
1155
  useEffect(() => {
@@ -1686,7 +1775,9 @@ window.__ModuleLoader__.load({
1686
1775
  const map = craftMapLoad(pid);
1687
1776
  if (!opts.force && map[rec.id]) return map[rec.id];
1688
1777
  const rel = craftProjectSlug() + '/media/' + rec.id + extForMime(rec.mime, rec.name);
1689
- await craft.write(rel, rec.blob);
1778
+ const blob = rec.blob || await assetBlob(rec.id);
1779
+ if (!blob) throw new Error('资产内容不可用: ' + rec.id);
1780
+ await craft.write(rel, blob);
1690
1781
  craftMapSet(pid, rec.id, rel);
1691
1782
  return rel;
1692
1783
  }
@@ -1834,12 +1925,16 @@ window.__ModuleLoader__.load({
1834
1925
 
1835
1926
  // ============================================================
1836
1927
  // 自动更新:当前副本版本 vs npm registry latest(与安装方式无关)
1837
- // 有新版本 → 横幅提示;安装一律引导到 DSH 插件管理,绝不自动安装/重启
1928
+ // 有新版本 → 横幅 + "去升级";点击后由 Host 在所属 profile 内
1929
+ // pnpm add 精确版本(绝不自动重启/刷新);旧 Host / 开发态 / 失败 → 回退为纯引导
1838
1930
  // ============================================================
1839
1931
  async function checkUpdate(silent) {
1840
1932
  update((s) => { s.updateInfo = null; s.updateChecking = true; s.updateMsg = null; });
1841
1933
  try {
1842
1934
  const info = await hostRpc('updateCheck');
1935
+ // 兼容旧 Host(SHA 比较语义会给出 upToDate=false 但 behind=0):
1936
+ // 只要有 behind 数值,就统一按"落后 N 版"判定最新与否
1937
+ if (info && typeof info.behind === 'number') info.upToDate = info.behind === 0;
1843
1938
  update((s) => { s.updateInfo = info; s.updateChecking = false; });
1844
1939
  return info;
1845
1940
  } catch (e) {
@@ -1848,10 +1943,32 @@ window.__ModuleLoader__.load({
1848
1943
  return null;
1849
1944
  }
1850
1945
  }
1851
- function applyUpdate() {
1852
- // 仅引导:插件不修改用户 profile、不执行任何安装动作、不刷新不重启
1853
- const target = S.updateInfo && S.updateInfo.remoteVersion ? S.updateInfo.remoteVersion : '';
1854
- update((s) => { s.updateBusy = false; s.updateMsg = T('npmUpgradeHint') + (target ? ' → v' + target : ''); });
1946
+ async function applyUpdate() {
1947
+ update((s) => { s.updateBusy = true; s.updateMsg = T('updateInstalling'); });
1948
+ try {
1949
+ const res = await hostRpc('updateInstall');
1950
+ if (res && res.ok && res.changed) {
1951
+ const relaxed = res.policyRelaxed ? ' ' + T('updatePolicySkipped') : '';
1952
+ update((s) => { s.updateBusy = false; s.updateMsg = T('updateInstalled') + ' v' + res.version + ' — ' + T('updateInstalledHint') + relaxed; });
1953
+ } else if (res && res.ok) {
1954
+ update((s) => { s.updateBusy = false; s.updateMsg = T('updateUpToDate') + (res.version ? ' v' + res.version : ''); });
1955
+ } else {
1956
+ const reason = res && res.output ? '(' + String(res.output).slice(-120) + ')' : '';
1957
+ update((s) => { s.updateBusy = false; s.updateMsg = T('updateFailGuide') + (res && res.version ? ' → v' + res.version : '') + reason; });
1958
+ }
1959
+ } catch (e) {
1960
+ // 旧 Host 没有 updateInstall RPC → 回退为纯引导(方案 A 兜底)
1961
+ update((s) => { s.updateBusy = false; s.updateMsg = T('npmUpgradeHint') + ((S.updateInfo && S.updateInfo.remoteVersion) ? ' → v' + S.updateInfo.remoteVersion : ''); });
1962
+ }
1963
+ }
1964
+ /** 从 Host 拉取数据根目录配置(Host 旧版无此 RPC → 标记不支持)。 */
1965
+ async function refreshDataRoot() {
1966
+ try {
1967
+ const r = await hostRpc('dataRootGet');
1968
+ update((s) => { s.dataRoot = r.dataRoot || null; s.dataRootExists = !!r.exists; s.dataRootSupported = true; });
1969
+ } catch (e) {
1970
+ update((s) => { s.dataRoot = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
1971
+ }
1855
1972
  }
1856
1973
 
1857
1974
  // ============================================================
@@ -2111,6 +2228,9 @@ window.__ModuleLoader__.load({
2111
2228
  assetsTick: 0,
2112
2229
  tick: 0,
2113
2230
  craftStatus: null, // {at, data} | {at, error}
2231
+ dataRoot: null, // 数据根目录(Host 配置);null = 未设置(资产走 IndexedDB)
2232
+ dataRootExists: false,
2233
+ dataRootSupported: true,
2114
2234
  updateInfo: null, // Host updateCheck 结果
2115
2235
  updateChecking: false,
2116
2236
  updateBusy: false,
@@ -2456,8 +2576,7 @@ window.__ModuleLoader__.load({
2456
2576
  if (!src) return null;
2457
2577
  const aid = servedAssetId(src, e.fromPort);
2458
2578
  if (!aid) return null;
2459
- const rec = await idbGet(aid);
2460
- return rec ? rec.blob : null;
2579
+ return await assetBlob(aid);
2461
2580
  }
2462
2581
  // 多参考图:按节点上的显式顺序(refOrder,未排序时按连线先后)解析,带名称/分类供 [图N] 绑定
2463
2582
  async function resolveRefMetas(nodeId, portId) {
@@ -2474,7 +2593,8 @@ window.__ModuleLoader__.load({
2474
2593
  if (!aid) continue;
2475
2594
  try {
2476
2595
  const rec = await idbGet(aid);
2477
- if (rec && rec.blob) metas.push({ blob: rec.blob, name: rec.name || '', category: rec.category || 'general' });
2596
+ const blob = await assetBlob(aid);
2597
+ if (blob) metas.push({ blob, name: (rec && rec.name) || '', category: (rec && rec.category) || 'general' });
2478
2598
  } catch { /* 单个参考图读取失败不影响其余 */ }
2479
2599
  }
2480
2600
  return metas;
@@ -3867,10 +3987,11 @@ window.__ModuleLoader__.load({
3867
3987
  if (!x.nd.data.assetId) continue;
3868
3988
  try {
3869
3989
  const rec = await idbGet(x.nd.data.assetId);
3870
- if (rec && rec.blob) {
3990
+ const blob = await assetBlob(x.nd.data.assetId);
3991
+ if (blob) {
3871
3992
  const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
3872
3993
  const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
3873
- metas.push({ url: URL.createObjectURL(rec.blob), name: rec.name || 'seg', sub: getSub(x), ttsUrl });
3994
+ metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl });
3874
3995
  }
3875
3996
  } catch { /* 跳过加载失败的片段 */ }
3876
3997
  }
@@ -3906,9 +4027,10 @@ window.__ModuleLoader__.load({
3906
4027
  if (!x.nd.data.assetId) continue;
3907
4028
  try {
3908
4029
  const rec = await idbGet(x.nd.data.assetId);
3909
- if (!(rec && rec.blob)) continue;
3910
- const rel = await ensureAssetOnDisk(rec);
3911
- items.push({ rel, name: rec.name || 'seg', sub: getSub(x), dur: await videoDurationOf(rec.blob) });
4030
+ const blob = await assetBlob(x.nd.data.assetId);
4031
+ if (!blob) continue;
4032
+ const rel = await ensureAssetOnDisk({ ...(rec || {}), id: x.nd.data.assetId, blob });
4033
+ items.push({ rel, name: (rec && rec.name) || 'seg', sub: getSub(x), dur: await videoDurationOf(blob) });
3912
4034
  } catch (e) { toast('Craft: ' + (e.message || e)); return; }
3913
4035
  }
3914
4036
  if (!items.length) { toast(T('noSegments')); return; }
@@ -4194,7 +4316,7 @@ window.__ModuleLoader__.load({
4194
4316
  h('button', {
4195
4317
  type: 'button', className: cls('dshic-btn'), style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', gap: 4 },
4196
4318
  title: T('updateBtn') + ((S.updateInfo && S.updateInfo.runningVersion) ? '(当前 v' + S.updateInfo.runningVersion + ')' : ''), disabled: S.updateChecking,
4197
- onClick: () => { checkUpdate(false).then((info) => { if (info && info.upToDate) toast(T('updateUpToDate')); }); },
4319
+ onClick: () => { checkUpdate(false).then((info) => { if (info && info.upToDate) toast(T('updateUpToDate') + (info.runningVersion ? ' v' + info.runningVersion : '')); }); },
4198
4320
  }, T('updateBtn'), S.updateChecking ? h('span', { className: 'dshic-spin' }) : ((S.updateInfo && !S.updateInfo.upToDate) ? ' ⬆' : '')),
4199
4321
  h('div', { className: 'dshic-toolbar-sep' }),
4200
4322
  h(Btn, {
@@ -4270,7 +4392,12 @@ window.__ModuleLoader__.load({
4270
4392
  lsSet(LS_ACTIVE, S.activeId);
4271
4393
  try {
4272
4394
  const recs = await idbByProject(pid);
4273
- for (const r of recs) { try { await idbDelete(r.id); } catch { /* 逐个容错 */ } }
4395
+ for (const r of recs) {
4396
+ try {
4397
+ if (r.rel) { try { await hostRpc('assetDelete', { rel: r.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
4398
+ await idbDelete(r.id);
4399
+ } catch { /* 逐个容错 */ }
4400
+ }
4274
4401
  } catch { /* 资产清理失败不影响项目删除 */ }
4275
4402
  setDelTarget(null);
4276
4403
  };
@@ -4428,6 +4555,7 @@ window.__ModuleLoader__.load({
4428
4555
  if (files.length) setTick((x) => x + 1);
4429
4556
  };
4430
4557
  const del = async (a) => {
4558
+ if (a.rel) { try { await hostRpc('assetDelete', { rel: a.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
4431
4559
  await idbDelete(a.id);
4432
4560
  update((s) => {
4433
4561
  for (const nd of Object.values(s.graph.nodes)) {
@@ -4441,7 +4569,7 @@ window.__ModuleLoader__.load({
4441
4569
  const syncAll = async () => {
4442
4570
  const st = await craftStatusSafe();
4443
4571
  if (!st) return;
4444
- const recs = assets.filter((a) => a.blob);
4572
+ const recs = assets.filter((a) => a.blob || a.rel);
4445
4573
  let okN = 0, failN = 0;
4446
4574
  for (const a of recs) {
4447
4575
  try { await syncAssetToDisk(a, { force: true }); okN++; } catch { failN++; }
@@ -4811,7 +4939,7 @@ window.__ModuleLoader__.load({
4811
4939
  if (!(st.bins && st.bins.photocraft && st.bins.photocraft.found)) { toast(T('craftCliMissing').replace('{name}', 'photocraft')); return; }
4812
4940
  let paramsObj;
4813
4941
  try { paramsObj = JSON.parse(paramsText || '{}'); } catch { toast(T('craftInvalidJson')); return; }
4814
- if (!rec || !rec.blob) { toast(T('noResult')); return; }
4942
+ if (!rec || (!rec.blob && !rec.rel)) { toast(T('noResult')); return; }
4815
4943
  setRunning(true); setErr(null); setProgress('…');
4816
4944
  try {
4817
4945
  const mediaRel = await ensureAssetOnDisk(rec);
@@ -4918,6 +5046,77 @@ window.__ModuleLoader__.load({
4918
5046
  ));
4919
5047
  }
4920
5048
 
5049
+ /** 数据根目录卡片:设置/打开/迁移历史资产。 */
5050
+ function DataRootCard() {
5051
+ const [val, setVal] = useState('');
5052
+ const [busy, setBusy] = useState(false);
5053
+ const [picking, setPicking] = useState(false);
5054
+ const [mig, setMig] = useState(null); // null | {done,total} | 'done'
5055
+ useEffect(() => { if (S.dataRoot) setVal(S.dataRoot); }, [S.dataRoot]);
5056
+ const save = async () => {
5057
+ if (busy) return;
5058
+ setBusy(true);
5059
+ try {
5060
+ await hostRpc('dataRootSet', { root: val.trim() });
5061
+ await refreshDataRoot();
5062
+ toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
5063
+ } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
5064
+ setBusy(false);
5065
+ };
5066
+ const applyRoot = async (root) => {
5067
+ await hostRpc('dataRootSet', { root });
5068
+ await refreshDataRoot();
5069
+ setVal(S.dataRoot || root);
5070
+ toast(T('dataRootSaved') + ':' + S.dataRoot);
5071
+ };
5072
+ const browse = async () => {
5073
+ if (busy || picking) return;
5074
+ setBusy(true); setPicking(true);
5075
+ try {
5076
+ const r = await hostRpc('pickFolder');
5077
+ if (r && r.ok && r.path) await applyRoot(r.path);
5078
+ else if (r && r.unsupported) toast(T('dataRootPickUnsupported'));
5079
+ // cancelled → 静默
5080
+ } catch (e) {
5081
+ const msg = String((e && e.message) || e);
5082
+ toast(/未知操作/.test(msg) ? T('dataRootUnsupported') : '目录选择失败:' + msg);
5083
+ }
5084
+ setPicking(false); setBusy(false);
5085
+ };
5086
+ const openDir = async () => { try { await hostRpc('dataRootReveal'); } catch (e) { toast((e && e.message) || e); } };
5087
+ const migrate = async () => {
5088
+ if (busy || picking || mig) return;
5089
+ setBusy(true); setMig({ done: 0, total: 0 });
5090
+ try {
5091
+ const r = await migrateAssetsToDisk((done, total) => setMig({ done, total }));
5092
+ setMig('done');
5093
+ toast(T('dataRootMigrated').replace('{n}', String(r.migrated)));
5094
+ } catch (e) { setMig(null); toast('迁移失败:' + ((e && e.message) || e)); }
5095
+ setBusy(false);
5096
+ };
5097
+ return h('div', { key: 'dataroot', className: 'dshic-card' },
5098
+ h('div', { className: 'dshic-row' }, Ic('image', 12), h('strong', { style: { fontSize: 12 } }, T('dataRootLabel'))),
5099
+ S.dataRootSupported === false
5100
+ ? h('div', { className: 'dshic-hint' }, T('dataRootUnsupported'))
5101
+ : [
5102
+ h('div', { className: 'dshic-row', key: 'row1' },
5103
+ h('input', {
5104
+ className: 'dshic-input', value: val, placeholder: T('dataRootPlaceholder'),
5105
+ onChange: (e) => setVal(e.target.value), onPointerDown: (e) => e.stopPropagation(),
5106
+ }),
5107
+ h('button', { type: 'button', className: 'dshic-mini', title: T('dataRootBrowse'), disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: browse }, Ic('folder', 12)),
5108
+ h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: save }, T('dataRootSave')),
5109
+ ),
5110
+ picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
5111
+ h('div', { className: 'dshic-hint', key: 'hint' }, T('dataRootHint')),
5112
+ h('div', { className: 'dshic-row', key: 'row2' },
5113
+ S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
5114
+ 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,
5115
+ (mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
5116
+ ),
5117
+ ],
5118
+ );
5119
+ }
4921
5120
  function SettingsTab() {
4922
5121
  const [s, setS] = useState(loadSettings);
4923
5122
  const apply = (fn) => {
@@ -4931,6 +5130,7 @@ window.__ModuleLoader__.load({
4931
5130
  const field = (label, value, onChange, placeholder, password) => h(Field, { label, value, onChange, placeholder, password });
4932
5131
  return [
4933
5132
  h(CraftCard, { key: 'craft' }),
5133
+ h(DataRootCard, { key: 'dataroot' }),
4934
5134
  h('div', { key: 'lang', className: 'dshic-card' },
4935
5135
  h('div', { className: 'dshic-row' }, Ic('text', 12), h('strong', { style: { fontSize: 12 } }, T('langLabel'))),
4936
5136
  h('select', {
@@ -4956,7 +5156,7 @@ window.__ModuleLoader__.load({
4956
5156
  h(ApiListEditor, { key: 'imglist', kind: 'image' }),
4957
5157
  h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
4958
5158
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
4959
- h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas v0.17.1'),
5159
+ h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
4960
5160
  ];
4961
5161
  }
4962
5162
 
@@ -5290,6 +5490,8 @@ window.__ModuleLoader__.load({
5290
5490
  probeCraft();
5291
5491
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
5292
5492
  checkUpdate(true);
5493
+ // 数据根目录(Host 旧版静默降级为 IndexedDB 模式)
5494
+ refreshDataRoot();
5293
5495
  // 后台路由探活:失败时在画布顶部给出可操作的提示
5294
5496
  hostRpc('ping').then(() => { S.hostOk = true; S.hostError = null; notify(); }).catch((e) => {
5295
5497
  S.hostOk = false;
package/docs/release.md CHANGED
@@ -9,7 +9,8 @@
9
9
  2. **绝不自动重启 Harness、绝不自动刷新页面。** 更新/安装后只提示(横幅/文案),何时生效由用户决定。
10
10
  3. **beta 版本号绝不复用**(npm 本身也禁止覆盖已发布版本);**正式版本号只在 main 上诞生**。
11
11
  4. **版本单一来源 = `package.json`**。改版本一律用 `npm version …`,不手改;Host 运行时读取它,禁止在代码里硬编码版本号。
12
- 5. npm token 只通过命令行注入,**绝不写入任何文件**。
12
+ 5. **版本号一律按 0.0.x(patch)递增**(如 0.17.3 → 0.17.4),不做 minor/major 升级——新功能同样走 patch 号。
13
+ 6. npm token 只通过命令行注入,**绝不写入任何文件**。
13
14
 
14
15
  ## 1. 测试三层漏斗
15
16
 
@@ -107,10 +108,11 @@ GET /infinite-canvas/api/rpc ping # 安装重启后 version 应为
107
108
  - **当前版本** = 当前加载副本的 `package.json`(Host 启动时读取)
108
109
  - **最新版本** = npm registry `…/@ninkch%2Fdsh-infinite-canvas/latest`(即 `latest` dist-tag,beta 不会出现)
109
110
  - 本地版本 ≥ 远端 → 视为最新;仅真正落后 → 画布顶部横幅"新版本可用 vX(当前 vY)"
110
- - 横幅"去升级"按钮 → **仅提示**:"请在 DSH 插件管理 中升级…重启 Harness 生效"
111
- - **绝不自动安装、绝不自动刷新、绝不自动重启**;网络失败静默(手动点击才提示错误)
111
+ - 横幅"去升级"按钮 → **真实安装**:Host 在所属 profile 内执行 `pnpm add <pkg>@<精确版本> --save-exact` 并同步 bundles 列表;完成后仅提示"已安装 vX — 重启 Harness 后生效"
112
+ - **降级为纯引导**的情形:开发态(link/.git)、找不到 pnpm、安装失败、旧 Host 无 `updateInstall` RPC
113
+ - **绝不自动刷新、绝不自动重启**;网络失败静默(手动点击才提示错误)
112
114
 
113
- 实现位置:`index.js` `updateCheck()`(RPC `updateCheck`);文案 key:`updateGo` / `npmUpgradeHint` / `updateCurrent`。
115
+ 实现位置:`index.js` `updateCheck()` / `updateInstall()`(RPC 同名);文案 key:`updateGo` / `updateInstalling` / `updateInstalled` / `updateInstalledHint` / `updateFailGuide` / `updateCurrent`。
114
116
 
115
117
  ## 7. 发布日检查清单
116
118
 
package/index.js CHANGED
@@ -6,7 +6,10 @@
6
6
  * through the Harness `llm` Service — the same providers/models the user has
7
7
  * configured for the Harness itself, no extra API key handling here.
8
8
  */
9
- import { readFileSync } from 'node:fs';
9
+ import { spawn } from 'node:child_process';
10
+ import fs from 'node:fs/promises';
11
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import os from 'node:os';
10
13
  import path from 'node:path';
11
14
  import { fileURLToPath } from 'node:url';
12
15
 
@@ -22,6 +25,167 @@ try {
22
25
  // 更新检查的唯一信息源 = npm registry latest(与安装方式无关:
23
26
  // npm 在线安装 / 本地 link 开发态 / 其它方式,一律走同一条逻辑)。
24
27
  const NPM_LATEST_URL = 'https://registry.npmjs.org/' + encodeURIComponent('@ninkch/dsh-infinite-canvas') + '/latest';
28
+ const PKG_NAME = '@ninkch/dsh-infinite-canvas';
29
+
30
+ // ---------- 安装形态探测(用于"点击即更新") ----------
31
+ // 受管安装 = 本副本位于某个 DSH profile 的 node_modules 下(向上找带 dsh.profile.bundles 的 package.json)。
32
+ // 开发态(目录含 .git)不受管 → 点击更新时降级为纯引导。
33
+ const PROFILE_DIR = (() => {
34
+ if (existsSync(path.join(HERE, '.git'))) return null;
35
+ let dir = HERE;
36
+ for (let i = 0; i < 6; i++) {
37
+ dir = path.dirname(dir);
38
+ try {
39
+ const m = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
40
+ if (m && m.dsh && m.dsh.profile && Array.isArray(m.dsh.profile.bundles)) return dir;
41
+ } catch { /* 继续向上 */ }
42
+ }
43
+ return null;
44
+ })();
45
+
46
+ /** 定位 pnpm:Desktop 安装布局 → 用户运行时目录 → PATH。返回 { execPath, args 前缀 } 或 null。 */
47
+ function findPnpm() {
48
+ const candidates = [];
49
+ try {
50
+ candidates.push(path.join(path.dirname(process.execPath), 'resources', 'runtime', 'pnpm', 'bin', 'pnpm.mjs'));
51
+ } catch { /* execPath 不可用 */ }
52
+ try {
53
+ const runtimes = path.join(os.homedir(), '.dsh', 'dsh-runtimes');
54
+ for (const entry of readdirSync(runtimes)) {
55
+ candidates.push(path.join(runtimes, entry, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'));
56
+ }
57
+ } catch { /* 无运行时目录 */ }
58
+ for (const c of candidates) {
59
+ try { if (existsSync(c)) return { kind: 'script', node: process.execPath, script: c }; } catch { /* 跳过 */ }
60
+ }
61
+ try { if (existsSync('C:\\Program Files\\nodejs\\pnpm.cmd')) return { kind: 'path', command: 'pnpm' }; } catch { /* 跳过 */ }
62
+ return { kind: 'path', command: 'pnpm' }; // 交给 spawn 兜底(PATH 上有就用)
63
+ }
64
+
65
+ /** 在 profile 目录执行 pnpm add <spec> --save-exact。opts.relaxReleaseAge → 跳过 24h 新版本冷却期。 */
66
+ function runPnpmAdd(pnpm, spec, opts = {}) {
67
+ return new Promise((resolve) => {
68
+ const started = Date.now();
69
+ const stdout = [];
70
+ const stderr = [];
71
+ let child;
72
+ const relax = opts.relaxReleaseAge ? ['--config.minimum-release-age=0'] : [];
73
+ const args = pnpm.kind === 'script'
74
+ ? ['--expose-internals', pnpm.script, 'add', spec, '--save-exact', ...relax]
75
+ : ['add', spec, '--save-exact', ...relax];
76
+ try {
77
+ child = pnpm.kind === 'script'
78
+ ? spawn(pnpm.node, args, { cwd: PROFILE_DIR, windowsHide: true, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } })
79
+ : spawn(pnpm.command, args, { cwd: PROFILE_DIR, windowsHide: true, shell: process.platform === 'win32' });
80
+ } catch (e) {
81
+ resolve({ ok: false, stdout: '', stderr: String(e?.message ?? e) });
82
+ return;
83
+ }
84
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* 忽略 */ } }, 240000);
85
+ child.stdout?.setEncoding('utf8');
86
+ child.stderr?.setEncoding('utf8');
87
+ child.stdout?.on('data', (d) => stdout.push(String(d)));
88
+ child.stderr?.on('data', (d) => stderr.push(String(d)));
89
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, stdout: stdout.join(''), stderr: String(e?.message ?? e) }); });
90
+ child.on('close', (code) => {
91
+ clearTimeout(timer);
92
+ resolve({ ok: code === 0, code, stdout: stdout.join(''), stderr: stderr.join(''), ms: Date.now() - started });
93
+ });
94
+ });
95
+ }
96
+
97
+ /** pnpm add 成功后同步 bundles 列表(等价 DSH reconcile 的 add 分支:新依赖必须成为 bundle 层)。 */
98
+ async function ensureBundleEntry(profileDir, pkg) {
99
+ const manifestPath = path.join(profileDir, 'package.json');
100
+ const m = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
101
+ const bundles = (m.dsh && m.dsh.profile && m.dsh.profile.bundles) || [];
102
+ if (bundles.includes(pkg)) return false;
103
+ bundles.push(pkg);
104
+ m.dsh = { ...(m.dsh || {}), profile: { ...(m.dsh.profile || {}), bundles } };
105
+ await fs.writeFile(manifestPath, JSON.stringify(m, null, 2) + '\n', 'utf8');
106
+ return true;
107
+ }
108
+
109
+ // ---------- 数据根目录(资产落盘)----------
110
+ // 配置存在 ~/.dsh/infinite-canvas/config.json:跨重装/重启稳定,绝不放进包目录。
111
+ const CONFIG_PATH = path.join(os.homedir(), '.dsh', 'infinite-canvas', 'config.json');
112
+
113
+ function readIcConfig() {
114
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) || {}; } catch { return {}; }
115
+ }
116
+ function writeIcConfig(cfg) {
117
+ mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
118
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
119
+ }
120
+ function dataRoot() {
121
+ const r = readIcConfig().dataRoot;
122
+ return (typeof r === 'string' && r.trim()) ? path.resolve(r.trim()) : null;
123
+ }
124
+ /** rel 限定在 dataRoot 之内:拒绝绝对路径 / 盘符 / 上级跳转,返回 { abs, clean }。 */
125
+ function safeJoin(root, rel) {
126
+ const clean = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
127
+ if (!clean) throw new Error('缺少相对路径');
128
+ if (/(^|\/)\.\.(\/|$)/.test(clean) || /^[a-z]:/i.test(clean)) throw new Error('非法路径: ' + rel);
129
+ const rootAbs = path.resolve(root);
130
+ const abs = path.resolve(rootAbs, ...clean.split('/'));
131
+ if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) throw new Error('路径越界');
132
+ return { abs, clean };
133
+ }
134
+ const MIME_BY_EXT = {
135
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml',
136
+ webm: 'video/webm', mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska',
137
+ wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
138
+ json: 'application/json', srt: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
139
+ };
140
+ function mimeOf(p) {
141
+ const m = /\.([a-z0-9]+)$/i.exec(p);
142
+ return m ? (MIME_BY_EXT[m[1].toLowerCase()] || 'application/octet-stream') : 'application/octet-stream';
143
+ }
144
+
145
+ /**
146
+ * 弹出系统"选择目录"对话框(阻塞至用户选择/取消)。
147
+ * Windows = PowerShell + WinForms FolderBrowserDialog(TopMost,可新建文件夹);
148
+ * macOS = osascript choose folder;Linux = zenity(若安装)。均不可用 → { ok:false, unsupported:true }。
149
+ */
150
+ function pickFolderDialog() {
151
+ return new Promise((resolve) => {
152
+ const collect = (child) => {
153
+ const stdout = [];
154
+ child.stdout?.setEncoding('utf8');
155
+ child.stdout?.on('data', (d) => stdout.push(String(d)));
156
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* 忽略 */ } }, 600000);
157
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, unsupported: e.code === 'ENOENT', error: String(e?.message ?? e) }); });
158
+ child.on('close', (code) => {
159
+ clearTimeout(timer);
160
+ const picked = stdout.join('').trim().split(/\r?\n/).filter(Boolean).pop() || '';
161
+ if (code === 0 && picked) resolve({ ok: true, path: picked });
162
+ else resolve({ ok: false, cancelled: true });
163
+ });
164
+ };
165
+ try {
166
+ if (process.platform === 'win32') {
167
+ const script = [
168
+ '$ErrorActionPreference="Stop"',
169
+ '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8',
170
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null',
171
+ '$o = New-Object System.Windows.Forms.Form',
172
+ '$o.TopMost = $true',
173
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
174
+ "$d.Description = '选择数据根目录(资产与视频将存到该目录)'",
175
+ '$d.ShowNewFolderButton = $true',
176
+ 'if ($d.ShowDialog($o) -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $d.SelectedPath }',
177
+ ].join('; ');
178
+ collect(spawn('powershell.exe', ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-Command', script], { windowsHide: true }));
179
+ } else if (process.platform === 'darwin') {
180
+ collect(spawn('osascript', ['-e', 'POSIX path of (choose folder with prompt "选择数据根目录")'], { windowsHide: true }));
181
+ } else {
182
+ collect(spawn('zenity', ['--file-selection', '--directory', '--title=选择数据根目录'], { windowsHide: true }));
183
+ }
184
+ } catch (e) {
185
+ resolve({ ok: false, unsupported: true, error: String((e && e.message) || e) });
186
+ }
187
+ });
188
+ }
25
189
 
26
190
  /** 读取 npm registry 上 latest dist-tag 的版本号。 */
27
191
  async function npmLatestVersion() {
@@ -62,7 +226,6 @@ function readLocalVersion() {
62
226
  /**
63
227
  * 更新检查(与安装方式无关):当前副本版本 vs npm registry latest。
64
228
  * 本地版本 ≥ 远端 → 最新;仅真正落后才提示。
65
- * 插件绝不自动安装、绝不自动重启/刷新 —— 安装一律引导到 DSH 插件管理。
66
229
  */
67
230
  async function updateCheck() {
68
231
  const runningVersion = readLocalVersion();
@@ -71,10 +234,49 @@ async function updateCheck() {
71
234
  return { source: 'npm', runningVersion, remoteVersion, behind, upToDate: behind === 0 };
72
235
  }
73
236
 
237
+ /**
238
+ * 点击即更新(仅在用户显式点击时由客户端调用,无 RPC 参数):
239
+ * 安装 registry latest 的精确版本到所属 profile,绝不自动重启/刷新。
240
+ * - 已是最新 → { ok:true, changed:false }
241
+ * - 受管安装 + pnpm 可用 → pnpm add <pkg>@<exact> --save-exact + 同步 bundles 列表
242
+ * - 开发态 / pnpm 不可用 / 安装失败 → { ok:false, guide:true }(客户端回退为纯引导文案)
243
+ */
244
+ async function updateInstall() {
245
+ const runningVersion = readLocalVersion();
246
+ const latest = await npmLatestVersion();
247
+ if (cmpVer(runningVersion, latest) >= 0) {
248
+ return { ok: true, changed: false, version: runningVersion };
249
+ }
250
+ if (!PROFILE_DIR) {
251
+ return { ok: false, guide: true, version: latest, output: '开发态安装,请自行更新工作区' };
252
+ }
253
+ const pnpm = findPnpm();
254
+ const spec = PKG_NAME + '@' + latest;
255
+ let res = await runPnpmAdd(pnpm, spec);
256
+ let policyRelaxed = false;
257
+ if (!res.ok && /MINIMUM_RELEASE_AGE_VIOLATION/.test((res.stdout || '') + (res.stderr || ''))) {
258
+ // pnpm 11 供应链保护对 24h 内新发布间歇性拦截;用户点击已是明确同意 → 放宽重试一次
259
+ policyRelaxed = true;
260
+ res = await runPnpmAdd(pnpm, spec, { relaxReleaseAge: true });
261
+ }
262
+ const output = ((res.stdout || '') + '\n' + (res.stderr || '')).trim().slice(-1500);
263
+ if (!res.ok) {
264
+ return { ok: false, guide: true, version: latest, output };
265
+ }
266
+ try {
267
+ await ensureBundleEntry(PROFILE_DIR, PKG_NAME);
268
+ } catch { /* bundles 同步失败不阻塞安装结果 */ }
269
+ let installed = latest;
270
+ try {
271
+ installed = JSON.parse(readFileSync(path.join(PROFILE_DIR, 'node_modules', ...PKG_NAME.split('/'), 'package.json'), 'utf8')).version || latest;
272
+ } catch { /* 保留 latest */ }
273
+ return { ok: true, changed: true, version: installed, output, policyRelaxed };
274
+ }
275
+
74
276
  export const inject = ['webServer'];
75
277
 
76
278
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
77
- export const _internals = { updateCheck, npmLatestVersion, cmpVer, getVersion: () => VERSION };
279
+ export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, safeJoin, mimeOf, getIcConfigPath: () => CONFIG_PATH };
78
280
 
79
281
  function resolveModel(providers, want) {
80
282
  const byProvider = want && want.provider
@@ -156,6 +358,43 @@ export function apply(ctx) {
156
358
  async llmOptions() { return listLlmOptions(); },
157
359
  async llmComplete(args) { return complete(args); },
158
360
  async updateCheck() { return updateCheck(); },
361
+ async updateInstall() { return updateInstall(); },
362
+ async dataRootGet() {
363
+ const root = dataRoot();
364
+ let exists = false;
365
+ if (root) { try { exists = statSync(root).isDirectory(); } catch { /* 不存在 */ } }
366
+ return { dataRoot: root, exists, configPath: CONFIG_PATH };
367
+ },
368
+ async dataRootSet(args) {
369
+ const raw = String((args && args.root) ?? '').trim();
370
+ if (!raw) throw new Error('需要数据根目录路径');
371
+ const root = path.resolve(raw);
372
+ if (!path.isAbsolute(root)) throw new Error('需要绝对路径(如 D:\\canvas-data)');
373
+ mkdirSync(root, { recursive: true });
374
+ const cfg = readIcConfig();
375
+ cfg.dataRoot = root;
376
+ writeIcConfig(cfg);
377
+ return { ok: true, dataRoot: root };
378
+ },
379
+ async dataRootReveal() {
380
+ const root = dataRoot();
381
+ if (!root) throw new Error('未设置数据根目录');
382
+ mkdirSync(root, { recursive: true });
383
+ const open = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
384
+ try { spawn(open, [root], { detached: true, stdio: 'ignore' }).unref(); } catch { /* 打开失败不阻塞 */ }
385
+ return { ok: true, dataRoot: root };
386
+ },
387
+ async pickFolder() {
388
+ const r = await pickFolderDialog();
389
+ return r; // { ok, path } | { ok:false, cancelled } | { ok:false, unsupported }
390
+ },
391
+ async assetDelete(args) {
392
+ const root = dataRoot();
393
+ if (!root) throw new Error('未设置数据根目录');
394
+ const { abs } = safeJoin(root, (args && args.rel) || '');
395
+ try { unlinkSync(abs); } catch { /* 已不存在视为成功 */ }
396
+ return { ok: true };
397
+ },
159
398
  async listSkills() {
160
399
  const skills = ctx.get('skills');
161
400
  if (!skills) return { skills: [], error: 'skills 服务不可用' };
@@ -185,31 +424,108 @@ export function apply(ctx) {
185
424
  },
186
425
  };
187
426
 
188
- return ctx.webServer.register({
189
- kind: 'exact',
190
- path: `/${NAME}/api/rpc`,
191
- async handler(req, res) {
192
- res.setHeader('content-type', 'application/json; charset=utf-8');
193
- try {
194
- if (req.method !== 'POST') {
195
- res.statusCode = 405;
196
- res.end(JSON.stringify({ error: '仅接受 POST' }));
197
- return;
427
+ const registrations = [
428
+ ctx.webServer.register({
429
+ kind: 'exact',
430
+ path: `/${NAME}/api/rpc`,
431
+ async handler(req, res) {
432
+ res.setHeader('content-type', 'application/json; charset=utf-8');
433
+ try {
434
+ if (req.method !== 'POST') {
435
+ res.statusCode = 405;
436
+ res.end(JSON.stringify({ error: '仅接受 POST' }));
437
+ return;
438
+ }
439
+ let raw = '';
440
+ for await (const chunk of req) {
441
+ raw += chunk;
442
+ if (raw.length > 2e6) throw new Error('请求过大');
443
+ }
444
+ const { method, args } = JSON.parse(raw);
445
+ const fn = methods[method];
446
+ if (!fn) throw new Error('未知操作: ' + method);
447
+ res.end(JSON.stringify(await fn(args ?? {})));
448
+ } catch (e) {
449
+ res.statusCode = 400;
450
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
198
451
  }
199
- let raw = '';
200
- for await (const chunk of req) {
201
- raw += chunk;
202
- if (raw.length > 2e6) throw new Error('请求过大');
452
+ },
453
+ }),
454
+ ctx.webServer.register({
455
+ kind: 'exact',
456
+ path: `/${NAME}/api/asset`,
457
+ async handler(req, res) {
458
+ res.setHeader('content-type', 'application/json; charset=utf-8');
459
+ try {
460
+ if (req.method !== 'POST' && req.method !== 'PUT') {
461
+ res.statusCode = 405;
462
+ res.end(JSON.stringify({ error: '仅接受 POST/PUT' }));
463
+ return;
464
+ }
465
+ const root = dataRoot();
466
+ if (!root) throw new Error('未设置数据根目录');
467
+ const u = new URL(req.url, 'http://localhost');
468
+ const { abs, clean } = safeJoin(root, u.searchParams.get('rel') || '');
469
+ mkdirSync(path.dirname(abs), { recursive: true });
470
+ let size = 0;
471
+ await new Promise((resolve, reject) => {
472
+ const ws = createWriteStream(abs);
473
+ req.on('data', (c) => { size += c.length; });
474
+ req.on('error', reject);
475
+ ws.on('error', reject);
476
+ ws.on('finish', resolve);
477
+ req.pipe(ws);
478
+ });
479
+ res.end(JSON.stringify({ ok: true, rel: clean, size }));
480
+ } catch (e) {
481
+ res.statusCode = 400;
482
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
203
483
  }
204
- const { method, args } = JSON.parse(raw);
205
- const fn = methods[method];
206
- if (!fn) throw new Error('未知操作: ' + method);
207
- res.end(JSON.stringify(await fn(args ?? {})));
208
- } catch (e) {
209
- res.statusCode = 400;
210
- res.end(JSON.stringify({ error: String((e && e.message) || e) }));
211
- }
212
- },
213
- });
214
- }, `${NAME}: api route`);
484
+ },
485
+ }),
486
+ ctx.webServer.register({
487
+ kind: 'exact',
488
+ path: `/${NAME}/api/file`,
489
+ async handler(req, res) {
490
+ try {
491
+ if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
492
+ const root = dataRoot();
493
+ if (!root) { res.statusCode = 404; res.end('未设置数据根目录'); return; }
494
+ const u = new URL(req.url, 'http://localhost');
495
+ const { abs } = safeJoin(root, u.searchParams.get('rel') || '');
496
+ let st;
497
+ try { st = statSync(abs); } catch { res.statusCode = 404; res.end('not found'); return; }
498
+ if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
499
+ res.setHeader('content-type', mimeOf(abs));
500
+ res.setHeader('accept-ranges', 'bytes');
501
+ res.setHeader('cache-control', 'no-cache');
502
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
503
+ if (m && (m[1] !== '' || m[2] !== '')) {
504
+ let start;
505
+ let end;
506
+ if (m[1] === '') { start = Math.max(0, st.size - Number(m[2])); end = st.size - 1; }
507
+ else { start = parseInt(m[1], 10); end = m[2] === '' ? st.size - 1 : Math.min(parseInt(m[2], 10), st.size - 1); }
508
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= st.size) {
509
+ res.statusCode = 416;
510
+ res.setHeader('content-range', 'bytes */' + st.size);
511
+ res.end();
512
+ return;
513
+ }
514
+ res.statusCode = 206;
515
+ res.setHeader('content-range', `bytes ${start}-${end}/${st.size}`);
516
+ res.setHeader('content-length', String(end - start + 1));
517
+ createReadStream(abs, { start, end }).pipe(res);
518
+ } else {
519
+ res.setHeader('content-length', String(st.size));
520
+ createReadStream(abs).pipe(res);
521
+ }
522
+ } catch (e) {
523
+ res.statusCode = 400;
524
+ res.end(String((e && e.message) || e));
525
+ }
526
+ },
527
+ }),
528
+ ];
529
+ return () => { for (const off of registrations) { try { if (off) off(); } catch { /* 忽略 */ } } };
530
+ }, `${NAME}: api routes`);
215
531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninkch/dsh-infinite-canvas",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
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",