@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
package/lib/web/app.js CHANGED
@@ -6,46 +6,75 @@ import { createCreationPanel } from './creation.js';
6
6
  import { createGallery } from './gallery.js';
7
7
  import { createProviderPanel } from './providers.js';
8
8
  import { createAppearance } from './appearance.js';
9
+ import { createNetworkPanel } from './network.js';
9
10
 
10
11
  const $ = selector => document.querySelector(selector);
11
12
  const urls = new Set();
12
13
  let client = null, mediaClient = null, recoveryTicket = 0, epoch = 0, runTicket = 0, runController = null, current = null;
13
14
  let runs = [], nextCursor = null, historyBusy = false, connecting = false, capabilities = [], page = 'studio', historyTotal = 0;
14
- let canManageProviders = false, audioEnabled = false;
15
+ let canManageProviders = false, audioEnabled = false, videoEnabled = false;
16
+ let modeLabel = '只读预览';
15
17
  let loginController = null;
16
18
  let startupSequence = 0;
17
19
  const passwordMode = document.documentElement.dataset.auth === 'password';
18
20
  let passwordLogin, passwordLoginLoading;
19
21
  let plugins = null;
22
+ let pluginStatus = null, pluginStatusSession = null;
20
23
  const RUN = /^run_[a-f0-9]{24}$/;
21
24
  const TOKEN = /^[A-Za-z0-9_-]{43}$/, SESSION_KEY = 'iris.workbench.session.v0';
22
25
  const date = value => new Date(value).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
23
26
  const valid = context => context.epoch === epoch && context.client === client;
24
27
  const context = () => ({ epoch, client });
28
+ const videoRun = view => view?.recipe === 'video-generate/v0';
25
29
  const audioRun = view => ['speech/v0', 'transcribe/v0'].includes(view?.recipe);
26
- const recipeLabel = recipe => ({ 'image-edit/v0': '图片编辑', 'image-generate/v0': '图片生成', 'speech/v0': '语音合成', 'transcribe/v0': '音频转写' })[recipe];
30
+ const recipeLabel = recipe => ({ 'image-edit/v0': '图片编辑', 'image-generate/v0': '图片生成', 'speech/v0': '语音合成', 'transcribe/v0': '音频转写', 'video-generate/v0': '视频生成' })[recipe];
27
31
  const connectionPanel = createConnectionPanel({ describe });
28
32
  const gallery = createGallery({ openRun: id => loadRun(id), describe });
29
- const providerPanel = createProviderPanel({ describe, onSaved: () => loadModels(context()), onSettings: (value, error) => overview.setSettings(value, error),
30
- onProbe: options => creation.prepareVerification(options) });
33
+ const providerPanel = createProviderPanel({ describe, onSaved: () => { void networkPanel.refresh(); return loadModels(context()); }, onSettings: (value, error) => overview.setSettings(value, error),
34
+ onProbe: options => creation.prepareVerification(options), onNetworkOpen: id => { tab('network'); networkPanel.focusProvider(id); } });
35
+ const networkPanel = createNetworkPanel({ describe, onChanged: value => providerPanel.setNetworkFacts(value) });
31
36
  const creation = createCreationPanel({ openRun: loadRun, refreshHistory: () => loadHistory(context(), false, false),
32
37
  showDraft, describe, recover: recoverActions, renderOperation });
33
38
  const overview = createOverviewPanel();
