@ninkch/dsh-infinite-canvas 0.17.18 → 0.18.1

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
@@ -249,6 +249,7 @@ window.__ModuleLoader__.load({
249
249
  'imgMode': '参考图模式',
250
250
  'modeGenerations': '生图 /images/generations(支持多参考图)',
251
251
  'modeEdits': '参考图编辑 /images/edits(多参考图上传)',
252
+ 'modeCompshare': '优云智算任务式 /image/v1/tasks(Qwen-Image,参考图登记 + 提示词优化)',
252
253
  'catMismatch': '提示:资产分类为「{a}」,端口期望「{b}」,已保留连接',
253
254
  'nStoryboard': '分镜表',
254
255
  'nScript': '剧本',
@@ -656,6 +657,7 @@ window.__ModuleLoader__.load({
656
657
  'imgMode': 'Reference mode',
657
658
  'modeGenerations': 'image /images/generations (with references)',
658
659
  'modeEdits': 'image edit /images/edits (multi-reference upload)',
660
+ 'modeCompshare': 'Compshare task /image/v1/tasks (Qwen-Image, reference upload)',
659
661
  'catMismatch': 'Note: asset category is "{a}", port expects "{b}". Connected anyway.',
660
662
  'nStoryboard': 'Storyboard',
661
663
  'nScript': 'Script',
@@ -1598,18 +1600,21 @@ window.__ModuleLoader__.load({
1598
1600
  const rest = nm.slice(pref.length);
1599
1601
  return rest === '' || !/[\p{L}\p{N}]/u.test(rest[0]);
1600
1602
  }
1601
- /** 生成用提示词:@标记去掉 + [图N] 绑定 + 命中实体的设定描述。 */
1602
- function buildEffectivePrompt(prompt, refs, entities, bible) {
1603
+ /** 生成用提示词:@标记去掉 + [图N] 绑定 + 命中实体的设定描述。
1604
+ * light=true(图片生成专用):只保留风格行与 [图N] 名称绑定——参考图本身就是外观事实源,
1605
+ * 内联设定全文会稀释参考图约束,且文字渲染型模型(Qwen-Image)会把追加文本直接画进画面(用户实测:
1606
+ * 设定集全文被渲染成画面右下角的乱码文字块、剧情词凭空生成道具)。视频路径不用 light(不渲染文字,上下文有益)。 */
1607
+ function buildEffectivePrompt(prompt, refs, entities, bible, light) {
1603
1608
  const zh = effLang() !== 'en';
1604
1609
  const bind = refs.map((r, i) => {
1605
1610
  if (r.entity) {
1606
1611
  const ent = matchBibleEntity(r.entity, bible);
1607
- const desc = ent ? ent.desc : '';
1608
- return `[图${i + 1}] ${entityLabelOf(ent ? ent.kind : 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
1612
+ const desc = light ? '' : ((ent && ent.desc) || '');
1613
+ return `[图${i + 1}] ${entityLabelOf((ent || {}).kind || 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
1609
1614
  }
1610
1615
  return `[图${i + 1}] ${catLabel(r.category)}:${r.name}`;
1611
1616
  }).join(';');
1612
- const entLines = entities.map((e) => `${entityLabelOf(e.kind)} ${e.name}:${e.desc}`);
1617
+ const entLines = light ? [] : entities.map((e) => `${entityLabelOf(e.kind)} ${e.name}:${e.desc}`);
1613
1618
  return [
1614
1619
  (bible.style || '').trim() ? (zh ? '风格:' : 'Style: ') + bible.style.trim() : '',
1615
1620
  bind ? (zh ? '参考图 → ' : 'Refs -> ') + bind : '',
@@ -1769,7 +1774,8 @@ window.__ModuleLoader__.load({
1769
1774
  tx.onerror = () => reject(tx.error);
1770
1775
  });
1771
1776
  }
1772
- async function idbGet(id) {
1777
+ const assetMem = new Map(); // id -> rec:服务端元数据缓存(0.18 起服务端是资产库唯一事实源)
1778
+ async function idbGetRaw(id) {
1773
1779
  const db = await openDb();
1774
1780
  return new Promise((resolve, reject) => {
1775
1781
  const rq = db.transaction(DB_STORE, 'readonly').objectStore(DB_STORE).get(id);
@@ -1777,6 +1783,19 @@ window.__ModuleLoader__.load({
1777
1783
  rq.onerror = () => reject(rq.error);
1778
1784
  });
1779
1785
  }
1786
+ /** 资产记录读取(0.18):内存缓存 → 服务端 → 旧 IndexedDB 兜底(迁移期遗留 blob)。 */
1787
+ async function idbGet(id) {
1788
+ if (!id) return null;
1789
+ if (assetMem.has(id)) return assetMem.get(id) || null;
1790
+ const local = await idbGetRaw(id).catch(() => null);
1791
+ if (local) return local;
1792
+ try {
1793
+ const r = await hostRpc('assetsGet', { ids: [id] });
1794
+ const rec = (r && r.items && r.items[0]) || null;
1795
+ if (rec) { assetMem.set(rec.id, rec); assetNameCache.set(rec.id, rec.name); }
1796
+ return rec;
1797
+ } catch { return null; }
1798
+ }
1780
1799
  async function idbDelete(id) {
1781
1800
  assetNameCache.delete(id);
1782
1801
  // 释放该资产的 objectURL:否则 Blob(视频可达几十 MB)会被 URL 钉在内存里直到刷新
@@ -1793,7 +1812,7 @@ window.__ModuleLoader__.load({
1793
1812
  tx.onerror = () => reject(tx.error);
1794
1813
  });
1795
1814
  }
1796
- async function idbByProject(pid) {
1815
+ async function idbByProjectRaw(pid) {
1797
1816
  const db = await openDb();
1798
1817
  return new Promise((resolve, reject) => {
1799
1818
  const idx = db.transaction(DB_STORE, 'readonly').objectStore(DB_STORE).index('projectId');
@@ -1802,6 +1821,29 @@ window.__ModuleLoader__.load({
1802
1821
  rq.onerror = () => reject(rq.error);
1803
1822
  });
1804
1823
  }
1824
+ /** 项目资产清单(0.18):服务端分页拉全 + 旧 IndexedDB 遗留合并(服务端不可用时本地兜底)。 */
1825
+ async function idbByProject(pid) {
1826
+ const out = [];
1827
+ try {
1828
+ let cursor = null;
1829
+ for (;;) {
1830
+ const r = await hostRpc('assetsList', { projectId: pid, limit: 200, cursor }).catch(() => null);
1831
+ if (!r || !Array.isArray(r.items)) break;
1832
+ for (const rec of r.items) {
1833
+ out.push(rec);
1834
+ if (rec) { assetMem.set(rec.id, rec); assetNameCache.set(rec.id, rec.name); }
1835
+ }
1836
+ if (!r.nextCursor) break;
1837
+ cursor = r.nextCursor;
1838
+ }
1839
+ } catch { /* 服务端不可用 → 本地兜底 */ }
1840
+ try {
1841
+ const locals = await idbByProjectRaw(pid);
1842
+ const have = new Set(out.map((x) => x.id));
1843
+ for (const l of locals) if (l && !have.has(l.id)) out.push(l);
1844
+ } catch { /* 忽略 */ }
1845
+ return out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1846
+ }
1805
1847
  async function idbAll() {
1806
1848
  const db = await openDb();
1807
1849
  return new Promise((resolve, reject) => {
@@ -1817,14 +1859,20 @@ window.__ModuleLoader__.load({
1817
1859
  const sub = String(mime || '').split(';')[0].split('/')[1] || 'bin';
1818
1860
  return /^[a-z0-9]{1,8}$/i.test(sub) ? sub : 'bin';
1819
1861
  }
1862
+ /** 资产内容 URL:CAS 对象(sha)→ /api/object(immutable,远程可拉);旧落盘(rel)→ /api/file;本地 blob → objectURL。 */
1863
+ function contentUrlFor(rec) {
1864
+ if (!rec) return null;
1865
+ if (rec.sha) return '/' + NS + '/api/object?sha=' + encodeURIComponent(rec.sha) + (rec.mime ? '&mime=' + encodeURIComponent(rec.mime) : '');
1866
+ if (rec.rel) return fileUrlFor(rec.rel);
1867
+ return null;
1868
+ }
1820
1869
  async function assetUrl(id) {
1821
1870
  if (!id) return null;
1822
1871
  if (assetUrlCache.has(id)) return assetUrlCache.get(id);
1823
1872
  const rec = await idbGet(id);
1824
1873
  if (!rec) return null;
1825
- let url = null;
1826
- if (rec.rel) url = fileUrlFor(rec.rel); // 磁盘资产:Host 文件服务(支持 Range)
1827
- else if (rec.blob) {
1874
+ let url = contentUrlFor(rec);
1875
+ if (!url && rec.blob) {
1828
1876
  // IndexedDB 资产:objectURL 有内存成本 → 缓存总量设上限,超出时释放最早的一批
1829
1877
  if (assetUrlCache.size >= 60) {
1830
1878
  const first = assetUrlCache.keys().next();
@@ -1839,13 +1887,14 @@ window.__ModuleLoader__.load({
1839
1887
  if (url) assetUrlCache.set(id, url);
1840
1888
  return url;
1841
1889
  }
1842
- /** 取资产 Blob:磁盘资产经 Host 文件服务拉取,旧资产直接读 IndexedDB。 */
1890
+ /** 取资产 Blob:CAS/磁盘资产经 Host 文件服务拉取(远程同样可用),旧资产直接读 IndexedDB。 */
1843
1891
  async function assetBlob(id) {
1844
1892
  const rec = await idbGet(id);
1845
1893
  if (!rec) return null;
1846
1894
  if (rec.blob) return rec.blob;
1847
- if (rec.rel) {
1848
- const r = await fetch(fileUrlFor(rec.rel));
1895
+ const url = contentUrlFor(rec);
1896
+ if (url) {
1897
+ const r = await fetch(url);
1849
1898
  if (!r.ok) throw new Error('资产读取失败 HTTP ' + r.status);
1850
1899
  return await r.blob();
1851
1900
  }
@@ -1869,7 +1918,22 @@ window.__ModuleLoader__.load({
1869
1918
  if (meta && meta.nodeLabel) rec.nodeLabel = meta.nodeLabel;
1870
1919
  if (meta && meta.effectivePrompt) rec.effectivePrompt = meta.effectivePrompt;
1871
1920
  if (S.dataRoot) {
1872
- // 数据根目录已配置:落盘到 <root>/projects/<项目>/<类型>/<可读名>-<id尾号>.<ext>,IndexedDB 只存元数据
1921
+ if (S.store === 'sqlite') {
1922
+ // 0.18 主路径:上传 CAS 对象(内容寻址去重)→ 服务端建资产行(任意浏览器/远程立即可见)
1923
+ const r = await fetch('/' + NS + '/api/object', { method: 'POST', body: blob });
1924
+ if (!r.ok) throw new Error('资产上传失败: ' + (await r.text()));
1925
+ const j = await r.json();
1926
+ rec.sha = j.sha;
1927
+ rec.size = j.size || blob.size;
1928
+ rec.stored = 'cas';
1929
+ const proj = S.projects.find((p) => p.id === projectId);
1930
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
1931
+ const out = (saved && saved.asset) || rec;
1932
+ assetMem.set(out.id, out);
1933
+ assetNameCache.set(out.id, out.name);
1934
+ return out;
1935
+ }
1936
+ // JSON 降级(Host 无 node:sqlite):旧落盘路径 + 索引镜像
1873
1937
  const rel = `projects/${projectId}/${kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
1874
1938
  const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
1875
1939
  if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
@@ -1882,7 +1946,7 @@ window.__ModuleLoader__.load({
1882
1946
  }
1883
1947
  await idbPut(rec);
1884
1948
  assetNameCache.set(rec.id, rec.name);
1885
- mirrorAssetIndex(rec); // 资产元数据镜像进数据目录 assets.json(迁移便利,失败静默)
1949
+ if (S.dataRoot) mirrorAssetIndex(rec); // JSON 降级模式:元数据镜像进 assets.json
1886
1950
  return rec;
1887
1951
  }
1888
1952
  /** 资产元数据索引记录(去 blob;文本资产保留 text 以便导入端免读文件即可预览)。 */
@@ -1893,6 +1957,7 @@ window.__ModuleLoader__.load({
1893
1957
  source: rec.source || '', category: rec.category || 'general',
1894
1958
  };
1895
1959
  if (rec.rel) { out.rel = rec.rel; out.size = rec.size || 0; }
1960
+ if (rec.sha) { out.sha = rec.sha; out.size = rec.size || 0; }
1896
1961
  if (rec.entity) out.entity = rec.entity;
1897
1962
  if (Array.isArray(rec.entities) && rec.entities.length) out.entities = rec.entities;
1898
1963
  if (rec.nodeLabel) out.nodeLabel = rec.nodeLabel;
@@ -1907,84 +1972,33 @@ window.__ModuleLoader__.load({
1907
1972
  projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
1908
1973
  }).catch(() => { /* 索引失败不影响资产本身 */ });
1909
1974
  }
1910
- /** 启动/设置数据目录后:一次性把 IndexedDB 里的全部资产元数据同步进 assets.json(补齐历史资产索引)。
1911
- * 全量**替换**语义(含空项目占位):IndexedDB 是资产库唯一事实源,借此自愈删除后残留的幽灵条目。 */
1912
- let assetIndexSynced = false;
1913
- async function syncAssetIndexToHost() {
1914
- if (assetIndexSynced || !S.dataRoot) return;
1915
- assetIndexSynced = true;
1916
- try {
1917
- const all = await idbAll();
1918
- const items = [];
1919
- const seen = new Set();
1920
- const projName = new Map(S.projects.map((p) => [p.id, p.name]));
1921
- for (const rec of all) {
1922
- if (!rec || !rec.id || !rec.projectId) continue;
1923
- seen.add(rec.projectId);
1924
- items.push({ projectId: rec.projectId, projectName: projName.get(rec.projectId) || rec.projectId, asset: assetIndexRecord(rec) });
1925
- }
1926
- // 本机已知但没有任何资产的项目也要发占位:assets.json 清空对齐(否则删除后会残留幽灵条目)
1927
- for (const p of S.projects) {
1928
- if (!seen.has(p.id)) items.push({ projectId: p.id, projectName: p.name, asset: null });
1929
- }
1930
- if (items.length) await hostRpc('assetIndexBulkPut', { items });
1931
- } catch { /* 索引同步失败不影响 */ }
1932
- }
1933
- /** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
1934
- async function migrateAssetsToDisk(onProgress) {
1935
- if (!S.dataRoot) throw new Error('请先设置数据根目录');
1936
- const all = await idbAll();
1937
- // 二进制资产(有 blob)与文本资产(text,此前从不落盘)都要迁移
1938
- const targets = all.filter((r) => (r.blob && !r.rel) || (r.kind === 'text' && !r.rel && typeof r.text === 'string' && r.text.length));
1939
- let done = 0;
1940
- for (const rec of targets) {
1941
- const isText = rec.kind === 'text' && !rec.blob;
1942
- const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${isText ? 'md' : extFromMime(rec.mime)}`;
1943
- const blob = isText ? new Blob([rec.text || ''], { type: 'text/markdown' }) : rec.blob;
1944
- const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
1945
- if (!r.ok) throw new Error('迁移失败: ' + rel + ' — ' + (await r.text()));
1946
- const next = { ...rec, rel, size: blob.size, stored: 'disk' };
1947
- if (isText) next.text = rec.text; // 文本保留在元数据里:预览/下载不必回读文件
1948
- else delete next.blob;
1949
- await idbPut(next);
1950
- mirrorAssetIndex(next); // 迁移后刷新数据目录 assets.json(补 rel/size)
1951
- assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
1952
- done++;
1953
- if (onProgress) onProgress(done, targets.length);
1954
- }
1955
- return { migrated: done, total: targets.length };
1956
- }
1957
- /** 启动自检:把分类为「通用」但命名符合规范前缀(角色-/场景-/道具-/剧本-/分镜-)的存量资产纠正分类。 */
1958
- async function fixGeneralCategories() {
1959
- try {
1960
- const all = await idbAll();
1961
- let fixed = 0;
1962
- for (const rec of all) {
1963
- if (!rec || !rec.id || (rec.kind !== 'image' && rec.kind !== 'video')) continue;
1964
- if ((rec.category || 'general') !== 'general') continue;
1965
- const c = categoryFromName(rec.name);
1966
- if (!c) continue;
1967
- const next = { ...rec, category: c };
1968
- await idbPut(next);
1969
- mirrorAssetIndex(next);
1970
- fixed++;
1971
- }
1972
- if (fixed) {
1973
- update((s) => { s.assetsTick++; });
1974
- toast(T('assetCategoriesFixed').replace('{n}', String(fixed)));
1975
- }
1976
- } catch { /* 静默:失败不影响主流程,下次启动再试 */ }
1977
- }
1978
- /** 数据根目录就绪后自动把浏览器 IndexedDB 里的存量资产搬到磁盘(替代原"迁移历史资产"按钮,全自动无需手动触发)。 */
1975
+ /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬进服务端(CAS + 资产行),搬完即从本地退役。
1976
+ * 0.17 旧版本的"落盘到项目文件夹 + assets.json 索引"由 Host 侧 v1→v2 迁移负责,这里只处理从未落盘的遗留。 */
1979
1977
  async function autoMigrateAssetsToRoot() {
1980
1978
  if (!S.dataRoot) return;
1981
1979
  try {
1982
1980
  const all = await idbAll();
1983
- const pending = all.some((r) => r && r.blob && !r.rel);
1984
- if (!pending) return;
1985
- const r = await migrateAssetsToDisk();
1986
- if (r.migrated) {
1987
- toast(T('dataRootMigrated').replace('{n}', String(r.migrated)));
1981
+ const pending = all.filter((r) => r && r.blob && !r.rel && !r.sha);
1982
+ if (!pending.length) return;
1983
+ let done = 0;
1984
+ for (const rec of pending) {
1985
+ try {
1986
+ const r = await fetch('/' + NS + '/api/object', { method: 'POST', body: rec.blob });
1987
+ if (!r.ok) continue;
1988
+ const j = await r.json();
1989
+ const meta = { ...rec, sha: j.sha, size: rec.size || rec.blob.size };
1990
+ delete meta.blob;
1991
+ meta.stored = 'cas';
1992
+ const proj = S.projects.find((p) => p.id === rec.projectId);
1993
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
1994
+ const out = (saved && saved.asset) || meta;
1995
+ assetMem.set(out.id, out);
1996
+ try { await idbDelete(rec.id); } catch { /* 本地清理失败不阻塞迁移 */ }
1997
+ done++;
1998
+ } catch { /* 单个失败继续下一个 */ }
1999
+ }
2000
+ if (done) {
2001
+ toast(T('dataRootMigrated').replace('{n}', String(done)));
1988
2002
  update((s) => { s.assetsTick++; });
1989
2003
  }
1990
2004
  } catch { /* 静默:迁移失败不影响主流程,下次设置数据根目录时再试 */ }
@@ -2142,6 +2156,8 @@ window.__ModuleLoader__.load({
2142
2156
  const refs = refBlobs || [];
2143
2157
  const auth = s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {};
2144
2158
  const extra = parseExtraParams(s.extra); // 厂商专属参数(留空不发送)
2159
+ // 优云智算(compshare)任务式:Qwen-Image 异步任务(提交 → 轮询 → output_url 下载)
2160
+ if (s.mode === 'compshare') return await compshareImageTask(prompt, refs, s, opts);
2145
2161
  const upstreamTimeout = Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000; // 商汤等同步接口 20s+,默认 120s
2146
2162
  // 参考图编辑模式:multipart 上传到 /images/edits(OpenAI gpt-image-1 兼容)
2147
2163
  if (refs.length && s.mode === 'edits') {
@@ -2207,6 +2223,106 @@ window.__ModuleLoader__.load({
2207
2223
  throw new Error('图片 API 响应中没有 data[0].b64_json / url');
2208
2224
  }
2209
2225
 
2226
+ // —— 优云智算(compshare)Qwen-Image 任务式图片 API ——
2227
+ // 提交 POST /image/v1/tasks(Idempotency-Key 必填,UUID)→ 3s 轮询 GET /image/v1/tasks/{id}
2228
+ // → succeeded 取 task.output_url 下载;failed/cancelled/blocked 抛出明确原因。
2229
+ // 参考图走三步上传(upload_urls → PUT 签名直传 → assets 登记)换 asset_id;
2230
+ // 登记结果缓存在画布资产元数据(compAssetIdCache):同一张 CAS 资产不重复上传。
2231
+ const compAssetIdCache = new Map(); // 画布资产 id → 优云 asset_id
2232
+ /** 参考图上传前置:统一 MIME/扩展名并测真实像素尺寸(平台校验上传宽高必须与图片一致)。 */
2233
+ async function imageBlobMeta(input) {
2234
+ const blob = input.blob || input;
2235
+ const type = (blob.type || 'image/png').toLowerCase();
2236
+ const mime = /jpeg|jpg/.test(type) ? 'image/jpeg' : /webp/.test(type) ? 'image/webp' : 'image/png';
2237
+ const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/webp' ? 'webp' : 'png';
2238
+ let width = 0;
2239
+ let height = 0;
2240
+ try {
2241
+ const bmp = await createImageBitmap(blob);
2242
+ width = bmp.width;
2243
+ height = bmp.height;
2244
+ try { bmp.close && bmp.close(); } catch { /* 忽略 */ }
2245
+ } catch { /* 测量失败交由下方显式报错 */ }
2246
+ if (!width || !height) throw new Error('参考图尺寸测量失败(仅支持 PNG/JPG/WebP)');
2247
+ return { blob, mime, ext, width, height };
2248
+ }
2249
+ async function compshareImageTask(prompt, refs, s, opts) {
2250
+ const o = opts || {};
2251
+ let base = String(s.baseUrl || '').trim().replace(/\/+$/, '');
2252
+ base = base.replace(/\/image\/v1$/i, ''); // 粘贴了完整路径前缀也兼容
2253
+ if (!base) throw new Error('未配置图片 API 地址(优云智算:https://cp.compshare.cn)');
2254
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2255
+ const extra = parseExtraParams(s.extra); // 透传:optimize_prompt / use_context_ir 等任务级字段
2256
+ const api = async (method, path, body, idem) => {
2257
+ const h = { ...headers };
2258
+ if (idem) h['Idempotency-Key'] = idem;
2259
+ const r = await fetchViaProxy(base + path, { method, headers: h, ...(body !== undefined ? { body } : {}), timeoutMs: 120000, label: '优云图片 ' + method });
2260
+ if (!r.ok) throw new Error('优云图片 API HTTP ' + r.status + ':' + r.text.slice(0, 300));
2261
+ let j = null; try { j = JSON.parse(r.text); } catch { throw new Error('优云图片 API 响应非 JSON:' + r.text.slice(0, 200)); }
2262
+ // 网关业务错误可能 HTTP 200:RetCode≠0 或 {type:'error'} 都算失败
2263
+ if ((j.RetCode != null && Number(j.RetCode) !== 0) || j.type === 'error') {
2264
+ throw new Error('优云图片 API 失败:' + ((j.error && j.error.message) || j.Message || JSON.stringify(j).slice(0, 200)));
2265
+ }
2266
+ return j;
2267
+ };
2268
+ // 参考图:三步上传换 asset_id(≤10 张,顺序生效;已登记过的资产直接复用)
2269
+ const referenceImages = [];
2270
+ for (const ref of (refs || []).slice(0, 10)) {
2271
+ const cached = ref.assetId ? compAssetIdCache.get(ref.assetId) : null;
2272
+ if (cached) { referenceImages.push({ asset_id: cached }); continue; }
2273
+ const meta = await imageBlobMeta(ref);
2274
+ const fileName = 'ref-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) + '.' + meta.ext;
2275
+ const up = await api('POST', '/image/v1/upload_urls', { file_name: fileName, content_type: meta.mime, file_size: meta.blob.size });
2276
+ const put = await fetchViaProxy(up.upload_url, {
2277
+ method: 'PUT',
2278
+ headers: { 'Content-Type': meta.mime, Authorization: up.authorization, bucket: up.bucket, ufile_indicated_region: up.region },
2279
+ bodyRaw: arrayBufferToBase64(await meta.blob.arrayBuffer()),
2280
+ bodyContentType: meta.mime,
2281
+ timeoutMs: 180000,
2282
+ label: '参考图上传',
2283
+ });
2284
+ if (!put.ok) throw new Error('参考图上传失败 HTTP ' + put.status + ':' + put.text.slice(0, 200));
2285
+ const reg = await api('POST', '/image/v1/assets', { object_key: up.object_key, file_name: fileName, content_type: meta.mime, file_size: meta.blob.size, width: meta.width, height: meta.height });
2286
+ const aid = reg && reg.reference && reg.reference.asset_id;
2287
+ if (!aid) throw new Error('参考图登记失败:响应中没有 reference.asset_id');
2288
+ if (ref.assetId) compAssetIdCache.set(ref.assetId, aid);
2289
+ referenceImages.push({ asset_id: aid });
2290
+ }
2291
+ // 任务体:用户可变项透传(比例/清晰度原样发送,厂商不支持会明确报错);文本任务不带模型字段(端点即 Qwen-Image)
2292
+ const body = {
2293
+ prompt,
2294
+ aspect_ratio: String(o.ratio || '1:1'),
2295
+ resolution: String(o.size || '1K'),
2296
+ ...(referenceImages.length ? { reference_images: referenceImages } : {}),
2297
+ ...(extra || {}),
2298
+ };
2299
+ const idem = (globalThis.crypto && crypto.randomUUID) ? crypto.randomUUID() : ('idem-' + Date.now().toString(36) + Math.random().toString(36).slice(2));
2300
+ const j1 = await api('POST', '/image/v1/tasks', body, idem);
2301
+ const tid = j1 && j1.task_id;
2302
+ if (!tid) throw new Error('优云图片任务创建失败:响应中没有 task_id');
2303
+ if (o.onTask) { try { o.onTask(tid, base + '/image/v1/tasks/' + encodeURIComponent(tid)); } catch { /* 忽略 */ } }
2304
+ const FINAL = { failed: 1, cancelled: 1, blocked: 1 };
2305
+ const deadline = Date.now() + 20 * 60 * 1000;
2306
+ for (;;) {
2307
+ await sleep(3000); // 官方推荐 3s 轮询
2308
+ if (genAborted()) {
2309
+ try { await api('POST', '/image/v1/tasks/' + encodeURIComponent(tid) + '/cancel', {}); } catch { /* 取消失败不影响中断(积分自动释放) */ }
2310
+ throw new Error(T('stoppedNote'));
2311
+ }
2312
+ const q = await api('GET', '/image/v1/tasks/' + encodeURIComponent(tid)).catch((e) => { console.warn('[infinite-canvas] 优云图片轮询失败(继续):', String((e && e.message) || e)); return null; });
2313
+ if (!q || !q.task) continue;
2314
+ const st = String(q.task.status || '');
2315
+ if (st === 'succeeded') {
2316
+ if (!q.task.output_url) throw new Error('优云图片任务成功但没有 output_url');
2317
+ const dl = await fetchViaProxy(q.task.output_url, { method: 'GET', timeoutMs: 180000, label: '下载生成图片' });
2318
+ if (!dl.ok) throw new Error('下载生成图片失败 HTTP ' + dl.status);
2319
+ return dl.blob;
2320
+ }
2321
+ if (FINAL[st]) throw new Error('优云图片任务' + (st === 'blocked' ? '被审核拦截' : st) + ':' + (q.task.error_message || q.task.error_code || st));
2322
+ if (Date.now() > deadline) throw new Error('优云图片任务超过 20 分钟未完成(任务 id ' + tid + ',可凭 id 继续回查)');
2323
+ }
2324
+ }
2325
+
2210
2326
  /** 带超时的 fetch:网关挂住时不会让节点永久处于 running(停止/刷新仍能恢复)。 */
2211
2327
  async function fetchT(url, init, timeoutMs, label) {
2212
2328
  const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : 120000;
@@ -2431,7 +2547,8 @@ window.__ModuleLoader__.load({
2431
2547
  seedance: { label: '火山方舟 · Seedance(豆包视频)', base: 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', keyPh: 'ARK API Key', note: 'Base URL 填方舟提交端点或 /api/v3 均可;模型 id 如 doubao-seedance-2-0-260128' },
2432
2548
  kling: { label: '快手可灵 Kling', base: 'https://api.klingai.com', keyPh: 'AK:SK(冒号分隔)', note: 'Key 填 AK:SK,代码自动签 JWT;国际站把 Base URL 改为 api-singapore.klingai.com;时长按官方 5/10s 取整' },
2433
2549
  wan: { label: '阿里通义万相 Wan(DashScope)', base: 'https://dashscope.aliyuncs.com', note: '异步任务制;国际站把 Base URL 改为 dashscope-intl.aliyuncs.com;图生视频走 img_url' },
2434
- minimax: { label: 'MiniMax 海螺视频', base: 'https://api.minimaxi.com', note: '异步任务制;时长按官方 6/10s 取整;首帧图支持 base64' },
2550
+ minimax: { label: 'MiniMax 海螺视频(V1 协议)', base: 'https://api.minimaxi.com', note: '异步任务制;时长按官方 6/10s 取整;首帧图支持 base64' },
2551
+ minimaxv2: { label: 'MiniMax H3 · V2 协议(优云智算 / 官方)', base: 'https://cp.compshare.cn', keyPh: 'sk-ml-…', note: 'V2 协议自动适配:优云智算走 /minimax/v2,官方(api.minimax.io/.cn)走 /v2;content[] 多模态(首帧/尾帧/参考图≤9/视频≤3/音频≤3),原生音频,分辨率 480P/768P/1080P/2K/4K(大小写敏感),时长 4-30s;use_context_ir / mute_audio 等走扩展参数' },
2435
2552
  vidu: { label: '生数科技 Vidu', base: 'https://api.vidu.com', note: '图片支持 base64;时长按官方 4/8s 取整' },
2436
2553
  runway: { label: 'Runway Gen-4', base: 'https://api.dev.runwayml.com', note: '代码自动带 X-Runway-Version 头;时长 5/10s;首帧图支持 dataURL' },
2437
2554
  veo: { label: 'Google Veo(Gemini API)', base: 'https://generativelanguage.googleapis.com', keyPh: 'API Key', note: '模型 id 如 veo-3.0-fast-generate-preview;图生视频走 inlineData base64' },
@@ -2513,6 +2630,7 @@ window.__ModuleLoader__.load({
2513
2630
  const model = String(s.model || '').toLowerCase();
2514
2631
  // 模型名优先:第三方网关(relayrouter 等)的 URL 看不出真实厂商,只有模型名能说明
2515
2632
  if (/wanx|万相|(^|[^a-z])wan(\d|[-_]\d|x)/.test(model)) return 'wan';
2633
+ if (/minimax-?h3|hailuo-?03/.test(model)) return 'minimaxv2'; // H3 走 V2 协议(必须先于 V1 minimax 判定)
2516
2634
  if (/(^|[^a-z])kling/.test(model)) return 'kling';
2517
2635
  if (/minimax|hailuo/.test(model)) return 'minimax';
2518
2636
  if (/vidu/.test(model)) return 'vidu';
@@ -2696,6 +2814,62 @@ window.__ModuleLoader__.load({
2696
2814
  });
2697
2815
  return await downloadVideo(url, {});
2698
2816
  },
2817
+ // —— MiniMax H3 · V2 协议(优云智算 /minimax/v2,官方 api.minimax.io|api.minimaxi.com /v2):
2818
+ // content[] 多模态自动路由(文生/首尾帧/参考素材),原生音频,分辨率大小写敏感 ——
2819
+ async minimaxv2(prompt, frameBlob, s, o) {
2820
+ const base = vendorBase(s.url, VIDEO_VENDORS.minimaxv2.base);
2821
+ const ex = (() => { try { return parseExtraParams(s.extra) || {}; } catch (e) { throw new Error('扩展参数配置有误:' + ((e && e.message) || e)); } })();
2822
+ // 路径风格:优云智算网关挂 /minimax/v2/...,MiniMax 官方裸 /v2/...;扩展参数 pathPrefix 可显式覆盖
2823
+ const prefix = String(ex.pathPrefix || (/compshare/i.test(base) ? '/minimax' : ''));
2824
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2825
+ // 分辨率大小写敏感(480P/768P/1080P/2K/4K):画布的 720P 语义等同原生 768P,显式映射不依赖厂商静默降级
2826
+ const RESO = { '480P': '480P', '720P': '768P', '768P': '768P', '1080P': '1080P', '2K': '2K', '4K': '4K' };
2827
+ const resolution = String(ex.resolution || RESO[String(o.resolution || '').toUpperCase()] || '768P');
2828
+ // content[] 组装:H3 规则——首尾帧与参考素材不可混用;参考音频不能独立使用(必须伴随图/视频)
2829
+ const refs = o.hardFrame ? [] : (o.refs || []).slice(0, 9);
2830
+ const auds = (o.audioRefs || []).slice(0, 3);
2831
+ const refMode = refs.length > 0 || auds.length > 0; // 有参考素材(含音频)→ 全部走参考模式
2832
+ const content = [{ type: 'text', text: prompt }];
2833
+ if (frameBlob && !refMode) {
2834
+ content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2835
+ } else {
2836
+ if (frameBlob) content[0].text = videoRefNote(o.refNames) + '\n' + prompt; // 首帧降级为首张参考图,衔接标注前置
2837
+ if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'reference_image' });
2838
+ for (const b of refs.slice(0, refMode && frameBlob ? 8 : 9)) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
2839
+ for (const b of auds) content.push({ type: 'audio_url', audio_url: { url: await blobToDataUrl(b) }, role: 'reference_audio' }); // 音色对齐(原生音画联合)
2840
+ }
2841
+ const body = {
2842
+ model: s.model || 'MiniMax-H3',
2843
+ content,
2844
+ resolution,
2845
+ duration: Math.round(Number(o.duration)) || 5, // 官方 4-30s 整数(优云智算扩展上限 30)
2846
+ ratio: (frameBlob && !refMode) ? 'adaptive' : (o.ratio || '16:9'), // 纯首帧模式比例跟随素材;其余用节点比例
2847
+ aigc_watermark: false, // 稳定默认:不加 AIGC 水印
2848
+ };
2849
+ // 扩展参数透传:use_context_ir / skill_id / mute_audio / pathPrefix / resolution 覆盖等
2850
+ for (const k of ['use_context_ir', 'skill_id', 'mute_audio', 'pathPrefix', 'resolution', 'callback_url', 'callback_token']) { if (ex[k] !== undefined) body[k] = ex[k]; }
2851
+ const r1 = await fetchViaProxy(base + prefix + '/v2/video_generation', { headers, body, label: 'H3 提交' });
2852
+ if (!r1.ok) throw new Error('H3 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2853
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('H3 响应非 JSON:' + r1.text.slice(0, 200)); }
2854
+ const tid = j1 && (j1.task_id || (j1.data && j1.data.task_id));
2855
+ if (!tid) throw new Error('H3 响应中没有 task_id:' + r1.text.slice(0, 200));
2856
+ if (o.onTask) { try { o.onTask(tid, base + prefix + '/v2/query/video_generation/' + encodeURIComponent(tid)); } catch { /* 持久化失败不影响生成 */ } }
2857
+ const url = await vendorPoll('H3', 8000, async () => {
2858
+ const r = await fetchViaProxy(base + prefix + '/v2/query/video_generation/' + encodeURIComponent(tid), { method: 'GET', headers, timeoutMs: 60000, label: 'H3 查询' });
2859
+ if (!r || !r.ok) return { state: 'wait' };
2860
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2861
+ const t = (j && j.task) || j;
2862
+ const st = String((t && t.status) || '').toLowerCase();
2863
+ if (st === 'succeeded') {
2864
+ const u = t && t.content && t.content.url;
2865
+ return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但响应中没有 content.url' };
2866
+ }
2867
+ if (st === 'failed') return { state: 'fail', error: (t && t.error && (t.error.message || t.error.code)) || '失败' };
2868
+ if (st === 'cancelled' || st === 'expired') return { state: 'fail', error: '任务' + st };
2869
+ return { state: 'wait' };
2870
+ });
2871
+ return await downloadVideo(url, {});
2872
+ },
2699
2873
  // —— 生数 Vidu:img2video/text2video,图片 base64 直传 ——
2700
2874
  async vidu(prompt, frameBlob, s, o) {
2701
2875
  const base = vendorBase(s.url, VIDEO_VENDORS.vidu.base);
@@ -3664,9 +3838,9 @@ window.__ModuleLoader__.load({
3664
3838
  async function refreshDataRoot() {
3665
3839
  try {
3666
3840
  const r = await hostRpc('dataRootGet');
3667
- update((s) => { s.dataRoot = r.dataRoot || null; s.dataRootExists = !!r.exists; s.dataRootSupported = true; });
3841
+ update((s) => { s.dataRoot = r.dataRoot || null; s.dataRootExists = !!r.exists; s.dataRootSupported = true; s.store = r.store || (r.sql ? 'sqlite' : (r.dataRoot ? 'json' : null)); });
3668
3842
  } catch (e) {
3669
- update((s) => { s.dataRoot = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
3843
+ update((s) => { s.dataRoot = null; s.store = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
3670
3844
  }
3671
3845
  }
3672
3846
  // ---------- 对话 ↔ 画布同步:轮询 Host 项目索引 + 跳转请求 ----------
@@ -4393,10 +4567,20 @@ window.__ModuleLoader__.load({
4393
4567
  source: (meta && meta.source) || 'ai',
4394
4568
  category: (meta && meta.category) || 'script',
4395
4569
  };
4396
- // 文本资产同样落盘到数据根目录(<root>/projects/<项目>/text/<名>.md),
4397
- // IndexedDB 保留元数据与 text 字段(文本很小,预览/下载不必回读文件)。
4398
- // 落盘失败降级为仅 IndexedDB(绝不阻塞生成主流程),控制台留痕。
4399
- if (S.dataRoot) {
4570
+ // 文本资产:0.18 起正文随元数据进 canvas.db(预览/下载不必回读文件);
4571
+ // JSON 降级落盘 .md 文件;无数据目录降级为仅 IndexedDB。绝不阻塞生成主流程。
4572
+ if (S.dataRoot && S.store === 'sqlite') {
4573
+ try {
4574
+ const proj = S.projects.find((p) => p.id === projectId);
4575
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
4576
+ const out = (saved && saved.asset) || rec;
4577
+ assetMem.set(out.id, out);
4578
+ assetNameCache.set(out.id, out.name);
4579
+ return out;
4580
+ } catch (err) {
4581
+ console.warn('[infinite-canvas] 文本资产入库失败(降级为浏览器存储):', String((err && err.message) || err));
4582
+ }
4583
+ } else if (S.dataRoot) {
4400
4584
  try {
4401
4585
  const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
4402
4586
  const blob = new Blob([rec.text], { type: 'text/markdown' });
@@ -4411,7 +4595,7 @@ window.__ModuleLoader__.load({
4411
4595
  }
4412
4596
  await idbPut(rec);
4413
4597
  assetNameCache.set(rec.id, rec.name);
4414
- mirrorAssetIndex(rec); // 文本资产元数据同样镜像进 assets.json
4598
+ if (S.dataRoot) mirrorAssetIndex(rec); // JSON 降级模式:镜像进 assets.json
4415
4599
  return rec;
4416
4600
  }
4417
4601
 
@@ -4433,6 +4617,7 @@ window.__ModuleLoader__.load({
4433
4617
  tick: 0,
4434
4618
  craftStatus: null, // {at, data} | {at, error}
4435
4619
  dataRoot: null, // 数据根目录(Host 配置);null = 未设置(资产走 IndexedDB)
4620
+ store: null, // 服务端存储模式:'sqlite'(0.18 主路径)| 'json'(降级)| null(未就绪/未设置)
4436
4621
  dataRootExists: false,
4437
4622
  dataRootSupported: true,
4438
4623
  updateInfo: null, // Host updateCheck 结果
@@ -5172,8 +5357,8 @@ window.__ModuleLoader__.load({
5172
5357
  if (type === 'image-gen') {
5173
5358
  // 参考图 = 连线(用户顺序)+ @引用命中的设定集资产(按出现顺序)
5174
5359
  const { refs, entities } = await collectRefs(n, prompt, bible);
5175
- const metas = refs.map((r) => ({ blob: r.blob, name: r.name, category: r.category }));
5176
- const p = buildEffectivePrompt(prompt, refs, entities, bible);
5360
+ const metas = refs.map((r) => ({ blob: r.blob, name: r.name, category: r.category, assetId: r.assetId }));
5361
+ const p = buildEffectivePrompt(prompt, refs, entities, bible, true); // light:图片不内联设定全文(防文字渲染污染 + 参考图约束稀释)
5177
5362
  // 模型解析:节点选择优先,未指定路由到星标默认模型;都没有 = 明确报错(不再静默走演示模式)
5178
5363
  const mcfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
5179
5364
  if (!mcfg) throw new Error(T('errNoImageModel'));
@@ -6065,6 +6250,18 @@ window.__ModuleLoader__.load({
6065
6250
  const models = kind === 'image' ? flattenImageModels(settings) : flattenVideoModels(settings);
6066
6251
  const star = kind === 'image' ? settings.imageStar : settings.videoStar;
6067
6252
  const cur = kind === 'image' ? nodeImageModelRef(n) : nodeVideoModelRef(n);
6253
+ // 当前选中模型的厂商:H3(V2 协议)分辨率档位扩展为 480P/768P/1080P/2K/4K(大小写敏感)
6254
+ const videoModelName = (() => {
6255
+ if (kind !== 'video') return '';
6256
+ const [pid, mid] = String(cur || '').split('|');
6257
+ const p = (settings.videoProviders || []).find((x) => x.id === pid);
6258
+ const m = p && (p.models || []).find((x) => x.id === mid);
6259
+ return (m && m.model) || '';
6260
+ })();
6261
+ const isH3 = kind === 'video' && /minimax-?h3|hailuo-?03/i.test(videoModelName);
6262
+ const resos = (kind === 'video' && isH3)
6263
+ ? (['480P', '768P', '1080P', '2K', '4K'].includes(String(n.data.resolution)) ? ['480P', '768P', '1080P', '2K', '4K'] : ['480P', '768P', '1080P', '2K', '4K'].concat([String(n.data.resolution || '')]))
6264
+ : (VIDEO_RESOLUTIONS.includes(String(n.data.resolution)) ? VIDEO_RESOLUTIONS : VIDEO_RESOLUTIONS.concat([String(n.data.resolution || '')]));
6068
6265
  const setModel = (v) => update((s) => {
6069
6266
  const nd = s.graph.nodes[n.id];
6070
6267
  if (!nd) return;
@@ -6108,7 +6305,7 @@ window.__ModuleLoader__.load({
6108
6305
  }),
6109
6306
  h(MiniSelect, {
6110
6307
  key: 'res', value: n.data.resolution || '720P', title: T('resolutionLabel'), width: 68,
6111
- options: VIDEO_RESOLUTIONS.map((x) => ({ value: x, label: x })),
6308
+ options: resos.map((x) => ({ value: x, label: x })),
6112
6309
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.resolution = v; }),
6113
6310
  }),
6114
6311
  ],
@@ -7607,15 +7804,17 @@ window.__ModuleLoader__.load({
7607
7804
  lsSet(LS_PROJECTS, S.projects);
7608
7805
  lsSet(LS_ACTIVE, S.activeId);
7609
7806
  try { hostRpc('projectsDelete', { id: pid }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
7610
- try {
7611
- const recs = await idbByProject(pid);
7612
- for (const r of recs) {
7613
- try {
7614
- if (r.rel) { try { await hostRpc('assetDelete', { rel: r.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
7615
- await idbDelete(r.id);
7616
- } catch { /* 逐个容错 */ }
7617
- }
7618
- } catch { /* 资产清理失败不影响项目删除 */ }
7807
+ if (!S.store) {
7808
+ // IndexedDB 模式(未设数据目录):本地逐个清理
7809
+ try {
7810
+ const recs = await idbByProjectRaw(pid);
7811
+ for (const r of recs) {
7812
+ try { await idbDelete(r.id); } catch { /* 逐个容错 */ }
7813
+ }
7814
+ } catch { /* 资产清理失败不影响项目删除 */ }
7815
+ } else {
7816
+ assetMem.clear(); // 服务端随项目删除资产行;清本地元数据缓存
7817
+ }
7619
7818
  setDelTarget(null);
7620
7819
  };
7621
7820
  return [
@@ -7728,23 +7927,53 @@ window.__ModuleLoader__.load({
7728
7927
  if (S.activeId && S.activeId !== prevActiveRef.current) { prevActiveRef.current = S.activeId; setScope(S.activeId); }
7729
7928
  }, [S.activeId]);
7730
7929
  const [search, setSearch] = useState('');
7930
+ const [qDeb, setQDeb] = useState('');
7931
+ const [total, setTotal] = useState(0);
7932
+ const [cursor, setCursor] = useState(null);
7731
7933
  const [craftFor, setCraftFor] = useState(null);
7732
7934
  const [delTarget, setDelTarget] = useState(null); // 待确认删除的资产(确认框见 DeleteAssetModal)
7733
7935
  const [assetName, setAssetName] = useState(''); // 删除确认框的输入内容
7734
7936
  const fileRef = useRef(null);
7937
+ // 搜索防抖:0.18 起检索在服务端(SQLite trigram 中文全文索引)
7938
+ useEffect(() => {
7939
+ const t = setTimeout(() => setQDeb(search), 300);
7940
+ return () => clearTimeout(t);
7941
+ }, [search]);
7735
7942
  useEffect(() => {
7736
7943
  probeCraft();
7737
7944
  let ok = true;
7738
7945
  (async () => {
7739
7946
  try {
7740
- let list;
7741
- if (scope === 'all') list = await idbAll();
7742
- else list = (await idbAll()).filter((a) => a.projectId === scope || a.projectId === GENERAL_PID);
7743
- if (ok) setAssets(list);
7947
+ if (!S.store) {
7948
+ // IndexedDB 模式(未设数据目录):本地全量
7949
+ let list;
7950
+ if (scope === 'all') list = await idbAll();
7951
+ else list = (await idbAll()).filter((a) => a.projectId === scope || a.projectId === GENERAL_PID);
7952
+ if (ok) { setAssets(list); setTotal(list.length); setCursor(null); }
7953
+ return;
7954
+ }
7955
+ // 服务端分页:首屏 50 条,滚动到底「加载更多」
7956
+ const r = await hostRpc('assetsList', {
7957
+ projectId: scope === 'all' ? '' : scope,
7958
+ category: filter === 'all' ? '' : filter,
7959
+ q: qDeb.trim(), limit: 50, cursor: null,
7960
+ }).catch(() => null);
7961
+ if (ok && r) { setAssets(r.items || []); setTotal(r.total || 0); setCursor(r.nextCursor || null); }
7744
7962
  } catch { if (ok) setAssets([]); }
7745
7963
  })();
7746
7964
  return () => { ok = false; };
7747
- }, [S.activeId, tick, S.assetsTick, scope]);
7965
+ }, [S.activeId, tick, S.assetsTick, scope, filter, qDeb, S.store]);
7966
+ const loadMore = async () => {
7967
+ if (!cursor) return;
7968
+ const r = await hostRpc('assetsList', {
7969
+ projectId: scope === 'all' ? '' : scope,
7970
+ category: filter === 'all' ? '' : filter,
7971
+ q: qDeb.trim(), limit: 50, cursor,
7972
+ }).catch(() => null);
7973
+ if (!r) return;
7974
+ setAssets((prev) => prev.concat(r.items || []));
7975
+ setCursor(r.nextCursor || null);
7976
+ };
7748
7977
  const projectName = (pid) => {
7749
7978
  if (pid === GENERAL_PID) return T('generalProject');
7750
7979
  const p = S.projects.find((x) => x.id === pid);
@@ -7753,10 +7982,17 @@ window.__ModuleLoader__.load({
7753
7982
  /** 资产改所属项目(通用/其它项目):通用资产多项目共用。 */
7754
7983
  const moveAsset = async (a, targetPid) => {
7755
7984
  if (!targetPid || targetPid === (a.projectId || '')) return;
7756
- const rec = await idbGet(a.id);
7757
- if (!rec) return;
7758
- rec.projectId = targetPid;
7759
- await idbPut(rec);
7985
+ if (S.store) {
7986
+ try {
7987
+ const r = await hostRpc('assetUpdate', { id: a.id, patch: { projectId: targetPid } });
7988
+ if (r && r.asset) assetMem.set(r.asset.id, r.asset);
7989
+ } catch { /* 服务端失败不动本地 */ }
7990
+ } else {
7991
+ const rec = await idbGet(a.id);
7992
+ if (!rec) return;
7993
+ rec.projectId = targetPid;
7994
+ await idbPut(rec);
7995
+ }
7760
7996
  update((s) => { s.assetsTick++; });
7761
7997
  setTick((x) => x + 1);
7762
7998
  toast(T('moved'));
@@ -7779,7 +8015,8 @@ window.__ModuleLoader__.load({
7779
8015
  };
7780
8016
  const changeCategory = (a, category) => {
7781
8017
  a.category = category;
7782
- idbPut(a).catch(() => {});
8018
+ if (S.store) hostRpc('assetUpdate', { id: a.id, patch: { category } }).catch(() => {});
8019
+ else idbPut(a).catch(() => {});
7783
8020
  update((s) => { s.assetsTick++; });
7784
8021
  };
7785
8022
  const onImport = async (e) => {
@@ -7797,12 +8034,13 @@ window.__ModuleLoader__.load({
7797
8034
  if (files.length) setTick((x) => x + 1);
7798
8035
  };
7799
8036
  const del = async (a) => {
7800
- // 数据目录同步:删文件(逻辑路径由 Host 映射到项目名文件夹)+ 清 assets.json 索引
8037
+ // 服务端删除(SQL:行 + FTS + CAS 引用计数;JSON:文件 + assets.json),随后清本地缓存
7801
8038
  const proj = S.projects.find((p) => p.id === (a.projectId || S.activeId));
7802
8039
  try {
7803
8040
  await hostRpc('assetDelete', { projectId: a.projectId || S.activeId, projectName: (proj && proj.name) || '', id: a.id, rel: a.rel || '' });
7804
8041
  } catch { /* 磁盘/索引清理失败不阻塞 */ }
7805
- await idbDelete(a.id);
8042
+ try { await idbDelete(a.id); } catch { /* 本地无记录则忽略 */ }
8043
+ assetMem.delete(a.id);
7806
8044
  update((s) => {
7807
8045
  for (const nd of Object.values(s.graph.nodes)) {
7808
8046
  if (nd.data && nd.data.assetId === a.id) { nd.data.assetId = null; nd.data.status = 'idle'; }
@@ -7811,12 +8049,19 @@ window.__ModuleLoader__.load({
7811
8049
  });
7812
8050
  setTick((x) => x + 1);
7813
8051
  };
7814
- // 资产重命名:写回 IndexedDB 记录并刷新名称缓存(@引用与连线名称都吃这份缓存)
8052
+ // 资产重命名:服务端写回(SQL/JSON 各自落库)+ 刷新名称缓存(@引用与连线名称都吃这份缓存)
7815
8053
  const rename = async (a, name) => {
7816
- const rec = await idbGet(a.id);
7817
- if (!rec) return;
7818
- rec.name = name;
7819
- await idbPut(rec);
8054
+ if (S.store) {
8055
+ try {
8056
+ const r = await hostRpc('assetUpdate', { id: a.id, patch: { name } });
8057
+ if (r && r.asset) assetMem.set(r.asset.id, r.asset);
8058
+ } catch { /* 服务端失败时仍更新本地缓存保持 UI 一致 */ }
8059
+ } else {
8060
+ const rec = await idbGet(a.id);
8061
+ if (!rec) return;
8062
+ rec.name = name;
8063
+ await idbPut(rec);
8064
+ }
7820
8065
  assetNameCache.set(a.id, name);
7821
8066
  update((s) => { s.assetsTick++; });
7822
8067
  setTick((x) => x + 1);
@@ -7876,6 +8121,10 @@ window.__ModuleLoader__.load({
7876
8121
  projectName: projectName(a.projectId),
7877
8122
  })),
7878
8123
  ),
8124
+ cursor
8125
+ ? h('button', { key: 'more', type: 'button', className: 'dshic-mini', style: { alignSelf: 'flex-start' }, onClick: loadMore, onPointerDown: (e) => e.stopPropagation() },
8126
+ '加载更多 / Load more(' + assets.length + ' / ' + total + ')')
8127
+ : null,
7879
8128
  delTarget ? h(DeleteAssetModal, {
7880
8129
  key: 'delasset', target: delTarget, name: assetName, setName: setAssetName,
7881
8130
  onCancel: () => { setDelTarget(null); setAssetName(''); },
@@ -8220,7 +8469,8 @@ window.__ModuleLoader__.load({
8220
8469
  h('span', { className: 'dshic-field-label' }, T('imgMode')),
8221
8470
  h('select', { className: 'dshic-select', value: p.mode || 'generations', onChange: (e) => patchProvider(p.id, 'mode', e.target.value), onPointerDown: (e) => e.stopPropagation() },
8222
8471
  h('option', { value: 'generations' }, T('modeGenerations')),
8223
- h('option', { value: 'edits' }, T('modeEdits')))),
8472
+ h('option', { value: 'edits' }, T('modeEdits')),
8473
+ h('option', { value: 'compshare' }, T('modeCompshare')))),
8224
8474
  ] : [
8225
8475
  (detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? h(Field, { key: 'of', label: T('vidOutFormat'), value: p.outputFormat || '', onChange: (v) => patchProvider(p.id, 'outputFormat', v), placeholder: 'mp4(留空不发送)' }) : null,
8226
8476
  (detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? h('label', { key: 'ga', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
@@ -8593,9 +8843,10 @@ window.__ModuleLoader__.load({
8593
8843
  await hostRpc('dataRootSet', { root: val.trim() });
8594
8844
  await refreshDataRoot();
8595
8845
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
8596
- if (same) { setBusy(false); return; } // 同目录重复保存:不重建索引(避免误清数据目录里手工补充的资产记录)
8597
- assetIndexSynced = false; // 换了目录:索引要往新目录重建
8598
- autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost()); // 先搬存量资产,再对齐索引
8846
+ if (same) { setBusy(false); return; } // 同目录重复保存:不重复迁移
8847
+ autoMigrateAssetsToRoot(); // 换了目录:把本地遗留 blob 搬进新目录(v1 旧布局由 Host 侧自动迁移)
8848
+ assetMem.clear();
8849
+ update((s) => { s.assetsTick++; });
8599
8850
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
8600
8851
  setBusy(false);
8601
8852
  };
@@ -8605,9 +8856,10 @@ window.__ModuleLoader__.load({
8605
8856
  await refreshDataRoot();
8606
8857
  setVal(S.dataRoot || root);
8607
8858
  toast(T('dataRootSaved') + ':' + S.dataRoot);
8608
- if (same) return; // 同目录:不重建索引
8609
- assetIndexSynced = false; // 换了目录:索引要往新目录重建
8610
- autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost());
8859
+ if (same) return; // 同目录:不重复迁移
8860
+ autoMigrateAssetsToRoot(); // 换了目录:把本地遗留 blob 搬进新目录
8861
+ assetMem.clear();
8862
+ update((s) => { s.assetsTick++; });
8611
8863
  };
8612
8864
  const browse = async () => {
8613
8865
  if (busy || picking) return;
@@ -9361,8 +9613,8 @@ window.__ModuleLoader__.load({
9361
9613
  probeCraft();
9362
9614
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
9363
9615
  checkUpdate(true);
9364
- // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后自动搬存量资产到磁盘、纠正分类,再对齐索引
9365
- refreshDataRoot().then(() => autoMigrateAssetsToRoot()).then(() => fixGeneralCategories()).then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
9616
+ // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把本地遗留 blob 搬进服务端(v1 旧布局由 Host 侧自动迁移)
9617
+ refreshDataRoot().then(() => autoMigrateAssetsToRoot()).catch(() => { /* 静默 */ });
9366
9618
  // 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
9367
9619
  hostSyncTick().then(() => pushAllProjectsToHost());
9368
9620
  const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);