@mokuyoaxis/dsh-iris 0.2.6 → 0.3.0-alpha.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 (177) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.en.md +104 -304
  3. package/README.md +101 -302
  4. package/bin/dsh-iris.js +446 -64
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +7 -5
  7. package/docs/ARCHITECTURE.md +20 -4
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CLI_MANAGEMENT.md +1 -1
  10. package/docs/CONTACT_SHEET.md +62 -0
  11. package/docs/CORE_CONNECTIONS.md +99 -0
  12. package/docs/CORE_QUICKSTART.md +108 -0
  13. package/docs/CORE_RUNTIME_CONTRACT.md +41 -5
  14. package/docs/CORE_SERVICE.md +97 -0
  15. package/docs/CORE_SKILLS.md +94 -0
  16. package/docs/DSH_COMPATIBILITY.md +14 -4
  17. package/docs/DSH_CORE_MIGRATION.md +4 -2
  18. package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +40 -0
  21. package/docs/FUNCTION_MODEL_POOLS.md +1 -1
  22. package/docs/HEADLESS_CLI.md +12 -4
  23. package/docs/HOST_DOCTOR.md +13 -1
  24. package/docs/IMAGE_FLOW.md +75 -0
  25. package/docs/IMAGE_GENERATION.md +127 -0
  26. package/docs/LOCAL_API.md +62 -0
  27. package/docs/LOCAL_API_CONTRACT.md +140 -0
  28. package/docs/LOCAL_CREATION.md +56 -0
  29. package/docs/LOCAL_OPERATIONS.md +46 -0
  30. package/docs/LOCAL_SELECTION.md +57 -0
  31. package/docs/LOCAL_WEB.md +201 -0
  32. package/docs/MCP.md +191 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/PANEL_EXTENSIONS.md +64 -0
  35. package/docs/PLUGINS.md +189 -0
  36. package/docs/PLUGIN_STARTUP.md +100 -0
  37. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  38. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  39. package/docs/PROVIDER_HEALTH.md +4 -2
  40. package/docs/PROVIDER_SETTINGS.md +85 -0
  41. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  42. package/docs/ROADMAP.md +28 -6
  43. package/docs/SECURITY.md +9 -1
  44. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  45. package/docs/VISION_CLI.md +2 -2
  46. package/docs/WEB_PROTOTYPE.md +62 -0
  47. package/docs/WEB_RECOVERY.md +46 -0
  48. package/docs/WORKBENCH_LOGIN.md +63 -0
  49. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  50. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  51. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  52. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  53. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  54. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  55. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  56. package/docs/releases/0.2.7.md +62 -0
  57. package/docs/releases/0.3.0-alpha.1.md +51 -0
  58. package/docs/screenshots.md +41 -1
  59. package/lib/actions.js +132 -70
  60. package/lib/adapters.js +20 -11
  61. package/lib/api.js +38 -38
  62. package/lib/application-runtime.js +213 -0
  63. package/lib/audio-flow-input.js +37 -0
  64. package/lib/client.js +434 -55
  65. package/lib/comfyui-adapter.js +94 -0
  66. package/lib/comfyui-client.js +51 -0
  67. package/lib/comfyui-inspection.js +73 -0
  68. package/lib/comfyui-workflow.js +94 -0
  69. package/lib/config.js +36 -1
  70. package/lib/configured-image-runtime.js +50 -0
  71. package/lib/connection-skill.js +89 -0
  72. package/lib/connection-template.js +23 -0
  73. package/lib/core-artifact-store.js +21 -2
  74. package/lib/core-bridge-selection.js +31 -0
  75. package/lib/core-bridge-tools.js +83 -0
  76. package/lib/core-capability-catalog.js +182 -0
  77. package/lib/core-connection-diagnostics.js +49 -0
  78. package/lib/core-connection-store.js +277 -0
  79. package/lib/core-connections.js +225 -0
  80. package/lib/core-maintenance.js +31 -2
  81. package/lib/core-runtime.js +20 -0
  82. package/lib/core-service-client.js +170 -0
  83. package/lib/core-tasks.js +84 -13
  84. package/lib/core-writer-lease.js +16 -0
  85. package/lib/creation-request-store.js +133 -0
  86. package/lib/doctor.js +12 -1
  87. package/lib/dsh-core-adapter.js +666 -572
  88. package/lib/dsh-host-adapter.js +8 -2
  89. package/lib/dsh-service-bridge.js +66 -0
  90. package/lib/execution-ownership.js +43 -0
  91. package/lib/flow-store.js +290 -0
  92. package/lib/generation-input.js +10 -1
  93. package/lib/headless-vision.js +7 -7
  94. package/lib/image-edit-input.js +1 -1
  95. package/lib/image-flow.js +232 -0
  96. package/lib/index.js +94 -68
  97. package/lib/local-api-projection.js +32 -0
  98. package/lib/local-api-token-directory.js +31 -0
  99. package/lib/local-api.js +1083 -0
  100. package/lib/local-creation.js +150 -0
  101. package/lib/local-operations.js +36 -0
  102. package/lib/local-request-root.js +86 -0
  103. package/lib/local-web-assets.js +37 -0
  104. package/lib/mcp-bridge.js +58 -0
  105. package/lib/mcp-check.js +26 -0
  106. package/lib/mcp-config.js +42 -0
  107. package/lib/mcp-delivery.js +46 -0
  108. package/lib/mcp-http.js +101 -0
  109. package/lib/mcp-server.js +47 -0
  110. package/lib/model-directory-service.js +16 -6
  111. package/lib/model-directory.js +3 -3
  112. package/lib/models.js +2 -2
  113. package/lib/music-transport.js +2 -1
  114. package/lib/operation-runner.js +236 -0
  115. package/lib/operation-store.js +216 -0
  116. package/lib/panel-extension.js +36 -0
  117. package/lib/plugin-contract.js +194 -0
  118. package/lib/plugin-execution.js +51 -0
  119. package/lib/plugin-launch.js +86 -0
  120. package/lib/plugin-operation-store.js +101 -0
  121. package/lib/plugin-operations.js +28 -0
  122. package/lib/plugin-registry.js +109 -0
  123. package/lib/plugin-selection.js +15 -0
  124. package/lib/plugin-source-image.js +5 -0
  125. package/lib/plugin-web-assets.js +54 -0
  126. package/lib/plugins.d.ts +288 -0
  127. package/lib/plugins.js +58 -0
  128. package/lib/private-storage.js +12 -0
  129. package/lib/provider-adapter.js +5 -1
  130. package/lib/provider-adapters.js +56 -18
  131. package/lib/provider-catalog.js +40 -29
  132. package/lib/provider-config-service.js +105 -23
  133. package/lib/provider-diagnostics.js +56 -0
  134. package/lib/provider-extensions.js +29 -0
  135. package/lib/provider-health.js +8 -1
  136. package/lib/provider-monitor.js +79 -0
  137. package/lib/provider-protocol.js +70 -20
  138. package/lib/provider-registry.js +118 -0
  139. package/lib/provider-response.js +28 -0
  140. package/lib/provider-settings.js +134 -0
  141. package/lib/provider-task-runner.js +40 -7
  142. package/lib/render-address.js +35 -0
  143. package/lib/render.js +52 -13
  144. package/lib/skill-installation.js +211 -0
  145. package/lib/vision-model-routing.js +3 -0
  146. package/lib/web/app.js +642 -0
  147. package/lib/web/appearance.js +111 -0
  148. package/lib/web/comfyui.js +146 -0
  149. package/lib/web/connections.js +377 -0
  150. package/lib/web/creation.js +355 -0
  151. package/lib/web/flow-view.js +33 -0
  152. package/lib/web/gallery.js +132 -0
  153. package/lib/web/index.html +291 -0
  154. package/lib/web/media-client.js +204 -0
  155. package/lib/web/plugin-panel.js +221 -0
  156. package/lib/web/plugin-surface.css +17 -0
  157. package/lib/web/providers.js +492 -0
  158. package/lib/web/recovery.js +105 -0
  159. package/lib/web/startup.js +18 -0
  160. package/lib/web/styles.css +518 -0
  161. package/lib/web/transport.js +96 -0
  162. package/lib/web/workbench-login.js +165 -0
  163. package/lib/workbench-artifacts.js +1 -2
  164. package/lib/workbench-auth-cli.js +47 -0
  165. package/lib/workbench-auth.js +200 -0
  166. package/lib/workbench-management.js +8 -9
  167. package/package.json +22 -6
  168. package/plugins/contact-sheet/index.d.mts +4 -0
  169. package/plugins/contact-sheet/index.mjs +33 -0
  170. package/plugins/contact-sheet/web/board.css +14 -0
  171. package/plugins/contact-sheet/web/board.mjs +93 -0
  172. package/skills/iris-core/SKILL.md +44 -0
  173. package/skills/iris-core/agents/openai.yaml +4 -0
  174. package/skills/iris-core/references/cli.md +95 -0
  175. package/skills/iris-core/references/comfyui.md +69 -0
  176. package/skills/iris-core/references/tasks.md +60 -0
  177. package/user_guide.md +19 -9