34
39
  function tab(next = 'studio') {
35
40
  page = next;
36
41
  document.body.dataset.page = page;
37
- $('#overview-panel').hidden = page !== 'overview'; $('#workspace').hidden = page !== 'studio'; $('#connections-panel').hidden = page !== 'connections'; $('#gallery-panel').hidden = page !== 'gallery'; $('#providers-panel').hidden = page !== 'providers';
38
- for (const [id, target] of [['overview-tab', 'overview'], ['studio-tab', 'studio'], ['media-tab', 'gallery'], ['connections-tab', 'connections'], ['providers-tab', 'providers']]) {
42
+ $('#overview-panel').hidden = page !== 'overview'; $('#workspace').hidden = page !== 'studio'; $('#connections-panel').hidden = page !== 'connections'; $('#gallery-panel').hidden = page !== 'gallery'; $('#providers-panel').hidden = page !== 'providers'; $('#plugins-panel').hidden = page !== 'plugins';
43
+ $('#network-panel').hidden = page !== 'network';
44
+ for (const [id, target] of [['overview-tab', 'overview'], ['studio-tab', 'studio'], ['media-tab', 'gallery'], ['connections-tab', 'connections'], ['providers-tab', 'providers'], ['network-tab', 'network'], ['plugins-tab', 'plugins']]) {
39
45
  $('#' + id).setAttribute('aria-current', page === target ? 'page' : 'false');
40
46
  }
41
- $('#page-label').textContent = { overview: '总览', studio: '创作', gallery: '作品', connections: '客户端连接', providers: '模型服务' }[page];
47
+ $('#page-label').textContent = { overview: '总览', studio: '创作', gallery: '作品', connections: '客户端连接', providers: '模型服务', network: '网络出口', plugins: '插件' }[page];
48
+ $('#mode-badge').textContent = page === 'network' ? '出口管理' : modeLabel;
42
49
  plugins?.show(page);
43
50
  if (page === 'plugin-settings') $('#page-label').textContent = '扩展设置';
44
51
  else if (page === 'plugin-history') $('#page-label').textContent = '业务记录';
45
52
  else if (page.startsWith('plugin:')) $('#page-label').textContent = $('#plugin-page-title').textContent;
46
53
  connectionPanel.show(page === 'connections'); gallery.show(page === 'gallery'); creation.show(page === 'studio');
47
54
  providerPanel.show(page === 'providers');
48
- if (page !== 'studio') { closePreview(); for (const id of ['source-audio', 'output-audio', 'audio-input-preview']) $('#' + id).pause(); }
55
+ networkPanel.show(page === 'network');
56
+ pluginStatus?.show(page === 'plugins');
57
+ if (page === 'plugins' && !pluginStatus) void openPluginStatus();
58
+ if (page !== 'studio') { closePreview(); for (const id of ['source-audio', 'output-audio', 'audio-input-preview', 'output-video']) $('#' + id).pause(); }
59
+ }
60
+
61
+ async function openPluginStatus() {
62
+ const ctx = context();
63
+ if (!ctx.client) return;
64
+ $('#plugins-refresh').disabled = true;
65
+ $('#plugins-feedback').hidden = false; $('#plugins-feedback').classList.remove('error'); $('#plugins-feedback').textContent = '正在载入插件页面…';
66
+ try {
67
+ const { createPluginStatusPanel } = await import('./plugin-status.js');
68
+ if (!valid(ctx) || page !== 'plugins') return;
69
+ if (!pluginStatus) { pluginStatus = createPluginStatusPanel({ describe, navigate: tab }); pluginStatus.connect(ctx.client, pluginStatusSession); }
70
+ pluginStatus.show(true);
71
+ } catch (error) {
72
+ if (valid(ctx) && page === 'plugins') {
73
+ $('#plugins-feedback').hidden = false;
74
+ $('#plugins-feedback').classList.add('error');
75
+ $('#plugins-feedback').textContent = '插件页面未能载入,请核对服务版本;源码更新后需正常重启 Core 并刷新页面。' + describe(error);
76
+ }
77
+ }
49
78
  }
50
79
 
51
80
  function feedback(message, error = false) {
@@ -118,11 +147,11 @@ function showDraft(preview, mode = 'edit') {
118
147
  runTicket++; current = null; releaseMedia(); tab();
119
148
  history.replaceState(null, '', location.pathname);
120
149
  $('#viewer').setAttribute('aria-busy', 'false'); $('#viewer-empty').hidden = false; $('#run-view').hidden = true;
121
- $('#viewer-empty h2').textContent = ({ generate: '从一句想法开始', edit: '从一张图片开始', tts: '让文字有声音', transcribe: '把声音留下来' })[mode];
150
+ $('#viewer-empty h2').textContent = ({ generate: '从一句想法开始', edit: '从一张图片开始', tts: '让文字有声音', transcribe: '把声音留下来', video: '让想法动起来' })[mode];
122
151
  $('#viewer-empty p').textContent = ({ generate: '写下你想看见的画面,选一个模型。作品会保存在当前工作区。', edit: '选好原图,写下想法。作品会保存在当前工作区。',
123
- tts: '写下要朗读的内容,选择语音模型。合成后可在这里试听、下载。', transcribe: '选择一段音频和转写模型。原音频与文字结果都会保存在当前工作区。' })[mode];
124
- $('.canvas-label').textContent = ['tts', 'transcribe'].includes(mode) ? '声音工作台' : '创作画布';
125
- $('#view-modes').hidden = mode !== 'edit'; $('#reuse-selected').hidden = ['tts', 'transcribe'].includes(mode);
152
+ video: '描述主体与动作,选择实际视频模型。原任务的结果可在这里播放、下载。', tts: '写下要朗读的内容,选择语音模型。合成后可在这里试听、下载。', transcribe: '选择一段音频和转写模型。原音频与文字结果都会保存在当前工作区。' })[mode];
153
+ $('.canvas-label').textContent = mode === 'video' ? '动态工作台' : ['tts', 'transcribe'].includes(mode) ? '声音工作台' : '创作画布';
154
+ $('#view-modes').hidden = mode !== 'edit'; $('#reuse-selected').hidden = ['tts', 'transcribe', 'video'].includes(mode);
126
155
  $('#draft-preview').removeAttribute('src'); $('#draft-preview').hidden = !preview; if (preview) $('#draft-preview').src = preview;
127
156
  $('#prompt').textContent = ''; $('#run-facts').replaceChildren(); $('#run-parameter-values').replaceChildren(); $('#run-parameters').hidden = true; $('#download').disabled = true;
128
157
  $('#run-details').open = false; $('[data-view="overlay"]').disabled = true; setView('pair'); feedback(''); renderHistory();
@@ -134,18 +163,24 @@ function renderOperation(operation) {
134
163
  $('#operation-note').textContent = active ? '只读取已接收操作的本地进度;停止仅结束本地等待。'
135
164
  : operation.state === 'canceled' ? '已停止本地等待。远端取消未请求,不代表退款或未计费。'
136
165
  : operation.state === 'interrupted' ? '原操作已中断,现有任务与结果仍保留;不会自动重提。'
166
+ : operation.state === 'failed' ? '本地操作未完成,原记录仍保留。请核对任务及 Core 错误记录后,再决定是否继续。'
137
167
  : '本次操作已结束,作品进展以当前流程为准。';
138
168
  if (active) {
139
- $('#status-title').textContent = operation.state === 'queued' ? '本次操作已接收' : '正在处理';
140
- $('#status-message').textContent = '可以离开此页,已接收的事实仍会保存。';
169
+ const waiting = { remote_capacity: ['等待远端任务名额', '已有任务尚未确认结束,本次生成还没有提交。'],
170
+ cooldown: ['等待模型冷却', '达到可用时间后继续,不会重复提交原请求。'],
171
+ rate_limit: ['等待发送窗口', '请求按当前发送速率排队。'], http_queue: ['等待网络名额', '其他请求正在提交、查询或下载。'],
172
+ candidates: ['正在核对模型', '核对本次模型与原连接身份。'] }[operation.scheduling?.phase];
173
+ $('#status-title').textContent = waiting?.[0] || (operation.state === 'queued' ? '本次操作已接收' : '正在处理');
174
+ $('#status-message').textContent = waiting?.[1] || '可以离开此页,已接收的事实仍会保存。';
141
175
  }
142
176
  }
143
177
  function releaseMedia() {
144
178
  plugins?.clearPreview();
145
179
  runController?.abort(); runController = null;
146
180
  closePreview();
147
- for (const id of ['source-audio', 'output-audio']) { const player = $('#' + id); player.pause(); player.removeAttribute('src'); player.load(); }
181
+ for (const id of ['source-audio', 'output-audio', 'output-video']) { const player = $('#' + id); player.pause(); player.removeAttribute('src'); player.load(); }
148
182
  for (const id of ['audio-source-result', 'audio-output-result', 'transcript-result']) $('#' + id).hidden = true;
183
+ $('#output-video').hidden = true; $('#video-result').hidden = true; $('#video-result-note').textContent = '';
149
184
  $('#transcript-result').textContent = ''; $('#audio-result-note').textContent = ''; $('#audio-result').hidden = true;
150
185
  for (const kind of ['source', 'output']) $('#overlay-' + kind).removeAttribute('src');
151
186
  for (const url of urls) URL.revokeObjectURL(url);
@@ -157,17 +192,20 @@ function releaseMedia() {
157
192
  }
158
193
  }
159
194
  function disconnect(message = '', { forget = true } = {}) {
195
+ pluginStatus?.disconnect(); pluginStatusSession = null;
196
+ $('#plugins-panel').hidden = true; $('#plugins-feedback').textContent = ''; $('#plugins-feedback').hidden = true;
160
197
  plugins?.disconnect();
161
198
  loginController?.abort(); loginController = null;
162
199
  if (forget) { if (passwordMode) passwordLogin?.forget(); else storedSession('removeItem'); }
163
- providerPanel.disconnect(); overview.disconnect(); canManageProviders = audioEnabled = false; $('#providers-panel').hidden = true; $('#providers-tab').hidden = true;
200
+ providerPanel.disconnect(); overview.disconnect(); canManageProviders = audioEnabled = videoEnabled = false; $('#providers-panel').hidden = true; $('#providers-tab').hidden = true;
201
+ networkPanel.disconnect(); $('#network-panel').hidden = true; $('#network-tab').hidden = true;
164
202
  creation.disconnect(); gallery.disconnect(); capabilities = []; document.body.classList.remove('connected');
165
203
  epoch++; runTicket++; recoveryTicket++; mediaClient?.close(); mediaClient = null; client?.close(); client = null; current = null;
166
204
  $('#recovery-panel').hidden = true; $('#recovery-panel').open = false;
167
205
  $('#recovery-list').replaceChildren(); $('#recovery-note').textContent = ''; $('#recovery-summary').textContent = '';
168
206
  $('#recovery-clear-confirm').hidden = true;
169
207
  connectionPanel.disconnect(); $('#workspace-nav').hidden = true; $('#app-sidebar').hidden = true; $('#connections-panel').hidden = true; $('#gallery-panel').hidden = true;
170
- $('#mode-badge').textContent = '只读预览';
208
+ modeLabel = '只读预览'; $('#mode-badge').textContent = modeLabel;
171
209
  connecting = false; historyBusy = false; runs = []; nextCursor = null; historyTotal = 0;
172
210
  releaseMedia(); $('#connect').disabled = false; $('#token').value = ''; $('#token-file').value = '';
173
211
  $('#workspace').hidden = true; $('#connection').hidden = false; $('#disconnect').hidden = true;
@@ -198,19 +236,21 @@ async function connectToken(raw, { restoring = false } = {}) {
198
236
  if (session.contractVersion !== 0 || !session.operations.includes('runs.inspect')) throw new TypeError('不支持的工作台会话');
199
237
  if (!passwordMode) storedSession('setItem', token);
200
238
  mediaClient = createMediaClient({ client: ctx.client, session, origin: location.origin });
201
- capabilities = session.operations; audioEnabled = session.audio === true; overview.connect(session); creation.connect(ctx.client, mediaClient, session); gallery.connect(ctx.client, session);
239
+ capabilities = session.operations; audioEnabled = session.audio === true; videoEnabled = session.video === true; overview.connect(session); creation.connect(ctx.client, mediaClient, session); gallery.connect(ctx.client, session);
202
240
  if (!plugins && (session.pluginExecution || session.panelContributions?.pages.length || session.panelContributions?.settings.length || session.panelContributions?.renderers.length)) {
203
241
  const { createPluginPanel } = await import('./plugin-panel.js');
204
242
  if (!valid(ctx)) return;
205
243
  plugins = createPluginPanel({ navigate: tab, describe, onProvidersSaved: result => { overview.setSettings(result); void loadModels(context()); } });
206
244
  }
207
245
  plugins?.connect(ctx.client, session);
246
+ pluginStatusSession = session; pluginStatus?.connect(ctx.client, session);
208
247
  canManageProviders = session.providerManagement === true; $('#providers-tab').hidden = !canManageProviders;
248
+ $('#network-tab').hidden = session.networkManagement !== true;
209
249
  document.body.classList.add('connected'); $('#app-sidebar').hidden = false;
210
250
  $('#connection').hidden = true; $('#workspace').hidden = false; $('#disconnect').hidden = false;
211
251
  tab(); $('#workspace-nav').hidden = false; $('#connections-tab').hidden = !session.connectionManagement;
212
252
  $('#recent-panel').open = !matchMedia('(max-width:850px)').matches;
213
- $('#mode-badge').textContent = session.operations.includes('runs.continue') ? '可创作' : session.connectionManagement ? 'Core 工作台' : session.readOnly ? '只读预览' : '可选择作品';
253
+ modeLabel = session.operations.includes('runs.continue') ? '可创作' : session.connectionManagement ? 'Core 工作台' : session.readOnly ? '只读预览' : '可选择作品'; $('#mode-badge').textContent = modeLabel;
214
254
  const overviewRequested = location.hash === '#overview';
215
255
  if (!RUN.test(location.hash.slice(1))) creation.beginDraft();
216
256
  if (overviewRequested) { history.replaceState(null, '', '#overview'); tab('overview'); }
@@ -218,7 +258,7 @@ async function connectToken(raw, { restoring = false } = {}) {
218
258
  $('#main-content').focus({ preventScroll: true });
219
259
  feedback('');
220
260
  await Promise.all([loadModels(ctx), loadHistory(ctx), recoverActions(ctx), ...(session.connectionManagement ? [connectionPanel.connect(ctx.client)] : []),
221
- ...(canManageProviders ? [providerPanel.connect(ctx.client)] : [])]);
261
+ ...(canManageProviders ? [providerPanel.connect(ctx.client)] : []), ...(session.networkManagement ? [networkPanel.connect(ctx.client)] : [])]);
222
262
  } catch (error) {
223
263
  if (valid(ctx)) disconnect(describe(error), { forget: !restoring || error.status === 401 || error.message.startsWith('不支持') });
224
264
  } finally {
@@ -245,7 +285,7 @@ async function recoverActions(ctx = context()) {
245
285
  ? '当前只保留内存中的动作标识,刷新恢复受限。请先核对原结果。' : '这里只读取原请求与已保存结果,不会自动开始或继续生成。');
246
286
  $('#recovery-check').hidden = saved.blocked || !saved.actions.length;
247
287
  $('#recovery-clear').hidden = !saved.blocked && !saved.actions.length;
248
- const labels = { 'image-import': '素材保存', 'flow-create': '改图流程创建', 'generation-create': '生图流程创建', 'audio-import': '音频保存',
288
+ const labels = { 'image-import': '素材保存', 'flow-create': '改图流程创建', 'generation-create': '生图流程创建', 'video-create': '视频流程创建', 'audio-import': '音频保存',
249
289
  'speech-create': '语音流程创建', 'transcription-create': '转写流程创建', continue: '执行', selection: '作品选择', stop: '本地停止' };
250
290
  const states = { confirmed: '已读取原结果', prepared: '本地意图已保存,仍需明确接回', current: '已读取当前选择',
251
291
  active: '原操作仍在处理中', not_found: '尚未找到原请求,请保留原身份后明确处理',
@@ -281,7 +321,7 @@ async function recoverActions(ctx = context()) {
281
321
 
282
322
  async function loadModels(ctx) {
283
323
  try {
284
- const results = await Promise.all([ctx.client.get('/api/v0/models'), ...(audioEnabled ? [ctx.client.get('/api/v0/audio-models')] : [])]);
324
+ const results = await Promise.all([ctx.client.get('/api/v0/models'), ...(audioEnabled ? [ctx.client.get('/api/v0/audio-models')] : []), ...(videoEnabled ? [ctx.client.get('/api/v0/video-models').then(value => ({ ...value, models: value.models.map(model => ({ ...model, capability: 'video-gen' })) }))] : [])]);
285
325
  if (!valid(ctx)) return;
286
326
  if (results.some(result => result.contractVersion !== 0)) throw new TypeError('不支持的模型列表版本');
287
327
  const models = results.flatMap(result => result.models), configured = results[0].configured;
@@ -290,7 +330,7 @@ async function loadModels(ctx) {
290
330
  $('#model-note').textContent = !configured ? '启动时可传入 --provider-config。读取已有作品不需要模型配置。'
291
331
  : '以下是配置中的候选,尚不代表已实测。此页面不会探测或调用模型。';
292
332
  $('#model-list').replaceChildren(...models.map(model => {
293
- const item = document.createElement('li'); item.textContent = model.displayName + (model.capability ? model.capability === 'tts' ? ' · 语音合成' : ' · 音频转写' : model.supportsImageEdit ? ' · 支持改图协议' : ''); return item;
333
+ const item = document.createElement('li'); item.textContent = model.displayName + (model.capability ? model.capability === 'video-gen' ? ' · 视频生成' : model.capability === 'tts' ? ' · 语音合成' : ' · 音频转写' : model.supportsImageEdit ? ' · 支持改图协议' : ''); return item;
294
334
  }));
295
335
  } catch (error) {
296
336
  if (valid(ctx)) { $('#model-summary').textContent = '配置不可用'; $('#model-note').textContent = describe(error); overview.setModels(null, describe(error)); }
@@ -303,9 +343,9 @@ function createOverviewPanel() {
303
343
  { id: 'image', cap: 'image-gen', title: '图片', subtitle: '生成、修改,或运行 ComfyUI 工作流。',
304
344
  icon: 'M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M8 8h.01', modes: [['generate', '生成图片', 'generations.create'], ['edit', '修改图片', 'runs.create']],
305
345
  detail: 'API 与 ComfyUI 共用图片作品。修改图片需所选协议支持带图编辑;ComfyUI 参数来自已保存工作流,真实服务仍需单独验收。', command: 'run image' },
306
- { id: 'video', cap: 'video-gen', title: '视频', subtitle: '文生视频、图生视频与语音驱动视频。',
307
- icon: 'M3 6h12v12H3z M15 10l6-3v10l-6-3', modes: [],
308
- detail: 'CLI 与 DSH 已有生成和原任务跟踪。Core 创作页与通用 MCP 尚未接入,配置用途不会自动增加入口。', command: 'run video' },
346
+ { id: 'video', cap: 'video-gen', title: '视频', subtitle: '从一段动态描述开始,接回原任务的视频。',
347
+ icon: 'M3 6h12v12H3z M15 10l6-3v10l-6-3', modes: [['video', '生成视频', 'videos.create']],
348
+ detail: '新 v4 工作区提供固定文生视频流程,支持可选尺寸与时长。明确开始后才调用模型;MP4/WebM 原件可播放下载。图生视频与 S2V 仍使用 CLI / DSH。', command: 'run video' },
309
349
  { id: 'tts', cap: 'tts', title: '语音', subtitle: '让一段文字成为可以试听的声音。',
310
350
  icon: 'M4 10h4l5-4v12l-5-4H4z M16 8c3 2 3 6 0 8 M19 5c5 4 5 10 0 14', modes: [['tts', '合成语音', 'speeches.create']],
311
351
  detail: '新 v3 音频工作区提供 Web / MCP 流程,当前内置合成接口为 DashScope Qwen-TTS 普通 HTTP;音色须与实际模型匹配。', command: 'run tts' },
@@ -328,24 +368,25 @@ function createOverviewPanel() {
328
368
  const operations = () => info?.operations || [];
329
369
  const executable = () => operations().includes('runs.continue');
330
370
  const candidates = feature => catalog?.models.filter(model => feature.id === 'image' ? !model.capability : model.capability === feature.cap);
331
- const supported = feature => feature.id === 'image' || feature.modes.length && info?.audio === true;
371
+ const supported = feature => feature.id === 'image' || feature.modes.length && (feature.id === 'video' ? info?.video === true : info?.audio === true);
332
372
  function fact(list, title, value) { list.append(node('dt', title), node('dd', value)); }
333
373
  function card(feature, index) {
334
374
  const article = node('article', undefined, 'overview-capability card'); article.dataset.overviewCapability = feature.id; article.dataset.providerTone = String(index);
335
375
  const heading = node('div', undefined, 'overview-capability-heading'), mark = node('span', undefined, 'overview-capability-icon');
336
376
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), path = document.createElementNS(svg.namespaceURI, 'path');
337
377
  svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); path.setAttribute('d', feature.icon); svg.append(path); mark.append(svg);
338
- heading.append(mark, node('h3', feature.title), node('span', supported(feature) ? '面板已接入' : feature.modes.length ? '需音频工作区' : 'CLI / DSH', 'overview-entry'));
378
+ heading.append(mark, node('h3', feature.title), node('span', supported(feature) ? '面板已接入' : feature.modes.length ? feature.id === 'video' ? '需视频工作区' : '需音频工作区' : 'CLI / DSH', 'overview-entry'));
339
379
  article.append(heading, node('p', feature.subtitle, 'overview-capability-description'));
340
380
  const declared = settings?.providers.flatMap(provider => provider.models.filter(model => model.capabilities.includes(feature.cap)));
341
381
  const policy = settings?.assignments[feature.cap], values = candidates(feature);
382
+ const workspaceKind = feature.id === 'video' ? '视频' : '音频';
342
383
  const config = errors.settings ? '配置读取失败,未作判断' : settings
343
384
  ? declared.length + ' 个已标注 · ' + ({ auto: '自动选择', manual: '手动排序', disabled: '功能已停用' })[policy.mode]
344
- : info.providerManagement ? '读取已保存配置…' : !feature.modes.length ? '当前会话未提供配置详情' : !supported(feature) ? '当前工作区未提供音频候选'
385
+ : info.providerManagement ? '读取已保存配置…' : !feature.modes.length ? '当前会话未提供配置详情' : !supported(feature) ? '当前工作区未提供' + workspaceKind + '候选'
345
386
  : errors.models ? '候选读取失败' : !catalog ? '读取模型候选…' : !catalog.configured ? '未指定配置' : values.length + ' 个候选';
346
387
  const facts = node('dl', undefined, 'overview-capability-facts'); fact(facts, '配置', config);
347
388
  if (feature.modes.length && info.providerManagement) fact(facts, '候选', errors.models ? '读取失败,未作判断'
348
- : !catalog ? '读取中…' : !supported(feature) ? '此工作区未接入音频流程' : values.length + ' 个当前候选');
389
+ : !catalog ? '读取中…' : !supported(feature) ? '此工作区未接入' + workspaceKind + '流程' : values.length + ' 个当前候选');
349
390
  const records = declared?.map(model => model.verified[feature.cap]).filter(Boolean), passed = records?.filter(record => record.ok).length;
350
391
  const evidence = errors.settings ? '记录读取失败' : !settings ? info.providerManagement ? '读取实测记录…' : '当前会话未提供记录'
351
392
  : !records.length ? '尚无实测记录' : passed + ' 通过 · ' + (records.length - passed) + ' 未通过';
@@ -379,9 +420,9 @@ function createOverviewPanel() {
379
420
  if (!info) return;
380
421
  const stats = $('#overview-stats'); stats.replaceChildren();
381
422
  const state = node('div'); fact(state, '当前服务', executable() ? '可创作' : '仅浏览');
382
- state.append(node('span', info.audio ? '图片、语音与转写工作区' : '图片工作区', 'overview-stat-note')); stats.append(state);
423
+ state.append(node('span', info.video ? '图片、视频、语音与转写工作区' : info.audio ? '图片、语音与转写工作区' : '图片工作区', 'overview-stat-note')); stats.append(state);
383
424
  const models = node('div'); fact(models, '创作候选', errors.models ? '读取失败' : !catalog ? '读取中…' : !catalog.configured ? '未指定配置' : new Set(catalog.models.map(model => model.modelRef)).size + ' 个');
384
- models.append(node('span', info.audio ? '图片/语音/转写,按模型去重' : '图片候选,按模型去重', 'overview-stat-note')); stats.append(models);
425
+ models.append(node('span', info.video ? '图片/视频/语音/转写,按模型去重' : info.audio ? '图片/语音/转写,按模型去重' : '图片候选,按模型去重', 'overview-stat-note')); stats.append(models);
385
426
  const history = node('div'); fact(history, '已保存流程', errors.history ? '读取失败' : total === null ? '读取中…' : total + ' 个');
386
427
  history.append(node('span', '仅统计当前工作区', 'overview-stat-note')); stats.append(history);
387
428
  const expanded = new Set([...document.querySelectorAll('#overview-capabilities details[open]')].map(details => details.closest('[data-overview-capability]').dataset.overviewCapability));
@@ -428,7 +469,7 @@ function renderHistory() {
428
469
  $('#history-list').replaceChildren(...runs.map((run, index) => {
429
470
  const button = document.createElement('button'); button.type = 'button'; button.className = 'history-item';
430
471
  button.dataset.runId = run.id; button.setAttribute('aria-current', run.id === current?.id ? 'true' : 'false');
431
- const title = document.createElement('strong'); title.textContent = (audioRun(run) ? recipeLabel(run.recipe) : '图片流程') + ' · ' + date(run.createdAt);
472
+ const title = document.createElement('strong'); title.textContent = (audioRun(run) || videoRun(run) ? recipeLabel(run.recipe) : '图片流程') + ' · ' + date(run.createdAt);
432
473
  const label = document.createElement('span'); label.textContent = run.modelRef;
433
474
  const number = document.createElement('small'); number.textContent = String(index + 1).padStart(2, '0');
434
475
  button.append(number, title, label); button.addEventListener('click', () => loadRun(run.id)); return button;
@@ -496,6 +537,19 @@ async function loadAudioMedia(ctx, ticket, kind, artifactId, signal) {
496
537
  if (valid(ctx) && ticket === runTicket && !signal.aborted) $('#audio-result-note').textContent += (text ? '文字' : '音频') + '读取失败:' + describe(error) + ' ';
497
538
  }
498
539
  }
540
+ async function loadVideoMedia(ctx, ticket, artifactId, signal) {
541
+ $('#video-result-note').textContent = artifactId ? '正在读取原视频…' : '明确执行后,原任务的视频会保存在这里。';
542
+ if (!artifactId) return;
543
+ try {
544
+ const blob = await ctx.client.get('/api/v0/artifacts/' + artifactId + '/video', { binary: true, signal });
545
+ if (!valid(ctx) || ticket !== runTicket || signal.aborted) return;
546
+ if (!['video/mp4', 'video/webm'].includes(blob.type) || blob.size > 32 * 1024 * 1024) throw new TypeError('不支持的视频格式或大小');
547
+ const url = URL.createObjectURL(blob); urls.add(url);
548
+ $('#output-video').src = url; $('#output-video').hidden = false;
549
+ $('#video-result-note').textContent = '原视频已载入。点击播放或下载;播放器支持取决于当前浏览器。';
550
+ return { artifactId, mediaType: blob.type };
551
+ } catch (error) { if (valid(ctx) && ticket === runTicket && !signal.aborted) $('#video-result-note').textContent = '视频读取失败:' + describe(error); }
552
+ }
499
553
  async function loadRun(id, { fromAction = false } = {}) {
500
554
  const ctx = context(); if (!ctx.client || !RUN.test(id)) return;
501
555
  if (!fromAction) creation.cancelChain();
@@ -510,22 +564,22 @@ async function loadRun(id, { fromAction = false } = {}) {
510
564
  try {
511
565
  const view = await ctx.client.get('/api/v0/runs/' + id, { signal });
512
566
  if (!valid(ctx) || ticket !== runTicket) return;
513
- const presentation = presentFlow(view, capabilities), isAudio = audioRun(view); current = view;
567
+ const presentation = presentFlow(view, capabilities), isAudio = audioRun(view), isVideo = videoRun(view); current = view;
514
568
  history.replaceState(null, '', '#' + view.id); renderHistory();
515
569
  $('#status-title').textContent = presentation.title; $('#status-message').textContent = presentation.message;
516
570
  $('#run-view').dataset.tone = presentation.tone;
517
571
  $('#run-view').dataset.hasOutput = String(Boolean(view.output));
518
- $('#run-view').dataset.recipe = view.recipe; $('#source-card').hidden = !view.source; $('#view-modes').hidden = isAudio || !view.source;
572
+ $('#run-view').dataset.recipe = view.recipe; $('#source-card').hidden = !view.source; $('#view-modes').hidden = isAudio || isVideo || !view.source;
519
573
  $('#prompt-title').textContent = isAudio ? view.recipe === 'speech/v0' ? '朗读内容' : '转写来源' : view.source ? '修改要求' : '画面描述';
520
574
  $('[data-view="overlay"]').disabled = !view.source || !view.output; if (!sameRun || !view.output || !view.source) setView('pair');
521
- if (isAudio) { $('#image-pair').hidden = true; $('#overlay-view').hidden = true; }
522
- $('#audio-result').hidden = !isAudio; $('#reuse-selected').hidden = isAudio;
523
- $('.canvas-label').textContent = isAudio ? '声音工作台' : '创作画布';
575
+ if (isAudio || isVideo) { $('#image-pair').hidden = true; $('#overlay-view').hidden = true; }
576
+ $('#audio-result').hidden = !isAudio; $('#video-result').hidden = !isVideo; $('#reuse-selected').hidden = isAudio || isVideo;
577
+ $('.canvas-label').textContent = isVideo ? '动态工作台' : isAudio ? '声音工作台' : '创作画布';
524
578
  $('#audio-result-kind').textContent = view.recipe === 'speech/v0' ? 'VOICE' : 'TRANSCRIPT';
525
579
  $('#audio-result-title').textContent = view.recipe === 'speech/v0' ? '让文字有声音' : '声音里的文字';
526
580
  $('#audio-result-note').textContent = view.output ? '作品已保存;点击播放或下载即可使用。' : '明确执行后,原任务的结果会保存在这里。';
527
581
  $('#operation-note').hidden = true;
528
- $('#selection-feedback').textContent = isAudio ? view.output ? view.recipe === 'speech/v0' ? '音频已保存,可以试听或下载。' : '转写已保存,可下载 UTF-8 文本。' : '结果尚未保存。'
582
+ $('#selection-feedback').textContent = isVideo ? view.output ? '视频已保存,可以播放或下载原件。' : '原任务结果尚未保存。' : isAudio ? view.output ? view.recipe === 'speech/v0' ? '音频已保存,可以试听或下载。' : '转写已保存,可下载 UTF-8 文本。' : '结果尚未保存。'
529
583
  : view.selection ? '当前选择已记录,可以下载原始图片。' : capabilities.includes('runs.select')
530
584
  ? '选择喜欢的版本,再保存到设备。' : '当前未开启选择权限;已有选择仍可下载。';
531
585
  $('#run-meta').textContent = view.input.modelRef + ' · ' + date(view.createdAt);
@@ -542,19 +596,24 @@ async function loadRun(id, { fromAction = false } = {}) {
542
596
  $('#output-card').classList.toggle('is-selected', !$('#output-selected').hidden);
543
597
  $('#select-source').setAttribute('aria-pressed', String(!$('#source-selected').hidden));
544
598
  $('#select-output').setAttribute('aria-pressed', String(!$('#output-selected').hidden));
545
- $('#download').disabled = isAudio ? !view.output : !view.selection;
546
- $('#download').textContent = isAudio ? view.recipe === 'speech/v0' ? '下载音频' : '下载文字' : '下载已选作品';
547
- $('#run-details .muted').hidden = isAudio;
599
+ $('#download').disabled = isAudio || isVideo ? !view.output : !view.selection;
600
+ $('#download').textContent = isVideo ? '下载视频' : isAudio ? view.recipe === 'speech/v0' ? '下载音频' : '下载文字' : '下载已选作品';
601
+ $('#run-details .muted').hidden = isAudio || isVideo;
548
602
  $('#run-facts').replaceChildren(); if (!sameRun) $('#run-details').open = false;
549
603
  creation.view(view, { sameRun });
550
604
  addFact('流程', view.id); addFact('版本', String(view.revision)); addFact('保存时间', date(view.updatedAt));
551
605
  addFact('类型', recipeLabel(view.recipe));
552
606
  if (view.input.voice) addFact('音色', view.input.voice);
607
+ if (view.input.duration !== undefined) addFact('时长', String(view.input.duration) + ' 秒');
608
+ if (isVideo && view.input.size) addFact('尺寸', view.input.size);
553
609
  if (view.source) addFact(isAudio ? '原音频' : '原图', view.source.artifactId); addFact('结果', view.output?.artifactId || '尚未保存');
554
610
  addFact('原任务', view.task?.id || '尚未创建');
611
+ const route = view.task?.networkRoutes?.reduce((latest, value) => !latest || value.lastAt > latest.lastAt ? value : latest, null);
612
+ if (view.task) addFact('最近记录的出口', route ? [route.route === 'proxy' ? '出口池 ' + route.poolId + ' / ' + route.nodeId : route.route === 'direct' ? '明确直连' : '继承环境',
613
+ ({ discover: '目录检查', prepareInput: '输入准备', submit: '提交', poll: '查询', download: '交付', cancel: '取消', vision: '视觉' })[route.operation], date(route.lastAt)].join(' · ') : '未记录');
555
614
  if (view.metrics) addFact('像素差异', (view.metrics.ratio * 100).toFixed(2) + '%');
556
615
  $('#viewer-empty').hidden = true; $('#run-view').hidden = false;
557
- const previewArtifacts = await Promise.all(isAudio ? [loadAudioMedia(ctx, ticket, 'source', view.source?.artifactId, signal),
616
+ const previewArtifacts = await Promise.all(isVideo ? [loadVideoMedia(ctx, ticket, view.output?.artifactId, signal)] : isAudio ? [loadAudioMedia(ctx, ticket, 'source', view.source?.artifactId, signal),
558
617
  loadAudioMedia(ctx, ticket, view.recipe === 'speech/v0' ? 'output' : 'transcript', view.output?.artifactId, signal)]
559
618
  : [loadMedia(ctx, ticket, 'source', view.source?.artifactId, signal), loadMedia(ctx, ticket, 'output', view.output?.artifactId, signal)]);
560
619
  if (valid(ctx) && ticket === runTicket && !signal.aborted) plugins?.previewArtifacts(previewArtifacts, view.selection?.artifactId || view.output?.artifactId);
@@ -565,7 +624,7 @@ async function loadRun(id, { fromAction = false } = {}) {
565
624
  } finally { if (valid(ctx) && ticket === runTicket) $('#viewer').setAttribute('aria-busy', 'false'); }
566
625
  }
567
626
  async function download() {
568
- if (!(audioRun(current) ? current?.output : current?.selection) || $('#download').disabled) return;
627
+ if (!(audioRun(current) || videoRun(current) ? current?.output : current?.selection) || $('#download').disabled) return;
569
628
  const ctx = context(), id = current.id, ticket = runTicket;
570
629
  $('#download').disabled = true; feedback('正在读取作品…');
571
630
  try {
@@ -573,11 +632,11 @@ async function download() {
573
632
  if (!valid(ctx) || ticket !== runTicket) return;
574
633
  const url = URL.createObjectURL(blob); urls.add(url);
575
634
  const link = document.createElement('a'); link.href = url;
576
- const extension = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'audio/wav': 'wav', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/flac': 'flac', 'text/plain': 'txt' }[blob.type];
635
+ const extension = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'audio/wav': 'wav', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/flac': 'flac', 'text/plain': 'txt', 'video/mp4': 'mp4', 'video/webm': 'webm' }[blob.type];
577
636
  if (!extension) throw new TypeError('不支持的下载格式');
578
637
  // The CLI may have changed the selection since this view was loaded. Use the server's
579
638
  // filename, which identifies the exact artifact returned by this download request.
580
- if (!/^iris-artifact_[a-f0-9]{24}\.(?:png|jpg|webp|wav|mp3|ogg|flac|txt)$/.test(filename)) throw new TypeError('不支持的下载文件名');
639
+ if (!/^iris-artifact_[a-f0-9]{24}\.(?:png|jpg|webp|wav|mp3|ogg|flac|txt|mp4|webm)$/.test(filename)) throw new TypeError('不支持的下载文件名');
581
640
  link.download = filename; link.click();
582
641
  setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url); }, 60000);
583
642
  feedback('下载请求已交给浏览器,请在浏览器下载记录中查看。');
@@ -612,6 +671,7 @@ $('#overview-tab').addEventListener('click', () => tab('overview'));
612
671
  $('#studio-tab').addEventListener('click', () => tab());
613
672
  $('#media-tab').addEventListener('click', () => tab('gallery'));
614
673
  $('#connections-tab').addEventListener('click', () => tab('connections'));
674
+ $('#plugins-tab').addEventListener('click', () => tab('plugins'));
615
675
  for (const id of ['new-run', 'gallery-new']) $('#' + id).addEventListener('click', () => creation.beginDraft());
616
676
  for (const button of document.querySelectorAll('#view-modes button')) button.addEventListener('click', () => { if (!button.disabled) setView(button.dataset.view); });
617
677
  $('#compare-split').addEventListener('input', event => $('#overlay-output').style.setProperty('--split', event.target.value + '%'));
@@ -626,6 +686,7 @@ for (const details of document.querySelectorAll('details')) details.addEventList
626
686
  });
627
687
  createAppearance();
628
688
  $('#providers-tab').addEventListener('click', () => { if (canManageProviders) tab('providers'); });
689
+ $('#network-tab').addEventListener('click', () => { if (!$('#network-tab').hidden) tab('network'); });
629
690
  $('#manage-models').addEventListener('click', () => {
630
691
  if (canManageProviders) tab('providers');
631
692
  else feedback('当前服务未开启供应商管理。启动时添加 --allow-provider-management 并指定 --provider-config 后可配置。');