@mokuyoaxis/dsh-iris 0.3.0-alpha.1 → 0.3.0-beta.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.en.md +20 -13
  3. package/README.md +20 -13
  4. package/bin/dsh-iris.js +143 -51
  5. package/docs/AGENT_CORE_QUICKSTART.md +2 -2
  6. package/docs/AGENT_QUICKSTART.md +1 -1
  7. package/docs/ARCHITECTURE.md +5 -5
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +20 -4
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +1 -1
  12. package/docs/CORE_CONNECTIONS.md +3 -1
  13. package/docs/CORE_QUICKSTART.md +7 -5
  14. package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
  15. package/docs/CORE_SERVICE.md +9 -5
  16. package/docs/CORE_SKILLS.md +2 -2
  17. package/docs/DSH_COMPATIBILITY.md +11 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RUNTIME.md +2 -2
  20. package/docs/HEADLESS_CLI.md +29 -4
  21. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  22. package/docs/IMAGE_FLOW.md +2 -2
  23. package/docs/IMAGE_GENERATION.md +1 -1
  24. package/docs/LOCAL_API.md +2 -2
  25. package/docs/LOCAL_API_CONTRACT.md +17 -15
  26. package/docs/LOCAL_CREATION.md +2 -2
  27. package/docs/LOCAL_OPERATIONS.md +2 -2
  28. package/docs/LOCAL_SELECTION.md +1 -1
  29. package/docs/LOCAL_WEB.md +11 -5
  30. package/docs/MCP.md +30 -9
  31. package/docs/MEDIA_SCHEDULING.md +72 -0
  32. package/docs/NETWORK_RESOURCES.md +106 -0
  33. package/docs/NETWORK_ROUTING.md +103 -0
  34. package/docs/NETWORK_WORKBENCH.md +65 -0
  35. package/docs/PANEL_EXTENSIONS.md +1 -1
  36. package/docs/PLUGINS.md +9 -5
  37. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  38. package/docs/PLUGIN_STARTUP.md +15 -2
  39. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  40. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  41. package/docs/PROVIDER_EXTENSIONS.md +8 -4
  42. package/docs/PROVIDER_HEALTH.md +4 -2
  43. package/docs/PROVIDER_NETWORK.md +45 -0
  44. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  45. package/docs/PROVIDER_SETTINGS.md +13 -7
  46. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
  47. package/docs/ROADMAP.md +6 -6
  48. package/docs/SECURITY.md +1 -1
  49. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  50. package/docs/VIDEO_FLOWS.md +89 -0
  51. package/docs/VISION_MODEL.md +2 -0
  52. package/docs/WEB_PROTOTYPE.md +3 -3
  53. package/docs/WEB_RECOVERY.md +2 -2
  54. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  55. package/docs/WORKBENCH_LOGIN.md +2 -2
  56. package/docs/releases/0.3.0-beta.1.md +34 -0
  57. package/docs/screenshots.md +2 -2
  58. package/lib/actions.js +5 -4
  59. package/lib/adapters.js +58 -41
  60. package/lib/api.js +23 -13
  61. package/lib/application-runtime.js +44 -15
  62. package/lib/application-writer.js +193 -0
  63. package/lib/client.d.ts +3 -3
  64. package/lib/client.js +62 -25
  65. package/lib/comfyui-adapter.js +3 -2
  66. package/lib/comfyui-client.js +10 -1
  67. package/lib/command-service.js +18 -5
  68. package/lib/config.js +13 -9
  69. package/lib/configured-image-runtime.js +28 -9
  70. package/lib/connection-skill.js +9 -0
  71. package/lib/core-artifact-store.js +68 -26
  72. package/lib/core-bridge-tools.js +5 -3
  73. package/lib/core-capability-catalog.js +21 -11
  74. package/lib/core-delivery-cache.js +8 -3
  75. package/lib/core-service-client.js +6 -2
  76. package/lib/core-tasks.js +86 -11
  77. package/lib/creation-request-store.js +8 -5
  78. package/lib/doctor.js +2 -2
  79. package/lib/dsh-core-adapter.js +61 -9
  80. package/lib/dsh-provider-config.js +1 -1
  81. package/lib/dsh-service-bridge.js +1 -0
  82. package/lib/flow-store.js +64 -17
  83. package/lib/image-flow.js +39 -17
  84. package/lib/index.js +10 -5
  85. package/lib/local-api-projection.js +6 -2
  86. package/lib/local-api.js +137 -49
  87. package/lib/local-creation.js +12 -5
  88. package/lib/local-operations.js +2 -2
  89. package/lib/local-request-root.js +2 -2
  90. package/lib/local-web-assets.js +2 -0
  91. package/lib/mcp-http.js +82 -9
  92. package/lib/mcp-server.js +23 -1
  93. package/lib/media-scheduler.js +194 -0
  94. package/lib/model-directory-service.js +25 -8
  95. package/lib/music-transport.js +4 -2
  96. package/lib/network-config-service.js +123 -0
  97. package/lib/network-config.js +127 -0
  98. package/lib/network-doctor.js +94 -0
  99. package/lib/network-management.js +51 -0
  100. package/lib/network-policy.js +19 -0
  101. package/lib/network-route-evidence.js +38 -0
  102. package/lib/network-routing.js +283 -0
  103. package/lib/operation-runner.js +83 -38
  104. package/lib/operation-store.js +6 -6
  105. package/lib/plugins.d.ts +25 -6
  106. package/lib/plugins.js +2 -1
  107. package/lib/provider-adapter.js +2 -2
  108. package/lib/provider-adapters.js +37 -5
  109. package/lib/provider-catalog.js +33 -16
  110. package/lib/provider-config-format.js +51 -0
  111. package/lib/provider-config-service.js +52 -22
  112. package/lib/provider-context.d.ts +90 -0
  113. package/lib/provider-context.js +42 -0
  114. package/lib/provider-credentials.js +97 -0
  115. package/lib/provider-diagnostics.js +5 -5
  116. package/lib/provider-health.js +10 -5
  117. package/lib/provider-monitor.js +5 -3
  118. package/lib/provider-network.js +52 -0
  119. package/lib/provider-protocol.js +3 -2
  120. package/lib/provider-registry.js +15 -5
  121. package/lib/provider-response.js +10 -0
  122. package/lib/provider-settings.js +14 -12
  123. package/lib/provider-task-runner.js +113 -37
  124. package/lib/provider-transport.js +16 -0
  125. package/lib/render.js +11 -1
  126. package/lib/scheduling-policy.js +66 -0
  127. package/lib/tasks.js +3 -2
  128. package/lib/video-flow-input.js +16 -0
  129. package/lib/video-input.js +12 -5
  130. package/lib/vision-model-routing.js +2 -0
  131. package/lib/web/app.js +107 -46
  132. package/lib/web/connections.js +151 -12
  133. package/lib/web/creation.js +29 -25
  134. package/lib/web/flow-view.js +4 -3
  135. package/lib/web/gallery.js +17 -16
  136. package/lib/web/index.html +58 -5
  137. package/lib/web/media-client.js +18 -4
  138. package/lib/web/network.js +232 -0
  139. package/lib/web/plugin-panel.js +4 -2
  140. package/lib/web/plugin-status.js +140 -0
  141. package/lib/web/providers.js +34 -13
  142. package/lib/web/recovery.js +2 -2
  143. package/lib/web/styles.css +54 -1
  144. package/lib/web/transport.js +6 -1
  145. package/lib/workbench-artifacts.js +3 -2
  146. package/package.json +5 -2
  147. package/skills/iris-core/references/comfyui.md +1 -1
  148. package/templates/plugin-starter/LICENSE +21 -0
  149. package/templates/plugin-starter/README.md +74 -0
  150. package/templates/plugin-starter/assets/peach.png +0 -0
  151. package/templates/plugin-starter/assets/violet.png +0 -0
  152. package/templates/plugin-starter/index.mjs +27 -0
  153. package/templates/plugin-starter/package.json +18 -0
  154. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  155. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  156. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  157. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  158. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  159. package/templates/plugin-starter/web/board.css +27 -0
  160. package/templates/plugin-starter/web/board.mjs +160 -0
  161. package/templates/plugin-starter/web/png.mjs +23 -0
  162. package/templates/provider-starter/LICENSE +21 -0
  163. package/templates/provider-starter/README.md +50 -0
  164. package/templates/provider-starter/index.d.mts +5 -0
  165. package/templates/provider-starter/index.mjs +165 -0
  166. package/templates/provider-starter/package.json +13 -0
  167. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  168. package/templates/provider-starter/scripts/environment.mjs +122 -0
  169. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  170. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  171. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  172. package/templates/provider-starter/scripts/setup.mjs +44 -0
  173. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  174. package/user_guide.md +3 -3
