@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.en.md +109 -311
  3. package/README.md +106 -309
  4. package/bin/dsh-iris.js +452 -92
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +87 -0
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +62 -0
  12. package/docs/CORE_CONNECTIONS.md +101 -0
  13. package/docs/CORE_QUICKSTART.md +110 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  15. package/docs/CORE_SERVICE.md +101 -0
  16. package/docs/CORE_SKILLS.md +35 -3
  17. package/docs/DSH_COMPATIBILITY.md +21 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +6 -2
  21. package/docs/HEADLESS_CLI.md +30 -5
  22. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  23. package/docs/IMAGE_FLOW.md +4 -0
  24. package/docs/IMAGE_GENERATION.md +127 -0
  25. package/docs/LOCAL_API.md +4 -2
  26. package/docs/LOCAL_API_CONTRACT.md +42 -15
  27. package/docs/LOCAL_CREATION.md +7 -1
  28. package/docs/LOCAL_OPERATIONS.md +46 -0
  29. package/docs/LOCAL_SELECTION.md +57 -0
  30. package/docs/LOCAL_WEB.md +207 -0
  31. package/docs/MCP.md +212 -0
  32. package/docs/MEDIA_SCHEDULING.md +72 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/NETWORK_RESOURCES.md +106 -0
  35. package/docs/NETWORK_ROUTING.md +103 -0
  36. package/docs/NETWORK_WORKBENCH.md +65 -0
  37. package/docs/PANEL_EXTENSIONS.md +64 -0
  38. package/docs/PLUGINS.md +193 -0
  39. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  40. package/docs/PLUGIN_STARTUP.md +113 -0
  41. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  42. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  43. package/docs/PROVIDER_EXTENSIONS.md +82 -0
  44. package/docs/PROVIDER_HEALTH.md +5 -1
  45. package/docs/PROVIDER_NETWORK.md +45 -0
  46. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  47. package/docs/PROVIDER_SETTINGS.md +91 -0
  48. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
  49. package/docs/ROADMAP.md +18 -8
  50. package/docs/SECURITY.md +1 -1
  51. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  52. package/docs/VIDEO_FLOWS.md +89 -0
  53. package/docs/VISION_CLI.md +1 -1
  54. package/docs/VISION_MODEL.md +2 -0
  55. package/docs/WEB_PROTOTYPE.md +21 -6
  56. package/docs/WEB_RECOVERY.md +46 -0
  57. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  58. package/docs/WORKBENCH_LOGIN.md +63 -0
  59. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  60. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  61. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  62. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  63. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  64. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  65. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  66. package/docs/releases/0.3.0-alpha.1.md +51 -0
  67. package/docs/releases/0.3.0-beta.1.md +34 -0
  68. package/docs/screenshots.md +41 -1
  69. package/lib/actions.js +56 -4
  70. package/lib/adapters.js +75 -49
  71. package/lib/api.js +23 -13
  72. package/lib/application-runtime.js +73 -17
  73. package/lib/application-writer.js +193 -0
  74. package/lib/audio-flow-input.js +37 -0
  75. package/lib/client.d.ts +3 -3
  76. package/lib/client.js +402 -72
  77. package/lib/comfyui-adapter.js +95 -0
  78. package/lib/comfyui-client.js +60 -0
  79. package/lib/comfyui-inspection.js +73 -0
  80. package/lib/comfyui-workflow.js +94 -0
  81. package/lib/command-service.js +18 -5
  82. package/lib/config.js +49 -10
  83. package/lib/configured-image-runtime.js +69 -0
  84. package/lib/connection-skill.js +98 -0
  85. package/lib/connection-template.js +23 -0
  86. package/lib/core-artifact-store.js +84 -28
  87. package/lib/core-bridge-selection.js +31 -0
  88. package/lib/core-bridge-tools.js +85 -0
  89. package/lib/core-capability-catalog.js +192 -0
  90. package/lib/core-connection-diagnostics.js +49 -0
  91. package/lib/core-connection-store.js +277 -0
  92. package/lib/core-connections.js +225 -0
  93. package/lib/core-delivery-cache.js +8 -3
  94. package/lib/core-maintenance.js +9 -3
  95. package/lib/core-runtime.js +2 -1
  96. package/lib/core-service-client.js +174 -0
  97. package/lib/core-tasks.js +113 -21
  98. package/lib/core-writer-lease.js +16 -0
  99. package/lib/creation-request-store.js +41 -64
  100. package/lib/doctor.js +2 -2
  101. package/lib/dsh-core-adapter.js +61 -9
  102. package/lib/dsh-host-adapter.js +4 -1
  103. package/lib/dsh-provider-config.js +1 -1
  104. package/lib/dsh-service-bridge.js +67 -0
  105. package/lib/execution-ownership.js +43 -0
  106. package/lib/flow-store.js +156 -14
  107. package/lib/generation-input.js +10 -1
  108. package/lib/headless-vision.js +7 -7
  109. package/lib/image-edit-input.js +1 -1
  110. package/lib/image-flow.js +97 -31
  111. package/lib/index.js +47 -22
  112. package/lib/local-api-projection.js +15 -6
  113. package/lib/local-api-token-directory.js +31 -0
  114. package/lib/local-api.js +861 -71
  115. package/lib/local-creation.js +96 -24
  116. package/lib/local-operations.js +36 -0
  117. package/lib/local-request-root.js +86 -0
  118. package/lib/local-web-assets.js +39 -0
  119. package/lib/mcp-bridge.js +58 -0
  120. package/lib/mcp-check.js +26 -0
  121. package/lib/mcp-config.js +42 -0
  122. package/lib/mcp-delivery.js +46 -0
  123. package/lib/mcp-http.js +174 -0
  124. package/lib/mcp-server.js +69 -0
  125. package/lib/media-scheduler.js +194 -0
  126. package/lib/model-directory-service.js +37 -10
  127. package/lib/model-directory.js +3 -3
  128. package/lib/models.js +2 -2
  129. package/lib/music-transport.js +6 -3
  130. package/lib/network-config-service.js +123 -0
  131. package/lib/network-config.js +127 -0
  132. package/lib/network-doctor.js +94 -0
  133. package/lib/network-management.js +51 -0
  134. package/lib/network-policy.js +19 -0
  135. package/lib/network-route-evidence.js +38 -0
  136. package/lib/network-routing.js +283 -0
  137. package/lib/operation-runner.js +281 -0
  138. package/lib/operation-store.js +216 -0
  139. package/lib/panel-extension.js +36 -0
  140. package/lib/plugin-contract.js +194 -0
  141. package/lib/plugin-execution.js +51 -0
  142. package/lib/plugin-launch.js +86 -0
  143. package/lib/plugin-operation-store.js +101 -0
  144. package/lib/plugin-operations.js +28 -0
  145. package/lib/plugin-registry.js +109 -0
  146. package/lib/plugin-selection.js +15 -0
  147. package/lib/plugin-source-image.js +5 -0
  148. package/lib/plugin-web-assets.js +54 -0
  149. package/lib/plugins.d.ts +307 -0
  150. package/lib/plugins.js +59 -0
  151. package/lib/provider-adapter.js +7 -3
  152. package/lib/provider-adapters.js +92 -22
  153. package/lib/provider-catalog.js +64 -36
  154. package/lib/provider-config-format.js +51 -0
  155. package/lib/provider-config-service.js +143 -31
  156. package/lib/provider-context.d.ts +90 -0
  157. package/lib/provider-context.js +42 -0
  158. package/lib/provider-credentials.js +97 -0
  159. package/lib/provider-diagnostics.js +56 -0
  160. package/lib/provider-extensions.js +29 -0
  161. package/lib/provider-health.js +18 -6
  162. package/lib/provider-monitor.js +81 -0
  163. package/lib/provider-network.js +52 -0
  164. package/lib/provider-protocol.js +73 -22
  165. package/lib/provider-registry.js +128 -0
  166. package/lib/provider-response.js +38 -0
  167. package/lib/provider-settings.js +136 -0
  168. package/lib/provider-task-runner.js +132 -43
  169. package/lib/provider-transport.js +16 -0
  170. package/lib/render.js +11 -1
  171. package/lib/scheduling-policy.js +66 -0
  172. package/lib/skill-installation.js +23 -11
  173. package/lib/tasks.js +3 -2
  174. package/lib/video-flow-input.js +16 -0
  175. package/lib/video-input.js +12 -5
  176. package/lib/vision-model-routing.js +5 -0
  177. package/lib/web/app.js +703 -0
  178. package/lib/web/appearance.js +111 -0
  179. package/lib/web/comfyui.js +146 -0
  180. package/lib/web/connections.js +516 -0
  181. package/lib/web/creation.js +359 -0
  182. package/lib/web/flow-view.js +34 -0
  183. package/lib/web/gallery.js +133 -0
  184. package/lib/web/index.html +344 -0
  185. package/lib/web/media-client.js +218 -0
  186. package/lib/web/network.js +232 -0
  187. package/lib/web/plugin-panel.js +223 -0
  188. package/lib/web/plugin-status.js +140 -0
  189. package/lib/web/plugin-surface.css +17 -0
  190. package/lib/web/providers.js +513 -0
  191. package/lib/web/recovery.js +105 -0
  192. package/lib/web/startup.js +18 -0
  193. package/lib/web/styles.css +571 -0
  194. package/lib/web/transport.js +101 -0
  195. package/lib/web/workbench-login.js +165 -0
  196. package/lib/workbench-artifacts.js +3 -2
  197. package/lib/workbench-auth-cli.js +47 -0
  198. package/lib/workbench-auth.js +200 -0
  199. package/package.json +24 -6
  200. package/plugins/contact-sheet/index.d.mts +4 -0
  201. package/plugins/contact-sheet/index.mjs +33 -0
  202. package/plugins/contact-sheet/web/board.css +14 -0
  203. package/plugins/contact-sheet/web/board.mjs +93 -0
  204. package/skills/iris-core/SKILL.md +8 -4
  205. package/skills/iris-core/references/cli.md +2 -2
  206. package/skills/iris-core/references/comfyui.md +69 -0
  207. package/skills/iris-core/references/tasks.md +2 -0
  208. package/templates/plugin-starter/LICENSE +21 -0
  209. package/templates/plugin-starter/README.md +74 -0
  210. package/templates/plugin-starter/assets/peach.png +0 -0
  211. package/templates/plugin-starter/assets/violet.png +0 -0
  212. package/templates/plugin-starter/index.mjs +27 -0
  213. package/templates/plugin-starter/package.json +18 -0
  214. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  215. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  216. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  217. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  218. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  219. package/templates/plugin-starter/web/board.css +27 -0
  220. package/templates/plugin-starter/web/board.mjs +160 -0
  221. package/templates/plugin-starter/web/png.mjs +23 -0
  222. package/templates/provider-starter/LICENSE +21 -0
  223. package/templates/provider-starter/README.md +50 -0
  224. package/templates/provider-starter/index.d.mts +5 -0
  225. package/templates/provider-starter/index.mjs +165 -0
  226. package/templates/provider-starter/package.json +13 -0
  227. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  228. package/templates/provider-starter/scripts/environment.mjs +122 -0
  229. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  230. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  231. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  232. package/templates/provider-starter/scripts/setup.mjs +44 -0
  233. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  234. package/user_guide.md +5 -1
