@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.
- package/CHANGELOG.md +103 -1
- package/README.en.md +104 -304
- package/README.md +101 -302
- package/bin/dsh-iris.js +446 -64
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +7 -5
- package/docs/ARCHITECTURE.md +20 -4
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CLI_MANAGEMENT.md +1 -1
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +41 -5
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +94 -0
- package/docs/DSH_COMPATIBILITY.md +14 -4
- package/docs/DSH_CORE_MIGRATION.md +4 -2
- package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +40 -0
- package/docs/FUNCTION_MODEL_POOLS.md +1 -1
- package/docs/HEADLESS_CLI.md +12 -4
- package/docs/HOST_DOCTOR.md +13 -1
- package/docs/IMAGE_FLOW.md +75 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +62 -0
- package/docs/LOCAL_API_CONTRACT.md +140 -0
- package/docs/LOCAL_CREATION.md +56 -0
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +4 -2
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +28 -6
- package/docs/SECURITY.md +9 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
- package/docs/VISION_CLI.md +2 -2
- package/docs/WEB_PROTOTYPE.md +62 -0
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.2.7.md +62 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +132 -70
- package/lib/adapters.js +20 -11
- package/lib/api.js +38 -38
- package/lib/application-runtime.js +213 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +434 -55
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +21 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +31 -2
- package/lib/core-runtime.js +20 -0
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +84 -13
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +133 -0
- package/lib/doctor.js +12 -1
- package/lib/dsh-core-adapter.js +666 -572
- package/lib/dsh-host-adapter.js +8 -2
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +290 -0
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +232 -0
- package/lib/index.js +94 -68
- package/lib/local-api-projection.js +32 -0
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +1083 -0
- package/lib/local-creation.js +150 -0
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/private-storage.js +12 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +40 -7
- package/lib/render-address.js +35 -0
- package/lib/render.js +52 -13
- package/lib/skill-installation.js +211 -0
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +1 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/lib/workbench-management.js +8 -9
- package/package.json +22 -6
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +44 -0
- package/skills/iris-core/agents/openai.yaml +4 -0
- package/skills/iris-core/references/cli.md +95 -0
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +60 -0
- 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
|
+
}
|