@@ -6,12 +6,61 @@ const element = (tag, text, className) => { const node = document.createElement(
6
6
  const checked = selector => [...document.querySelectorAll(selector + ':checked')].map(input => input.value);
7
7
  const date = value => new Date(value).toLocaleString('zh-CN');
8
8
 
9
+ function selectedToolNames(record, catalog) {
10
+ const controls = catalog.controlTools || catalog.tools.filter(tool => !catalog.groups.some(group => group.tools.includes(tool.name))).map(tool => tool.name);
11
+ return new Set([...controls, ...catalog.groups.filter(group => record.capabilities.includes(group.id)).flatMap(group => group.tools),
12
+ ...(record.pluginOperations?.length ? catalog.pluginControlTools || [] : [])]);
13
+ }
14
+ function effectiveTools(record, catalog) {
15
+ const selected = selectedToolNames(record, catalog);
16
+ const definitions = new Map([...catalog.tools, ...(catalog.pluginTools || [])].map(tool => [tool.name, tool]));
17
+ return record.tools.filter(name => selected.has(name) && catalog.availableTools.includes(name)
18
+ && definitions.has(name) && record.scopes.includes(definitions.get(name).scope) && catalog.availableScopes.includes(definitions.get(name).scope));
19
+ }
20
+ const samePin = (a, b) => ['contractVersion', 'pluginId', 'pluginVersion', 'kind', 'contributionId', 'contributionVersion'].every(key => a[key] === b[key]);
21
+
22
+ /** Owner GET facts only. This is a configuration check, never a transport probe or authority. */
23
+ export function inspectMcpSetup(record, catalog, { transport = 'stdio', location = 'same' } = {}) {
24
+ if (catalog.contractVersion !== 0) throw new TypeError('不支持的连接管理版本');
25
+ const issues = [], issue = (code, message) => issues.push({ code, message });
26
+ const revoked = Boolean(record.revokedAt || record.state === 'revoked');
27
+ if (location !== 'same') issue('location', '当前导出使用 Core 主机的回环地址与本机路径,不能直接交给另一台机器上的客户端。');
28
+ if (transport === 'http' && !catalog.httpEnabled) issue('transport', '此服务没有开启 HTTP MCP;可选本机 stdio,或在下次明确启动服务时开启 HTTP。');
29
+ if (revoked) issue('revoked', '此连接已撤销,旧配置不能继续使用。需要接入时请新建连接。');
30
+ if (record.scopes.some(scope => !catalog.availableScopes.includes(scope))) issue('scope', '保存的操作权限超出此服务当前开放的范围,请核对服务开关与连接设置。');
31
+ if (record.capabilities.some(id => !catalog.groups.some(group => group.id === id))) issue('capability', '此工作区没有开放全部已选媒体分组,原授权未被自动替换。');
32
+ for (const pin of record.pluginOperations || []) {
33
+ const entry = catalog.contributions.operations.find(item => item.id === pin.contributionId);
34
+ if (!entry) issue('plugin', pin.contributionId + ':业务未加载,历史仍保留,请核对准确插件版本。');
35
+ else if (!samePin(entry.dependency, pin)) issue('plugin', pin.contributionId + ':当前业务与保存的准确依赖版本不一致。');
36
+ else if (entry.access.mcp !== 'requires-selection') issue('plugin', pin.contributionId + ':此服务尚未启用该业务;历史读取可能仍可用。');
37
+ }
38
+ const tools = revoked ? [] : effectiveTools(record, catalog);
39
+ if (!revoked && !tools.length) issue('tools', '当前选择没有可用工具,请明确选择功能、工具与对应权限;不会自动补齐。');
40
+ return { ok: issues.length === 0, issues, tools, mediaTested: false, transportTested: false };
41
+ }
42
+
43
+ /** A raw bridge/self-check handshake is not an MCP client's declaration. No online inference. */
44
+ export function mcpClientEvidence(record, catalog) {
45
+ if (record.revokedAt || record.state === 'revoked') return { state: 'revoked', label: '连接已撤销', instances: [] };
46
+ const instances = (record.instances || []).filter(instance => instance.declaredClient?.source === 'client-declared').map(instance => ({
47
+ state: instance.state, at: instance.declaredClient.at, clientInfo: instance.declaredClient.clientInfo,
48
+ transport: instance.declaredClient.transport, tools: effectiveTools(instance, catalog) }));
49
+ if (instances.some(instance => instance.state === 'connected')) return { state: 'observed', label: '收到客户端握手与声明', instances };
50
+ if (instances.length) return { state: 'disconnected', label: '客户端握手已断开', instances };
51
+ if (record.lastContact) return { state: 'historical', label: '有历史客户端声明', instances, contact: record.lastContact };
52
+ if (record.instances?.some(instance => instance.state === 'connected')) return { state: 'bridge', label: '仅收到桥接握手', instances };
53
+ return { state: 'waiting', label: '等待客户端握手与声明', instances };
54
+ }
55
+
9
56
  export function createConnectionPanel({ describe }) {
10
57
  let client, epoch = 0, catalog, records = [], selected = null, key, busy = false, visible = false, viewTicket = 0;
11
- let scopes = {}, control = [];
58
+ let scopes = {};
12
59
  let capabilities = [], tools = [], pluginOperations = [], allowedScopes = ['read'], templates = [], skillBundle = null;
13
60
  let view = 'settings', exportDelivery = null;
14
61
  let formDirty = false;
62
+ const exports = new Map(), inspections = new Map();
63
+ let checkController = null;
15
64
  const closeButton = element('button', '返回列表', 'quiet'); closeButton.type = 'button'; closeButton.id = 'connection-cancel';
16
65
  const closeArea = element('div', '', 'provider-footer'); closeArea.append(closeButton); $('#connection-editor').append(closeArea);
17
66
  const urls = new Set();
@@ -19,11 +68,13 @@ export function createConnectionPanel({ describe }) {
19
68
  const valid = (at, connection) => at === epoch && client === connection;
20
69
  function editor(open) { $('#connection-editor').hidden = !open; $('#connections-overview').hidden = open; }
21
70
  function setView(next) {
71
+ if (next !== view) cancelCheck();
22
72
  if (next !== view) clearExport(); view = next;
23
73
  for (const button of document.querySelectorAll('[data-connection-view]')) {
24
74
  const active = button.dataset.connectionView === view; button.setAttribute('aria-pressed', String(active));
25
75
  $('#connection-view-' + button.dataset.connectionView).hidden = !active;
26
76
  }
77
+ renderOnboarding(); controls();
27
78
  }
28
79
  function feedback(text = '', error = false) {
29
80
  const node = $('#connections-feedback'); node.hidden = !text; node.textContent = text; node.classList.toggle('error', error);
@@ -35,6 +86,7 @@ export function createConnectionPanel({ describe }) {
35
86
  $('#connection-export-note').textContent = '';
36
87
  skillBundle = null; $('#connection-skill-preview').value = ''; $('#connection-skill-result').hidden = true;
37
88
  for (const url of urls) URL.revokeObjectURL(url); urls.clear();
89
+ renderOnboarding();
38
90
  }
39
91
  function controls() {
40
92
  const record = current(), editable = !record || record.managed && !record.revokedAt;
@@ -46,7 +98,11 @@ export function createConnectionPanel({ describe }) {
46
98
  $('#connection-back').disabled = busy;
47
99
  closeButton.disabled = busy;
48
100
  for (const node of document.querySelectorAll('#connections-list .connection-item')) node.disabled = busy;
49
- for (const button of document.querySelectorAll('[data-connection-view]')) button.disabled = busy || button.dataset.connectionView === 'delivery' && (!record?.managed || Boolean(record.revokedAt)) || button.dataset.connectionView === 'details' && !record;
101
+ for (const button of document.querySelectorAll('[data-connection-view]')) button.disabled = busy || button.dataset.connectionView === 'delivery' && (!record?.managed || Boolean(record.revokedAt)) || ['details', 'verification'].includes(button.dataset.connectionView) && !record;
102
+ $('#connection-export-button').disabled ||= formDirty || $('#connection-location').value !== 'same';
103
+ $('#connection-check').disabled = busy || !catalog || !record || formDirty;
104
+ $('#connection-location').disabled = busy;
105
+ $('#connection-verify-next').disabled = busy || !record;
50
106
  }
51
107
  function renderList() {
52
108
  $('#connections-count').textContent = String(records.filter(record => record.state !== 'revoked').length);
@@ -70,8 +126,7 @@ export function createConnectionPanel({ describe }) {
70
126
  if (note) text.append(element('small', note)); label.append(input, text); return label;
71
127
  }
72
128
  function enabledTools() {
73
- return new Set([...control, ...catalog.groups.filter(group => capabilities.includes(group.id)).flatMap(group => group.tools),
74
- ...(pluginOperations.length ? catalog.pluginControlTools || [] : [])].filter(name => !catalog.availableTools || catalog.availableTools.includes(name)));
129
+ return new Set([...selectedToolNames({ capabilities, pluginOperations }, catalog)].filter(name => !catalog.availableTools || catalog.availableTools.includes(name)));
75
130
  }
76
131
  function renderTools() {
77
132
  const enabled = enabledTools();
@@ -160,6 +215,7 @@ export function createConnectionPanel({ describe }) {
160
215
  transportNote();
161
216
  renderOptions(); renderList();
162
217
  formDirty = false; $('#connection-leave-confirm').hidden = true; closeButton.textContent = '返回列表';
218
+ renderOnboarding(); controls();
163
219
  }
164
220
  async function reload(connection) {
165
221
  const result = await connection.get(BASE + '/connections');
@@ -187,7 +243,6 @@ export function createConnectionPanel({ describe }) {
187
243
  catalog = { ...value, tools: [...value.tools, ...(value.pluginTools || [])] };
188
244
  // A running preview may still serve the earlier v0 DTO. Derive safe choices from its declarations.
189
245
  scopes = Object.fromEntries((catalog.scopeOptions || catalog.availableScopes.map(id => ({ id, title: id }))).map(item => [item.id, item.title]));
190
- control = catalog.controlTools || catalog.tools.filter(tool => !catalog.groups.some(group => group.tools.includes(tool.name))).map(tool => tool.name);
191
246
  renderContributions();
192
247
  }
193
248
  function transportNote() {
@@ -220,6 +275,79 @@ export function createConnectionPanel({ describe }) {
220
275
  const formats = value.formats || ['mcpServers', 'entry'];
221
276
  for (const option of $('#connection-format').options) option.disabled = !formats.includes(option.value);
222
277
  }
278
+ const choiceKey = () => [$('#connection-location').value, $('#connection-transport').value, $('#connection-format').value].join(':');
279
+ function renderOnboarding() {
280
+ const record = current();
281
+ const scopeTitles = allowedScopes.map(id => scopes[id] || id);
282
+ const names = (catalog?.groups || []).filter(group => capabilities.includes(group.id)).map(group => group.label || group.title);
283
+ for (const pin of pluginOperations) names.push(catalog?.contributions?.operations?.find(item => samePin(item.dependency, pin))?.title || pin.contributionId);
284
+ $('#connection-grant-summary').textContent = (names.join('、') || '仅控制工具') + ' · ' + scopeTitles.join('、');
285
+ $('#connection-unsaved-note').hidden = !formDirty;
286
+ $('#connection-location-note').textContent = $('#connection-location').value === 'same'
287
+ ? '这里指运行 Agent 的机器,不是打开此网页的手机或电脑。两种传输都需要 Core 保持运行。'
288
+ : '当前配置含 Core 主机的回环地址或本机路径,不能直接交给另一台机器上的客户端。请先确认部署方案;本页不会开启公网访问。';
289
+ const exported = record && exports.get(record.connectionId), inspected = record && inspections.get(record.connectionId);
290
+ const exportCurrent = exported && exported.revision === record.revision && exported.choice === choiceKey() && !formDirty;
291
+ const inspectionCurrent = inspected && inspected.revision === record.revision && inspected.choice === choiceKey() && !formDirty;
292
+ $('#connection-export-state').textContent = exportCurrent ? '当前配置已生成' : '本页未记录当前配置导出';
293
+ $('#connection-export-evidence').textContent = exportCurrent ? date(exported.at) + ' · ' + exported.transport + ' · ' + exported.format + ';仍需导入客户端。'
294
+ : '只记录本次登录中生成的配置。若你已在客户端配置过,可直接核对接入。';
295
+ $('#connection-check-state').textContent = inspectionCurrent ? inspected.ok ? 'Iris 端配置检查通过' : '有待核对项' : '尚未检查当前配置';
296
+ $('#connection-check-state').dataset.state = inspectionCurrent ? inspected.ok ? 'passed' : 'limited' : 'pending';
297
+ $('#connection-check-evidence').textContent = inspectionCurrent ? date(inspected.at) + ' · 只读取此服务与保存授权,未检查客户端传输或媒体。'
298
+ : '检查不会建立 MCP 连接,不会代替客户端握手或调用模型。';
299
+ $('#connection-check-issues').replaceChildren(...(inspectionCurrent ? inspected.issues : []).map(item => { const row = element('li', item.message); row.dataset.check = item.code; return row; }));
300
+ $('#connection-check-tools').replaceChildren(...(inspectionCurrent ? inspected.tools : []).map(name => element('li', name)));
301
+ $('#connection-check-tools-note').textContent = inspectionCurrent ? inspected.tools.length + ' 个工具按当前授权允许使用;需在客户端确认是否已发现。' : '完成只读检查后显示 Core 当前允许的工具。';
302
+ const evidence = record && catalog ? mcpClientEvidence(record, catalog) : null;
303
+ $('#connection-client-state').textContent = evidence?.label || '等待创建连接';
304
+ $('#connection-client-state').dataset.state = evidence?.state || 'waiting';
305
+ $('#connection-client-evidence').replaceChildren();
306
+ for (const instance of evidence?.instances || []) {
307
+ const item = element('li', instance.clientInfo.name + ' · ' + instance.clientInfo.version + ' · ' + instance.transport + ' · '
308
+ + (instance.state === 'connected' ? '握手记录' : '已断开') + ' · ' + date(instance.at));
309
+ const details = element('details', '', 'tool-details'), summary = element('summary', ''), disclosure = element('span', '展开', 'disclosure');
310
+ summary.append(element('span', '本次协商允许 ' + instance.tools.length + ' 个工具'), disclosure);
311
+ const names = element('ul', '', 'connection-check-tools'); names.append(...instance.tools.map(name => element('li', name)));
312
+ details.append(summary, names); details.addEventListener('toggle', () => { disclosure.textContent = details.open ? '收起' : '展开'; }); item.append(details);
313
+ $('#connection-client-evidence').append(item);
314
+ }
315
+ if (evidence?.contact) $('#connection-client-evidence').append(element('li', evidence.contact.clientInfo.name + ' · ' + date(evidence.contact.at) + ' · 仅为历史声明'));
316
+ }
317
+ function cancelCheck() {
318
+ if (!checkController) return;
319
+ checkController.abort(); checkController = null; busy = false;
320
+ feedback('只读检查已取消,未建立客户端连接。');
321
+ }
322
+ async function checkSetup() {
323
+ const original = current();
324
+ if (busy || !client || !original || formDirty || !visible) return;
325
+ const at = epoch, connection = client, id = selected, ticket = viewTicket, choice = choiceKey();
326
+ const controller = new AbortController(); checkController = controller; inspections.delete(id); renderOnboarding(); busy = true; controls(); feedback('正在只读核对 Iris 配置和客户端记录…');
327
+ const currentRequest = () => valid(at, connection) && checkController === controller && visible && selected === id && viewTicket === ticket;
328
+ try {
329
+ const options = { signal: controller.signal };
330
+ const [available, saved, session] = await Promise.all([connection.get(BASE + '/catalog', options), connection.get(BASE + '/connections', options), connection.get('/api/v0/session', options)]);
331
+ if (!currentRequest()) return;
332
+ if ([available, saved, session].some(value => value.contractVersion !== 0)) throw new TypeError('不支持的连接检查版本');
333
+ records = saved.connections; setCatalog(available);
334
+ const record = current();
335
+ if (!record || record.revision !== original.revision) {
336
+ inspections.delete(id); exports.delete(id); select(record?.connectionId || null);
337
+ feedback('连接授权已变化,请核对当前设置后重新检查。', true); return;
338
+ }
339
+ const result = inspectMcpSetup(record, catalog, { transport: $('#connection-transport').value, location: $('#connection-location').value });
340
+ inspections.set(id, { ...result, at: new Date().toISOString(), revision: record.revision, choice });
341
+ // No draft can be active here. Refresh the badge/facts from the same saved record as the evidence.
342
+ select(id);
343
+ feedback(result.ok ? mcpClientEvidence(record, catalog).state === 'observed'
344
+ ? '已读取当前配置与客户端声明;握手不代表持续在线或媒体已实测。'
345
+ : 'Iris 端配置检查通过。请在客户端导入配置,再核对实际握手;媒体尚未实测。'
346
+ : '发现需核对的配置项,读取或历史功能可能仍可用。', !result.ok);
347
+ } catch (error) {
348
+ if (currentRequest()) { inspections.delete(id); renderOnboarding(); feedback('只读检查未完成:' + describe(error), true); }
349
+ } finally { if (checkController === controller) { checkController = null; busy = false; controls(); } }
350
+ }
223
351
  function download(value, filename, mediaType = 'application/json') {
224
352
  const url = URL.createObjectURL(new Blob([value], { type: mediaType })); urls.add(url);
225
353
  const link = document.createElement('a'); link.href = url; link.download = filename; link.click();
@@ -248,6 +376,7 @@ export function createConnectionPanel({ describe }) {
248
376
  });
249
377
  });
250
378
  $('#connection-export-button').addEventListener('click', () => {
379
+ if (formDirty || $('#connection-location').value !== 'same') return;
251
380
  clearExport();
252
381
  const id = selected, ticket = viewTicket;
253
382
  perform(async ctx => {
@@ -256,18 +385,22 @@ export function createConnectionPanel({ describe }) {
256
385
  ? choice.presetId ? { presetId: choice.presetId } : { templateId: choice.templateId }
257
386
  : { transport: $('#connection-transport').value, format: $('#connection-format').value });
258
387
  if (!ctx.valid()) return;
259
- if (selected !== id || !visible || viewTicket !== ticket) { feedback('配置已准备,但页面已切换。需要时请重新生成配置。'); return; }
388
+ if (selected !== id || !visible || viewTicket !== ticket) return;
260
389
  $('#connection-configuration').value = result.content ?? JSON.stringify(result.configuration, null, 2) + '\n';
261
390
  exportDelivery = { filename: result.filename || 'iris-mcp.json', mediaType: result.mediaType || 'application/json' };
262
391
  $('#connection-download').textContent = exportDelivery.filename.endsWith('.toml') ? '下载 TOML' : '下载 JSON';
263
392
  $('#connection-export-note').textContent = result.containsCredentials ? '此 HTTP 配置含连接密钥,请仅交给获授权的本机客户端。' : 'stdio 配置引用本机私有文件,需要使用本机路径。';
264
393
  $('#connection-export-result').hidden = false; feedback('配置已生成。客户端是否安装或加载,需要在客户端确认。');
394
+ const record = current();
395
+ exports.set(id, { at: new Date().toISOString(), revision: record.revision, choice: choiceKey(), transport: $('#connection-transport').value, format: $('#connection-format').value });
396
+ renderOnboarding();
265
397
  });
266
398
  });
267
399
  $('#connection-copy').addEventListener('click', async () => {
268
- const at = epoch;
269
- try { await navigator.clipboard.writeText($('#connection-configuration').value); if (at === epoch) feedback('配置已复制。'); }
270
- catch (error) { if (at === epoch) feedback('未能复制,请手动选择配置文本。' + error.message, true); }
400
+ const at = epoch, id = selected, ticket = viewTicket;
401
+ const currentCopy = () => at === epoch && id === selected && ticket === viewTicket && visible;
402
+ try { await navigator.clipboard.writeText($('#connection-configuration').value); if (currentCopy()) feedback('配置已复制。'); }
403
+ catch (error) { if (currentCopy()) feedback('未能复制,请手动选择配置文本。' + error.message, true); }
271
404
  });
272
405
  $('#connection-download').addEventListener('click', () => {
273
406
  const value = $('#connection-configuration').value; if (!value) return;
@@ -296,7 +429,7 @@ export function createConnectionPanel({ describe }) {
296
429
  perform(async ctx => {
297
430
  const result = await ctx.connection.postManagement(BASE + '/connections/' + id + '/skill', {});
298
431
  if (!ctx.valid()) return;
299
- if (selected !== id || !visible || viewTicket !== ticket) { feedback('说明已准备,但页面已切换。需要时请重新生成说明。'); return; }
432
+ if (selected !== id || !visible || viewTicket !== ticket) return;
300
433
  skillBundle = result.bundle; $('#connection-skill-preview').value = result.preview; $('#connection-skill-result').hidden = false;
301
434
  feedback('说明已生成。下载并安装后,还需在客户端确认已发现此 Skill。');
302
435
  });
@@ -332,7 +465,7 @@ export function createConnectionPanel({ describe }) {
332
465
  if (formDirty && !discard) { $('#connection-leave-confirm').hidden = false; $('#connection-leave-confirm').scrollIntoView({ block: 'center' }); $('#connection-leave-keep').focus(); return; }
333
466
  clearExport(); formDirty = false; $('#connection-leave-confirm').hidden = true; editor(false); feedback(); $('#connection-new').focus();
334
467
  }
335
- $('#connection-form').addEventListener('input', () => { formDirty = true; closeButton.textContent = '取消编辑'; });
468
+ $('#connection-form').addEventListener('input', () => { formDirty = true; closeButton.textContent = '取消编辑'; renderOnboarding(); controls(); });
336
469
  $('#connection-back').addEventListener('click', () => closeEditor());
337
470
  closeButton.addEventListener('click', () => closeEditor());
338
471
  $('#connection-leave-keep').addEventListener('click', () => { $('#connection-leave-confirm').hidden = true; });
@@ -346,6 +479,10 @@ export function createConnectionPanel({ describe }) {
346
479
  for (const id of ['connection-transport', 'connection-format', 'connection-name']) $('#' + id).addEventListener('input', clearExport);
347
480
  $('#connection-transport').addEventListener('change', transportNote);
348
481
  $('#connection-clear-export').addEventListener('click', clearExport);
482
+ $('#connection-location').addEventListener('change', () => { clearExport(); controls(); });
483
+ $('#connection-check').addEventListener('click', checkSetup);
484
+ $('#connection-verify-next').addEventListener('click', () => setView('verification'));
485
+ $('#connection-return-settings').addEventListener('click', () => setView('settings'));
349
486
  async function connect(connection) {
350
487
  const at = ++epoch; client = connection; busy = true; records = []; selected = null; editor(false); clearExport(); feedback('正在读取连接…'); controls();
351
488
  try {
@@ -361,8 +498,10 @@ export function createConnectionPanel({ describe }) {
361
498
  }
362
499
  return Object.freeze({
363
500
  connect,
364
- show(value) { if (visible !== value) viewTicket++; visible = value; if (!value) clearExport(); },
501
+ show(value) { if (visible !== value) viewTicket++; visible = value; if (!value) { cancelCheck(); clearExport(); feedback(); controls(); } },
365
502
  disconnect() {
503
+ cancelCheck(); exports.clear(); inspections.clear();
504
+ capabilities = []; tools = []; pluginOperations = []; allowedScopes = ['read']; scopes = {}; $('#connection-location').value = 'same';
366
505
  epoch++; client = null; busy = false; visible = false; catalog = null; records = []; templates = []; selected = null; editor(false); clearExport(); feedback();
367
506
  formDirty = false; $('#connection-leave-confirm').hidden = true; closeButton.textContent = '返回列表';
368
507
  $('#connection-template').replaceChildren(); $('#connection-template-note').replaceChildren(); $('#connection-template-note').hidden = true; $('#connection-template-file').value = '';
@@ -11,12 +11,13 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
11
11
  let preview, retryAction = null, recoveryAfterLoad = null;
12
12
  let comfyForm = null, comfyKey = '', inspection = null;
13
13
  const cap = name => Boolean(session?.operations.includes(name));
14
- const mode = () => view ? ({ 'image-edit/v0': 'edit', 'image-generate/v0': 'generate', 'speech/v0': 'tts', 'transcribe/v0': 'transcribe' })[view.recipe] : draft.mode || 'edit';
14
+ const mode = () => view ? ({ 'image-edit/v0': 'edit', 'image-generate/v0': 'generate', 'speech/v0': 'tts', 'transcribe/v0': 'transcribe', 'video-generate/v0': 'video' })[view.recipe] : draft.mode || 'edit';
15
15
  const generating = () => mode() === 'generate';
16
+ const video = () => mode() === 'video';
16
17
  const audio = () => ['tts', 'transcribe'].includes(mode());
17
- const createCapabilities = { generate: 'generations.create', edit: 'runs.create', tts: 'speeches.create', transcribe: 'transcriptions.create' };
18
+ const createCapabilities = { generate: 'generations.create', edit: 'runs.create', tts: 'speeches.create', transcribe: 'transcriptions.create', video: 'videos.create' };
18
19
  const createCapability = () => createCapabilities[mode()];
19
- const candidates = () => models.filter(model => audio() ? model.capability === mode() : !model.capability && (generating() || model.supportsImageEdit));
20
+ const candidates = () => models.filter(model => video() ? model.capability === 'video-gen' : audio() ? model.capability === mode() : !model.capability && (generating() || model.supportsImageEdit));
20
21
  const valid = at => at === epoch && Boolean(client);
21
22
  const validJob = task => valid(task.epoch) && task.intent === intent && visible && job === task;
22
23
  function feedback(text = '', error = false) {
@@ -70,8 +71,8 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
70
71
  }
71
72
  function controls() {
72
73
  const generate = generating(), currentMode = mode(), isAudio = audio(), transcribe = currentMode === 'transcribe';
73
- $('#studio-title').textContent = currentMode === 'tts' ? '让文字,有声音。' : transcribe ? '让声音,成文字。' : '让想法,有画面。';
74
- $('#studio-note').textContent = currentMode === 'tts' ? '写下要说的话,留下你的声音作品。' : transcribe ? '听见的内容,留成可以继续使用的文字。' : '保留喜欢的部分,试一点新的可能。';
74
+ $('#studio-title').textContent = video() ? '让想法,动起来。' : currentMode === 'tts' ? '让文字,有声音。' : transcribe ? '让声音,成文字。' : '让想法,有画面。';
75
+ $('#studio-note').textContent = video() ? '从一段描述开始,接回原任务的视频作品。' : currentMode === 'tts' ? '写下要说的话,留下你的声音作品。' : transcribe ? '听见的内容,留成可以继续使用的文字。' : '保留喜欢的部分,试一点新的可能。';
75
76
  const needsSource = ['edit', 'transcribe'].includes(currentMode);
76
77
  const pending = media?.snapshot(), unresolved = pending?.actions.some(action => CREATIONS.includes(action.kind)
77
78
  || view && action.runId === view.id);
@@ -80,14 +81,16 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
80
81
  button.setAttribute('aria-pressed', String(button.dataset.creationMode === currentMode));
81
82
  button.disabled = !cap(createCapabilities[button.dataset.creationMode]);
82
83
  if (['tts', 'transcribe'].includes(button.dataset.creationMode)) button.hidden = !session?.audio;
84
+ if (button.dataset.creationMode === 'video') button.hidden = !session?.video;
83
85
  }
84
86
  $('#source-input').hidden = currentMode !== 'edit'; $('#audio-source-input').hidden = !transcribe;
85
87
  $('#prompt-label').hidden = transcribe; $('#edit-prompt').hidden = transcribe; $('#edit-prompt').required = !transcribe;
88
+ $('#video-duration-field').hidden = !video();
86
89
  $('#speech-options').hidden = currentMode !== 'tts'; $('#advanced-settings').hidden = isAudio;
87
- $('#model-label').firstChild.textContent = currentMode === 'tts' ? '语音模型 ' : transcribe ? '转写模型 ' : '图片模型 ';
88
- $('#prompt-label').textContent = currentMode === 'tts' ? '要朗读的文字' : generate ? '想画些什么?' : '想改些什么?';
89
- $('#edit-prompt').maxLength = isAudio ? 20000 : 12000;
90
- $('#edit-prompt').placeholder = currentMode === 'tts' ? '写下希望朗读的内容…' : generate ? '描述画面、人物、色彩与风格…' : '描述想保留的细节,以及想尝试的变化…';
90
+ $('#model-label').firstChild.textContent = video() ? '视频模型 ' : currentMode === 'tts' ? '语音模型 ' : transcribe ? '转写模型 ' : '图片模型 ';
91
+ $('#prompt-label').textContent = video() ? '想让什么动起来?' : currentMode === 'tts' ? '要朗读的文字' : generate ? '想画些什么?' : '想改些什么?';
92
+ $('#edit-prompt').maxLength = isAudio || video() ? 20000 : 12000;
93
+ $('#edit-prompt').placeholder = video() ? '描述主体、动作、镜头与氛围…' : currentMode === 'tts' ? '写下希望朗读的内容…' : generate ? '描述画面、人物、色彩与风格…' : '描述想保留的细节,以及想尝试的变化…';
91
94
  const canCreate = cap(createCapability()) && cap('runs.continue') && (!needsSource || Boolean(draft.file || draft.artifactId)
92
95
  && (draft.artifactId || cap(transcribe ? 'audio.create' : 'images.create')))
93
96
  && Boolean($('#model').value) && (transcribe || Boolean($('#edit-prompt').value.trim()));
@@ -99,7 +102,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
99
102
  || Boolean(activeOperation && ACTIVE.has(activeOperation.state)) || Boolean(inspection) || comfyForm && !comfyForm.valid()
100
103
  || !(retryAction ? canRetry : view ? canContinue : canCreate);
101
104
  $('#start-edit').textContent = busy ? '正在处理…' : retryAction ? '按原 Key 接回' : view && view.status !== 'not_started'
102
- ? labels[view.status] || '查看已有作品' : currentMode === 'tts' ? '合成语音' : transcribe ? '开始转写' : generate ? '开始生成' : '开始改图';
105
+ ? labels[view.status] || '查看已有作品' : currentMode === 'tts' ? '合成语音' : transcribe ? '开始转写' : generate || video() ? '开始生成' : '开始改图';
103
106
  $('#locked-note').hidden = !view;
104
107
  $('#reuse-selected').disabled = busy || loading || !cap('runs.create') || !view?.selection;
105
108
  for (const kind of ['source', 'output']) {
@@ -110,9 +113,9 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
110
113
  $('#creation-help').textContent = !client ? '读取权限中…' : loading ? '当前流程尚未确认,读取完成前暂停操作。可重新读取或新建流程。'
111
114
  : retryAction ? '使用原身份与原输入接回。明确执行仍可能产生供应商费用。'
112
115
  : pending?.blocked ? '恢复记录需要先明确处理,暂不接收新动作。'
113
- : !cap('runs.continue') ? isAudio ? '当前服务只读,可浏览和下载已保存的音频或文字。'
116
+ : !cap('runs.continue') ? video() ? '当前服务只读,可播放和下载已保存的视频。' : isAudio ? '当前服务只读,可浏览和下载已保存的音频或文字。'
114
117
  : cap('runs.select') ? '当前可选择已有图片。开始创作需要显式开启执行服务。' : '当前服务只读,可浏览图片流程和下载已选作品。'
115
- : !view && !candidates().length ? isAudio ? '尚无此音频功能的可用候选。请在模型服务中配置并分配用途。' : '尚无可用图片候选。可在供应商页配置模型。'
118
+ : !view && !candidates().length ? video() ? '尚无文生视频候选。请在模型服务中配置并分配视频用途。' : isAudio ? '尚无此音频功能的可用候选。请在模型服务中配置并分配用途。' : '尚无可用图片候选。可在供应商页配置模型。'
116
119
  : view?.status === 'needs_attention' ? '先核对原任务;受理未知时请避免重复生成。'
117
120
  : '明确执行可能产生供应商费用。刷新不会自动生成。';
118
121
  $('#creation-settings').dataset.fixed = String(Boolean(view));
@@ -122,7 +125,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
122
125
  $('#edit-size-note').textContent = workflow && !workflow.sizeNode ? '此工作流未绑定尺寸节点,沿用模板尺寸。' : '填写模型支持的尺寸;留空使用默认值。';
123
126
  }
124
127
  function fillModels(selected = '') {
125
- const choices = candidates(), label = mode() === 'tts' ? '语音' : mode() === 'transcribe' ? '转写' : '图片';
128
+ const choices = candidates(), label = video() ? '视频' : mode() === 'tts' ? '语音' : mode() === 'transcribe' ? '转写' : '图片';
126
129
  $('#model').replaceChildren(new Option(choices.length ? '选择一个' + label + '模型' : '没有可用' + label + '候选', ''));
127
130
  for (const model of choices) $('#model').append(new Option(model.displayName + ' · ' + model.modelRef, model.modelRef));
128
131
  if (selected && !choices.some(model => model.modelRef === selected)) $('#model').append(new Option(selected + ' · 原流程模型', selected));
@@ -136,8 +139,8 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
136
139
  if (preview && mode === 'transcribe') { $('#audio-input-preview').src = preview; $('#audio-input-preview').hidden = false; }
137
140
  $('#audio-file').value = ''; $('#speech-voice').value = ''; $('#audio-file-name').textContent = label || file?.name || (artifactId ? '已保存的原音频' : 'WAV / MP3 / OGG / FLAC,最大20 MiB。');
138
141
  $('#file-name').textContent = label || file?.name || (artifactId ? '已选作品' : '选择一张图片');
139
- $('#image-file').value = ''; $('#edit-prompt').value = ''; $('#edit-size').value = ''; fillModels(modelRef); renderComfy(true);
140
- $('#input-summary').textContent = mode === 'tts' ? '让文字成为声音' : mode === 'transcribe' ? '把声音留成文字' : mode === 'generate' ? '从一句想法开始' : file || artifactId ? '填写你的修改要求' : '从一张图片开始';
142
+ $('#image-file').value = ''; $('#edit-prompt').value = ''; $('#edit-size').value = ''; $('#video-duration').value = ''; fillModels(modelRef); renderComfy(true);
143
+ $('#input-summary').textContent = mode === 'video' ? '从一段动态描述开始' : mode === 'tts' ? '让文字成为声音' : mode === 'transcribe' ? '把声音留成文字' : mode === 'generate' ? '从一句想法开始' : file || artifactId ? '填写你的修改要求' : '从一张图片开始';
141
144
  $('#creation-settings').open = true; $('#advanced-settings').open = false; feedback();
142
145
  showDraft(preview, mode); controls();
143
146
  }
@@ -168,7 +171,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
168
171
  if (!valid(at) || !visible || view?.id !== operation.runId) return;
169
172
  await openRun(operation.runId, { fromAction: true });
170
173
  if (!valid(at)) return;
171
- if (view?.id === operation.runId && audio() && operation.state === 'succeeded') feedback();
174
+ if (view?.id === operation.runId && (audio() || video()) && operation.state === 'succeeded') feedback();
172
175
  await refreshHistory(); controls();
173
176
  }
174
177
  function watch(operation) {
@@ -197,9 +200,10 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
197
200
  const generate = generating(), currentMode = mode(), isAudio = audio(), transcribe = currentMode === 'transcribe';
198
201
  const input = isAudio ? { modelRef: $('#model').value, ...(transcribe ? { sourceArtifactId: draft.artifactId }
199
202
  : { text: $('#edit-prompt').value, ...($('#speech-voice').value.trim() ? { voice: $('#speech-voice').value.trim() } : {}) }) }
200
- : { ...(!generate ? { sourceArtifactId: draft.artifactId } : {}), modelRef: $('#model').value, prompt: $('#edit-prompt').value,
203
+ : { ...(!generate && !video() ? { sourceArtifactId: draft.artifactId } : {}), modelRef: $('#model').value, prompt: $('#edit-prompt').value,
201
204
  ...($('#edit-size').value.trim() ? { size: $('#edit-size').value.trim() } : {}),
202
- ...(parameters && Object.keys(parameters).length ? { workflowParameters: parameters } : {}) };
205
+ ...(parameters && Object.keys(parameters).length ? { workflowParameters: parameters } : {}),
206
+ ...(video() && $('#video-duration').value !== '' ? { duration: Number($('#video-duration').value) } : {}) };
203
207
  const file = draft.file, original = retryAction;
204
208
  try {
205
209
  if (original) {
@@ -216,7 +220,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
216
220
  feedback('正在保存素材…'); const imported = await media[transcribe ? 'uploadAudio' : 'uploadImage'](file);
217
221
  if (!validJob(task)) return; draft.artifactId = input.sourceArtifactId = imported.artifact.artifactId;
218
222
  }
219
- feedback('正在建立流程…'); const created = await media[({ generate: 'createGeneration', edit: 'createRun', tts: 'createSpeech', transcribe: 'createTranscription' })[currentMode]](input);
223
+ feedback('正在建立流程…'); const created = await media[({ generate: 'createGeneration', edit: 'createRun', tts: 'createSpeech', transcribe: 'createTranscription', video: 'createVideo' })[currentMode]](input);
220
224
  if (!validJob(task)) return; task.runId = created.flow.id;
221
225
  await openRun(created.flow.id, { fromAction: true });
222
226
  if (!validJob(task)) return;
@@ -287,7 +291,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
287
291
  });
288
292
  $('#image-file').addEventListener('change', event => { void chooseFile(event.target.files[0]); event.target.value = ''; });
289
293
  $('#model').addEventListener('change', () => { renderComfy(); controls(); });
290
- for (const id of ['model', 'edit-prompt', 'edit-size', 'speech-voice']) $('#' + id).addEventListener('input', () => { if (id === 'model') renderComfy(); else invalidateInspection(); controls(); });
294
+ for (const id of ['model', 'edit-prompt', 'edit-size', 'speech-voice', 'video-duration']) $('#' + id).addEventListener('input', () => { if (id === 'model') renderComfy(); else invalidateInspection(); controls(); });
291
295
  $('#audio-file').addEventListener('change', event => { const file = event.target.files[0]; event.target.value = ''; chooseAudio(file); });
292
296
  $('#comfy-creation-check').addEventListener('click', () => { void checkComfy(); });
293
297
  $('#image-drop').addEventListener('dragover', event => { event.preventDefault(); if (!view && !busy) $('#image-drop').classList.add('dragging'); });
@@ -305,7 +309,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
305
309
  beginDraft,
306
310
  async prepareVerification({ capability, modelRef }) {
307
311
  if (!client || busy || !cap('runs.continue')) return;
308
- const at = epoch, next = capability === 'tts' ? 'tts' : capability === 'transcribe' ? 'transcribe' : cap('generations.create') ? 'generate' : 'edit';
312
+ const at = epoch, next = capability === 'video-gen' ? 'video' : capability === 'tts' ? 'tts' : capability === 'transcribe' ? 'transcribe' : cap('generations.create') ? 'generate' : 'edit';
309
313
  if (!cap(createCapabilities[next])) { feedback('当前工作区没有此验证流程,请使用已接入的 CLI / DSH 功能。', true); return; }
310
314
  let file;
311
315
  if (next === 'edit') {
@@ -318,7 +322,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
318
322
  }
319
323
  if (!valid(at)) return;
320
324
  beginDraft({ mode: next, modelRef, file });
321
- $('#edit-prompt').value = next === 'tts' ? '你好' : next === 'edit' ? '将红色圆形改为蓝色,保留白色背景。' : next === 'generate' ? 'A simple red circle on a plain white background.' : '';
325
+ $('#edit-prompt').value = next === 'tts' ? '你好' : next === 'edit' ? '将红色圆形改为蓝色,保留白色背景。' : next === 'generate' ? 'A simple red circle on a plain white background.' : next === 'video' ? 'A purple iris flower swaying gently in the breeze.' : '';
322
326
  feedback(next === 'transcribe' ? '验证草稿已准备。请选择实际音频,确认后点击开始转写,可能产生费用。'
323
327
  : '验证草稿已准备。请核对模型及参数,再明确点击生成;可能产生供应商费用。'); controls();
324
328
  },
@@ -327,7 +331,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
327
331
  const changed = view?.id !== value.id;
328
332
  loading = false; view = value; retryAction = null;
329
333
  if (changed) { activeOperation = null; stopWatch(); }
330
- if (!sameRun) { fillModels(value.input.modelRef); $('#edit-prompt').value = value.input.text ?? value.input.prompt ?? ''; $('#edit-size').value = value.input.size || ''; $('#speech-voice').value = value.input.voice || '';
334
+ if (!sameRun) { fillModels(value.input.modelRef); $('#edit-prompt').value = value.input.text ?? value.input.prompt ?? ''; $('#edit-size').value = value.input.size || ''; $('#speech-voice').value = value.input.voice || ''; $('#video-duration').value = value.input.duration ?? '';
331
335
  $('#input-summary').textContent = value.input.modelRef; $('#file-name').textContent = '流程原图';
332
336
  if (matchMedia('(max-width:640px)').matches) $('#creation-settings').open = false; }
333
337
  renderComfy(); controls();
@@ -341,7 +345,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
341
345
  if (busy) return;
342
346
  if (['continue', 'stop'].includes(action.kind)) { retryAction = action; feedback('将使用原身份接回此操作,请确认后点击。'); controls(); return; }
343
347
  beginDraft({ artifactId: ['flow-create', 'transcription-create'].includes(action.kind) ? action.artifactId : undefined,
344
- mode: ({ 'generation-create': 'generate', 'audio-import': 'transcribe', 'transcription-create': 'transcribe', 'speech-create': 'tts' })[action.kind] || 'edit' });
348
+ mode: ({ 'generation-create': 'generate', 'video-create': 'video', 'audio-import': 'transcribe', 'transcription-create': 'transcribe', 'speech-create': 'tts' })[action.kind] || 'edit' });
345
349
  retryAction = action; feedback(IMPORTS.includes(action.kind) ? '请重新提供原素材字节,再按原 Key 接回。' : '请重新提供原模型、要求与参数,再按原 Key 接回。'); controls();
346
350
  },
347
351
  async refresh() { if (activeOperation && ACTIVE.has(activeOperation.state)) watch(activeOperation); controls(); },
@@ -349,7 +353,7 @@ export function createCreationPanel({ openRun, refreshHistory, showDraft, descri
349
353
  epoch++; intent++; stopWatch(); client = media = session = null; view = null; draft = {}; models = []; busy = stopping = loading = false; job = activeOperation = retryAction = recoveryAfterLoad = null;
350
354
  releasePreview(); $('#input-preview').removeAttribute('src'); $('#input-preview').hidden = true; $('#image-file').value = '';
351
355
  $('#audio-file').value = ''; $('#audio-file-name').textContent = 'WAV / MP3 / OGG / FLAC,最大20 MiB。'; $('#speech-voice').value = '';
352
- $('#edit-prompt').value = ''; $('#edit-size').value = ''; fillModels(); renderComfy(true); feedback(); controls();
356
+ $('#edit-prompt').value = ''; $('#edit-size').value = ''; $('#video-duration').value = ''; fillModels(); renderComfy(true); feedback(); controls();
353
357
  }
354
358
  });
355
359
  }
@@ -1,6 +1,6 @@
1
1
  // Consume the HTTP DTO, not private Core records or the prototype's in-memory transport.
2
2
  export function presentReadOnlyFlow(view) {
3
- if (view.contractVersion !== 0 || !['image-edit/v0', 'image-generate/v0', 'speech/v0', 'transcribe/v0'].includes(view.recipe)) throw new TypeError('不支持的流程版本');
3
+ if (view.contractVersion !== 0 || !['image-edit/v0', 'image-generate/v0', 'speech/v0', 'transcribe/v0', 'video-generate/v0'].includes(view.recipe)) throw new TypeError('不支持的流程版本');
4
4
  switch (view.status) {
5
5
  case 'not_started': return { title: '已保存,尚未开始', message: '输入已保存。当前页面只读,不会提交生成。', tone: 'neutral' };
6
6
  case 'running': return { title: '原任务进行中', message: '这是本地保存的进展。页面刷新不会查询模型服务;可通过 CLI 查看原任务。', tone: 'working' };
@@ -10,7 +10,8 @@ export function presentReadOnlyFlow(view) {
10
10
  case 'delivery_failed': return { title: '原结果待保存', message: '原任务已成功,本地保存尚未完成。请通过 CLI 恢复原结果。', tone: 'warning' };
11
11
  case 'needs_comparison': return { title: '结果已保存', message: '本地比较尚未完成。可查看现有图片,通过 CLI 继续流程。', tone: 'neutral' };
12
12
  case 'needs_result': return { title: '结果已保存', message: '原任务已交付,流程结果仍待接回。请通过 CLI 继续原流程。', tone: 'neutral' };
13
- case 'ready': return { title: view.recipe === 'speech/v0' ? '语音已就绪' : '转写已完成',
13
+ case 'ready': if (view.recipe === 'video-generate/v0') return { title: '视频已就绪', message: '原视频已保存,可以播放或下载。', tone: 'success' };
14
+ return { title: view.recipe === 'speech/v0' ? '语音已就绪' : '转写已完成',
14
15
  message: view.recipe === 'speech/v0' ? '语音已保存,可以试听或下载音频。' : '转写文字已保存,可以查看或下载文本。', tone: 'success' };
15
16
  case 'awaiting_selection': return { title: '图片已就绪', message: '图片已保存。请通过 CLI 明确选择作品,再回来下载。', tone: 'success' };
16
17
  case 'selected': return { title: view.source && view.selection?.artifactId === view.source.artifactId ? '已选择原图' : '已选择结果',
@@ -23,7 +24,7 @@ export function presentFlow(view, operations) {
23
24
  const base = presentReadOnlyFlow(view), execute = operations.includes('runs.continue');
24
25
  if (execute && view.status === 'not_started') return { ...base, message: '输入已固定。明确开始后才会调用模型,可能产生费用。' };
25
26
  if (execute && view.status === 'running') return { ...base, message: '原任务仍在进行。点击“查看原任务”读取一次进展;刷新只读本地记录。' };
26
- if (execute && view.status === 'delivery_failed') return { ...base, message: ['speech/v0', 'transcribe/v0'].includes(view.recipe)
27
+ if (execute && view.status === 'delivery_failed') return { ...base, message: ['speech/v0', 'transcribe/v0', 'video-generate/v0'].includes(view.recipe)
27
28
  ? '原任务已成功。点击“恢复原结果”接回交付,不重复提交。' : '生成已经成功。点击“恢复原结果”接回交付,不新建生成。' };
28
29
  if (execute && view.status === 'needs_comparison') return { ...base, message: '图片已保存。点击“完成比较”执行本地比较,不产生生成请求。' };
29
30
  if (execute && view.status === 'needs_result') return { ...base, message: '原任务已交付。点击“接回结果”保存流程引用,不产生生成请求。' };
@@ -3,7 +3,7 @@ const element = (tag, text, className) => { const node = document.createElement(
3
3
 
4
4
  /** Read-only gallery. Preview bytes are fetched only while a card is visible. */
5
5
  export function createGallery({ openRun, describe }) {
6
- let client, visible = false, filter = 'all', kind = 'image', audioEnabled = false, cursor = null, busy = false, ticket = 0, controller, observer, activeAudio;
6
+ let client, visible = false, filter = 'all', kind = 'image', audioEnabled = false, videoEnabled = false, cursor = null, busy = false, ticket = 0, controller, observer, activeAudio;
7
7
  const previews = new Map();
8
8
  function clearAudio() {
9
9
  if (!activeAudio) return;
@@ -38,7 +38,7 @@ export function createGallery({ openRun, describe }) {
38
38
  for (const card of previews.keys()) observer.observe(card);
39
39
  }
40
40
  function card(run) {
41
- if (['speech/v0', 'transcribe/v0'].includes(run.recipe)) return audioCard(run);
41
+ if (['speech/v0', 'transcribe/v0', 'video-generate/v0'].includes(run.recipe)) return audioCard(run);
42
42
  const button = element('button', '', 'gallery-card'); button.type = 'button'; button.dataset.galleryRun = run.id;
43
43
  button.setAttribute('aria-label', '打开图片流程 · ' + (run.selection ? '已选择' : '未选择'));
44
44
  const cover = element('div', '', 'gallery-cover'), image = document.createElement('img'), marker = element('span', '读取预览…');
@@ -53,27 +53,27 @@ export function createGallery({ openRun, describe }) {
53
53
  return button;
54
54
  }
55
55
  function audioCard(run) {
56
- const speech = run.recipe === 'speech/v0', article = element('article', '', 'gallery-card gallery-audio'); article.dataset.galleryRun = run.id;
56
+ const speech = run.recipe === 'speech/v0', video = run.recipe === 'video-generate/v0', article = element('article', '', 'gallery-card gallery-audio'); article.dataset.galleryRun = run.id;
57
57
  const cover = element('div', '', 'gallery-audio-cover');
58
- cover.append(element('span', speech ? '♪' : '≋', 'gallery-audio-icon'), element('span', speech ? 'VOICE' : 'TRANSCRIPT', 'eyebrow'));
58
+ cover.append(element('span', video ? '▷' : speech ? '♪' : '≋', 'gallery-audio-icon'), element('span', video ? 'MOTION' : speech ? 'VOICE' : 'TRANSCRIPT', 'eyebrow'));
59
59
  const info = element('div', '', 'gallery-info');
60
- info.append(element('strong', (run.output ? '已保存 · ' : '待完成 · ') + (speech ? '语音合成' : '音频转写')),
60
+ info.append(element('strong', (run.output ? '已保存 · ' : '待完成 · ') + (video ? '视频生成' : speech ? '语音合成' : '音频转写')),
61
61
  element('p', new Date(run.updatedAt).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + ' · ' + run.modelRef));
62
- const actions = element('div', '', 'export-actions'), open = element('button', speech ? '查看作品' : '查看文字', 'quiet'); open.type = 'button';
62
+ const actions = element('div', '', 'export-actions'), open = element('button', video || speech ? '查看作品' : '查看文字', 'quiet'); open.type = 'button';
63
63
  open.addEventListener('click', () => openRun(run.id)); actions.append(open);
64
- const artifact = speech ? run.output : run.source;
64
+ const artifact = speech || video ? run.output : run.source;
65
65
  if (artifact) {
66
- const play = element('button', speech ? '载入试听' : '载入原音频', 'quiet'); play.type = 'button'; play.dataset.galleryPlay = run.id; actions.append(play);
67
- const player = document.createElement('audio'); player.controls = true; player.preload = 'metadata'; player.hidden = true;
66
+ const play = element('button', video ? '载入视频' : speech ? '载入试听' : '载入原音频', 'quiet'); play.type = 'button'; play.dataset.galleryPlay = run.id; actions.append(play);
67
+ const player = document.createElement(video ? 'video' : 'audio'); player.playsInline = true; player.controls = true; player.preload = 'metadata'; player.hidden = true;
68
68
  const note = element('p', '', 'field-note');
69
69
  play.addEventListener('click', async () => {
70
70
  clearAudio();
71
71
  const at = ticket, http = client, job = { controller: new AbortController(), player, button: play, url: null };
72
- activeAudio = job; play.disabled = true; note.textContent = '读取音频…';
72
+ activeAudio = job; play.disabled = true; note.textContent = video ? '读取原视频…' : '读取音频…';
73
73
  try {
74
- const blob = await http.get('/api/v0/artifacts/' + artifact.artifactId + '/audio', { binary: true, signal: job.controller.signal });
74
+ const blob = await http.get('/api/v0/artifacts/' + artifact.artifactId + (video ? '/video' : '/audio'), { binary: true, signal: job.controller.signal });
75
75
  if (!visible || at !== ticket || job !== activeAudio) return;
76
- job.url = URL.createObjectURL(blob); player.src = job.url; player.hidden = false; play.hidden = true; note.textContent = '点击播放器试听。';
76
+ job.url = URL.createObjectURL(blob); player.src = job.url; player.hidden = false; play.hidden = true; note.textContent = video ? '点击播放;仅载入原视频,不调用模型。' : '点击播放器试听。';
77
77
  } catch (error) { if (job === activeAudio && !job.controller.signal.aborted) note.textContent = describe(error); }
78
78
  finally { if (job === activeAudio) play.disabled = false; }
79
79
  });
@@ -97,7 +97,7 @@ export function createGallery({ openRun, describe }) {
97
97
  $('#gallery-feedback').textContent = '正在读取作品…'; $('#gallery-empty').hidden = true;
98
98
  try {
99
99
  const value = await http.get('/api/v0/runs?' + new URLSearchParams({ limit: '20', selection: filter,
100
- ...(audioEnabled ? { kind } : {}), ...(more && cursor ? { cursor } : {}) }), { signal });
100
+ ...(audioEnabled || videoEnabled ? { kind } : {}), ...(more && cursor ? { cursor } : {}) }), { signal });
101
101
  if (at !== ticket || client !== http || !visible) return;
102
102
  if (value.contractVersion !== 0 || !value.counts || !Array.isArray(value.runs)) throw new TypeError('不支持的作品列表');
103
103
  cursor = value.nextCursor; counts(value.counts);
@@ -114,7 +114,7 @@ export function createGallery({ openRun, describe }) {
114
114
  void load();
115
115
  });
116
116
  for (const button of document.querySelectorAll('[data-gallery-kind]')) button.addEventListener('click', () => {
117
- if (!audioEnabled || button.dataset.galleryKind === kind) return;
117
+ if (!(audioEnabled || videoEnabled) || button.hidden || button.dataset.galleryKind === kind) return;
118
118
  kind = button.dataset.galleryKind; filter = 'all'; cursor = null;
119
119
  for (const item of document.querySelectorAll('[data-gallery-kind]')) item.setAttribute('aria-pressed', String(item === button));
120
120
  $('#gallery-filters').hidden = kind !== 'image'; void load();
@@ -122,9 +122,10 @@ export function createGallery({ openRun, describe }) {
122
122
  $('#gallery-refresh').addEventListener('click', () => { void load(); });
123
123
  $('#gallery-more').addEventListener('click', () => { void load(true); });
124
124
  return Object.freeze({
125
- connect(http, session) { client = http; audioEnabled = session?.audio === true; $('#gallery-types').hidden = !audioEnabled; },
125
+ connect(http, session) { client = http; audioEnabled = session?.audio === true; videoEnabled = session?.video === true; $('#gallery-types').hidden = !audioEnabled && !videoEnabled;
126
+ for (const item of document.querySelectorAll('[data-gallery-kind]')) item.hidden = item.dataset.galleryKind === 'video' ? !videoEnabled : item.dataset.galleryKind !== 'image' && !audioEnabled; },
126
127
  show(value) { if (visible === value) return; visible = value; if (value) void load(); else { ticket++; busy = false; clear(); $('#gallery-grid').replaceChildren(); } },
127
- disconnect() { visible = false; client = null; ticket++; busy = false; cursor = null; filter = 'all'; kind = 'image'; audioEnabled = false; clear();
128
+ disconnect() { visible = false; client = null; ticket++; busy = false; cursor = null; filter = 'all'; kind = 'image'; audioEnabled = videoEnabled = false; clear();
128
129
  for (const item of document.querySelectorAll('[data-gallery-kind]')) item.setAttribute('aria-pressed', String(item.dataset.galleryKind === kind));
129
130
  $('#gallery-types').hidden = true; $('#gallery-filters').hidden = false; $('#gallery-grid').replaceChildren(); counts({ all: 0, selected: 0, unselected: 0 }); $('#gallery-feedback').textContent = ''; },
130
131
  refresh() { return load(); }