package/lib/web/app.js ADDED
@@ -0,0 +1,642 @@
1
+ import { createReadClient, redeemLoginLink } from './transport.js';
2
+ import { presentFlow } from './flow-view.js';
3
+ import { createConnectionPanel } from './connections.js';
4
+ import { createMediaClient, CREATIONS, IMPORTS } from './media-client.js';
5
+ import { createCreationPanel } from './creation.js';
6
+ import { createGallery } from './gallery.js';
7
+ import { createProviderPanel } from './providers.js';
8
+ import { createAppearance } from './appearance.js';
9
+
10
+ const $ = selector => document.querySelector(selector);
11
+ const urls = new Set();
12
+ let client = null, mediaClient = null, recoveryTicket = 0, epoch = 0, runTicket = 0, runController = null, current = null;
13
+ let runs = [], nextCursor = null, historyBusy = false, connecting = false, capabilities = [], page = 'studio', historyTotal = 0;
14
+ let canManageProviders = false, audioEnabled = false;
15
+ let loginController = null;
16
+ let startupSequence = 0;
17
+ const passwordMode = document.documentElement.dataset.auth === 'password';
18
+ let passwordLogin, passwordLoginLoading;
19
+ let plugins = null;
20
+ const RUN = /^run_[a-f0-9]{24}$/;
21
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/, SESSION_KEY = 'iris.workbench.session.v0';
22
+ const date = value => new Date(value).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
23
+ const valid = context => context.epoch === epoch && context.client === client;
24
+ const context = () => ({ epoch, client });
25
+ 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];
27
+ const connectionPanel = createConnectionPanel({ describe });
28
+ 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) });
31
+ const creation = createCreationPanel({ openRun: loadRun, refreshHistory: () => loadHistory(context(), false, false),
32
+ showDraft, describe, recover: recoverActions, renderOperation });
33
+ const overview = createOverviewPanel();
34
+ function tab(next = 'studio') {
35
+ page = next;
36
+ 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']]) {
39
+ $('#' + id).setAttribute('aria-current', page === target ? 'page' : 'false');
40
+ }
41
+ $('#page-label').textContent = { overview: '总览', studio: '创作', gallery: '作品', connections: '客户端连接', providers: '模型服务' }[page];
42
+ plugins?.show(page);
43
+ if (page === 'plugin-settings') $('#page-label').textContent = '扩展设置';
44
+ else if (page === 'plugin-history') $('#page-label').textContent = '业务记录';
45
+ else if (page.startsWith('plugin:')) $('#page-label').textContent = $('#plugin-page-title').textContent;
46
+ connectionPanel.show(page === 'connections'); gallery.show(page === 'gallery'); creation.show(page === 'studio');
47
+ providerPanel.show(page === 'providers');
48
+ if (page !== 'studio') { closePreview(); for (const id of ['source-audio', 'output-audio', 'audio-input-preview']) $('#' + id).pause(); }
49
+ }
50
+
51
+ function feedback(message, error = false) {
52
+ $('#feedback').textContent = message; $('#feedback').hidden = !message;
53
+ $('#feedback').classList.toggle('error', error);
54
+ }
55
+ function storedSession(method, value) {
56
+ try {
57
+ const result = window.sessionStorage[method](SESSION_KEY, value);
58
+ if (method !== 'getItem') $('#session-notice').hidden = true;
59
+ return result;
60
+ } catch (error) {
61
+ if (!['SecurityError', 'QuotaExceededError', 'InvalidStateError'].includes(error.name)) throw error;
62
+ const note = $('#session-notice'); note.hidden = false;
63
+ note.textContent = method === 'removeItem' ? '浏览器无法清除保存的登录信息,请关闭此标签页。'
64
+ : '浏览器不允许保存标签页登录,本次仍可连接,刷新后需要重新登录。';
65
+ return null;
66
+ }
67
+ }
68
+ function restoreSession() {
69
+ const token = storedSession('getItem');
70
+ if (!token) return;
71
+ if (!TOKEN.test(token)) { storedSession('removeItem'); feedback('保存的登录信息已失效,请重新连接。', true); return; }
72
+ return connectToken(token, { restoring: true });
73
+ }
74
+ async function openSession() {
75
+ if (passwordMode) {
76
+ passwordLoginLoading ||= import('./workbench-login.js').then(({ createWorkbenchLogin }) => passwordLogin = createWorkbenchLogin({
77
+ connect: token => connectToken(token, { restoring: true }), disconnect: () => disconnect('', { forget: false }), feedback, describe
78
+ }));
79
+ await passwordLoginLoading; return passwordLogin.open();
80
+ }
81
+ if (!location.hash.startsWith('#login=')) return restoreSession();
82
+ const ticket = location.hash.slice('#login='.length);
83
+ history.replaceState(null, '', location.pathname + location.search);
84
+ disconnect('', { forget: false });
85
+ if (!TOKEN.test(ticket)) { feedback('登录链接无效,请从启动终端获取新链接,或展开备用登录。', true); return; }
86
+ const attempt = epoch, controller = new AbortController();
87
+ loginController = controller; connecting = true; $('#connect').disabled = true;
88
+ feedback('正在进入工作台…');
89
+ try {
90
+ const token = await redeemLoginLink(ticket, controller.signal);
91
+ if (attempt === epoch) await connectToken(token);
92
+ } catch (error) {
93
+ if (attempt === epoch) feedback(describe(error), true);
94
+ } finally {
95
+ if (attempt === epoch) { connecting = false; $('#connect').disabled = false; }
96
+ if (loginController === controller) loginController = null;
97
+ }
98
+ }
99
+ async function openWorkbench() {
100
+ const attempt = ++startupSequence;
101
+ // Explicit legacy links retain the manual fallback while a ticket exchange is in flight.
102
+ document.documentElement.dataset.startup = !passwordMode && location.hash.startsWith('#login=') ? 'ready' : 'pending';
103
+ try { await openSession(); }
104
+ catch (error) { feedback(describe(error), true); }
105
+ finally { if (attempt === startupSequence) document.documentElement.dataset.startup = 'ready'; }
106
+ }
107
+ function describe(error) {
108
+ if (error.name === 'TimeoutError') return '读取超时,请确认本地服务仍在运行后重试。';
109
+ if (error instanceof TypeError && !error.message.startsWith('不支持')) return '无法连接本地服务,请确认启动地址并重新连接。';
110
+ return [error.message, error.code, error.requestId ? '关联 ID:' + error.requestId : ''].filter(Boolean).join(' · ');
111
+ }
112
+ function closePreview() { if ($('#image-dialog').open) $('#image-dialog').close(); $('#expanded-image').removeAttribute('src'); }
113
+ function setView(mode) {
114
+ const overlay = mode === 'overlay'; $('#image-pair').hidden = overlay; $('#overlay-view').hidden = !overlay;
115
+ for (const button of document.querySelectorAll('#view-modes button')) button.setAttribute('aria-pressed', String(button.dataset.view === mode));
116
+ }
117
+ function showDraft(preview, mode = 'edit') {
118
+ runTicket++; current = null; releaseMedia(); tab();
119
+ history.replaceState(null, '', location.pathname);
120
+ $('#viewer').setAttribute('aria-busy', 'false'); $('#viewer-empty').hidden = false; $('#run-view').hidden = true;
121
+ $('#viewer-empty h2').textContent = ({ generate: '从一句想法开始', edit: '从一张图片开始', tts: '让文字有声音', transcribe: '把声音留下来' })[mode];
122
+ $('#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);
126
+ $('#draft-preview').removeAttribute('src'); $('#draft-preview').hidden = !preview; if (preview) $('#draft-preview').src = preview;
127
+ $('#prompt').textContent = ''; $('#run-facts').replaceChildren(); $('#run-parameter-values').replaceChildren(); $('#run-parameters').hidden = true; $('#download').disabled = true;
128
+ $('#run-details').open = false; $('[data-view="overlay"]').disabled = true; setView('pair'); feedback(''); renderHistory();
129
+ }
130
+ function renderOperation(operation) {
131
+ if (current?.id !== operation.runId) return;
132
+ $('#operation-note').hidden = false;
133
+ const active = ['queued', 'running'].includes(operation.state);
134
+ $('#operation-note').textContent = active ? '只读取已接收操作的本地进度;停止仅结束本地等待。'
135
+ : operation.state === 'canceled' ? '已停止本地等待。远端取消未请求,不代表退款或未计费。'
136
+ : operation.state === 'interrupted' ? '原操作已中断,现有任务与结果仍保留;不会自动重提。'
137
+ : '本次操作已结束,作品进展以当前流程为准。';
138
+ if (active) {
139
+ $('#status-title').textContent = operation.state === 'queued' ? '本次操作已接收' : '正在处理';
140
+ $('#status-message').textContent = '可以离开此页,已接收的事实仍会保存。';
141
+ }
142
+ }
143
+ function releaseMedia() {
144
+ plugins?.clearPreview();
145
+ runController?.abort(); runController = null;
146
+ closePreview();
147
+ for (const id of ['source-audio', 'output-audio']) { const player = $('#' + id); player.pause(); player.removeAttribute('src'); player.load(); }
148
+ for (const id of ['audio-source-result', 'audio-output-result', 'transcript-result']) $('#' + id).hidden = true;
149
+ $('#transcript-result').textContent = ''; $('#audio-result-note').textContent = ''; $('#audio-result').hidden = true;
150
+ for (const kind of ['source', 'output']) $('#overlay-' + kind).removeAttribute('src');
151
+ for (const url of urls) URL.revokeObjectURL(url);
152
+ urls.clear();
153
+ for (const kind of ['source', 'output']) {
154
+ const image = $('#' + kind + '-image'); image.hidden = true; image.removeAttribute('src');
155
+ $('#' + kind + '-dimensions').textContent = '';
156
+ $('#' + kind + '-placeholder').hidden = false;
157
+ }
158
+ }
159
+ function disconnect(message = '', { forget = true } = {}) {
160
+ plugins?.disconnect();
161
+ loginController?.abort(); loginController = null;
162
+ 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;
164
+ creation.disconnect(); gallery.disconnect(); capabilities = []; document.body.classList.remove('connected');
165
+ epoch++; runTicket++; recoveryTicket++; mediaClient?.close(); mediaClient = null; client?.close(); client = null; current = null;
166
+ $('#recovery-panel').hidden = true; $('#recovery-panel').open = false;
167
+ $('#recovery-list').replaceChildren(); $('#recovery-note').textContent = ''; $('#recovery-summary').textContent = '';
168
+ $('#recovery-clear-confirm').hidden = true;
169
+ connectionPanel.disconnect(); $('#workspace-nav').hidden = true; $('#app-sidebar').hidden = true; $('#connections-panel').hidden = true; $('#gallery-panel').hidden = true;
170
+ $('#mode-badge').textContent = '只读预览';
171
+ connecting = false; historyBusy = false; runs = []; nextCursor = null; historyTotal = 0;
172
+ releaseMedia(); $('#connect').disabled = false; $('#token').value = ''; $('#token-file').value = '';
173
+ $('#workspace').hidden = true; $('#connection').hidden = false; $('#disconnect').hidden = true;
174
+ $('#run-view').hidden = true; $('#viewer-empty').hidden = false; $('#download').disabled = true;
175
+ $('#viewer-empty h2').textContent = '从一张图片开始'; $('#draft-preview').hidden = true; $('#draft-preview').removeAttribute('src');
176
+ $('#viewer').setAttribute('aria-busy', 'false');
177
+ $('#history-list').replaceChildren(); $('#model-list').replaceChildren(); $('#run-facts').replaceChildren(); $('#run-parameter-values').replaceChildren(); $('#run-parameters').hidden = true;
178
+ $('#prompt').textContent = ''; $('#status-title').textContent = ''; $('#status-message').textContent = ''; $('#operation-note').textContent = ''; $('#operation-note').hidden = true;
179
+ $('#run-meta').textContent = ''; $('#model-summary').textContent = '读取中'; $('#model-note').textContent = '';
180
+ $('#model-panel').open = false; $('#run-details').open = false; $('#history-count').textContent = '0';
181
+ $('#refresh').disabled = false; $('#load-more').hidden = true;
182
+ feedback(message, Boolean(message));
183
+ }
184
+ async function connectToken(raw, { restoring = false } = {}) {
185
+ disconnect('', { forget: !restoring });
186
+ const token = raw.trim();
187
+ if (!TOKEN.test(token)) { feedback('Token 格式无效,请选择本次启动生成的会话文件。', true); return; }
188
+ const attempt = epoch;
189
+ connecting = true; $('#connect').disabled = true; feedback('正在连接本地工作台…');
190
+ client = createReadClient(token, { onUnauthorized: () => {
191
+ if (attempt === epoch) disconnect(passwordMode ? '登录已失效,请重新输入访问口令。'
192
+ : '登录已失效,请打开当前服务的登录链接,或展开备用登录使用本次启动的 Token 文件。');
193
+ } });
194
+ const ctx = context();
195
+ try {
196
+ const session = await client.get('/api/v0/session');
197
+ if (!valid(ctx)) return;
198
+ if (session.contractVersion !== 0 || !session.operations.includes('runs.inspect')) throw new TypeError('不支持的工作台会话');
199
+ if (!passwordMode) storedSession('setItem', token);
200
+ 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);
202
+ if (!plugins && (session.pluginExecution || session.panelContributions?.pages.length || session.panelContributions?.settings.length || session.panelContributions?.renderers.length)) {
203
+ const { createPluginPanel } = await import('./plugin-panel.js');
204
+ if (!valid(ctx)) return;
205
+ plugins = createPluginPanel({ navigate: tab, describe, onProvidersSaved: result => { overview.setSettings(result); void loadModels(context()); } });
206
+ }
207
+ plugins?.connect(ctx.client, session);
208
+ canManageProviders = session.providerManagement === true; $('#providers-tab').hidden = !canManageProviders;
209
+ document.body.classList.add('connected'); $('#app-sidebar').hidden = false;
210
+ $('#connection').hidden = true; $('#workspace').hidden = false; $('#disconnect').hidden = false;
211
+ tab(); $('#workspace-nav').hidden = false; $('#connections-tab').hidden = !session.connectionManagement;
212
+ $('#recent-panel').open = !matchMedia('(max-width:850px)').matches;
213
+ $('#mode-badge').textContent = session.operations.includes('runs.continue') ? '可创作' : session.connectionManagement ? 'Core 工作台' : session.readOnly ? '只读预览' : '可选择作品';
214
+ const overviewRequested = location.hash === '#overview';
215
+ if (!RUN.test(location.hash.slice(1))) creation.beginDraft();
216
+ if (overviewRequested) { history.replaceState(null, '', '#overview'); tab('overview'); }
217
+ document.documentElement.dataset.startup = 'ready';
218
+ $('#main-content').focus({ preventScroll: true });
219
+ feedback('');
220
+ await Promise.all([loadModels(ctx), loadHistory(ctx), recoverActions(ctx), ...(session.connectionManagement ? [connectionPanel.connect(ctx.client)] : []),
221
+ ...(canManageProviders ? [providerPanel.connect(ctx.client)] : [])]);
222
+ } catch (error) {
223
+ if (valid(ctx)) disconnect(describe(error), { forget: !restoring || error.status === 401 || error.message.startsWith('不支持') });
224
+ } finally {
225
+ if (attempt === epoch) { connecting = false; $('#connect').disabled = false; }
226
+ }
227
+ }
228
+
229
+ async function recoverActions(ctx = context()) {
230
+ if (!ctx.client || !mediaClient) return;
231
+ const media = mediaClient, ticket = ++recoveryTicket;
232
+ $('#recovery-check').disabled = true;
233
+ try {
234
+ const results = await media.recover();
235
+ if (!valid(ctx) || media !== mediaClient || ticket !== recoveryTicket) return;
236
+ const saved = media.snapshot();
237
+ creation.recovered(results);
238
+ $('#recovery-panel').hidden = !results.length && !saved.notice && !saved.actions.length;
239
+ $('#recovery-summary').textContent = saved.actions.length ? saved.actions.length + ' 项待核对' : results.length ? '已读取当前结果' : '';
240
+ const notices = { storage_unavailable: '浏览器恢复存储不可用。本次仅保留内存中的操作标识,刷新后恢复受限。',
241
+ root_changed: '当前工作区已改变,旧的本地恢复标识已清除,原服务记录仍保留。',
242
+ corrupt: '本标签页的恢复记录损坏,原记录已保留。本页仍可浏览作品,新操作暂停。',
243
+ root_unavailable: '当前工作区没有创建请求身份,刷新后请按已有流程核对。' };
244
+ $('#recovery-note').textContent = notices[saved.notice] || (saved.mode === 'memory' && saved.actions.length
245
+ ? '当前只保留内存中的动作标识,刷新恢复受限。请先核对原结果。' : '这里只读取原请求与已保存结果,不会自动开始或继续生成。');
246
+ $('#recovery-check').hidden = saved.blocked || !saved.actions.length;
247
+ $('#recovery-clear').hidden = !saved.blocked && !saved.actions.length;
248
+ const labels = { 'image-import': '素材保存', 'flow-create': '改图流程创建', 'generation-create': '生图流程创建', 'audio-import': '音频保存',
249
+ 'speech-create': '语音流程创建', 'transcription-create': '转写流程创建', continue: '执行', selection: '作品选择', stop: '本地停止' };
250
+ const states = { confirmed: '已读取原结果', prepared: '本地意图已保存,仍需明确接回', current: '已读取当前选择',
251
+ active: '原操作仍在处理中', not_found: '尚未找到原请求,请保留原身份后明确处理',
252
+ conflict: '原结果不可用或记录冲突,请核对详情', unavailable: '暂时无法核对,请稍后重试',
253
+ capability_unavailable: '当前服务未开放对应查询,原标识已保留' };
254
+ $('#recovery-list').replaceChildren(...results.map(item => {
255
+ const row = document.createElement('li');
256
+ const text = document.createElement('span'); text.textContent = labels[item.action.kind] + ' · ' + states[item.status]; row.append(text);
257
+ const runId = item.result?.result?.runId || item.result?.operation?.runId || item.action.runId;
258
+ if (RUN.test(runId || '')) {
259
+ const view = document.createElement('button'); view.type = 'button'; view.className = 'quiet'; view.textContent = '查看流程';
260
+ view.addEventListener('click', () => { tab(); loadRun(runId); }); row.append(view);
261
+ }
262
+ if (['prepared', 'not_found'].includes(item.status) && [...CREATIONS, 'continue'].includes(item.action.kind)) {
263
+ const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'secondary'; retry.textContent = '准备按原 Key 接回';
264
+ retry.addEventListener('click', async () => {
265
+ tab(); if (item.action.runId) await loadRun(item.action.runId);
266
+ if (valid(ctx)) creation.retry(item.action);
267
+ }); row.append(retry);
268
+ }
269
+ return row;
270
+ }));
271
+ const found = results.find(item => item.status === 'confirmed' && CREATIONS.includes(item.action.kind) && !IMPORTS.includes(item.action.kind));
272
+ if (found && page === 'studio' && !current && !RUN.test(location.hash.slice(1))) {
273
+ await loadRun(found.result.result.runId, { fromAction: true });
274
+ }
275
+ } catch (error) {
276
+ if (valid(ctx) && media === mediaClient && ticket === recoveryTicket) {
277
+ $('#recovery-panel').hidden = false; $('#recovery-note').textContent = describe(error);
278
+ }
279
+ } finally { if (valid(ctx) && media === mediaClient && ticket === recoveryTicket) $('#recovery-check').disabled = false; }
280
+ }
281
+
282
+ async function loadModels(ctx) {
283
+ try {
284
+ const results = await Promise.all([ctx.client.get('/api/v0/models'), ...(audioEnabled ? [ctx.client.get('/api/v0/audio-models')] : [])]);
285
+ if (!valid(ctx)) return;
286
+ if (results.some(result => result.contractVersion !== 0)) throw new TypeError('不支持的模型列表版本');
287
+ const models = results.flatMap(result => result.models), configured = results[0].configured;
288
+ creation.setModels(models); overview.setModels({ models, configured });
289
+ $('#model-summary').textContent = !configured ? '未指定配置' : models.length + ' 个候选';
290
+ $('#model-note').textContent = !configured ? '启动时可传入 --provider-config。读取已有作品不需要模型配置。'
291
+ : '以下是配置中的候选,尚不代表已实测。此页面不会探测或调用模型。';
292
+ $('#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;
294
+ }));
295
+ } catch (error) {
296
+ if (valid(ctx)) { $('#model-summary').textContent = '配置不可用'; $('#model-note').textContent = describe(error); overview.setModels(null, describe(error)); }
297
+ }
298
+ }
299
+
300
+ // A read-only view of existing session/catalogue facts. Opening a card never executes media.
301
+ function createOverviewPanel() {
302
+ const features = [
303
+ { id: 'image', cap: 'image-gen', title: '图片', subtitle: '生成、修改,或运行 ComfyUI 工作流。',
304
+ icon: 'M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M8 8h.01', modes: [['generate', '生成图片', 'generations.create'], ['edit', '修改图片', 'runs.create']],
305
+ 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' },
309
+ { id: 'tts', cap: 'tts', title: '语音', subtitle: '让一段文字成为可以试听的声音。',
310
+ 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
+ detail: '新 v3 音频工作区提供 Web / MCP 流程,当前内置合成接口为 DashScope Qwen-TTS 普通 HTTP;音色须与实际模型匹配。', command: 'run tts' },
312
+ { id: 'music', cap: 'music-gen', title: '音乐', subtitle: '按创作要求生成并保存音频。',
313
+ icon: 'M9 17V5l11-2v12 M9 5v4l11-2 M9 17a3 3 0 1 1-3-3h3 M20 15a3 3 0 1 1-3-3h3', modes: [],
314
+ detail: 'CLI / DSH 当前支持 Chat Completions 响应文字中的 MP3/WAV 内嵌音频,需明确选择模型音乐协议。Core 创作页与通用 MCP 待接入。', command: 'run music' },
315
+ { id: 'transcribe', cap: 'transcribe', title: '转写', subtitle: '保存原音频,接回可查看的文字。',
316
+ icon: 'M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0z M5 10v2a7 7 0 0 0 14 0v-2 M12 19v3 M8 22h8', modes: [['transcribe', '转写音频', 'transcriptions.create']],
317
+ detail: '新 v3 工作区提供 Web / MCP 流程,沿用现有 DashScope 转写接口。原音频保存在当前工作区,明确开始后才上传并调用模型。', command: 'run transcribe' },
318
+ { id: 'vision', cap: 'vision', title: '视觉', subtitle: '看图、长图 OCR、定位与视频摘要。',
319
+ icon: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0', modes: [],
320
+ detail: 'CLI 与 DSH 已有视觉工具,Core 创作页与通用 MCP 待接入。独立 CLI 需配置视觉模型;DSH 的宿主视觉选项不代表独立 Core 具有宿主服务。', command: 'vision look / locate / ocr / summarize' }
321
+ ];
322
+ const node = (tag, text, className) => {
323
+ const value = document.createElement(tag); if (text !== undefined) value.textContent = text;
324
+ if (className) value.className = className; return value;
325
+ };
326
+ let info = null, catalog = null, settings = null, total = null, refreshing = false;
327
+ const errors = { models: '', settings: '', history: '' };
328
+ const operations = () => info?.operations || [];
329
+ const executable = () => operations().includes('runs.continue');
330
+ 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;
332
+ function fact(list, title, value) { list.append(node('dt', title), node('dd', value)); }
333
+ function card(feature, index) {
334
+ const article = node('article', undefined, 'overview-capability card'); article.dataset.overviewCapability = feature.id; article.dataset.providerTone = String(index);
335
+ const heading = node('div', undefined, 'overview-capability-heading'), mark = node('span', undefined, 'overview-capability-icon');
336
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), path = document.createElementNS(svg.namespaceURI, 'path');
337
+ 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'));
339
+ article.append(heading, node('p', feature.subtitle, 'overview-capability-description'));
340
+ const declared = settings?.providers.flatMap(provider => provider.models.filter(model => model.capabilities.includes(feature.cap)));
341
+ const policy = settings?.assignments[feature.cap], values = candidates(feature);
342
+ const config = errors.settings ? '配置读取失败,未作判断' : settings
343
+ ? declared.length + ' 个已标注 · ' + ({ auto: '自动选择', manual: '手动排序', disabled: '功能已停用' })[policy.mode]
344
+ : info.providerManagement ? '读取已保存配置…' : !feature.modes.length ? '当前会话未提供配置详情' : !supported(feature) ? '当前工作区未提供音频候选'
345
+ : errors.models ? '候选读取失败' : !catalog ? '读取模型候选…' : !catalog.configured ? '未指定配置' : values.length + ' 个候选';
346
+ const facts = node('dl', undefined, 'overview-capability-facts'); fact(facts, '配置', config);
347
+ if (feature.modes.length && info.providerManagement) fact(facts, '候选', errors.models ? '读取失败,未作判断'
348
+ : !catalog ? '读取中…' : !supported(feature) ? '此工作区未接入音频流程' : values.length + ' 个当前候选');
349
+ const records = declared?.map(model => model.verified[feature.cap]).filter(Boolean), passed = records?.filter(record => record.ok).length;
350
+ const evidence = errors.settings ? '记录读取失败' : !settings ? info.providerManagement ? '读取实测记录…' : '当前会话未提供记录'
351
+ : !records.length ? '尚无实测记录' : passed + ' 通过 · ' + (records.length - passed) + ' 未通过';
352
+ fact(facts, '历史实测', evidence); article.append(facts);
353
+ const actions = node('div', undefined, 'overview-capability-actions');
354
+ if (supported(feature) && executable()) {
355
+ for (const [mode, label, operation] of feature.modes) {
356
+ const action = node('button', label, 'secondary'); action.type = 'button'; action.dataset.overviewCreate = mode;
357
+ const count = values?.filter(model => mode !== 'edit' || model.supportsImageEdit).length;
358
+ action.disabled = !operations().includes(operation) || Boolean(errors.models) || !count;
359
+ action.title = action.disabled ? !operations().includes(operation) ? '当前服务未开放此创建入口' : errors.models ? '模型候选尚未读取成功' : '暂无此功能的可用候选' : '打开创作表单,不会自动提交';
360
+ action.addEventListener('click', () => creation.beginDraft({ mode })); actions.append(action);
361
+ }
362
+ } else if (supported(feature)) actions.append(node('span', '当前仅浏览,开启执行后可创作。', 'overview-action-note'));
363
+ if (info.providerManagement && (!supported(feature) || !values?.length || !executable())) {
364
+ const manage = node('button', '配置模型', 'quiet'); manage.type = 'button'; manage.dataset.overviewConfigure = feature.id;
365
+ manage.addEventListener('click', () => tab('providers')); actions.append(manage);
366
+ }
367
+ article.append(actions);
368
+ const more = node('details', undefined, 'overview-capability-more'), summary = node('summary');
369
+ const disclosure = node('span', '展开', 'disclosure'); summary.append(node('span', '入口与说明'), disclosure); more.append(summary, node('p', feature.detail));
370
+ more.append(node('p', 'CLI 入口 · dsh-iris ' + feature.command, 'overview-command'));
371
+ const latest = records?.filter(record => Number.isFinite(Date.parse(record.at))).sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0];
372
+ if (latest) {
373
+ const time = node('time', '最近实测记录 · ' + date(latest.at), 'overview-evidence-time'); time.dateTime = latest.at; more.append(time);
374
+ }
375
+ more.addEventListener('toggle', () => { disclosure.textContent = more.open ? '收起' : '展开'; }); article.append(more);
376
+ return article;
377
+ }
378
+ function render() {
379
+ if (!info) return;
380
+ const stats = $('#overview-stats'); stats.replaceChildren();
381
+ const state = node('div'); fact(state, '当前服务', executable() ? '可创作' : '仅浏览');
382
+ state.append(node('span', info.audio ? '图片、语音与转写工作区' : '图片工作区', 'overview-stat-note')); stats.append(state);
383
+ 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);
385
+ const history = node('div'); fact(history, '已保存流程', errors.history ? '读取失败' : total === null ? '读取中…' : total + ' 个');
386
+ history.append(node('span', '仅统计当前工作区', 'overview-stat-note')); stats.append(history);
387
+ const expanded = new Set([...document.querySelectorAll('#overview-capabilities details[open]')].map(details => details.closest('[data-overview-capability]').dataset.overviewCapability));
388
+ $('#overview-capabilities').replaceChildren(...features.map(card));
389
+ for (const article of $('#overview-capabilities').children) if (expanded.has(article.dataset.overviewCapability)) article.querySelector('details').open = true;
390
+ $('#overview-create').textContent = executable() ? catalog && !catalog.models.length && info.providerManagement ? '配置模型' : '开始创作' : '查看创作页';
391
+ $('#overview-refresh').disabled = refreshing; $('#overview-refresh').textContent = refreshing ? '读取中…' : '刷新状态';
392
+ const failures = Object.values(errors).filter(Boolean); $('#overview-feedback').hidden = !failures.length;
393
+ $('#overview-feedback').textContent = failures.length ? '部分状态读取失败,未将未知状态判断为未配置。' + failures.join(' · ') : '';
394
+ $('#overview-feedback').classList.toggle('error', failures.length > 0);
395
+ }
396
+ $('#overview-create').addEventListener('click', () => {
397
+ if (!executable()) tab();
398
+ else if (catalog && !catalog.models.length && info.providerManagement) tab('providers');
399
+ else creation.beginDraft();
400
+ });
401
+ $('#overview-gallery').addEventListener('click', () => tab('gallery'));
402
+ $('#overview-refresh').addEventListener('click', async () => {
403
+ if (refreshing || !info) return;
404
+ const ctx = context(); refreshing = true; render();
405
+ try {
406
+ await Promise.all([loadModels(ctx), loadHistory(ctx, false, false), ...(info.providerManagement ? [(async () => {
407
+ try {
408
+ const value = await ctx.client.get('/api/v0/provider-management/settings');
409
+ if (!valid(ctx)) return;
410
+ if (value.contractVersion !== 0) throw new TypeError('不支持的供应商设置版本');
411
+ settings = value; errors.settings = '';
412
+ } catch (error) { if (valid(ctx)) { settings = null; errors.settings = describe(error); } }
413
+ })()] : [])]);
414
+ } finally { if (valid(ctx)) { refreshing = false; render(); } }
415
+ });
416
+ return Object.freeze({
417
+ connect(session) { info = session; catalog = settings = null; total = null; for (const key of Object.keys(errors)) errors[key] = ''; render(); },
418
+ setModels(value, error = '') { catalog = value; errors.models = error; render(); },
419
+ setSettings(value, error = '') { settings = value; errors.settings = error; render(); },
420
+ setHistory(value, error = '') { total = value; errors.history = error; render(); },
421
+ disconnect() { info = catalog = settings = null; total = null; refreshing = false; $('#overview-panel').hidden = true; $('#overview-capabilities').replaceChildren(); $('#overview-stats').replaceChildren(); $('#overview-feedback').hidden = true; }
422
+ });
423
+ }
424
+
425
+ function renderHistory() {
426
+ $('#history-count').textContent = String(historyTotal);
427
+ $('#history-empty').hidden = runs.length !== 0;
428
+ $('#history-list').replaceChildren(...runs.map((run, index) => {
429
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'history-item';
430
+ 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);
432
+ const label = document.createElement('span'); label.textContent = run.modelRef;
433
+ const number = document.createElement('small'); number.textContent = String(index + 1).padStart(2, '0');
434
+ button.append(number, title, label); button.addEventListener('click', () => loadRun(run.id)); return button;
435
+ }));
436
+ $('#load-more').hidden = !nextCursor; $('#load-more').disabled = historyBusy;
437
+ }
438
+ async function loadHistory(ctx = context(), more = false, openDesired = true) {
439
+ if (!ctx.client || historyBusy) return;
440
+ const selectionAtStart = runTicket;
441
+ historyBusy = true; $('#refresh').disabled = true; $('#load-more').disabled = true;
442
+ try {
443
+ const result = await ctx.client.get('/api/v0/runs?limit=20' + (more && nextCursor ? '&cursor=' + nextCursor : ''));
444
+ if (!valid(ctx)) return;
445
+ if (result.contractVersion !== 0) throw new TypeError('不支持的历史列表版本');
446
+ runs = more ? [...runs, ...result.runs] : result.runs; nextCursor = result.nextCursor; historyTotal = result.total;
447
+ renderHistory(); overview.setHistory(result.total);
448
+ if (openDesired && !more && selectionAtStart === runTicket) {
449
+ const desired = current?.id || (RUN.test(location.hash.slice(1)) ? location.hash.slice(1) : null);
450
+ if (desired) await loadRun(desired);
451
+ }
452
+ } catch (error) { if (valid(ctx)) { feedback(describe(error), true); overview.setHistory(null, describe(error)); } }
453
+ finally {
454
+ if (valid(ctx)) { historyBusy = false; $('#refresh').disabled = false; $('#load-more').disabled = false; }
455
+ }
456
+ }
457
+ function addFact(label, value) {
458
+ const key = document.createElement('dt'), item = document.createElement('dd');
459
+ key.textContent = label; item.textContent = value; $('#run-facts').append(key, item);
460
+ }
461
+ async function loadMedia(ctx, ticket, kind, artifactId, signal) {
462
+ const placeholder = $('#' + kind + '-placeholder'); placeholder.textContent = artifactId ? '读取中…' : '尚无已保存的结果';
463
+ if (!artifactId) return;
464
+ try {
465
+ const blob = await ctx.client.get('/api/v0/artifacts/' + artifactId + '/content', { binary: true, signal });
466
+ if (!valid(ctx) || ticket !== runTicket) return;
467
+ const url = URL.createObjectURL(blob); urls.add(url);
468
+ const image = $('#' + kind + '-image'); image.src = url;
469
+ await image.decode();
470
+ if (!valid(ctx) || ticket !== runTicket) return;
471
+ image.hidden = false; placeholder.hidden = true;
472
+ $('#overlay-' + kind).src = url;
473
+ if (kind === 'source') creation.source(url);
474
+ $('#' + kind + '-dimensions').textContent = image.naturalWidth + ' × ' + image.naturalHeight;
475
+ return { id: artifactId, mediaType: blob.type.split(';')[0], label: kind === 'source' ? '原图' : '生成结果' };
476
+ } catch (error) {
477
+ if (valid(ctx) && ticket === runTicket && !signal.aborted) placeholder.textContent = '图片读取失败。' + describe(error);
478
+ }
479
+ }
480
+ async function loadAudioMedia(ctx, ticket, kind, artifactId, signal) {
481
+ if (!artifactId) return;
482
+ const text = kind === 'transcript';
483
+ try {
484
+ const blob = await ctx.client.get('/api/v0/artifacts/' + artifactId + (text ? '/text' : '/audio'), { binary: true, signal });
485
+ if (!valid(ctx) || ticket !== runTicket) return;
486
+ if (text) {
487
+ const value = new TextDecoder('utf-8', { fatal: true }).decode(await blob.arrayBuffer());
488
+ if (!valid(ctx) || ticket !== runTicket) return;
489
+ $('#transcript-result').textContent = value; $('#transcript-result').hidden = false;
490
+ } else {
491
+ const url = URL.createObjectURL(blob); urls.add(url);
492
+ $('#' + kind + '-audio').src = url; $('#audio-' + kind + '-result').hidden = false;
493
+ }
494
+ return { id: artifactId, mediaType: blob.type.split(';')[0], label: text ? '转写文字' : kind === 'source' ? '原音频' : '合成语音' };
495
+ } catch (error) {
496
+ if (valid(ctx) && ticket === runTicket && !signal.aborted) $('#audio-result-note').textContent += (text ? '文字' : '音频') + '读取失败:' + describe(error) + ' ';
497
+ }
498
+ }
499
+ async function loadRun(id, { fromAction = false } = {}) {
500
+ const ctx = context(); if (!ctx.client || !RUN.test(id)) return;
501
+ if (!fromAction) creation.cancelChain();
502
+ creation.loading();
503
+ tab(); const sameRun = current?.id === id;
504
+ const ticket = ++runTicket;
505
+ current = null; releaseMedia(); runController = new AbortController();
506
+ const signal = runController.signal;
507
+ $('#run-view').hidden = true; $('#viewer-empty').hidden = false;
508
+ $('#viewer-empty h2').textContent = '正在读取流程…'; $('#viewer').setAttribute('aria-busy', 'true');
509
+ $('#download').disabled = true; feedback('');
510
+ try {
511
+ const view = await ctx.client.get('/api/v0/runs/' + id, { signal });
512
+ if (!valid(ctx) || ticket !== runTicket) return;
513
+ const presentation = presentFlow(view, capabilities), isAudio = audioRun(view); current = view;
514
+ history.replaceState(null, '', '#' + view.id); renderHistory();
515
+ $('#status-title').textContent = presentation.title; $('#status-message').textContent = presentation.message;
516
+ $('#run-view').dataset.tone = presentation.tone;
517
+ $('#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;
519
+ $('#prompt-title').textContent = isAudio ? view.recipe === 'speech/v0' ? '朗读内容' : '转写来源' : view.source ? '修改要求' : '画面描述';
520
+ $('[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 ? '声音工作台' : '创作画布';
524
+ $('#audio-result-kind').textContent = view.recipe === 'speech/v0' ? 'VOICE' : 'TRANSCRIPT';
525
+ $('#audio-result-title').textContent = view.recipe === 'speech/v0' ? '让文字有声音' : '声音里的文字';
526
+ $('#audio-result-note').textContent = view.output ? '作品已保存;点击播放或下载即可使用。' : '明确执行后,原任务的结果会保存在这里。';
527
+ $('#operation-note').hidden = true;
528
+ $('#selection-feedback').textContent = isAudio ? view.output ? view.recipe === 'speech/v0' ? '音频已保存,可以试听或下载。' : '转写已保存,可下载 UTF-8 文本。' : '结果尚未保存。'
529
+ : view.selection ? '当前选择已记录,可以下载原始图片。' : capabilities.includes('runs.select')
530
+ ? '选择喜欢的版本,再保存到设备。' : '当前未开启选择权限;已有选择仍可下载。';
531
+ $('#run-meta').textContent = view.input.modelRef + ' · ' + date(view.createdAt);
532
+ $('#prompt').textContent = isAudio ? view.input.text || '使用此流程已保存的原音频。' : view.input.prompt;
533
+ const parameters = Object.entries(view.input.workflowParameters || {});
534
+ $('#run-parameters').hidden = !parameters.length;
535
+ $('#run-parameter-values').replaceChildren(...parameters.flatMap(([id, value]) => {
536
+ const label = document.createElement('dt'), item = document.createElement('dd'); label.textContent = id;
537
+ item.textContent = typeof value === 'boolean' ? value ? '开启' : '关闭' : String(value); return [label, item];
538
+ }));
539
+ $('#source-selected').hidden = !view.source || view.selection?.artifactId !== view.source.artifactId;
540
+ $('#output-selected').hidden = !view.output || view.selection?.artifactId !== view.output.artifactId;
541
+ $('#source-card').classList.toggle('is-selected', !$('#source-selected').hidden);
542
+ $('#output-card').classList.toggle('is-selected', !$('#output-selected').hidden);
543
+ $('#select-source').setAttribute('aria-pressed', String(!$('#source-selected').hidden));
544
+ $('#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;
548
+ $('#run-facts').replaceChildren(); if (!sameRun) $('#run-details').open = false;
549
+ creation.view(view, { sameRun });
550
+ addFact('流程', view.id); addFact('版本', String(view.revision)); addFact('保存时间', date(view.updatedAt));
551
+ addFact('类型', recipeLabel(view.recipe));
552
+ if (view.input.voice) addFact('音色', view.input.voice);
553
+ if (view.source) addFact(isAudio ? '原音频' : '原图', view.source.artifactId); addFact('结果', view.output?.artifactId || '尚未保存');
554
+ addFact('原任务', view.task?.id || '尚未创建');
555
+ if (view.metrics) addFact('像素差异', (view.metrics.ratio * 100).toFixed(2) + '%');
556
+ $('#viewer-empty').hidden = true; $('#run-view').hidden = false;
557
+ const previewArtifacts = await Promise.all(isAudio ? [loadAudioMedia(ctx, ticket, 'source', view.source?.artifactId, signal),
558
+ loadAudioMedia(ctx, ticket, view.recipe === 'speech/v0' ? 'output' : 'transcript', view.output?.artifactId, signal)]
559
+ : [loadMedia(ctx, ticket, 'source', view.source?.artifactId, signal), loadMedia(ctx, ticket, 'output', view.output?.artifactId, signal)]);
560
+ if (valid(ctx) && ticket === runTicket && !signal.aborted) plugins?.previewArtifacts(previewArtifacts, view.selection?.artifactId || view.output?.artifactId);
561
+ } catch (error) {
562
+ if (valid(ctx) && ticket === runTicket && !signal.aborted) {
563
+ $('#viewer-empty h2').textContent = '暂时无法读取此流程'; feedback(describe(error), true);
564
+ }
565
+ } finally { if (valid(ctx) && ticket === runTicket) $('#viewer').setAttribute('aria-busy', 'false'); }
566
+ }
567
+ async function download() {
568
+ if (!(audioRun(current) ? current?.output : current?.selection) || $('#download').disabled) return;
569
+ const ctx = context(), id = current.id, ticket = runTicket;
570
+ $('#download').disabled = true; feedback('正在读取作品…');
571
+ try {
572
+ const { blob, filename } = await ctx.client.get('/api/v0/runs/' + id + '/download', { download: true });
573
+ if (!valid(ctx) || ticket !== runTicket) return;
574
+ const url = URL.createObjectURL(blob); urls.add(url);
575
+ 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];
577
+ if (!extension) throw new TypeError('不支持的下载格式');
578
+ // The CLI may have changed the selection since this view was loaded. Use the server's
579
+ // 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('不支持的下载文件名');
581
+ link.download = filename; link.click();
582
+ setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url); }, 60000);
583
+ feedback('下载请求已交给浏览器,请在浏览器下载记录中查看。');
584
+ } catch (error) { if (valid(ctx) && ticket === runTicket) feedback(describe(error), true); }
585
+ finally { if (valid(ctx) && ticket === runTicket) $('#download').disabled = false; }
586
+ }
587
+
588
+ $('#connect-form').addEventListener('submit', event => {
589
+ event.preventDefault(); if (connecting) return;
590
+ const token = $('#token').value; $('#token').value = ''; connectToken(token);
591
+ });
592
+ $('#token-file').addEventListener('change', async event => {
593
+ const file = event.target.files[0], attempt = epoch; event.target.value = '';
594
+ if (!file) return;
595
+ if (file.size > 256) { feedback('请选择启动时生成的小型 Token 文本文件。', true); return; }
596
+ try { const token = await file.text(); if (attempt === epoch) await connectToken(token); }
597
+ catch (error) { if (attempt === epoch) feedback('无法读取 Token 文件:' + error.message, true); }
598
+ });
599
+ $('#disconnect').addEventListener('click', () => passwordMode ? passwordLogin?.logout() : disconnect());
600
+ $('#refresh').addEventListener('click', async () => { await loadHistory(); await recoverActions(); await creation.refresh(); });
601
+ $('#recovery-check').addEventListener('click', () => recoverActions());
602
+ $('#recovery-clear').addEventListener('click', () => { $('#recovery-clear-confirm').hidden = false; });
603
+ $('#recovery-clear-no').addEventListener('click', () => { $('#recovery-clear-confirm').hidden = true; });
604
+ $('#recovery-clear-yes').addEventListener('click', async () => {
605
+ if (!mediaClient) return;
606
+ try { mediaClient.clearRecovery(); $('#recovery-clear-confirm').hidden = true; await recoverActions(); }
607
+ catch (error) { feedback(describe(error), true); }
608
+ });
609
+ $('#load-more').addEventListener('click', () => loadHistory(context(), true));
610
+ $('#download').addEventListener('click', download);
611
+ $('#overview-tab').addEventListener('click', () => tab('overview'));
612
+ $('#studio-tab').addEventListener('click', () => tab());
613
+ $('#media-tab').addEventListener('click', () => tab('gallery'));
614
+ $('#connections-tab').addEventListener('click', () => tab('connections'));
615
+ for (const id of ['new-run', 'gallery-new']) $('#' + id).addEventListener('click', () => creation.beginDraft());
616
+ for (const button of document.querySelectorAll('#view-modes button')) button.addEventListener('click', () => { if (!button.disabled) setView(button.dataset.view); });
617
+ $('#compare-split').addEventListener('input', event => $('#overlay-output').style.setProperty('--split', event.target.value + '%'));
618
+ for (const kind of ['source', 'output']) $('#' + kind + '-preview').addEventListener('click', () => {
619
+ const image = $('#' + kind + '-image'); if (image.hidden || !image.src) return;
620
+ $('#expanded-image').src = image.src; $('#image-dialog-title').textContent = kind === 'source' ? '原图预览' : '结果预览'; $('#image-dialog').showModal();
621
+ });
622
+ $('#close-image-dialog').addEventListener('click', closePreview);
623
+ $('#image-dialog').addEventListener('close', () => $('#expanded-image').removeAttribute('src'));
624
+ for (const details of document.querySelectorAll('details')) details.addEventListener('toggle', () => {
625
+ details.querySelector('.disclosure').textContent = details.open ? '收起' : '展开';
626
+ });
627
+ createAppearance();
628
+ $('#providers-tab').addEventListener('click', () => { if (canManageProviders) tab('providers'); });
629
+ $('#manage-models').addEventListener('click', () => {
630
+ if (canManageProviders) tab('providers');
631
+ else feedback('当前服务未开启供应商管理。启动时添加 --allow-provider-management 并指定 --provider-config 后可配置。');
632
+ });
633
+ window.addEventListener('pagehide', () => { startupSequence++; document.documentElement.dataset.startup = 'pending'; passwordLogin?.pause(); disconnect('', { forget: false }); });
634
+ window.addEventListener('pageshow', event => { if (event.persisted && !client && !connecting) openWorkbench(); });
635
+ matchMedia('(max-width:850px)').addEventListener('change', event => { if (client) $('#recent-panel').open = !event.matches; });
636
+ window.addEventListener('hashchange', () => {
637
+ if (location.hash.startsWith('#login=') || passwordMode && location.hash.startsWith('#setup=')) { openWorkbench(); return; }
638
+ if (location.hash === '#overview') { if (client) tab('overview'); return; }
639
+ const id = location.hash.slice(1);
640
+ if (RUN.test(id)) { if (id !== current?.id) loadRun(id); else if (client) tab(); }
641
+ });
642
+ openWorkbench();