@ninkch/dsh-infinite-canvas 0.17.3 → 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 +1 -1
- package/README.zh-CN.md +1 -1
- package/client.js +194 -19
- package/docs/release.md +2 -1
- package/index.js +221 -27
- package/package.json +1 -1
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
|
@@ -338,6 +338,20 @@ window.__ModuleLoader__.load({
|
|
|
338
338
|
'updateInstalledHint': '重启 Harness 后生效(网页部分刷新页面即可)',
|
|
339
339
|
'updateFailGuide': '自动安装未成功,请在 DSH 插件管理 中升级',
|
|
340
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': '当前平台不支持目录选择器,请手动填写路径',
|
|
341
355
|
'updateCurrent': '当前',
|
|
342
356
|
'updateGo': '去升级',
|
|
343
357
|
'npmUpgradeHint': '请在 DSH 插件管理 中升级到最新版(本插件绝不自动安装或重启);安装完成后重启 Harness 生效',
|
|
@@ -600,6 +614,20 @@ window.__ModuleLoader__.load({
|
|
|
600
614
|
'updateInstalledHint': 'restart Harness to take effect (refresh the page for the web part)',
|
|
601
615
|
'updateFailGuide': 'Auto-install failed — please upgrade via the DSH Plugin Manager',
|
|
602
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',
|
|
603
631
|
'updateCurrent': 'current',
|
|
604
632
|
'updateGo': 'Upgrade',
|
|
605
633
|
'npmUpgradeHint': 'Please upgrade via the DSH Plugin Manager (this plugin never auto-installs or restarts); restart Harness after installing',
|
|
@@ -1047,30 +1075,81 @@ window.__ModuleLoader__.load({
|
|
|
1047
1075
|
}
|
|
1048
1076
|
const assetUrlCache = new Map();
|
|
1049
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
|
+
}
|
|
1050
1083
|
async function assetUrl(id) {
|
|
1051
1084
|
if (!id) return null;
|
|
1052
1085
|
if (assetUrlCache.has(id)) return assetUrlCache.get(id);
|
|
1053
1086
|
const rec = await idbGet(id);
|
|
1054
|
-
if (!rec
|
|
1055
|
-
|
|
1056
|
-
|
|
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);
|
|
1057
1092
|
return url;
|
|
1058
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
|
+
}
|
|
1059
1106
|
async function saveAssetRecord(projectId, kind, blob, meta) {
|
|
1107
|
+
const id = uid('a');
|
|
1108
|
+
const mime = blob.type || (kind === 'image' ? 'image/png' : 'video/webm');
|
|
1060
1109
|
const rec = {
|
|
1061
|
-
id
|
|
1062
|
-
mime
|
|
1110
|
+
id, projectId, kind,
|
|
1111
|
+
mime,
|
|
1063
1112
|
name: (meta && meta.name) || (kind === 'image' ? 'image' : 'video') + '-' + Date.now().toString(36),
|
|
1064
|
-
blob,
|
|
1065
1113
|
createdAt: Date.now(),
|
|
1066
1114
|
prompt: (meta && meta.prompt) || '',
|
|
1067
1115
|
source: (meta && meta.source) || 'demo',
|
|
1068
1116
|
category: (meta && meta.category) || 'general',
|
|
1069
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
|
+
}
|
|
1070
1130
|
await idbPut(rec);
|
|
1071
1131
|
assetNameCache.set(rec.id, rec.name);
|
|
1072
1132
|
return rec;
|
|
1073
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
|
+
}
|
|
1074
1153
|
function useAssetUrl(id) {
|
|
1075
1154
|
const [url, setUrl] = useState(null);
|
|
1076
1155
|
useEffect(() => {
|
|
@@ -1696,7 +1775,9 @@ window.__ModuleLoader__.load({
|
|
|
1696
1775
|
const map = craftMapLoad(pid);
|
|
1697
1776
|
if (!opts.force && map[rec.id]) return map[rec.id];
|
|
1698
1777
|
const rel = craftProjectSlug() + '/media/' + rec.id + extForMime(rec.mime, rec.name);
|
|
1699
|
-
await
|
|
1778
|
+
const blob = rec.blob || await assetBlob(rec.id);
|
|
1779
|
+
if (!blob) throw new Error('资产内容不可用: ' + rec.id);
|
|
1780
|
+
await craft.write(rel, blob);
|
|
1700
1781
|
craftMapSet(pid, rec.id, rel);
|
|
1701
1782
|
return rel;
|
|
1702
1783
|
}
|
|
@@ -1880,6 +1961,15 @@ window.__ModuleLoader__.load({
|
|
|
1880
1961
|
update((s) => { s.updateBusy = false; s.updateMsg = T('npmUpgradeHint') + ((S.updateInfo && S.updateInfo.remoteVersion) ? ' → v' + S.updateInfo.remoteVersion : ''); });
|
|
1881
1962
|
}
|
|
1882
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
|
+
}
|
|
1972
|
+
}
|
|
1883
1973
|
|
|
1884
1974
|
// ============================================================
|
|
1885
1975
|
// 尺寸 / 比例(Toonflow 式:清晰度 × 比例 → 像素)
|
|
@@ -2138,6 +2228,9 @@ window.__ModuleLoader__.load({
|
|
|
2138
2228
|
assetsTick: 0,
|
|
2139
2229
|
tick: 0,
|
|
2140
2230
|
craftStatus: null, // {at, data} | {at, error}
|
|
2231
|
+
dataRoot: null, // 数据根目录(Host 配置);null = 未设置(资产走 IndexedDB)
|
|
2232
|
+
dataRootExists: false,
|
|
2233
|
+
dataRootSupported: true,
|
|
2141
2234
|
updateInfo: null, // Host updateCheck 结果
|
|
2142
2235
|
updateChecking: false,
|
|
2143
2236
|
updateBusy: false,
|
|
@@ -2483,8 +2576,7 @@ window.__ModuleLoader__.load({
|
|
|
2483
2576
|
if (!src) return null;
|
|
2484
2577
|
const aid = servedAssetId(src, e.fromPort);
|
|
2485
2578
|
if (!aid) return null;
|
|
2486
|
-
|
|
2487
|
-
return rec ? rec.blob : null;
|
|
2579
|
+
return await assetBlob(aid);
|
|
2488
2580
|
}
|
|
2489
2581
|
// 多参考图:按节点上的显式顺序(refOrder,未排序时按连线先后)解析,带名称/分类供 [图N] 绑定
|
|
2490
2582
|
async function resolveRefMetas(nodeId, portId) {
|
|
@@ -2501,7 +2593,8 @@ window.__ModuleLoader__.load({
|
|
|
2501
2593
|
if (!aid) continue;
|
|
2502
2594
|
try {
|
|
2503
2595
|
const rec = await idbGet(aid);
|
|
2504
|
-
|
|
2596
|
+
const blob = await assetBlob(aid);
|
|
2597
|
+
if (blob) metas.push({ blob, name: (rec && rec.name) || '', category: (rec && rec.category) || 'general' });
|
|
2505
2598
|
} catch { /* 单个参考图读取失败不影响其余 */ }
|
|
2506
2599
|
}
|
|
2507
2600
|
return metas;
|
|
@@ -3894,10 +3987,11 @@ window.__ModuleLoader__.load({
|
|
|
3894
3987
|
if (!x.nd.data.assetId) continue;
|
|
3895
3988
|
try {
|
|
3896
3989
|
const rec = await idbGet(x.nd.data.assetId);
|
|
3897
|
-
|
|
3990
|
+
const blob = await assetBlob(x.nd.data.assetId);
|
|
3991
|
+
if (blob) {
|
|
3898
3992
|
const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
|
|
3899
3993
|
const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
|
|
3900
|
-
metas.push({ url: URL.createObjectURL(
|
|
3994
|
+
metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl });
|
|
3901
3995
|
}
|
|
3902
3996
|
} catch { /* 跳过加载失败的片段 */ }
|
|
3903
3997
|
}
|
|
@@ -3933,9 +4027,10 @@ window.__ModuleLoader__.load({
|
|
|
3933
4027
|
if (!x.nd.data.assetId) continue;
|
|
3934
4028
|
try {
|
|
3935
4029
|
const rec = await idbGet(x.nd.data.assetId);
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
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) });
|
|
3939
4034
|
} catch (e) { toast('Craft: ' + (e.message || e)); return; }
|
|
3940
4035
|
}
|
|
3941
4036
|
if (!items.length) { toast(T('noSegments')); return; }
|
|
@@ -4297,7 +4392,12 @@ window.__ModuleLoader__.load({
|
|
|
4297
4392
|
lsSet(LS_ACTIVE, S.activeId);
|
|
4298
4393
|
try {
|
|
4299
4394
|
const recs = await idbByProject(pid);
|
|
4300
|
-
for (const r of recs) {
|
|
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
|
+
}
|
|
4301
4401
|
} catch { /* 资产清理失败不影响项目删除 */ }
|
|
4302
4402
|
setDelTarget(null);
|
|
4303
4403
|
};
|
|
@@ -4455,6 +4555,7 @@ window.__ModuleLoader__.load({
|
|
|
4455
4555
|
if (files.length) setTick((x) => x + 1);
|
|
4456
4556
|
};
|
|
4457
4557
|
const del = async (a) => {
|
|
4558
|
+
if (a.rel) { try { await hostRpc('assetDelete', { rel: a.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
|
|
4458
4559
|
await idbDelete(a.id);
|
|
4459
4560
|
update((s) => {
|
|
4460
4561
|
for (const nd of Object.values(s.graph.nodes)) {
|
|
@@ -4468,7 +4569,7 @@ window.__ModuleLoader__.load({
|
|
|
4468
4569
|
const syncAll = async () => {
|
|
4469
4570
|
const st = await craftStatusSafe();
|
|
4470
4571
|
if (!st) return;
|
|
4471
|
-
const recs = assets.filter((a) => a.blob);
|
|
4572
|
+
const recs = assets.filter((a) => a.blob || a.rel);
|
|
4472
4573
|
let okN = 0, failN = 0;
|
|
4473
4574
|
for (const a of recs) {
|
|
4474
4575
|
try { await syncAssetToDisk(a, { force: true }); okN++; } catch { failN++; }
|
|
@@ -4838,7 +4939,7 @@ window.__ModuleLoader__.load({
|
|
|
4838
4939
|
if (!(st.bins && st.bins.photocraft && st.bins.photocraft.found)) { toast(T('craftCliMissing').replace('{name}', 'photocraft')); return; }
|
|
4839
4940
|
let paramsObj;
|
|
4840
4941
|
try { paramsObj = JSON.parse(paramsText || '{}'); } catch { toast(T('craftInvalidJson')); return; }
|
|
4841
|
-
if (!rec || !rec.blob) { toast(T('noResult')); return; }
|
|
4942
|
+
if (!rec || (!rec.blob && !rec.rel)) { toast(T('noResult')); return; }
|
|
4842
4943
|
setRunning(true); setErr(null); setProgress('…');
|
|
4843
4944
|
try {
|
|
4844
4945
|
const mediaRel = await ensureAssetOnDisk(rec);
|
|
@@ -4945,6 +5046,77 @@ window.__ModuleLoader__.load({
|
|
|
4945
5046
|
));
|
|
4946
5047
|
}
|
|
4947
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
|
+
}
|
|
4948
5120
|
function SettingsTab() {
|
|
4949
5121
|
const [s, setS] = useState(loadSettings);
|
|
4950
5122
|
const apply = (fn) => {
|
|
@@ -4958,6 +5130,7 @@ window.__ModuleLoader__.load({
|
|
|
4958
5130
|
const field = (label, value, onChange, placeholder, password) => h(Field, { label, value, onChange, placeholder, password });
|
|
4959
5131
|
return [
|
|
4960
5132
|
h(CraftCard, { key: 'craft' }),
|
|
5133
|
+
h(DataRootCard, { key: 'dataroot' }),
|
|
4961
5134
|
h('div', { key: 'lang', className: 'dshic-card' },
|
|
4962
5135
|
h('div', { className: 'dshic-row' }, Ic('text', 12), h('strong', { style: { fontSize: 12 } }, T('langLabel'))),
|
|
4963
5136
|
h('select', {
|
|
@@ -4983,7 +5156,7 @@ window.__ModuleLoader__.load({
|
|
|
4983
5156
|
h(ApiListEditor, { key: 'imglist', kind: 'image' }),
|
|
4984
5157
|
h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
|
|
4985
5158
|
h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
|
|
4986
|
-
h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas
|
|
5159
|
+
h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
|
|
4987
5160
|
];
|
|
4988
5161
|
}
|
|
4989
5162
|
|
|
@@ -5317,6 +5490,8 @@ window.__ModuleLoader__.load({
|
|
|
5317
5490
|
probeCraft();
|
|
5318
5491
|
// 检查更新(Host RPC;Host 旧版未带此方法时静默)
|
|
5319
5492
|
checkUpdate(true);
|
|
5493
|
+
// 数据根目录(Host 旧版静默降级为 IndexedDB 模式)
|
|
5494
|
+
refreshDataRoot();
|
|
5320
5495
|
// 后台路由探活:失败时在画布顶部给出可操作的提示
|
|
5321
5496
|
hostRpc('ping').then(() => { S.hostOk = true; S.hostError = null; notify(); }).catch((e) => {
|
|
5322
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.
|
|
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
|
|
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 {
|
|
11
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, 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';
|
|
@@ -106,6 +106,87 @@ async function ensureBundleEntry(profileDir, pkg) {
|
|
|
106
106
|
return true;
|
|
107
107
|
}
|
|
108
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
|
+
}
|
|
189
|
+
|
|
109
190
|
/** 读取 npm registry 上 latest dist-tag 的版本号。 */
|
|
110
191
|
async function npmLatestVersion() {
|
|
111
192
|
const res = await fetch(NPM_LATEST_URL, {
|
|
@@ -195,7 +276,7 @@ async function updateInstall() {
|
|
|
195
276
|
export const inject = ['webServer'];
|
|
196
277
|
|
|
197
278
|
// 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
|
|
198
|
-
export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION };
|
|
279
|
+
export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, safeJoin, mimeOf, getIcConfigPath: () => CONFIG_PATH };
|
|
199
280
|
|
|
200
281
|
function resolveModel(providers, want) {
|
|
201
282
|
const byProvider = want && want.provider
|
|
@@ -278,6 +359,42 @@ export function apply(ctx) {
|
|
|
278
359
|
async llmComplete(args) { return complete(args); },
|
|
279
360
|
async updateCheck() { return updateCheck(); },
|
|
280
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
|
+
},
|
|
281
398
|
async listSkills() {
|
|
282
399
|
const skills = ctx.get('skills');
|
|
283
400
|
if (!skills) return { skills: [], error: 'skills 服务不可用' };
|
|
@@ -307,31 +424,108 @@ export function apply(ctx) {
|
|
|
307
424
|
},
|
|
308
425
|
};
|
|
309
426
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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) }));
|
|
320
451
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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) }));
|
|
325
483
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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`);
|
|
337
531
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninkch/dsh-infinite-canvas",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.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",
|