@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
@@ -0,0 +1,492 @@
1
+ import { readWorkflowImport, workflowSlots, buildWorkflow, renderWorkflowParameters, inspectionMessage, parameterLabel } from './comfyui.js';
2
+
3
+ const LABELS = { 'image-gen': '图片', 'video-gen': '视频', tts: '语音', 'music-gen': '音乐', transcribe: '转写', vision: '视觉' };
4
+ const $ = id => document.getElementById(id);
5
+ const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
6
+ const ref = (providerId, modelId) => encodeURIComponent(providerId) + '::' + encodeURIComponent(modelId);
7
+
8
+ export function createProviderPanel({ describe, onSaved, onSettings, onProbe = () => {} }) {
9
+ let client, data, draft, busy = false, dirty = false, unknown = false, generation = 0, originalIds = new Set();
10
+ let policies = {}, touched = new Set(), directory = [], comfyTemplate = null, slots = [], importRead = 0, importAdded = false;
11
+ let checkController, apiDraft = null, editing = false, view = 'connection';
12
+ let section = 'suppliers', diagnostics = null, healthCapability = 'image-gen', pendingLeave;
13
+ function editor(open) {
14
+ editing = open; $('provider-editor').hidden = !open; $('providers-overview').hidden = open || section !== 'suppliers';
15
+ $('provider-sections').hidden = open;
16
+ $('provider-close-confirm').hidden = true;
17
+ }
18
+ function setView(next) {
19
+ view = next;
20
+ for (const button of document.querySelectorAll('[data-provider-view]')) {
21
+ const active = button.dataset.providerView === view; button.setAttribute('aria-pressed', String(active));
22
+ $('provider-view-' + button.dataset.providerView).hidden = !active;
23
+ }
24
+ }
25
+ const connectionFields = ['url', 'media-url', 'protocol', 'auth', 'key-action', 'key'];
26
+ const apiFields = () => Object.fromEntries(connectionFields.map(field => [field, $('provider-' + field).value]));
27
+ function restoreApiDraft() {
28
+ const fields = apiDraft || { url: '', 'media-url': '', protocol: 'openai-images', auth: 'bearer', 'key-action': draft.hasCredential ? 'keep' : 'replace', key: '' };
29
+ for (const field of connectionFields) $('provider-' + field).value = fields[field];
30
+ keyAction();
31
+ }
32
+ const feedback = (text, error = false) => { $('providers-feedback').textContent = text; $('providers-feedback').hidden = !text; $('providers-feedback').classList.toggle('error', error); };
33
+ const status = () => { $('provider-state').textContent = busy ? '处理中…' : dirty ? '未保存' : '已保存'; };
34
+ function mark() { dirty = true; status(); summary(); }
35
+ function summary() {
36
+ const configured = draft?.models.filter(model => model.capabilities.length).length || 0;
37
+ $('provider-save-summary').textContent = configured + ' 个已标注模型 · ' + (touched.size ? [...touched].map(cap => LABELS[cap] + ':' + ({ auto: '自动', manual: '手动', disabled: '停用' }[policies[cap].mode])).join(',') : '功能池保持现有设置');
38
+ }
39
+ function controls() {
40
+ $('provider-fields').disabled = busy || !draft;
41
+ $('providers-refresh').disabled = busy; $('provider-new').disabled = busy || !data; $('provider-back').disabled = busy;
42
+ $('provider-close-discard').disabled = busy || unknown; $('provider-close-cancel').disabled = busy;
43
+ for (const button of document.querySelectorAll('#providers-list button')) button.disabled = busy;
44
+ $('provider-save').disabled = busy || unknown || Boolean(comfyTemplate && !importAdded);
45
+ $('provider-save').textContent = busy ? '正在处理…' : '保存设置';
46
+ $('provider-pool-fields').disabled = busy || unknown || !data;
47
+ $('provider-new').hidden = editing || section !== 'suppliers';
48
+ for (const id of ['provider-cancel', 'provider-pool-save', 'provider-pool-cancel', 'provider-health-refresh']) $(id).disabled = busy || unknown || !data;
49
+ $('provider-pool-save').disabled ||= !touched.size;
50
+ for (const button of document.querySelectorAll('[data-provider-section]')) button.disabled = busy || !data;
51
+ const saved = data?.providers.find(provider => provider.id === draft?.id);
52
+ $('provider-management-tools').hidden = !saved;
53
+ for (const id of ['provider-toggle', 'provider-delete', 'provider-delete-no']) $(id).disabled = busy || dirty || unknown;
54
+ $('provider-toggle').textContent = saved?.enabled ? '停用供应商' : '恢复供应商';
55
+ $('provider-discover').disabled = busy || dirty || !data?.providers.some(provider => provider.id === draft?.id && provider.configured);
56
+ status();
57
+ const comfy = $('provider-protocol').value === 'comfyui';
58
+ document.querySelector('.provider-editor').classList.toggle('comfy-mode', comfy);
59
+ $('provider-comfy-mode').hidden = !comfy;
60
+ $('provider-preset-note').textContent = comfy ? '工作流模式已开启。导入 API 工作流,选择开放参数和 LoRA;保存配置不会生成图片。'
61
+ : '按服务文档填写;编程订阅或聊天登录不等于媒体 API 认证。';
62
+ $('comfyui-tools').hidden = !comfy;
63
+ document.querySelector('[data-provider-view="workflow"]').hidden = !comfy;
64
+ if (!comfy && view === 'workflow') setView('connection');
65
+ $('comfyui-check').disabled = busy || !comfyTemplate || !savedComfyConnection();
66
+ $('provider-discover').textContent = comfy ? '检查服务与工作流目录' : '读取模型目录';
67
+ let url;
68
+ try { url = new URL($('provider-media-url').value || $('provider-url').value); } catch (_) { /* The address is still being typed. */ }
69
+ const safe = url && ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
70
+ $('comfyui-open').hidden = !safe; $('comfyui-open').removeAttribute('href'); if (safe) $('comfyui-open').href = url.href;
71
+ }
72
+ function options(select, values, selected) {
73
+ select.replaceChildren(...values.map(([value, label]) => { const option = element('option', label); option.value = value; return option; }));
74
+ if (selected && !values.some(([value]) => value === selected)) { const option = element('option', selected + ' · 未加载扩展'); option.value = selected; select.append(option); }
75
+ select.value = selected;
76
+ }
77
+ function list() {
78
+ $('providers-count').textContent = String(data.providers.length); $('providers-empty').hidden = data.providers.length > 0;
79
+ $('providers-list').replaceChildren(...data.providers.map((provider, index) => {
80
+ const button = element('button', '', 'service-card provider-item'); button.type = 'button'; button.dataset.providerId = provider.id;
81
+ button.dataset.providerTone = String(index % 6);
82
+ button.setAttribute('aria-current', String(provider.id === draft?.id));
83
+ const heading = element('span', '', 'service-card-heading');
84
+ heading.append(element('span', provider.mediaProtocol === 'comfyui' ? 'CF' : 'API', 'service-mark'), element('small', !provider.enabled ? '已停用' : provider.configured ? '已配置' : '待配置', 'service-status'));
85
+ button.append(heading, element('strong', provider.name), element('span', provider.baseUrl || '尚未填写地址', 'service-endpoint'),
86
+ element('span', provider.models.length + ' 个模型 · ' + (provider.mediaProtocol === 'comfyui' ? 'ComfyUI 工作流' : 'API 服务'), 'service-meta'), element('span', '配置服务 →', 'service-action'));
87
+ button.addEventListener('click', () => { if (busy) return; if (dirty || comfyTemplate) { editor(true); feedback('当前有未保存编辑,请先保存,或重新读取后切换。', true); return; } edit(provider); editor(true); setView('connection'); feedback(''); });
88
+ return button;
89
+ }));
90
+ }
91
+ function details(title) {
92
+ const node = element('details', '', 'model-routing'); const head = element('summary'); const disclosure = element('span', '展开', 'disclosure');
93
+ head.append(element('span', title), disclosure); node.append(head);
94
+ node.addEventListener('toggle', () => { disclosure.textContent = node.open ? '收起' : '展开'; }); return node;
95
+ }
96
+ function renderModels() {
97
+ $('provider-models-empty').hidden = draft.models.length > 0;
98
+ $('provider-models').replaceChildren(...draft.models.map(model => {
99
+ const row = element('article', '', 'provider-model'); row.dataset.modelId = model.id;
100
+ const heading = element('div', '', 'section-heading'); heading.append(element('h3', model.id));
101
+ const remove = element('button', originalIds.has(model.id) ? '清空用途' : '撤销添加', 'quiet inline-button'); remove.type = 'button';
102
+ remove.addEventListener('click', () => { if (originalIds.has(model.id)) model.capabilities = []; else draft.models = draft.models.filter(item => item !== model); mark(); renderModels(); renderPolicies(); });
103
+ heading.append(remove); row.append(heading);
104
+ const choices = element('div', '', 'model-capabilities');
105
+ for (const [cap, label] of Object.entries(LABELS)) {
106
+ const choice = element('label', '', 'mini-check'); const input = element('input'); input.type = 'checkbox'; input.checked = model.capabilities.includes(cap); input.dataset.capability = cap;
107
+ input.addEventListener('change', () => {
108
+ model.capabilities = input.checked ? [...model.capabilities, cap] : model.capabilities.filter(value => value !== cap);
109
+ if (input.checked && policies[cap].mode === 'disabled') { policies[cap].mode = 'auto'; touched.add(cap); }
110
+ mark(); renderPolicies();
111
+ }); choice.append(input, element('span', label)); choices.append(choice);
112
+ }
113
+ row.append(choices);
114
+ const issues = model.unavailableCapabilities?.length ? '已知不可用:' + model.unavailableCapabilities.map(cap => LABELS[cap]).join('、')
115
+ : model.rateLimited ? '限流或额度停用,需核对后实测'
116
+ : Object.values(model.verified || {}).some(value => value.ok) ? '有已保存的实测记录;不代表所有用途通过' : '能力由你确认,尚无成功实测记录';
117
+ row.append(element('p', issues, 'field-note'));
118
+ if (model.comfyWorkflow) row.append(element('p', 'ComfyUI · 提示词 ' + model.comfyWorkflow.promptNode + ' → 输出 ' + model.comfyWorkflow.outputNode
119
+ + (model.comfyWorkflow.graph ? ' · 工作流尚未保存' : ' · 已保存工作流 ' + model.comfyWorkflow.sha256.slice(0, 12)), 'field-note'));
120
+ const advanced = details('模型协议与验证');
121
+ for (const [key, cap, label] of [['imageProtocol', 'image-gen', '图片协议'], ['musicProtocol', 'music-gen', '音乐协议']]) {
122
+ const select = element('select'); select.setAttribute('aria-label', model.id + ' · ' + label);
123
+ options(select, [[key === 'imageProtocol' ? 'auto' : '', key === 'imageProtocol' ? '沿用供应商默认' : '未指定(需选择)'],
124
+ ...data.protocols.filter(item => item.capabilities.includes(cap)).map(item => [item.id, item.label])], model[key] || (key === 'imageProtocol' ? 'auto' : ''));
125
+ select.addEventListener('change', () => { model[key] = select.value; mark(); });
126
+ const wrapper = element('label', label, 'field-label'); wrapper.append(select); advanced.append(wrapper);
127
+ if (key === 'musicProtocol') advanced.append(element('p', '使用音乐功能时需单独选择协议。Chat Completions 内嵌音频需返回 MP3/WAV,不限供应商名称。', 'field-note'));
128
+ }
129
+ for (const [cap, value] of Object.entries(model.verified || {})) advanced.append(element('p', (LABELS[cap] || cap) + ' · ' + (value.ok ? '通过' : '未通过') + ' · ' + (value.category || '未知') + ' · ' + (value.at || ''), 'field-note'));
130
+ advanced.append(element('p', '可在健康检测准备媒体测试,或使用 CLI models test;实际调用可能计费,目录读取不生成媒体。', 'field-note'));
131
+ row.append(advanced); return row;
132
+ }));
133
+ summary(); controls();
134
+ }
135
+ function renderPolicies() {
136
+ const all = draft ? [...data.providers.filter(provider => provider.id !== draft.id), draft] : data.providers;
137
+ $('provider-assignments').replaceChildren(...Object.keys(LABELS).map(cap => {
138
+ const row = element('div', '', 'function-policy'), heading = element('div', '', 'section-heading');
139
+ heading.append(element('h3', LABELS[cap])); const select = element('select'); select.dataset.function = cap; select.setAttribute('aria-label', LABELS[cap] + '选择方式');
140
+ options(select, [['disabled', '停用'], ['auto', '自动选择'], ['manual', '手动排序']], policies[cap].mode);
141
+ select.addEventListener('change', () => { policies[cap].mode = select.value; touched.add(cap); mark(); renderPolicies(); });
142
+ heading.append(select); row.append(heading);
143
+ if (policies[cap].mode === 'manual') {
144
+ const candidates = all.flatMap(provider => provider.models.filter(model => model.capabilities.includes(cap)).map(model => ({ name: provider.name + ' / ' + model.id, ref: ref(provider.id, model.id) })));
145
+ for (const saved of policies[cap].models) if (!candidates.some(item => item.ref === saved)) candidates.push({ name: saved + ' · 当前不可用', ref: saved });
146
+ candidates.sort((a, b) => { const order = value => { const index = policies[cap].models.indexOf(value); return index < 0 ? 9999 : index; }; return order(a.ref) - order(b.ref); });
147
+ for (const candidate of candidates) {
148
+ const choice = element('label', '', 'mini-check'); const input = element('input'); input.type = 'checkbox'; input.checked = policies[cap].models.includes(candidate.ref);
149
+ input.addEventListener('change', () => { policies[cap].models = input.checked ? [...policies[cap].models, candidate.ref] : policies[cap].models.filter(item => item !== candidate.ref); touched.add(cap); mark(); renderPolicies(); });
150
+ choice.append(input, element('span', (input.checked ? (policies[cap].models.indexOf(candidate.ref) + 1) + '. ' : '') + candidate.name));
151
+ const rank = element('div', '', 'provider-rank-row'); rank.append(choice);
152
+ if (input.checked) for (const [text, step] of [['上移', -1], ['下移', 1]]) {
153
+ const move = element('button', text, 'quiet'); move.type = 'button'; move.setAttribute('aria-label', candidate.name + ' · ' + text);
154
+ const index = policies[cap].models.indexOf(candidate.ref); move.disabled = index + step < 0 || index + step >= policies[cap].models.length;
155
+ move.addEventListener('click', () => { const values = policies[cap].models; [values[index], values[index + step]] = [values[index + step], values[index]]; touched.add(cap); mark(); renderPolicies(); }); rank.append(move);
156
+ }
157
+ row.append(rank);
158
+ }
159
+ if (!candidates.length) row.append(element('p', '请先给模型勾选此用途。', 'field-note'));
160
+ }
161
+ if (cap === 'vision') {
162
+ const label = element('label', '', 'mini-check'), input = element('input'); input.type = 'checkbox'; input.checked = policies[cap].hostVision;
163
+ input.addEventListener('change', () => { policies[cap].hostVision = input.checked; touched.add(cap); mark(); }); label.append(input, element('span', '允许宿主视觉(独立 Core 无此宿主服务)')); row.append(label);
164
+ }
165
+ return row;
166
+ }));
167
+ $('provider-pool-note').textContent = touched.size ? '尚未保存 · ' + [...touched].map(cap => LABELS[cap]).join('、') : '已保存 · 按顺序优先选择,不是跨请求轮询。';
168
+ controls();
169
+ }
170
+ function edit(provider) {
171
+ resetComfyImport(); $('comfyui-tools').open = false; apiDraft = null;
172
+ draft = provider ? structuredClone(provider) : { id: 'provider-' + crypto.randomUUID().slice(0, 8), name: '', baseUrl: '', mediaBaseUrl: '', mediaProtocol: 'openai-images', enabled: true, auth: 'bearer', hasCredential: false, models: [] };
173
+ document.querySelector('.provider-editor').dataset.providerTone = String((provider ? data.providers.indexOf(provider) : data.providers.length) % 6);
174
+ policies = structuredClone(data.assignments); touched = new Set(); originalIds = new Set(draft.models.map(model => model.id)); dirty = !provider; unknown = false; directory = [];
175
+ $('provider-editor-title').textContent = provider ? '编辑供应商' : '添加供应商';
176
+ for (const [key, field] of [['name', 'name'], ['url', 'baseUrl'], ['media-url', 'mediaBaseUrl'], ['auth', 'auth']]) $('provider-' + key).value = draft[field];
177
+ options($('provider-protocol'), data.protocols.map(item => [item.id, item.label]), draft.mediaProtocol);
178
+ $('provider-enabled').checked = draft.enabled; $('provider-key-action').value = provider ? 'keep' : 'replace'; $('provider-key').value = ''; keyAction();
179
+ $('provider-key-state').textContent = draft.hasCredential ? '已保存 · 不回显' : '尚未保存';
180
+ $('provider-id-note').textContent = '稳定 ID:' + draft.id; $('provider-preset').value = draft.mediaProtocol === 'comfyui' ? 'comfyui' : 'custom'; $('provider-model-id').value = '';
181
+ if (draft.mediaProtocol !== 'comfyui') apiDraft = apiFields();
182
+ $('provider-advanced').open = false; $('provider-management-tools').open = false; $('provider-delete-confirm').hidden = true; $('provider-directory').hidden = true;
183
+ $('provider-directory-note').textContent = '目录只使用已保存连接;成功不代表媒体实测通过。';
184
+ list(); renderModels(); renderPolicies(); controls();
185
+ }
186
+ function keyAction() { const replacing = $('provider-key-action').value === 'replace'; $('provider-key-field').hidden = !replacing; $('provider-key').required = replacing; if (!replacing) $('provider-key').value = ''; }
187
+ function readForm() {
188
+ draft.name = $('provider-name').value; draft.baseUrl = $('provider-url').value; draft.mediaBaseUrl = $('provider-media-url').value;
189
+ draft.mediaProtocol = $('provider-protocol').value; draft.auth = $('provider-auth').value; draft.enabled = $('provider-enabled').checked;
190
+ if (draft.mediaProtocol !== 'comfyui') apiDraft = apiFields();
191
+ }
192
+ async function refresh(discard = false) {
193
+ if (!client || busy) return;
194
+ if ((dirty || comfyTemplate) && !discard) { $('providers-reload-confirm').hidden = false; return; }
195
+ $('providers-reload-confirm').hidden = true;
196
+ const active = client, ticket = generation, reloading = Boolean(data); busy = true; controls(); feedback('正在读取设置…');
197
+ try {
198
+ const result = await active.get('/api/v0/provider-management/settings');
199
+ if (ticket !== generation || active !== client) return;
200
+ if (result.contractVersion !== 0) throw new TypeError('不支持的供应商设置版本');
201
+ const id = draft?.id; data = result; onSettings(result);
202
+ if (editing) edit(data.providers.find(provider => provider.id === id) || data.providers[0]);
203
+ else { draft = apiDraft = null; dirty = false; unknown = false; policies = structuredClone(data.assignments); touched.clear(); resetComfyImport(); list(); if (section === 'pools') renderPolicies(); controls(); }
204
+ await readDiagnostics(); feedback('');
205
+ if (reloading) await onSaved();
206
+ } catch (error) { if (ticket === generation) { feedback(describe(error), true); onSettings(null, describe(error)); } }
207
+ finally { if (ticket === generation) { busy = false; controls(); } }
208
+ }
209
+ const reasonLabels = { provider_disabled: '供应商已停用', connection_unconfigured: '连接未就绪', function_disabled: '功能已停用', unconfirmed: '用途待确认', protocol_unsupported: '当前协议不支持', model_missing: '模型不存在,已隔离', cooling: '限流冷却中', quota: '额度或预算耗尽', outside_pool: '未加入当前模型池' };
210
+ const time = value => value ? new Date(value).toLocaleString('zh-CN') : '尚无记录';
211
+ async function readDiagnostics() {
212
+ if (!client || !data) return;
213
+ const active = client, ticket = generation;
214
+ try {
215
+ const value = await active.get('/api/v0/provider-management/diagnostics');
216
+ if (ticket !== generation || active !== client) return;
217
+ if (value.contractVersion !== 0 || value.revision !== data.revision) throw new Error('配置已变化,请重新读取模型服务后核对。');
218
+ diagnostics = value; renderHealth();
219
+ } catch (error) { if (ticket === generation) { diagnostics = null; renderHealth(); feedback(describe(error), true); } }
220
+ }
221
+ function renderHealth() {
222
+ $('provider-spectrum').replaceChildren(...Object.entries(LABELS).map(([cap, label], index) => {
223
+ const fact = diagnostics?.capabilities.find(item => item.capability === cap), button = element('button', '', 'provider-spectrum-card');
224
+ button.type = 'button'; button.dataset.tone = String(index); button.dataset.healthCapability = cap;
225
+ button.append(element('strong', label), element('small', fact ? fact.candidates.length + ' 个候选 · ' + fact.verified + ' 个近期成功' : '状态未知'),
226
+ element('small', fact ? ({ auto: '自动池', manual: '手动优先级', disabled: '已停用' }[fact.mode]) + ' · ' + fact.models.filter(item => !item.eligible).length + ' 项未参与' : '请读取状态'));
227
+ button.addEventListener('click', () => { healthCapability = cap; renderHealth(); $('provider-health-details').open = true; }); return button;
228
+ }));
229
+ $('provider-health-title').textContent = LABELS[healthCapability] + ' · 候选与验证';
230
+ const fact = diagnostics?.capabilities.find(item => item.capability === healthCapability);
231
+ $('provider-health-models').replaceChildren(...(fact?.models.length ? fact.models.map(model => {
232
+ const card = element('article', '', 'provider-health-model'); card.append(element('strong', model.providerName + ' / ' + model.modelId));
233
+ card.append(element('p', (model.reason ? reasonLabels[model.reason] : '候选 #' + model.rank) + ' · '
234
+ + ({ verified: '近期成功', failed: '认证或权限受阻', configured: '尚无近期成功证据' }[model.state] || model.state), 'field-note'));
235
+ card.append(element('p', '最近证据 · ' + time(model.evidence?.at) + (model.retryAt ? ' · 冷却至 ' + time(model.retryAt) : ''), 'field-note'));
236
+ const supported = diagnostics.execution && model.eligible && (healthCapability === 'image-gen' || healthCapability === 'tts' && diagnostics.audio || healthCapability === 'transcribe' && diagnostics.audio);
237
+ if (supported) {
238
+ const test = element('button', '准备功能验证', 'secondary'); test.type = 'button'; test.dataset.probeModel = model.ref;
239
+ test.disabled = busy; test.addEventListener('click', () => onProbe({ capability: healthCapability, modelRef: model.ref, comfy: model.comfy })); card.append(test);
240
+ card.append(element('p', model.comfy ? '在创作页核对工作流和参数后提交,可能产生费用。' : '打开指定模型的验证草稿;点击创作页的生成按钮后才调用,可能产生费用。', 'field-note'));
241
+ } else card.append(element('p', ['video-gen', 'music-gen', 'vision'].includes(healthCapability) ? '此功能的 Web 执行尚未接入,请使用现有 CLI / DSH 明确验证。' : '请先开放执行、配置功能池并处理受阻原因,再准备验证。', 'field-note'));
242
+ return card;
243
+ }) : [element('p', diagnostics ? '当前没有已标注此用途的模型。' : '检测状态尚未读取;不会当作通过。', 'field-note')]));
244
+ $('provider-monitors').replaceChildren(...(data?.providers || []).map(provider => {
245
+ const state = diagnostics?.monitors.find(item => item.providerId === provider.id), card = element('article', '', 'card provider-monitor');
246
+ card.append(element('h3', provider.name), element('p', !state?.last ? '尚未检查连接' : (state.last.ok ? '最近连接检查通过' : '最近连接检查未通过 / 未确认')
247
+ + ' · ' + time(state.last.at) + (state.last.code ? ' · ' + state.last.code : ''), 'field-note'));
248
+ const test = element('button', '检查连接', 'secondary'); test.type = 'button'; test.dataset.checkProvider = provider.id;
249
+ test.disabled = busy || !provider.configured || !provider.enabled || !diagnostics;
250
+ test.addEventListener('click', () => manageProvider('check', { providerId: provider.id })); card.append(test);
251
+ const controls = element('div', '', 'provider-monitor-options'), label = element('label', '定期轻量保活'), enabled = element('input'); enabled.type = 'checkbox'; enabled.checked = state?.enabled === true; enabled.dataset.monitorProvider = provider.id;
252
+ const interval = element('select'); interval.setAttribute('aria-label', provider.name + '保活间隔'); options(interval, [['60', '每分钟'], ['300', '每5分钟'], ['900', '每15分钟']], String(state?.intervalSeconds || 300));
253
+ enabled.disabled = interval.disabled = busy || !provider.enabled || !provider.configured || !state?.automaticSupported;
254
+ const update = () => manageProvider('monitor', { providerId: provider.id, enabled: enabled.checked, intervalSeconds: Number(interval.value) });
255
+ enabled.addEventListener('change', update); interval.addEventListener('change', () => { if (enabled.checked) update(); });
256
+ label.prepend(enabled); controls.append(label, interval); card.append(controls);
257
+ if (state && !state.automaticSupported) card.append(element('p', '此扩展协议没有已支持的自动轻量检查。', 'field-note'));
258
+ return card;
259
+ }));
260
+ }
261
+ function enterSection(next) {
262
+ section = next; draft = apiDraft = null; dirty = unknown = false; touched.clear(); resetComfyImport(); $('provider-key').value = '';
263
+ policies = structuredClone(data?.assignments || {}); editor(false);
264
+ if (data) list();
265
+ $('provider-pool-panel').hidden = next !== 'pools'; $('provider-health-panel').hidden = next !== 'health';
266
+ for (const button of document.querySelectorAll('[data-provider-section]')) button.setAttribute('aria-pressed', String(button.dataset.providerSection === next));
267
+ if (next === 'pools') renderPolicies(); if (next === 'health') { renderHealth(); void readDiagnostics(); }
268
+ controls(); feedback('');
269
+ }
270
+ function leave(action) {
271
+ if (busy) return;
272
+ if (unknown) { feedback('保存结果尚未确认,请先重新读取核对。', true); return; }
273
+ if (dirty || comfyTemplate) { pendingLeave = action; $('provider-close-confirm').hidden = false; $('provider-close-confirm').scrollIntoView({ block: 'center' }); $('provider-close-cancel').focus(); return; }
274
+ action();
275
+ }
276
+ async function manageProvider(action, value) {
277
+ if (!client || busy || dirty && action !== 'policies' || unknown) return;
278
+ const active = client, ticket = generation; busy = true; controls(); renderHealth(); feedback('正在处理…');
279
+ try {
280
+ const result = await active.postProvider(action, { expectedRevision: data.revision, ...value });
281
+ if (ticket !== generation) return;
282
+ if (action === 'impact') {
283
+ const blocked = result.blockingTasks.length + result.blockingRuns.length;
284
+ $('provider-delete-impact').textContent = '将移除 ' + result.modelCount + ' 个模型及相关手工池引用。当前工作区:' + result.blockingTasks.length + ' 个待处理任务、' + result.blockingRuns.length + ' 个依赖流程。其他程序或工作区未扫描。作品与历史记录保留;私有配置备份保留原凭据。';
285
+ $('provider-delete-yes').disabled = Boolean(blocked); $('provider-delete-confirm').hidden = false; feedback(blocked ? '仍有未完成或可恢复依赖,暂不能删除。' : '请核对影响后确认删除。', Boolean(blocked));
286
+ } else if (['enabled', 'remove', 'policies'].includes(action)) {
287
+ data = result; onSettings(result); dirty = unknown = false; touched.clear(); policies = structuredClone(data.assignments);
288
+ if (action === 'enabled') {
289
+ const toolsOpen = $('provider-management-tools').open;
290
+ edit(data.providers.find(provider => provider.id === value.providerId));
291
+ $('provider-management-tools').open = toolsOpen;
292
+ }
293
+ else enterSection(action === 'policies' ? 'pools' : 'suppliers');
294
+ await onSaved(); await readDiagnostics(); feedback('已保存。');
295
+ } else { await readDiagnostics(); feedback(action === 'check' ? result.ok ? '连接检查通过;未实测媒体。' : '连接检查未通过或未确认,请核对地址、认证和服务。' : '保活设置已更新,仅本次 Core 服务生效。', action === 'check' && !result.ok); }
296
+ } catch (error) {
297
+ if (ticket === generation) { unknown = ['enabled', 'remove', 'policies'].includes(action) && (!error.status || error.status >= 500 || error.status === 409); feedback(describe(error) + (unknown ? '。请重新读取核对结果,不会自动重试。' : ''), true); }
298
+ } finally { if (ticket === generation) { busy = false; if (section === 'pools') renderPolicies(); controls(); renderHealth(); } }
299
+ }
300
+ function addModel(id) {
301
+ id = id.trim(); if (!id) return;
302
+ if (draft.models.some(model => model.id === id)) { feedback('模型已在配置中。'); return; }
303
+ draft.models.push({ id, capabilities: [], imageProtocol: 'auto', musicProtocol: '' }); mark(); renderModels(); renderPolicies();
304
+ feedback('已加入草稿。请勾选用途,再保存;原本停用的对应功能会改为自动选择。');
305
+ }
306
+ function directoryList() {
307
+ const search = $('provider-directory-search').value.toLowerCase();
308
+ $('provider-directory-list').replaceChildren(...directory.filter(model => model.id.toLowerCase().includes(search)).map(model => {
309
+ const row = element('div', '', 'directory-item'); row.append(element('span', model.id));
310
+ const add = element('button', draft.models.some(item => item.id === model.id) ? '已添加' : '加入草稿', 'quiet'); add.type = 'button'; add.disabled = draft.models.some(item => item.id === model.id);
311
+ add.addEventListener('click', () => { addModel(model.id); directoryList(); }); row.append(add); return row;
312
+ }));
313
+ }
314
+ $('provider-form').addEventListener('input', event => { if (draft && !event.target.closest('#comfyui-tools')) { readForm(); mark(); invalidateComfyCheck(); controls(); } });
315
+ $('provider-key-action').addEventListener('change', keyAction);
316
+ $('provider-protocol').addEventListener('change', () => {
317
+ $('provider-preset').value = $('provider-protocol').value === 'comfyui' ? 'comfyui' : 'custom';
318
+ if ($('provider-protocol').value !== 'comfyui') resetComfyImport(); controls();
319
+ });
320
+ $('provider-auth').addEventListener('change', () => {
321
+ if ($('provider-auth').value === 'none' && !draft.hasCredential && !$('provider-key').value) { $('provider-key-action').value = 'keep'; keyAction(); }
322
+ });
323
+ $('provider-new').addEventListener('click', () => { if (!data || busy) return; if (dirty || comfyTemplate) { editor(true); feedback('请先保存当前草稿,或重新读取后添加。', true); return; } edit(); editor(true); setView('connection'); feedback(''); $('provider-name').focus(); });
324
+ for (const button of document.querySelectorAll('[data-provider-view]')) button.addEventListener('click', () => setView(button.dataset.providerView));
325
+ $('provider-form').addEventListener('invalid', event => {
326
+ const section = event.target.closest('[id^="provider-view-"]'); if (section) setView(section.id.slice('provider-view-'.length));
327
+ }, true);
328
+ const returnToList = () => { enterSection('suppliers'); $('provider-new').focus(); };
329
+ $('provider-back').addEventListener('click', () => leave(returnToList));
330
+ $('provider-cancel').addEventListener('click', () => leave(returnToList));
331
+ $('provider-close-cancel').addEventListener('click', () => { pendingLeave = null; $('provider-close-confirm').hidden = true; });
332
+ $('provider-close-discard').addEventListener('click', () => {
333
+ const action = pendingLeave || returnToList; pendingLeave = null; dirty = false; resetComfyImport(); action(); feedback('未保存修改已放弃。');
334
+ });
335
+ for (const button of document.querySelectorAll('[data-provider-section]')) button.addEventListener('click', () => leave(() => enterSection(button.dataset.providerSection)));
336
+ $('provider-pool-form').addEventListener('submit', event => { event.preventDefault(); if (touched.size) void manageProvider('policies', { assignments: Object.fromEntries([...touched].map(cap => [cap, policies[cap]])) }); });
337
+ $('provider-pool-cancel').addEventListener('click', () => leave(() => enterSection('pools')));
338
+ $('provider-health-refresh').addEventListener('click', () => readDiagnostics());
339
+ $('provider-toggle').addEventListener('click', () => { const saved = data.providers.find(provider => provider.id === draft.id); void manageProvider('enabled', { providerId: saved.id, enabled: !saved.enabled }); });
340
+ $('provider-delete').addEventListener('click', () => manageProvider('impact', { providerId: draft.id }));
341
+ $('provider-delete-yes').addEventListener('click', () => manageProvider('remove', { providerId: draft.id, confirm: true }));
342
+ $('provider-delete-no').addEventListener('click', () => { $('provider-delete-confirm').hidden = true; });
343
+ $('providers-refresh').addEventListener('click', () => refresh());
344
+ $('providers-reload-yes').addEventListener('click', () => refresh(true));
345
+ $('providers-reload-no').addEventListener('click', () => { $('providers-reload-confirm').hidden = true; });
346
+ $('provider-model-add').addEventListener('click', () => { addModel($('provider-model-id').value); $('provider-model-id').value = ''; });
347
+ $('provider-model-id').addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); $('provider-model-add').click(); } });
348
+ $('provider-preset').addEventListener('change', event => {
349
+ if (event.target.value !== 'comfyui' && $('provider-protocol').value === 'comfyui') restoreApiDraft();
350
+ if (event.target.value === 'openai') { $('provider-url').value = 'https://api.openai.com/v1'; $('provider-protocol').value = 'openai-images'; if (!$('provider-name').value) $('provider-name').value = 'OpenAI'; }
351
+ if (event.target.value === 'dashscope') { $('provider-url').value = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; $('provider-protocol').value = 'dashscope'; if (!$('provider-name').value) $('provider-name').value = '百炼'; }
352
+ if (['openai', 'dashscope'].includes(event.target.value)) { $('provider-auth').value = 'bearer'; $('provider-media-url').value = ''; }
353
+ if (event.target.value === 'comfyui') {
354
+ if ($('provider-protocol').value !== 'comfyui') apiDraft = apiFields();
355
+ $('provider-url').value = 'http://127.0.0.1:8188'; $('provider-media-url').value = ''; $('provider-protocol').value = 'comfyui'; $('provider-auth').value = 'none';
356
+ $('provider-key-action').value = 'keep'; keyAction(); if (!$('provider-name').value) $('provider-name').value = 'ComfyUI';
357
+ $('comfyui-tools').open = true;
358
+ }
359
+ if ($('provider-protocol').value !== 'comfyui') resetComfyImport();
360
+ readForm(); mark(); controls();
361
+ });
362
+ $('provider-directory-search').addEventListener('input', directoryList);
363
+ $('provider-discover').addEventListener('click', async () => {
364
+ const active = client, ticket = generation; if (!active || busy || dirty) return;
365
+ busy = true; controls(); feedback('正在读取模型目录…');
366
+ try {
367
+ const result = await active.postProvider('discover', { expectedRevision: data.revision, providerId: draft.id });
368
+ if (ticket !== generation) return;
369
+ directory = result.models; $('provider-directory').hidden = false; $('provider-directory-search').value = ''; directoryList();
370
+ $('provider-directory-note').textContent = '目录读取成功 · ' + directory.length + ' 个模型' + (result.truncated ? '(已截取前 256 个)' : '') + ' · 未写入配置,也未实测媒体'; feedback('');
371
+ } catch (error) { if (ticket === generation) feedback(describe(error), true); }
372
+ finally { if (ticket === generation) { busy = false; controls(); } }
373
+ });
374
+ $('provider-form').addEventListener('submit', async event => {
375
+ event.preventDefault(); if (!client || busy || unknown || comfyTemplate && !importAdded) return;
376
+ readForm(); const active = client, ticket = generation;
377
+ const provider = Object.fromEntries(['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth'].map(key => [key, draft[key]]));
378
+ provider.models = draft.models.map(model => ({ id: model.id, capabilities: model.capabilities, imageProtocol: model.imageProtocol || 'auto', musicProtocol: model.musicProtocol || '',
379
+ ...(model.comfyWorkflow?.graph ? { comfyWorkflow: model.comfyWorkflow } : {}) }));
380
+ const action = $('provider-key-action').value, credential = { action, ...(action === 'replace' ? { value: $('provider-key').value } : {}) };
381
+ const input = { expectedRevision: data.revision, provider, credential, assignments: Object.fromEntries([...touched].map(cap => [cap, policies[cap]])) };
382
+ $('provider-close-confirm').hidden = true; busy = true; controls(); feedback('正在保存连接、模型与用途…');
383
+ try {
384
+ const result = await active.postProvider('save', input);
385
+ if (ticket !== generation) return;
386
+ data = result; onSettings(result); const id = draft.id; edit(data.providers.find(provider => provider.id === id)); feedback('已保存。新请求将使用当前配置。'); await onSaved(); await readDiagnostics();
387
+ } catch (error) {
388
+ if (ticket !== generation) return;
389
+ unknown = !error.status || error.status >= 500 || error.status === 409;
390
+ feedback(describe(error) + (unknown ? '。草稿已保留,请重新读取并核对保存结果;不会自动重试。' : ''), true);
391
+ } finally { credential.value = ''; if (ticket === generation) { busy = false; controls(); } }
392
+ });
393
+ function savedComfyConnection() {
394
+ const saved = data?.providers.find(provider => provider.id === draft?.id);
395
+ return saved?.configured && saved.enabled && saved.mediaProtocol === 'comfyui'
396
+ && ['baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'auth', 'enabled'].every(key => draft[key] === saved[key])
397
+ && $('provider-key-action').value === 'keep';
398
+ }
399
+ function invalidateComfyCheck() {
400
+ importRead++; checkController?.abort(); checkController = null;
401
+ $('comfyui-replace').hidden = true;
402
+ if (comfyTemplate) $('comfyui-note').textContent = '草稿已变化,请重新检查。导入与预览不生成图片。';
403
+ }
404
+ function resetComfyImport() {
405
+ invalidateComfyCheck(); comfyTemplate = null; slots = []; importAdded = false;
406
+ $('comfyui-bindings').hidden = true; $('comfyui-file').value = ''; $('comfyui-slots').replaceChildren(); $('comfyui-preview').replaceChildren();
407
+ $('comfyui-note').textContent = '最多 40 KiB。支持 API 图和完整 Iris v0/v1 模板,导入不执行生成。';
408
+ }
409
+ function bindings() { return { promptNode: $('comfyui-prompt-node').value, outputNode: $('comfyui-output-node').value, sizeNode: $('comfyui-size-node').value }; }
410
+ function importWorkflow() { return buildWorkflow(comfyTemplate, bindings(), slots); }
411
+ function previewImport() {
412
+ importAdded = false; invalidateComfyCheck(); controls();
413
+ $('comfyui-exposed-count').textContent = slots.filter(slot => slot.exposed).length + ' / 32';
414
+ try {
415
+ const workflow = importWorkflow(), parameters = (workflow.parameters || []).map(parameter => ({ ...parameter, default: workflow.graph[parameter.node].inputs[parameter.input] }));
416
+ $('comfyui-preview-count').textContent = parameters.length ? parameters.length + ' 个开放参数' : '固定工作流';
417
+ renderWorkflowParameters($('comfyui-preview'), parameters, { disabled: true, preserveOpen: true });
418
+ $('comfyui-add').disabled = false;
419
+ } catch (error) { $('comfyui-preview').replaceChildren(element('p', error.message, 'field-note')); $('comfyui-add').disabled = true; }
420
+ }
421
+ function renderSlots() {
422
+ const previous = new Map(slots.map(slot => [slot.node + '\0' + slot.input, slot]));
423
+ slots = workflowSlots(comfyTemplate, bindings()).map(slot => previous.get(slot.node + '\0' + slot.input) || slot);
424
+ $('comfyui-slots').replaceChildren(...slots.map(slot => {
425
+ const row = element('div', '', 'comfy-slot'), toggle = element('label', '', 'mini-check'), exposed = element('input'); exposed.type = 'checkbox'; exposed.checked = slot.exposed;
426
+ exposed.dataset.comfySlot = slot.node + ':' + slot.input; toggle.append(exposed, element('span', slot.node + ' · ' + slot.input));
427
+ const name = element('input'); name.value = parameterLabel(slot); name.maxLength = 80; name.setAttribute('aria-label', '参数名称 · ' + slot.node + ':' + slot.input); name.disabled = !slot.exposed;
428
+ const type = element('select'); type.setAttribute('aria-label', '参数类型 · ' + slot.node + ':' + slot.input);
429
+ options(type, (['integer', 'number'].includes(slot.type) ? [['integer', '整数'], ['number', '小数']] : [[slot.type, slot.type === 'boolean' ? '开关' : '文本']]), slot.type); type.disabled = !slot.exposed;
430
+ exposed.addEventListener('change', () => { slot.exposed = exposed.checked; name.disabled = type.disabled = !slot.exposed; previewImport(); });
431
+ name.addEventListener('input', () => { slot.label = name.value; previewImport(); }); type.addEventListener('change', () => { slot.type = type.value; previewImport(); });
432
+ row.append(toggle, name, type); return row;
433
+ }));
434
+ previewImport();
435
+ }
436
+ for (const id of ['comfyui-prompt-node', 'comfyui-output-node', 'comfyui-size-node']) $(id).addEventListener('change', () => { if (comfyTemplate) renderSlots(); });
437
+ $('comfyui-model').addEventListener('input', () => { importAdded = false; invalidateComfyCheck(); controls(); });
438
+ $('comfyui-discard').addEventListener('click', () => { resetComfyImport(); controls(); });
439
+ $('comfyui-file').addEventListener('change', async event => {
440
+ const file = event.target.files[0], ticket = generation; event.target.value = '';
441
+ if (!file || busy) return;
442
+ resetComfyImport(); const read = importRead;
443
+ try {
444
+ if (file.size > 40 * 1024) throw new TypeError('工作流超过 40 KiB,请精简后导入。');
445
+ const template = readWorkflowImport(await file.text());
446
+ if (ticket !== generation || read !== importRead || !client) return;
447
+ comfyTemplate = template; const graph = template.graph;
448
+ const entries = Object.entries(graph), label = ([id, node]) => [id, id + ' · ' + node.class_type];
449
+ const prompts = entries.filter(([, node]) => typeof node.inputs.text === 'string').map(label);
450
+ const outputs = entries.filter(([, node]) => /SaveImage|PreviewImage/i.test(node.class_type)).map(label);
451
+ options($('comfyui-prompt-node'), [['', '明确选择提示词节点'], ...prompts], template.promptNode || '');
452
+ options($('comfyui-output-node'), [['', '明确选择输出节点'], ...entries.map(label)], template.outputNode || (outputs.length === 1 ? outputs[0][0] : ''));
453
+ options($('comfyui-size-node'), [['', '沿用工作流尺寸'], ...entries.filter(([, node]) => ['width', 'height'].every(key => Number.isSafeInteger(node.inputs[key]) && node.inputs[key] > 0)).map(label)], template.sizeNode || '');
454
+ $('comfyui-model').value = file.name.replace(/\.json$/i, '').slice(0, 120); $('comfyui-bindings').hidden = false;
455
+ $('comfyui-exposed').open = false; renderSlots();
456
+ $('comfyui-note').textContent = '已读取 ' + entries.length + ' 个节点。请核对绑定及表单预览,再加入设置草稿。';
457
+ } catch (error) { if (ticket === generation && read === importRead) { resetComfyImport(); $('comfyui-note').textContent = error.message; } }
458
+ controls();
459
+ });
460
+ $('comfyui-check').addEventListener('click', async () => {
461
+ if (busy || !client || !comfyTemplate || !savedComfyConnection()) return;
462
+ let workflow; try { workflow = importWorkflow(); } catch (error) { $('comfyui-note').textContent = error.message; return; }
463
+ const active = client, ticket = generation, read = ++importRead; checkController = new AbortController();
464
+ busy = true; controls(); $('comfyui-note').textContent = '正在只读检查…';
465
+ try {
466
+ const report = await active.postProvider('workflow-check', { expectedRevision: data.revision, providerId: draft.id, comfyWorkflow: workflow }, { signal: checkController.signal });
467
+ if (ticket === generation && read === importRead) $('comfyui-note').textContent = inspectionMessage(report);
468
+ } catch (error) { if (ticket === generation && read === importRead) $('comfyui-note').textContent = describe(error); }
469
+ finally { if (ticket === generation) { busy = false; checkController = null; controls(); } }
470
+ });
471
+ function addWorkflow(confirmed = false) {
472
+ if (!comfyTemplate || busy) return;
473
+ const id = $('comfyui-model').value.trim();
474
+ if (!id) { $('comfyui-note').textContent = '请填写工作流名称。'; return; }
475
+ let workflow; try { workflow = importWorkflow(); } catch (error) { $('comfyui-note').textContent = error.message; return; }
476
+ let model = draft.models.find(model => model.id === id);
477
+ if (model && !confirmed) { $('comfyui-replace').hidden = false; return; }
478
+ if (!model) { model = { id, capabilities: [], imageProtocol: 'comfyui', musicProtocol: '' }; draft.models.push(model); }
479
+ model.comfyWorkflow = workflow; importAdded = true; $('comfyui-replace').hidden = true;
480
+ model.imageProtocol = 'comfyui'; if (!model.capabilities.includes('image-gen')) model.capabilities.push('image-gen');
481
+ if (policies['image-gen'].mode === 'disabled') { policies['image-gen'].mode = 'auto'; touched.add('image-gen'); }
482
+ mark(); renderModels(); renderPolicies(); $('comfyui-note').textContent = '已加入模型草稿。点击“保存设置”后,创作页可选择此工作流。';
483
+ }
484
+ $('comfyui-add').addEventListener('click', () => addWorkflow());
485
+ $('comfyui-replace-yes').addEventListener('click', () => addWorkflow(true));
486
+ $('comfyui-replace-no').addEventListener('click', () => { $('comfyui-replace').hidden = true; });
487
+ return Object.freeze({
488
+ async connect(value) { client = value; generation++; dirty = false; data = null; draft = null; section = 'suppliers'; diagnostics = null; editor(false); $('provider-pool-panel').hidden = $('provider-health-panel').hidden = true; await refresh(); },
489
+ disconnect() { generation++; resetComfyImport(); client = null; data = draft = apiDraft = diagnostics = null; pendingLeave = null; directory = []; policies = {}; touched.clear(); dirty = busy = unknown = false; section = 'suppliers'; editor(false); $('provider-pool-panel').hidden = $('provider-health-panel').hidden = true; for (const id of ['provider-spectrum','provider-health-models','provider-monitors']) $(id).replaceChildren(); $('provider-key').value = ''; $('provider-form').reset(); $('provider-models').replaceChildren(); $('providers-list').replaceChildren(); $('provider-directory-list').replaceChildren(); $('provider-assignments').replaceChildren(); $('providers-reload-confirm').hidden = true; feedback(''); },
490
+ show(visible) { if (visible && client && data && !busy && !dirty && !comfyTemplate) void refresh(); }
491
+ });
492
+ }
@@ -0,0 +1,105 @@
1
+ // A tab-local journal of identities only. Core records remain the source of truth.
2
+ export const RECOVERY_STORAGE_KEY = 'iris.workbench.recovery.v0';
3
+ export const MAX_RECOVERY_ACTIONS = 8;
4
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
5
+ const IDS = { artifactId: /^artifact_[a-f0-9]{24}$/, runId: /^run_[a-f0-9]{24}$/, operationId: /^op_[a-f0-9]{64}$/ };
6
+ const FIELDS = {
7
+ 'image-import': ['key', 'artifactId'], 'flow-create': ['key', 'artifactId', 'runId'], 'generation-create': ['key', 'runId'],
8
+ 'audio-import': ['key', 'artifactId'], 'speech-create': ['key', 'runId'], 'transcription-create': ['key', 'artifactId', 'runId'],
9
+ continue: ['key', 'runId', 'expectedRevision', 'operationId'],
10
+ selection: ['runId', 'artifactId', 'expectedRevision'], stop: ['operationId']
11
+ };
12
+ const REQUIRED = {
13
+ 'image-import': ['key'], 'flow-create': ['key', 'artifactId'], 'generation-create': ['key'], continue: ['key', 'runId', 'expectedRevision'],
14
+ 'audio-import': ['key'], 'speech-create': ['key'], 'transcription-create': ['key', 'artifactId'],
15
+ selection: ['runId', 'artifactId', 'expectedRevision'], stop: ['operationId']
16
+ };
17
+ export class WorkbenchMediaError extends Error {
18
+ constructor(code, message) { super(message); this.name = 'WorkbenchMediaError'; this.code = code; }
19
+ }
20
+ function fail(code, message) { throw new WorkbenchMediaError(code, message); }
21
+ function validAction(value) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.hasOwn(FIELDS, value.kind)
23
+ || typeof value.id !== 'string' || !UUID.test(value.id)
24
+ || Object.keys(value).some(key => !['id', 'kind', ...FIELDS[value.kind]].includes(key))
25
+ || REQUIRED[value.kind].some(key => !Object.hasOwn(value, key))) return false;
26
+ if (Object.hasOwn(value, 'key') && (typeof value.key !== 'string' || !UUID.test(value.key) || value.key !== value.id)) return false;
27
+ if (Object.hasOwn(value, 'expectedRevision') && (!Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 1)) return false;
28
+ return Object.entries(IDS).every(([key, pattern]) => !Object.hasOwn(value, key) || typeof value[key] === 'string' && pattern.test(value[key]));
29
+ }
30
+ function validTable(value) {
31
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 4
32
+ && value.schemaVersion === 0 && typeof value.origin === 'string' && value.origin.length <= 256
33
+ && typeof value.rootId === 'string' && UUID.test(value.rootId)
34
+ && Array.isArray(value.actions) && value.actions.length <= MAX_RECOVERY_ACTIONS && value.actions.every(validAction)
35
+ && new Set(value.actions.map(item => item.id)).size === value.actions.length;
36
+ }
37
+
38
+ export function createRecoveryJournal({ origin, rootId, getStorage = () => globalThis.sessionStorage }) {
39
+ if (typeof origin !== 'string' || new URL(origin).origin !== origin || origin.length > 256
40
+ || rootId !== null && (typeof rootId !== 'string' || !UUID.test(rootId))) throw new TypeError('工作台根身份无效');
41
+ let actions = [], storage, mode = 'memory', notice = null, blocked = false, closed = false;
42
+ try { storage = getStorage(); if (!storage) throw new TypeError('sessionStorage unavailable'); }
43
+ catch (_) { notice = 'storage_unavailable'; }
44
+ if (storage) {
45
+ let raw;
46
+ try { raw = storage.getItem(RECOVERY_STORAGE_KEY); }
47
+ catch (_) { storage = null; notice = 'storage_unavailable'; }
48
+ if (storage) {
49
+ mode = rootId ? 'session' : 'memory';
50
+ if (raw !== null) {
51
+ let table;
52
+ try { if (raw.length > 16384) throw new TypeError('recovery table too large'); table = JSON.parse(raw); }
53
+ catch (_) { blocked = true; notice = 'corrupt'; }
54
+ if (!blocked && !validTable(table)) { blocked = true; notice = 'corrupt'; }
55
+ if (!blocked && (table.origin !== origin || table.rootId !== rootId)) {
56
+ notice = 'root_changed';
57
+ try { storage.removeItem(RECOVERY_STORAGE_KEY); }
58
+ catch (_) { mode = 'memory'; notice = 'storage_unavailable'; }
59
+ } else if (!blocked) actions = table.actions;
60
+ }
61
+ }
62
+ }
63
+ function open() {
64
+ if (closed) throw new DOMException('工作台会话已关闭', 'AbortError');
65
+ if (blocked) fail('IRIS_WEB_RECOVERY_CORRUPT', '待核对记录损坏;记录已保留,请明确清除后再发起新操作');
66
+ }
67
+ function persist() {
68
+ if (mode !== 'session') return;
69
+ try {
70
+ if (actions.length) storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({ schemaVersion: 0, origin, rootId, actions }));
71
+ else storage.removeItem(RECOVERY_STORAGE_KEY);
72
+ } catch (_) { mode = 'memory'; notice = 'storage_unavailable'; }
73
+ }
74
+ const snapshot = () => ({ mode, notice, blocked, rootId, actions: structuredClone(actions) });
75
+ return Object.freeze({
76
+ snapshot,
77
+ add(value) {
78
+ open();
79
+ if (!validAction(value)) throw new TypeError('待核对动作包含无效字段或标识');
80
+ if (actions.length >= MAX_RECOVERY_ACTIONS) fail('IRIS_WEB_RECOVERY_FULL', '已有八项待核对操作,请先读取或明确处理原记录');
81
+ if (actions.some(item => item.id === value.id)) throw new TypeError('待核对动作身份重复');
82
+ actions.push(structuredClone(value)); persist();
83
+ },
84
+ update(id, patch) {
85
+ open();
86
+ const index = actions.findIndex(item => item.id === id);
87
+ if (index < 0 || Object.keys(patch).some(key => !Object.hasOwn(IDS, key))) throw new TypeError('待核对动作更新无效');
88
+ const next = { ...actions[index], ...patch };
89
+ if (!validAction(next)) throw new TypeError('待核对动作结果标识无效');
90
+ actions[index] = next; persist();
91
+ },
92
+ remove(id) {
93
+ open(); const index = actions.findIndex(item => item.id === id);
94
+ if (index < 0) throw new TypeError('待核对动作不存在');
95
+ actions.splice(index, 1); persist();
96
+ },
97
+ clear() {
98
+ if (closed) throw new DOMException('工作台会话已关闭', 'AbortError');
99
+ actions = []; blocked = false; notice = rootId ? null : 'root_unavailable';
100
+ try { storage?.removeItem(RECOVERY_STORAGE_KEY); }
101
+ catch (_) { mode = 'memory'; notice = 'storage_unavailable'; }
102
+ },
103
+ close() { closed = true; }
104
+ });
105
+ }