@@ -0,0 +1,513 @@
1
+ import { readWorkflowImport, workflowSlots, buildWorkflow, renderWorkflowParameters, inspectionMessage, parameterLabel } from './comfyui.js';
2
+ import { networkRouteLabel } from './network.js';
3
+
4
+ const LABELS = { 'image-gen': '图片', 'video-gen': '视频', tts: '语音', 'music-gen': '音乐', transcribe: '转写', vision: '视觉' };
5
+ const $ = id => document.getElementById(id);
6
+ const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
7
+ const ref = (providerId, modelId) => encodeURIComponent(providerId) + '::' + encodeURIComponent(modelId);
8
+
9
+ export function createProviderPanel({ describe, onSaved, onSettings, onProbe = () => {}, onNetworkOpen = () => {} }) {
10
+ let client, data, draft, busy = false, dirty = false, unknown = false, generation = 0, originalIds = new Set();
11
+ let policies = {}, touched = new Set(), directory = [], comfyTemplate = null, slots = [], importRead = 0, importAdded = false;
12
+ let checkController, apiDraft = null, editing = false, view = 'connection';
13
+ let section = 'suppliers', diagnostics = null, healthCapability = 'image-gen', pendingLeave;
14
+ let networkFacts = null;
15
+ const routeFor = id => networkFacts?.providers.find(provider => provider.id === id)?.route;
16
+ function editor(open) {
17
+ editing = open; $('provider-editor').hidden = !open; $('providers-overview').hidden = open || section !== 'suppliers';
18
+ $('provider-sections').hidden = open;
19
+ $('provider-close-confirm').hidden = true;
20
+ }
21
+ function setView(next) {
22
+ view = next;
23
+ for (const button of document.querySelectorAll('[data-provider-view]')) {
24
+ const active = button.dataset.providerView === view; button.setAttribute('aria-pressed', String(active));
25
+ $('provider-view-' + button.dataset.providerView).hidden = !active;
26
+ }
27
+ }
28
+ const connectionFields = ['url', 'media-url', 'protocol', 'auth', 'key-action', 'key', 'key-env'];
29
+ const apiFields = () => Object.fromEntries(connectionFields.map(field => [field, $('provider-' + field).value]));
30
+ function restoreApiDraft() {
31
+ const fields = apiDraft || { url: '', 'media-url': '', protocol: 'openai-images', auth: 'bearer', 'key-action': draft.hasCredential || draft.apiKeyEnv ? 'keep' : 'replace', key: '', 'key-env': draft.apiKeyEnv || '' };
32
+ for (const field of connectionFields) $('provider-' + field).value = fields[field];
33
+ keyAction();
34
+ }
35
+ const feedback = (text, error = false) => { $('providers-feedback').textContent = text; $('providers-feedback').hidden = !text; $('providers-feedback').classList.toggle('error', error); };
36
+ const status = () => { $('provider-state').textContent = busy ? '处理中…' : dirty ? '未保存' : '已保存'; };
37
+ function mark() { dirty = true; status(); summary(); }
38
+ function summary() {
39
+ const configured = draft?.models.filter(model => model.capabilities.length).length || 0;
40
+ $('provider-save-summary').textContent = configured + ' 个已标注模型 · ' + (touched.size ? [...touched].map(cap => LABELS[cap] + ':' + ({ auto: '自动', manual: '手动', disabled: '停用' }[policies[cap].mode])).join(',') : '功能池保持现有设置');
41
+ }
42
+ function controls() {
43
+ $('provider-fields').disabled = busy || !draft;
44
+ $('providers-refresh').disabled = busy; $('provider-new').disabled = busy || !data; $('provider-back').disabled = busy;
45
+ $('provider-close-discard').disabled = busy || unknown; $('provider-close-cancel').disabled = busy;
46
+ for (const button of document.querySelectorAll('#providers-list button')) button.disabled = busy;
47
+ $('provider-save').disabled = busy || unknown || Boolean(comfyTemplate && !importAdded);
48
+ $('provider-save').textContent = busy ? '正在处理…' : '保存设置';
49
+ $('provider-pool-fields').disabled = busy || unknown || !data;
50
+ $('provider-new').hidden = editing || section !== 'suppliers';
51
+ for (const id of ['provider-cancel', 'provider-pool-save', 'provider-pool-cancel', 'provider-health-refresh']) $(id).disabled = busy || unknown || !data;
52
+ $('provider-pool-save').disabled ||= !touched.size;
53
+ for (const button of document.querySelectorAll('[data-provider-section]')) button.disabled = busy || !data;
54
+ const saved = data?.providers.find(provider => provider.id === draft?.id);
55
+ $('provider-network-link').hidden = !saved || !networkFacts;
56
+ $('provider-management-tools').hidden = !saved;
57
+ for (const id of ['provider-toggle', 'provider-delete', 'provider-delete-no']) $(id).disabled = busy || dirty || unknown;
58
+ $('provider-toggle').textContent = saved?.enabled ? '停用供应商' : '恢复供应商';
59
+ $('provider-discover').disabled = busy || dirty || !data?.providers.some(provider => provider.id === draft?.id && provider.configured);
60
+ status();
61
+ const comfy = $('provider-protocol').value === 'comfyui';
62
+ document.querySelector('.provider-editor').classList.toggle('comfy-mode', comfy);
63
+ $('provider-comfy-mode').hidden = !comfy;
64
+ $('provider-preset-note').textContent = comfy ? '工作流模式已开启。导入 API 工作流,选择开放参数和 LoRA;保存配置不会生成图片。'
65
+ : '按服务文档填写;编程订阅或聊天登录不等于媒体 API 认证。';
66
+ $('comfyui-tools').hidden = !comfy;
67
+ document.querySelector('[data-provider-view="workflow"]').hidden = !comfy;
68
+ if (!comfy && view === 'workflow') setView('connection');
69
+ $('comfyui-check').disabled = busy || !comfyTemplate || !savedComfyConnection();
70
+ $('provider-discover').textContent = comfy ? '检查服务与工作流目录' : '读取模型目录';
71
+ let url;
72
+ try { url = new URL($('provider-media-url').value || $('provider-url').value); } catch (_) { /* The address is still being typed. */ }
73
+ const safe = url && ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
74
+ $('comfyui-open').hidden = !safe; $('comfyui-open').removeAttribute('href'); if (safe) $('comfyui-open').href = url.href;
75
+ }
76
+ function options(select, values, selected) {
77
+ select.replaceChildren(...values.map(([value, label]) => { const option = element('option', label); option.value = value; return option; }));
78
+ if (selected && !values.some(([value]) => value === selected)) { const option = element('option', selected + ' · 未加载扩展'); option.value = selected; select.append(option); }
79
+ select.value = selected;
80
+ }
81
+ function list() {
82
+ $('providers-count').textContent = String(data.providers.length); $('providers-empty').hidden = data.providers.length > 0;
83
+ $('providers-list').replaceChildren(...data.providers.map((provider, index) => {
84
+ const button = element('button', '', 'service-card provider-item'); button.type = 'button'; button.dataset.providerId = provider.id;
85
+ button.dataset.providerTone = String(index % 6);
86
+ button.setAttribute('aria-current', String(provider.id === draft?.id));
87
+ const heading = element('span', '', 'service-card-heading');
88
+ heading.append(element('span', provider.mediaProtocol === 'comfyui' ? 'CF' : 'API', 'service-mark'), element('small', !provider.enabled ? '已停用' : provider.configured ? '已配置' : provider.apiKeyEnv ? '环境凭据未就绪' : '待配置', 'service-status'));
89
+ button.append(heading, element('strong', provider.name), element('span', provider.baseUrl || '尚未填写地址', 'service-endpoint'),
90
+ element('span', provider.models.length + ' 个模型 · ' + (provider.mediaProtocol === 'comfyui' ? 'ComfyUI 工作流' : 'API 服务'), 'service-meta'), element('span', '配置服务 →', 'service-action'));
91
+ if (networkFacts) button.insertBefore(element('span', '配置出口 · ' + networkRouteLabel(routeFor(provider.id)), 'network-route-note'), button.lastChild);
92
+ button.addEventListener('click', () => { if (busy) return; if (dirty || comfyTemplate) { editor(true); feedback('当前有未保存编辑,请先保存,或重新读取后切换。', true); return; } edit(provider); editor(true); setView('connection'); feedback(''); });
93
+ return button;
94
+ }));
95
+ }
96
+ function details(title) {
97
+ const node = element('details', '', 'model-routing'); const head = element('summary'); const disclosure = element('span', '展开', 'disclosure');
98
+ head.append(element('span', title), disclosure); node.append(head);
99
+ node.addEventListener('toggle', () => { disclosure.textContent = node.open ? '收起' : '展开'; }); return node;
100
+ }
101
+ function renderModels() {
102
+ $('provider-models-empty').hidden = draft.models.length > 0;
103
+ $('provider-models').replaceChildren(...draft.models.map(model => {
104
+ const row = element('article', '', 'provider-model'); row.dataset.modelId = model.id;
105
+ const heading = element('div', '', 'section-heading'); heading.append(element('h3', model.id));
106
+ const remove = element('button', originalIds.has(model.id) ? '清空用途' : '撤销添加', 'quiet inline-button'); remove.type = 'button';
107
+ remove.addEventListener('click', () => { if (originalIds.has(model.id)) model.capabilities = []; else draft.models = draft.models.filter(item => item !== model); mark(); renderModels(); renderPolicies(); });
108
+ heading.append(remove); row.append(heading);
109
+ const choices = element('div', '', 'model-capabilities');
110
+ for (const [cap, label] of Object.entries(LABELS)) {
111
+ const choice = element('label', '', 'mini-check'); const input = element('input'); input.type = 'checkbox'; input.checked = model.capabilities.includes(cap); input.dataset.capability = cap;
112
+ input.addEventListener('change', () => {
113
+ model.capabilities = input.checked ? [...model.capabilities, cap] : model.capabilities.filter(value => value !== cap);
114
+ if (input.checked && policies[cap].mode === 'disabled') { policies[cap].mode = 'auto'; touched.add(cap); }
115
+ mark(); renderPolicies();
116
+ }); choice.append(input, element('span', label)); choices.append(choice);
117
+ }
118
+ row.append(choices);
119
+ const issues = model.unavailableCapabilities?.length ? '已知不可用:' + model.unavailableCapabilities.map(cap => LABELS[cap]).join('、')
120
+ : model.rateLimited ? '限流或额度停用,需核对后实测'
121
+ : Object.values(model.verified || {}).some(value => value.ok) ? '有已保存的实测记录;不代表所有用途通过' : '能力由你确认,尚无成功实测记录';
122
+ row.append(element('p', issues, 'field-note'));
123
+ if (model.comfyWorkflow) row.append(element('p', 'ComfyUI · 提示词 ' + model.comfyWorkflow.promptNode + ' → 输出 ' + model.comfyWorkflow.outputNode
124
+ + (model.comfyWorkflow.graph ? ' · 工作流尚未保存' : ' · 已保存工作流 ' + model.comfyWorkflow.sha256.slice(0, 12)), 'field-note'));
125
+ const advanced = details('模型协议与验证');
126
+ for (const [key, cap, label] of [['imageProtocol', 'image-gen', '图片协议'], ['musicProtocol', 'music-gen', '音乐协议']]) {
127
+ const select = element('select'); select.setAttribute('aria-label', model.id + ' · ' + label);
128
+ options(select, [[key === 'imageProtocol' ? 'auto' : '', key === 'imageProtocol' ? '沿用供应商默认' : '未指定(需选择)'],
129
+ ...data.protocols.filter(item => item.capabilities.includes(cap)).map(item => [item.id, item.label])], model[key] || (key === 'imageProtocol' ? 'auto' : ''));
130
+ select.addEventListener('change', () => { model[key] = select.value; mark(); });
131
+ const wrapper = element('label', label, 'field-label'); wrapper.append(select); advanced.append(wrapper);
132
+ if (key === 'musicProtocol') advanced.append(element('p', '使用音乐功能时需单独选择协议。Chat Completions 内嵌音频需返回 MP3/WAV,不限供应商名称。', 'field-note'));
133
+ }
134
+ for (const [cap, value] of Object.entries(model.verified || {})) advanced.append(element('p', (LABELS[cap] || cap) + ' · ' + (value.ok ? '通过' : '未通过') + ' · ' + (value.category || '未知') + ' · ' + (value.at || ''), 'field-note'));
135
+ advanced.append(element('p', '可在健康检测准备媒体测试,或使用 CLI models test;实际调用可能计费,目录读取不生成媒体。', 'field-note'));
136
+ row.append(advanced); return row;
137
+ }));
138
+ summary(); controls();
139
+ }
140
+ function renderPolicies() {
141
+ const all = draft ? [...data.providers.filter(provider => provider.id !== draft.id), draft] : data.providers;
142
+ $('provider-assignments').replaceChildren(...Object.keys(LABELS).map(cap => {
143
+ const row = element('div', '', 'function-policy'), heading = element('div', '', 'section-heading');
144
+ heading.append(element('h3', LABELS[cap])); const select = element('select'); select.dataset.function = cap; select.setAttribute('aria-label', LABELS[cap] + '选择方式');
145
+ options(select, [['disabled', '停用'], ['auto', '自动选择'], ['manual', '手动排序']], policies[cap].mode);
146
+ select.addEventListener('change', () => { policies[cap].mode = select.value; touched.add(cap); mark(); renderPolicies(); });
147
+ heading.append(select); row.append(heading);
148
+ if (networkFacts) {
149
+ const ids = all.filter(provider => provider.models.some(model => model.capabilities.includes(cap))).map(provider => provider.id);
150
+ const routes = [...new Set(ids.map(id => networkRouteLabel(routeFor(id))))];
151
+ row.append(element('p', '候选配置出口 · ' + (routes.join(' / ') || '暂无候选') + ';实际路线以任务记录为准。', 'field-note network-route-note'));
152
+ }
153
+ if (policies[cap].mode === 'manual') {
154
+ 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) })));
155
+ for (const saved of policies[cap].models) if (!candidates.some(item => item.ref === saved)) candidates.push({ name: saved + ' · 当前不可用', ref: saved });
156
+ 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); });
157
+ for (const candidate of candidates) {
158
+ const choice = element('label', '', 'mini-check'); const input = element('input'); input.type = 'checkbox'; input.checked = policies[cap].models.includes(candidate.ref);
159
+ 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(); });
160
+ choice.append(input, element('span', (input.checked ? (policies[cap].models.indexOf(candidate.ref) + 1) + '. ' : '') + candidate.name));
161
+ const rank = element('div', '', 'provider-rank-row'); rank.append(choice);
162
+ if (input.checked) for (const [text, step] of [['上移', -1], ['下移', 1]]) {
163
+ const move = element('button', text, 'quiet'); move.type = 'button'; move.setAttribute('aria-label', candidate.name + ' · ' + text);
164
+ const index = policies[cap].models.indexOf(candidate.ref); move.disabled = index + step < 0 || index + step >= policies[cap].models.length;
165
+ 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);
166
+ }
167
+ row.append(rank);
168
+ }
169
+ if (!candidates.length) row.append(element('p', '请先给模型勾选此用途。', 'field-note'));
170
+ }
171
+ if (cap === 'vision') {
172
+ const label = element('label', '', 'mini-check'), input = element('input'); input.type = 'checkbox'; input.checked = policies[cap].hostVision;
173
+ input.addEventListener('change', () => { policies[cap].hostVision = input.checked; touched.add(cap); mark(); }); label.append(input, element('span', '允许宿主视觉(独立 Core 无此宿主服务)')); row.append(label);
174
+ }
175
+ return row;
176
+ }));
177
+ $('provider-pool-note').textContent = touched.size ? '尚未保存 · ' + [...touched].map(cap => LABELS[cap]).join('、') : '已保存 · 按顺序优先选择,不是跨请求轮询。';
178
+ controls();
179
+ }
180
+ function edit(provider) {
181
+ resetComfyImport(); $('comfyui-tools').open = false; apiDraft = null;
182
+ draft = provider ? structuredClone(provider) : { id: 'provider-' + crypto.randomUUID().slice(0, 8), name: '', baseUrl: '', mediaBaseUrl: '', mediaProtocol: 'openai-images', enabled: true, auth: 'bearer', hasCredential: false, models: [] };
183
+ document.querySelector('.provider-editor').dataset.providerTone = String((provider ? data.providers.indexOf(provider) : data.providers.length) % 6);
184
+ policies = structuredClone(data.assignments); touched = new Set(); originalIds = new Set(draft.models.map(model => model.id)); dirty = !provider; unknown = false; directory = [];
185
+ $('provider-editor-title').textContent = provider ? '编辑供应商' : '添加供应商';
186
+ for (const [key, field] of [['name', 'name'], ['url', 'baseUrl'], ['media-url', 'mediaBaseUrl'], ['auth', 'auth']]) $('provider-' + key).value = draft[field];
187
+ options($('provider-protocol'), data.protocols.map(item => [item.id, item.label]), draft.mediaProtocol);
188
+ $('provider-enabled').checked = draft.enabled; $('provider-key-action').value = provider ? 'keep' : 'replace'; $('provider-key').value = ''; $('provider-key-env').value = draft.apiKeyEnv || ''; keyAction();
189
+ $('provider-key-state').textContent = draft.apiKeyEnv ? '环境变量 · ' + ({ ready: '已就绪', missing: '未就绪', invalid: '值无效' }[draft.credentialStatus] || '未就绪') : draft.hasCredential ? '已保存 · 不回显' : '尚未保存';
190
+ $('provider-id-note').textContent = '稳定 ID:' + draft.id; $('provider-preset').value = draft.mediaProtocol === 'comfyui' ? 'comfyui' : 'custom'; $('provider-model-id').value = '';
191
+ if (draft.mediaProtocol !== 'comfyui') apiDraft = apiFields();
192
+ $('provider-advanced').open = false; $('provider-management-tools').open = false; $('provider-delete-confirm').hidden = true; $('provider-directory').hidden = true;
193
+ $('provider-directory-note').textContent = '目录只使用已保存连接;成功不代表媒体实测通过。';
194
+ list(); renderModels(); renderPolicies(); controls();
195
+ }
196
+ function keyAction() {
197
+ const action = $('provider-key-action').value;
198
+ $('provider-key-field').hidden = action !== 'replace'; $('provider-key').required = action === 'replace';
199
+ $('provider-key-env-field').hidden = action !== 'env' && !(action === 'keep' && draft?.apiKeyEnv);
200
+ $('provider-key-env').required = action === 'env'; $('provider-key-env').readOnly = action !== 'env';
201
+ if (action !== 'replace') $('provider-key').value = '';
202
+ }
203
+ function readForm() {
204
+ draft.name = $('provider-name').value; draft.baseUrl = $('provider-url').value; draft.mediaBaseUrl = $('provider-media-url').value;
205
+ draft.mediaProtocol = $('provider-protocol').value; draft.auth = $('provider-auth').value; draft.enabled = $('provider-enabled').checked;
206
+ if (draft.mediaProtocol !== 'comfyui') apiDraft = apiFields();
207
+ }
208
+ async function refresh(discard = false) {
209
+ if (!client || busy) return;
210
+ if ((dirty || comfyTemplate) && !discard) { $('providers-reload-confirm').hidden = false; return; }
211
+ $('providers-reload-confirm').hidden = true;
212
+ const active = client, ticket = generation, reloading = Boolean(data); busy = true; controls(); feedback('正在读取设置…');
213
+ try {
214
+ const result = await active.get('/api/v0/provider-management/settings');
215
+ if (ticket !== generation || active !== client) return;
216
+ if (result.contractVersion !== 0) throw new TypeError('不支持的供应商设置版本');
217
+ const id = draft?.id; data = result; onSettings(result);
218
+ if (editing) edit(data.providers.find(provider => provider.id === id) || data.providers[0]);
219
+ else { draft = apiDraft = null; dirty = false; unknown = false; policies = structuredClone(data.assignments); touched.clear(); resetComfyImport(); list(); if (section === 'pools') renderPolicies(); controls(); }
220
+ await readDiagnostics(); feedback('');
221
+ if (reloading) await onSaved();
222
+ } catch (error) { if (ticket === generation) { feedback(describe(error), true); onSettings(null, describe(error)); } }
223
+ finally { if (ticket === generation) { busy = false; controls(); } }
224
+ }
225
+ const reasonLabels = { provider_disabled: '供应商已停用', credential_unavailable: '环境凭据未就绪', connection_unconfigured: '连接未就绪', function_disabled: '功能已停用', unconfirmed: '用途待确认', protocol_unsupported: '当前协议不支持', model_missing: '模型不存在,已隔离', cooling: '限流冷却中', quota: '额度或预算耗尽', outside_pool: '未加入当前模型池' };
226
+ const time = value => value ? new Date(value).toLocaleString('zh-CN') : '尚无记录';
227
+ async function readDiagnostics() {
228
+ if (!client || !data) return;
229
+ const active = client, ticket = generation;
230
+ try {
231
+ const value = await active.get('/api/v0/provider-management/diagnostics');
232
+ if (ticket !== generation || active !== client) return;
233
+ if (value.contractVersion !== 0 || value.revision !== data.revision) throw new Error('配置已变化,请重新读取模型服务后核对。');
234
+ diagnostics = value; renderHealth();
235
+ } catch (error) { if (ticket === generation) { diagnostics = null; renderHealth(); feedback(describe(error), true); } }
236
+ }
237
+ function renderHealth() {
238
+ $('provider-spectrum').replaceChildren(...Object.entries(LABELS).map(([cap, label], index) => {
239
+ const fact = diagnostics?.capabilities.find(item => item.capability === cap), button = element('button', '', 'provider-spectrum-card');
240
+ button.type = 'button'; button.dataset.tone = String(index); button.dataset.healthCapability = cap;
241
+ button.append(element('strong', label), element('small', fact ? fact.candidates.length + ' 个候选 · ' + fact.verified + ' 个近期成功' : '状态未知'),
242
+ element('small', fact ? ({ auto: '自动池', manual: '手动优先级', disabled: '已停用' }[fact.mode]) + ' · ' + fact.models.filter(item => !item.eligible).length + ' 项未参与' : '请读取状态'));
243
+ button.addEventListener('click', () => { healthCapability = cap; renderHealth(); $('provider-health-details').open = true; }); return button;
244
+ }));
245
+ $('provider-health-title').textContent = LABELS[healthCapability] + ' · 候选与验证';
246
+ const fact = diagnostics?.capabilities.find(item => item.capability === healthCapability);
247
+ $('provider-health-models').replaceChildren(...(fact?.models.length ? fact.models.map(model => {
248
+ const card = element('article', '', 'provider-health-model'); card.append(element('strong', model.providerName + ' / ' + model.modelId));
249
+ card.append(element('p', (model.reason ? reasonLabels[model.reason] : '候选 #' + model.rank) + ' · '
250
+ + ({ verified: '近期成功', failed: '认证或权限受阻', configured: '尚无近期成功证据' }[model.state] || model.state), 'field-note'));
251
+ card.append(element('p', '最近证据 · ' + time(model.evidence?.at) + (model.retryAt ? ' · 冷却至 ' + time(model.retryAt) : ''), 'field-note'));
252
+ const videoReady = diagnostics.video && !/s2v/i.test(model.modelId);
253
+ const supported = diagnostics.execution && model.eligible && (healthCapability === 'image-gen' || healthCapability === 'video-gen' && videoReady
254
+ || healthCapability === 'tts' && diagnostics.audio || healthCapability === 'transcribe' && diagnostics.audio);
255
+ if (supported) {
256
+ const test = element('button', '准备功能验证', 'secondary'); test.type = 'button'; test.dataset.probeModel = model.ref;
257
+ test.disabled = busy; test.addEventListener('click', () => onProbe({ capability: healthCapability, modelRef: model.ref, comfy: model.comfy })); card.append(test);
258
+ card.append(element('p', model.comfy ? '在创作页核对工作流和参数后提交,可能产生费用。' : '打开指定模型的验证草稿;点击创作页的生成按钮后才调用,可能产生费用。', 'field-note'));
259
+ } else card.append(element('p', healthCapability === 'video-gen' ? '文生视频验证需要新 v4 工作区、执行权限与可用候选;S2V 使用现有 CLI / DSH。'
260
+ : ['music-gen', 'vision'].includes(healthCapability) ? '此功能的 Web 执行尚未接入,请使用现有 CLI / DSH 明确验证。' : '请先开放执行、配置功能池并处理受阻原因,再准备验证。', 'field-note'));
261
+ return card;
262
+ }) : [element('p', diagnostics ? '当前没有已标注此用途的模型。' : '检测状态尚未读取;不会当作通过。', 'field-note')]));
263
+ $('provider-monitors').replaceChildren(...(data?.providers || []).map(provider => {
264
+ const state = diagnostics?.monitors.find(item => item.providerId === provider.id), card = element('article', '', 'card provider-monitor');
265
+ card.append(element('h3', provider.name), element('p', !state?.last ? '尚未检查连接' : (state.last.ok ? '最近连接检查通过' : '最近连接检查未通过 / 未确认')
266
+ + ' · ' + time(state.last.at) + (state.last.code ? ' · ' + state.last.code : ''), 'field-note'));
267
+ const test = element('button', '检查连接', 'secondary'); test.type = 'button'; test.dataset.checkProvider = provider.id;
268
+ test.disabled = busy || !provider.configured || !provider.enabled || !diagnostics;
269
+ test.addEventListener('click', () => manageProvider('check', { providerId: provider.id })); card.append(test);
270
+ 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;
271
+ const interval = element('select'); interval.setAttribute('aria-label', provider.name + '保活间隔'); options(interval, [['60', '每分钟'], ['300', '每5分钟'], ['900', '每15分钟']], String(state?.intervalSeconds || 300));
272
+ enabled.disabled = interval.disabled = busy || !provider.enabled || !provider.configured || !state?.automaticSupported;
273
+ const update = () => manageProvider('monitor', { providerId: provider.id, enabled: enabled.checked, intervalSeconds: Number(interval.value) });
274
+ enabled.addEventListener('change', update); interval.addEventListener('change', () => { if (enabled.checked) update(); });
275
+ label.prepend(enabled); controls.append(label, interval); card.append(controls);
276
+ if (state && !state.automaticSupported) card.append(element('p', '此扩展协议没有已支持的自动轻量检查。', 'field-note'));
277
+ return card;
278
+ }));
279
+ }
280
+ function enterSection(next) {
281
+ section = next; draft = apiDraft = null; dirty = unknown = false; touched.clear(); resetComfyImport(); $('provider-key').value = ''; $('provider-key-env').value = '';
282
+ policies = structuredClone(data?.assignments || {}); editor(false);
283
+ if (data) list();
284
+ $('provider-pool-panel').hidden = next !== 'pools'; $('provider-health-panel').hidden = next !== 'health';
285
+ for (const button of document.querySelectorAll('[data-provider-section]')) button.setAttribute('aria-pressed', String(button.dataset.providerSection === next));
286
+ if (next === 'pools') renderPolicies(); if (next === 'health') { renderHealth(); void readDiagnostics(); }
287
+ controls(); feedback('');
288
+ }
289
+ function leave(action) {
290
+ if (busy) return;
291
+ if (unknown) { feedback('保存结果尚未确认,请先重新读取核对。', true); return; }
292
+ if (dirty || comfyTemplate) { pendingLeave = action; $('provider-close-confirm').hidden = false; $('provider-close-confirm').scrollIntoView({ block: 'center' }); $('provider-close-cancel').focus(); return; }
293
+ action();
294
+ }
295
+ async function manageProvider(action, value) {
296
+ if (!client || busy || dirty && action !== 'policies' || unknown) return;
297
+ const active = client, ticket = generation; busy = true; controls(); renderHealth(); feedback('正在处理…');
298
+ try {
299
+ const result = await active.postProvider(action, { expectedRevision: data.revision, ...value });
300
+ if (ticket !== generation) return;
301
+ if (action === 'impact') {
302
+ const blocked = result.blockingTasks.length + result.blockingRuns.length;
303
+ $('provider-delete-impact').textContent = '将移除 ' + result.modelCount + ' 个模型及相关手工池引用。当前工作区:' + result.blockingTasks.length + ' 个待处理任务、' + result.blockingRuns.length + ' 个依赖流程。其他程序或工作区未扫描。作品与历史记录保留;私有配置备份保留原凭据。';
304
+ $('provider-delete-yes').disabled = Boolean(blocked); $('provider-delete-confirm').hidden = false; feedback(blocked ? '仍有未完成或可恢复依赖,暂不能删除。' : '请核对影响后确认删除。', Boolean(blocked));
305
+ } else if (['enabled', 'remove', 'policies'].includes(action)) {
306
+ data = result; onSettings(result); dirty = unknown = false; touched.clear(); policies = structuredClone(data.assignments);
307
+ if (action === 'enabled') {
308
+ const toolsOpen = $('provider-management-tools').open;
309
+ edit(data.providers.find(provider => provider.id === value.providerId));
310
+ $('provider-management-tools').open = toolsOpen;
311
+ }
312
+ else enterSection(action === 'policies' ? 'pools' : 'suppliers');
313
+ await onSaved(); await readDiagnostics(); feedback('已保存。');
314
+ } else { await readDiagnostics(); feedback(action === 'check' ? result.ok ? '连接检查通过;未实测媒体。' : '连接检查未通过或未确认,请核对地址、认证和服务。' : '保活设置已更新,仅本次 Core 服务生效。', action === 'check' && !result.ok); }
315
+ } catch (error) {
316
+ if (ticket === generation) { unknown = ['enabled', 'remove', 'policies'].includes(action) && (!error.status || error.status >= 500 || error.status === 409); feedback(describe(error) + (unknown ? '。请重新读取核对结果,不会自动重试。' : ''), true); }
317
+ } finally { if (ticket === generation) { busy = false; if (section === 'pools') renderPolicies(); controls(); renderHealth(); } }
318
+ }
319
+ function addModel(id) {
320
+ id = id.trim(); if (!id) return;
321
+ if (draft.models.some(model => model.id === id)) { feedback('模型已在配置中。'); return; }
322
+ draft.models.push({ id, capabilities: [], imageProtocol: 'auto', musicProtocol: '' }); mark(); renderModels(); renderPolicies();
323
+ feedback('已加入草稿。请勾选用途,再保存;原本停用的对应功能会改为自动选择。');
324
+ }
325
+ function directoryList() {
326
+ const search = $('provider-directory-search').value.toLowerCase();
327
+ $('provider-directory-list').replaceChildren(...directory.filter(model => model.id.toLowerCase().includes(search)).map(model => {
328
+ const row = element('div', '', 'directory-item'); row.append(element('span', model.id));
329
+ 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);
330
+ add.addEventListener('click', () => { addModel(model.id); directoryList(); }); row.append(add); return row;
331
+ }));
332
+ }
333
+ $('provider-form').addEventListener('input', event => { if (draft && !event.target.closest('#comfyui-tools')) { readForm(); mark(); invalidateComfyCheck(); controls(); } });
334
+ $('provider-key-action').addEventListener('change', keyAction);
335
+ $('provider-protocol').addEventListener('change', () => {
336
+ $('provider-preset').value = $('provider-protocol').value === 'comfyui' ? 'comfyui' : 'custom';
337
+ if ($('provider-protocol').value !== 'comfyui') resetComfyImport(); controls();
338
+ });
339
+ $('provider-auth').addEventListener('change', () => {
340
+ if ($('provider-auth').value === 'none' && !draft.hasCredential && !$('provider-key').value && $('provider-key-action').value !== 'env') { $('provider-key-action').value = 'keep'; keyAction(); }
341
+ });
342
+ $('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(); });
343
+ for (const button of document.querySelectorAll('[data-provider-view]')) button.addEventListener('click', () => setView(button.dataset.providerView));
344
+ $('provider-form').addEventListener('invalid', event => {
345
+ const section = event.target.closest('[id^="provider-view-"]'); if (section) setView(section.id.slice('provider-view-'.length));
346
+ }, true);
347
+ const returnToList = () => { enterSection('suppliers'); $('provider-new').focus(); };
348
+ $('provider-back').addEventListener('click', () => leave(returnToList));
349
+ $('provider-cancel').addEventListener('click', () => leave(returnToList));
350
+ $('provider-close-cancel').addEventListener('click', () => { pendingLeave = null; $('provider-close-confirm').hidden = true; });
351
+ $('provider-close-discard').addEventListener('click', () => {
352
+ const action = pendingLeave || returnToList; pendingLeave = null; dirty = false; resetComfyImport(); action(); feedback('未保存修改已放弃。');
353
+ });
354
+ for (const button of document.querySelectorAll('[data-provider-section]')) button.addEventListener('click', () => leave(() => enterSection(button.dataset.providerSection)));
355
+ $('provider-pool-form').addEventListener('submit', event => { event.preventDefault(); if (touched.size) void manageProvider('policies', { assignments: Object.fromEntries([...touched].map(cap => [cap, policies[cap]])) }); });
356
+ $('provider-pool-cancel').addEventListener('click', () => leave(() => enterSection('pools')));
357
+ $('provider-health-refresh').addEventListener('click', () => readDiagnostics());
358
+ $('provider-network-link').addEventListener('click', () => leave(() => { const id = draft?.id; enterSection('suppliers'); onNetworkOpen(id); }));
359
+ $('provider-toggle').addEventListener('click', () => { const saved = data.providers.find(provider => provider.id === draft.id); void manageProvider('enabled', { providerId: saved.id, enabled: !saved.enabled }); });
360
+ $('provider-delete').addEventListener('click', () => manageProvider('impact', { providerId: draft.id }));
361
+ $('provider-delete-yes').addEventListener('click', () => manageProvider('remove', { providerId: draft.id, confirm: true }));
362
+ $('provider-delete-no').addEventListener('click', () => { $('provider-delete-confirm').hidden = true; });
363
+ $('providers-refresh').addEventListener('click', () => refresh());
364
+ $('providers-reload-yes').addEventListener('click', () => refresh(true));
365
+ $('providers-reload-no').addEventListener('click', () => { $('providers-reload-confirm').hidden = true; });
366
+ $('provider-model-add').addEventListener('click', () => { addModel($('provider-model-id').value); $('provider-model-id').value = ''; });
367
+ $('provider-model-id').addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); $('provider-model-add').click(); } });
368
+ $('provider-preset').addEventListener('change', event => {
369
+ if (event.target.value !== 'comfyui' && $('provider-protocol').value === 'comfyui') restoreApiDraft();
370
+ 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'; }
371
+ 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 = '百炼'; }
372
+ if (['openai', 'dashscope'].includes(event.target.value)) { $('provider-auth').value = 'bearer'; $('provider-media-url').value = ''; }
373
+ if (event.target.value === 'comfyui') {
374
+ if ($('provider-protocol').value !== 'comfyui') apiDraft = apiFields();
375
+ $('provider-url').value = 'http://127.0.0.1:8188'; $('provider-media-url').value = ''; $('provider-protocol').value = 'comfyui'; $('provider-auth').value = 'none';
376
+ $('provider-key-action').value = 'keep'; keyAction(); if (!$('provider-name').value) $('provider-name').value = 'ComfyUI';
377
+ $('comfyui-tools').open = true;
378
+ }
379
+ if ($('provider-protocol').value !== 'comfyui') resetComfyImport();
380
+ readForm(); mark(); controls();
381
+ });
382
+ $('provider-directory-search').addEventListener('input', directoryList);
383
+ $('provider-discover').addEventListener('click', async () => {
384
+ const active = client, ticket = generation; if (!active || busy || dirty) return;
385
+ busy = true; controls(); feedback('正在读取模型目录…');
386
+ try {
387
+ const result = await active.postProvider('discover', { expectedRevision: data.revision, providerId: draft.id });
388
+ if (ticket !== generation) return;
389
+ directory = result.models; $('provider-directory').hidden = false; $('provider-directory-search').value = ''; directoryList();
390
+ $('provider-directory-note').textContent = '目录读取成功 · ' + directory.length + ' 个模型' + (result.truncated ? '(已截取前 256 个)' : '') + ' · 未写入配置,也未实测媒体'; feedback('');
391
+ } catch (error) { if (ticket === generation) feedback(describe(error), true); }
392
+ finally { if (ticket === generation) { busy = false; controls(); } }
393
+ });
394
+ $('provider-form').addEventListener('submit', async event => {
395
+ event.preventDefault(); if (!client || busy || unknown || comfyTemplate && !importAdded) return;
396
+ readForm(); const active = client, ticket = generation;
397
+ const provider = Object.fromEntries(['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth'].map(key => [key, draft[key]]));
398
+ provider.models = draft.models.map(model => ({ id: model.id, capabilities: model.capabilities, imageProtocol: model.imageProtocol || 'auto', musicProtocol: model.musicProtocol || '',
399
+ ...(model.comfyWorkflow?.graph ? { comfyWorkflow: model.comfyWorkflow } : {}) }));
400
+ const action = $('provider-key-action').value, credential = { action, ...(action === 'replace' ? { value: $('provider-key').value } : action === 'env' ? { name: $('provider-key-env').value } : {}) };
401
+ const input = { expectedRevision: data.revision, provider, credential, assignments: Object.fromEntries([...touched].map(cap => [cap, policies[cap]])) };
402
+ $('provider-close-confirm').hidden = true; busy = true; controls(); feedback('正在保存连接、模型与用途…');
403
+ try {
404
+ const result = await active.postProvider('save', input);
405
+ if (ticket !== generation) return;
406
+ data = result; onSettings(result); const id = draft.id; edit(data.providers.find(provider => provider.id === id)); feedback('已保存。新请求将使用当前配置。'); await onSaved(); await readDiagnostics();
407
+ } catch (error) {
408
+ if (ticket !== generation) return;
409
+ unknown = !error.status || error.status >= 500 || error.status === 409;
410
+ feedback(describe(error) + (unknown ? '。草稿已保留,请重新读取并核对保存结果;不会自动重试。' : ''), true);
411
+ } finally { credential.value = ''; if (ticket === generation) { busy = false; controls(); } }
412
+ });
413
+ function savedComfyConnection() {
414
+ const saved = data?.providers.find(provider => provider.id === draft?.id);
415
+ return saved?.configured && saved.enabled && saved.mediaProtocol === 'comfyui'
416
+ && ['baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'auth', 'enabled'].every(key => draft[key] === saved[key])
417
+ && $('provider-key-action').value === 'keep';
418
+ }
419
+ function invalidateComfyCheck() {
420
+ importRead++; checkController?.abort(); checkController = null;
421
+ $('comfyui-replace').hidden = true;
422
+ if (comfyTemplate) $('comfyui-note').textContent = '草稿已变化,请重新检查。导入与预览不生成图片。';
423
+ }
424
+ function resetComfyImport() {
425
+ invalidateComfyCheck(); comfyTemplate = null; slots = []; importAdded = false;
426
+ $('comfyui-bindings').hidden = true; $('comfyui-file').value = ''; $('comfyui-slots').replaceChildren(); $('comfyui-preview').replaceChildren();
427
+ $('comfyui-note').textContent = '最多 40 KiB。支持 API 图和完整 Iris v0/v1 模板,导入不执行生成。';
428
+ }
429
+ function bindings() { return { promptNode: $('comfyui-prompt-node').value, outputNode: $('comfyui-output-node').value, sizeNode: $('comfyui-size-node').value }; }
430
+ function importWorkflow() { return buildWorkflow(comfyTemplate, bindings(), slots); }
431
+ function previewImport() {
432
+ importAdded = false; invalidateComfyCheck(); controls();
433
+ $('comfyui-exposed-count').textContent = slots.filter(slot => slot.exposed).length + ' / 32';
434
+ try {
435
+ const workflow = importWorkflow(), parameters = (workflow.parameters || []).map(parameter => ({ ...parameter, default: workflow.graph[parameter.node].inputs[parameter.input] }));
436
+ $('comfyui-preview-count').textContent = parameters.length ? parameters.length + ' 个开放参数' : '固定工作流';
437
+ renderWorkflowParameters($('comfyui-preview'), parameters, { disabled: true, preserveOpen: true });
438
+ $('comfyui-add').disabled = false;
439
+ } catch (error) { $('comfyui-preview').replaceChildren(element('p', error.message, 'field-note')); $('comfyui-add').disabled = true; }
440
+ }
441
+ function renderSlots() {
442
+ const previous = new Map(slots.map(slot => [slot.node + '\0' + slot.input, slot]));
443
+ slots = workflowSlots(comfyTemplate, bindings()).map(slot => previous.get(slot.node + '\0' + slot.input) || slot);
444
+ $('comfyui-slots').replaceChildren(...slots.map(slot => {
445
+ const row = element('div', '', 'comfy-slot'), toggle = element('label', '', 'mini-check'), exposed = element('input'); exposed.type = 'checkbox'; exposed.checked = slot.exposed;
446
+ exposed.dataset.comfySlot = slot.node + ':' + slot.input; toggle.append(exposed, element('span', slot.node + ' · ' + slot.input));
447
+ const name = element('input'); name.value = parameterLabel(slot); name.maxLength = 80; name.setAttribute('aria-label', '参数名称 · ' + slot.node + ':' + slot.input); name.disabled = !slot.exposed;
448
+ const type = element('select'); type.setAttribute('aria-label', '参数类型 · ' + slot.node + ':' + slot.input);
449
+ options(type, (['integer', 'number'].includes(slot.type) ? [['integer', '整数'], ['number', '小数']] : [[slot.type, slot.type === 'boolean' ? '开关' : '文本']]), slot.type); type.disabled = !slot.exposed;
450
+ exposed.addEventListener('change', () => { slot.exposed = exposed.checked; name.disabled = type.disabled = !slot.exposed; previewImport(); });
451
+ name.addEventListener('input', () => { slot.label = name.value; previewImport(); }); type.addEventListener('change', () => { slot.type = type.value; previewImport(); });
452
+ row.append(toggle, name, type); return row;
453
+ }));
454
+ previewImport();
455
+ }
456
+ for (const id of ['comfyui-prompt-node', 'comfyui-output-node', 'comfyui-size-node']) $(id).addEventListener('change', () => { if (comfyTemplate) renderSlots(); });
457
+ $('comfyui-model').addEventListener('input', () => { importAdded = false; invalidateComfyCheck(); controls(); });
458
+ $('comfyui-discard').addEventListener('click', () => { resetComfyImport(); controls(); });
459
+ $('comfyui-file').addEventListener('change', async event => {
460
+ const file = event.target.files[0], ticket = generation; event.target.value = '';
461
+ if (!file || busy) return;
462
+ resetComfyImport(); const read = importRead;
463
+ try {
464
+ if (file.size > 40 * 1024) throw new TypeError('工作流超过 40 KiB,请精简后导入。');
465
+ const template = readWorkflowImport(await file.text());
466
+ if (ticket !== generation || read !== importRead || !client) return;
467
+ comfyTemplate = template; const graph = template.graph;
468
+ const entries = Object.entries(graph), label = ([id, node]) => [id, id + ' · ' + node.class_type];
469
+ const prompts = entries.filter(([, node]) => typeof node.inputs.text === 'string').map(label);
470
+ const outputs = entries.filter(([, node]) => /SaveImage|PreviewImage/i.test(node.class_type)).map(label);
471
+ options($('comfyui-prompt-node'), [['', '明确选择提示词节点'], ...prompts], template.promptNode || '');
472
+ options($('comfyui-output-node'), [['', '明确选择输出节点'], ...entries.map(label)], template.outputNode || (outputs.length === 1 ? outputs[0][0] : ''));
473
+ options($('comfyui-size-node'), [['', '沿用工作流尺寸'], ...entries.filter(([, node]) => ['width', 'height'].every(key => Number.isSafeInteger(node.inputs[key]) && node.inputs[key] > 0)).map(label)], template.sizeNode || '');
474
+ $('comfyui-model').value = file.name.replace(/\.json$/i, '').slice(0, 120); $('comfyui-bindings').hidden = false;
475
+ $('comfyui-exposed').open = false; renderSlots();
476
+ $('comfyui-note').textContent = '已读取 ' + entries.length + ' 个节点。请核对绑定及表单预览,再加入设置草稿。';
477
+ } catch (error) { if (ticket === generation && read === importRead) { resetComfyImport(); $('comfyui-note').textContent = error.message; } }
478
+ controls();
479
+ });
480
+ $('comfyui-check').addEventListener('click', async () => {
481
+ if (busy || !client || !comfyTemplate || !savedComfyConnection()) return;
482
+ let workflow; try { workflow = importWorkflow(); } catch (error) { $('comfyui-note').textContent = error.message; return; }
483
+ const active = client, ticket = generation, read = ++importRead; checkController = new AbortController();
484
+ busy = true; controls(); $('comfyui-note').textContent = '正在只读检查…';
485
+ try {
486
+ const report = await active.postProvider('workflow-check', { expectedRevision: data.revision, providerId: draft.id, comfyWorkflow: workflow }, { signal: checkController.signal });
487
+ if (ticket === generation && read === importRead) $('comfyui-note').textContent = inspectionMessage(report);
488
+ } catch (error) { if (ticket === generation && read === importRead) $('comfyui-note').textContent = describe(error); }
489
+ finally { if (ticket === generation) { busy = false; checkController = null; controls(); } }
490
+ });
491
+ function addWorkflow(confirmed = false) {
492
+ if (!comfyTemplate || busy) return;
493
+ const id = $('comfyui-model').value.trim();
494
+ if (!id) { $('comfyui-note').textContent = '请填写工作流名称。'; return; }
495
+ let workflow; try { workflow = importWorkflow(); } catch (error) { $('comfyui-note').textContent = error.message; return; }
496
+ let model = draft.models.find(model => model.id === id);
497
+ if (model && !confirmed) { $('comfyui-replace').hidden = false; return; }
498
+ if (!model) { model = { id, capabilities: [], imageProtocol: 'comfyui', musicProtocol: '' }; draft.models.push(model); }
499
+ model.comfyWorkflow = workflow; importAdded = true; $('comfyui-replace').hidden = true;
500
+ model.imageProtocol = 'comfyui'; if (!model.capabilities.includes('image-gen')) model.capabilities.push('image-gen');
501
+ if (policies['image-gen'].mode === 'disabled') { policies['image-gen'].mode = 'auto'; touched.add('image-gen'); }
502
+ mark(); renderModels(); renderPolicies(); $('comfyui-note').textContent = '已加入模型草稿。点击“保存设置”后,创作页可选择此工作流。';
503
+ }
504
+ $('comfyui-add').addEventListener('click', () => addWorkflow());
505
+ $('comfyui-replace-yes').addEventListener('click', () => addWorkflow(true));
506
+ $('comfyui-replace-no').addEventListener('click', () => { $('comfyui-replace').hidden = true; });
507
+ return Object.freeze({
508
+ setNetworkFacts(value) { networkFacts = value; if (data) { list(); if (section === 'pools') renderPolicies(); controls(); } },
509
+ 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(); },
510
+ 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(''); },
511
+ show(visible) { if (visible && client && data && !busy && !dirty && !comfyTemplate) void refresh(); }
512
+ });
513
+ }