@mokuyoaxis/dsh-iris 0.2.7 → 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 +81 -1
- package/README.en.md +103 -312
- package/README.md +100 -310
- package/bin/dsh-iris.js +333 -65
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/AUDIO_FLOWS.md +71 -0
- 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 +7 -2
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +10 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +2 -2
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +39 -14
- package/docs/LOCAL_CREATION.md +7 -1
- 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 +3 -1
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +17 -7
- package/docs/VISION_CLI.md +1 -1
- package/docs/WEB_PROTOTYPE.md +21 -6
- 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.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +53 -2
- package/lib/adapters.js +20 -11
- package/lib/application-runtime.js +34 -7
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +346 -53
- 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 +16 -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 +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +29 -12
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +38 -64
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +105 -10
- 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 +73 -29
- package/lib/index.js +37 -17
- package/lib/local-api-projection.js +10 -5
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +769 -67
- package/lib/local-creation.js +89 -24
- 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/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 +22 -9
- package/lib/skill-installation.js +23 -11
- 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-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +20 -5
- 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 +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- package/user_guide.md +5 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Finite user actions; reopening a Run never invokes continue.
|
|
2
|
+
import { renderWorkflowParameters, inspectionMessage } from './comfyui.js';
|
|
3
|
+
import { CREATIONS, IMPORTS } from './media-client.js';
|
|
4
|
+
|
|
5
|
+
const $ = selector => document.querySelector(selector);
|
|
6
|
+
const ACTIVE = new Set(['queued', 'running']);
|
|
7
|
+
const labels = { not_started: '开始改图', running: '查看原任务', delivery_failed: '恢复原结果', needs_comparison: '完成比较', needs_result: '接回结果' };
|
|
8
|
+
export function createCreationPanel({ openRun, refreshHistory, showDraft, describe, recover, renderOperation }) {
|
|
9
|
+
let client, media, session, view = null, draft = {}, models = [], epoch = 0, intent = 0;
|
|
10
|
+
let busy = false, stopping = false, loading = false, visible = true, job = null, activeOperation = null, timer, watchTicket = 0;
|
|
11
|
+
let preview, retryAction = null, recoveryAfterLoad = null;
|
|
12
|
+
let comfyForm = null, comfyKey = '', inspection = null;
|
|
13
|
+
const cap = name => Boolean(session?.operations.includes(name));
|
|
14
|
+
const mode = () => view ? ({ 'image-edit/v0': 'edit', 'image-generate/v0': 'generate', 'speech/v0': 'tts', 'transcribe/v0': 'transcribe' })[view.recipe] : draft.mode || 'edit';
|
|
15
|
+
const generating = () => mode() === 'generate';
|
|
16
|
+
const audio = () => ['tts', 'transcribe'].includes(mode());
|
|
17
|
+
const createCapabilities = { generate: 'generations.create', edit: 'runs.create', tts: 'speeches.create', transcribe: 'transcriptions.create' };
|
|
18
|
+
const createCapability = () => createCapabilities[mode()];
|
|
19
|
+
const candidates = () => models.filter(model => audio() ? model.capability === mode() : !model.capability && (generating() || model.supportsImageEdit));
|
|
20
|
+
const valid = at => at === epoch && Boolean(client);
|
|
21
|
+
const validJob = task => valid(task.epoch) && task.intent === intent && visible && job === task;
|
|
22
|
+
function feedback(text = '', error = false) {
|
|
23
|
+
$('#creation-feedback').hidden = !text; $('#creation-feedback').textContent = text;
|
|
24
|
+
$('#creation-feedback').classList.toggle('error', error);
|
|
25
|
+
}
|
|
26
|
+
function releasePreview() {
|
|
27
|
+
$('#audio-input-preview').pause(); $('#audio-input-preview').removeAttribute('src'); $('#audio-input-preview').load(); $('#audio-input-preview').hidden = true;
|
|
28
|
+
if (preview) URL.revokeObjectURL(preview); preview = null;
|
|
29
|
+
}
|
|
30
|
+
function stopWatch() { clearTimeout(timer); watchTicket++; }
|
|
31
|
+
const selectedComfy = () => !view && generating() ? models.find(model => model.modelRef === $('#model').value && model.comfyWorkflow)?.comfyWorkflow : null;
|
|
32
|
+
function invalidateInspection() {
|
|
33
|
+
inspection?.controller.abort(); inspection = null;
|
|
34
|
+
$('#comfy-creation-note').textContent = '尚未检查;生成前会核对实际参数,不会自动生成。';
|
|
35
|
+
}
|
|
36
|
+
function renderComfy(force = false, values = {}, checked = []) {
|
|
37
|
+
const workflow = selectedComfy(), key = workflow ? $('#model').value + ':' + workflow.sha256 : '';
|
|
38
|
+
if (!force && key === comfyKey) return;
|
|
39
|
+
const preserveOpen = key === comfyKey;
|
|
40
|
+
const updated = comfyKey && key && comfyKey !== key;
|
|
41
|
+
invalidateInspection(); comfyKey = key; comfyForm = null;
|
|
42
|
+
$('#comfy-creation').hidden = !workflow;
|
|
43
|
+
if (!workflow) { $('#comfy-creation-fields').replaceChildren(); return; }
|
|
44
|
+
const parameters = workflow.parameters || [];
|
|
45
|
+
$('#comfy-creation-summary').textContent = parameters.length + ' 个开放参数 · 其余输入沿用工作流';
|
|
46
|
+
comfyForm = renderWorkflowParameters($('#comfy-creation-fields'), parameters, { values, checked, preserveOpen, onChange() {
|
|
47
|
+
invalidateInspection(); try { comfyForm.read(); } catch (error) { $('#comfy-creation-note').textContent = error.message; } controls();
|
|
48
|
+
} });
|
|
49
|
+
$('#comfy-creation-check').hidden = !session?.providerManagement;
|
|
50
|
+
if (!workflow.sizeNode) $('#edit-size').value = '';
|
|
51
|
+
if (updated) $('#comfy-creation-note').textContent = '已切换工作流,参数已按当前模板重新载入,请核对。';
|
|
52
|
+
}
|
|
53
|
+
async function checkComfy() {
|
|
54
|
+
if (!client || busy || inspection || !selectedComfy() || !session.providerManagement) return;
|
|
55
|
+
let values; try { values = comfyForm.read(); } catch (error) { feedback(error.message, true); return; }
|
|
56
|
+
const job = { controller: new AbortController(), key: comfyKey, client, modelRef: $('#model').value };
|
|
57
|
+
const workflow = selectedComfy(); inspection = job; controls(); $('#comfy-creation-note').textContent = '正在检查参数…';
|
|
58
|
+
try {
|
|
59
|
+
const settings = await job.client.get('/api/v0/provider-management/settings', { signal: job.controller.signal });
|
|
60
|
+
if (inspection !== job) return;
|
|
61
|
+
const current = settings.providers.flatMap(provider => provider.models.map(model => ({ ref: encodeURIComponent(provider.id) + '::' + encodeURIComponent(model.id), workflow: model.comfyWorkflow }))).find(item => item.ref === job.modelRef);
|
|
62
|
+
if (current?.workflow?.sha256 !== workflow.sha256) throw new Error('工作流已变化,请重新读取模型后核对参数。');
|
|
63
|
+
const result = await job.client.postProvider('workflow-check', { expectedRevision: settings.revision, modelRef: job.modelRef,
|
|
64
|
+
...(Object.keys(values).length ? { workflowParameters: values } : {}) }, { signal: job.controller.signal });
|
|
65
|
+
if (inspection !== job) return;
|
|
66
|
+
renderComfy(true, values, result.parameters);
|
|
67
|
+
$('#comfy-creation-note').textContent = inspectionMessage(result);
|
|
68
|
+
} catch (error) { if (inspection === job) $('#comfy-creation-note').textContent = describe(error); }
|
|
69
|
+
finally { if (inspection === job) inspection = null; controls(); }
|
|
70
|
+
}
|
|
71
|
+
function controls() {
|
|
72
|
+
const generate = generating(), currentMode = mode(), isAudio = audio(), transcribe = currentMode === 'transcribe';
|
|
73
|
+
$('#studio-title').textContent = currentMode === 'tts' ? '让文字,有声音。' : transcribe ? '让声音,成文字。' : '让想法,有画面。';
|
|
74
|
+
$('#studio-note').textContent = currentMode === 'tts' ? '写下要说的话,留下你的声音作品。' : transcribe ? '听见的内容,留成可以继续使用的文字。' : '保留喜欢的部分,试一点新的可能。';
|
|
75
|
+
const needsSource = ['edit', 'transcribe'].includes(currentMode);
|
|
76
|
+
const pending = media?.snapshot(), unresolved = pending?.actions.some(action => CREATIONS.includes(action.kind)
|
|
77
|
+
|| view && action.runId === view.id);
|
|
78
|
+
$('#draft-fields').disabled = !client || busy || loading || Boolean(view) || !Object.values(createCapabilities).some(cap);
|
|
79
|
+
for (const button of document.querySelectorAll('[data-creation-mode]')) {
|
|
80
|
+
button.setAttribute('aria-pressed', String(button.dataset.creationMode === currentMode));
|
|
81
|
+
button.disabled = !cap(createCapabilities[button.dataset.creationMode]);
|
|
82
|
+
if (['tts', 'transcribe'].includes(button.dataset.creationMode)) button.hidden = !session?.audio;
|
|
83
|
+
}
|
|
84
|
+
$('#source-input').hidden = currentMode !== 'edit'; $('#audio-source-input').hidden = !transcribe;
|
|
85
|
+
$('#prompt-label').hidden = transcribe; $('#edit-prompt').hidden = transcribe; $('#edit-prompt').required = !transcribe;
|
|
86
|
+
$('#speech-options').hidden = currentMode !== 'tts'; $('#advanced-settings').hidden = isAudio;
|
|
87
|
+
$('#model-label').firstChild.textContent = currentMode === 'tts' ? '语音模型 ' : transcribe ? '转写模型 ' : '图片模型 ';
|
|
88
|
+
$('#prompt-label').textContent = currentMode === 'tts' ? '要朗读的文字' : generate ? '想画些什么?' : '想改些什么?';
|
|
89
|
+
$('#edit-prompt').maxLength = isAudio ? 20000 : 12000;
|
|
90
|
+
$('#edit-prompt').placeholder = currentMode === 'tts' ? '写下希望朗读的内容…' : generate ? '描述画面、人物、色彩与风格…' : '描述想保留的细节,以及想尝试的变化…';
|
|
91
|
+
const canCreate = cap(createCapability()) && cap('runs.continue') && (!needsSource || Boolean(draft.file || draft.artifactId)
|
|
92
|
+
&& (draft.artifactId || cap(transcribe ? 'audio.create' : 'images.create')))
|
|
93
|
+
&& Boolean($('#model').value) && (transcribe || Boolean($('#edit-prompt').value.trim()));
|
|
94
|
+
const canContinue = view && cap('runs.continue') && Object.hasOwn(labels, view.status);
|
|
95
|
+
const canRetry = retryAction && (IMPORTS.includes(retryAction.kind) ? cap(retryAction.kind === 'audio-import' ? 'audio.create' : 'images.create') && Boolean(draft.file)
|
|
96
|
+
: CREATIONS.includes(retryAction.kind) ? cap(createCapability()) && Boolean((!needsSource || draft.artifactId) && $('#model').value && (transcribe || $('#edit-prompt').value.trim()))
|
|
97
|
+
: cap(retryAction.kind === 'stop' ? 'operations.stop' : 'runs.continue'));
|
|
98
|
+
$('#start-edit').disabled = !client || busy || stopping || loading || pending?.blocked || unresolved && !retryAction
|
|
99
|
+
|| Boolean(activeOperation && ACTIVE.has(activeOperation.state)) || Boolean(inspection) || comfyForm && !comfyForm.valid()
|
|
100
|
+
|| !(retryAction ? canRetry : view ? canContinue : canCreate);
|
|
101
|
+
$('#start-edit').textContent = busy ? '正在处理…' : retryAction ? '按原 Key 接回' : view && view.status !== 'not_started'
|
|
102
|
+
? labels[view.status] || '查看已有作品' : currentMode === 'tts' ? '合成语音' : transcribe ? '开始转写' : generate ? '开始生成' : '开始改图';
|
|
103
|
+
$('#locked-note').hidden = !view;
|
|
104
|
+
$('#reuse-selected').disabled = busy || loading || !cap('runs.create') || !view?.selection;
|
|
105
|
+
for (const kind of ['source', 'output']) {
|
|
106
|
+
$('#select-' + kind).disabled = busy || stopping || loading || !view?.[kind] || !cap('runs.select') || !['awaiting_selection', 'selected'].includes(view?.status);
|
|
107
|
+
}
|
|
108
|
+
$('#stop-operation').hidden = !cap('operations.stop') || !activeOperation || !ACTIVE.has(activeOperation.state);
|
|
109
|
+
$('#stop-operation').disabled = stopping; $('#stop-operation').textContent = stopping ? '正在停止…' : '停止等待';
|
|
110
|
+
$('#creation-help').textContent = !client ? '读取权限中…' : loading ? '当前流程尚未确认,读取完成前暂停操作。可重新读取或新建流程。'
|
|
111
|
+
: retryAction ? '使用原身份与原输入接回。明确执行仍可能产生供应商费用。'
|
|
112
|
+
: pending?.blocked ? '恢复记录需要先明确处理,暂不接收新动作。'
|
|
113
|
+
: !cap('runs.continue') ? isAudio ? '当前服务只读,可浏览和下载已保存的音频或文字。'
|
|
114
|
+
: cap('runs.select') ? '当前可选择已有图片。开始创作需要显式开启执行服务。' : '当前服务只读,可浏览图片流程和下载已选作品。'
|
|
115
|
+
: !view && !candidates().length ? isAudio ? '尚无此音频功能的可用候选。请在模型服务中配置并分配用途。' : '尚无可用图片候选。可在供应商页配置模型。'
|
|
116
|
+
: view?.status === 'needs_attention' ? '先核对原任务;受理未知时请避免重复生成。'
|
|
117
|
+
: '明确执行可能产生供应商费用。刷新不会自动生成。';
|
|
118
|
+
$('#creation-settings').dataset.fixed = String(Boolean(view));
|
|
119
|
+
$('#comfy-creation-check').disabled = Boolean(inspection) || busy || loading;
|
|
120
|
+
$('#comfy-creation-check').textContent = inspection ? '正在检查…' : '检查参数并读取选项';
|
|
121
|
+
const workflow = selectedComfy(); $('#edit-size').disabled = Boolean(workflow && !workflow.sizeNode);
|
|
122
|
+
$('#edit-size-note').textContent = workflow && !workflow.sizeNode ? '此工作流未绑定尺寸节点,沿用模板尺寸。' : '填写模型支持的尺寸;留空使用默认值。';
|
|
123
|
+
}
|
|
124
|
+
function fillModels(selected = '') {
|
|
125
|
+
const choices = candidates(), label = mode() === 'tts' ? '语音' : mode() === 'transcribe' ? '转写' : '图片';
|
|
126
|
+
$('#model').replaceChildren(new Option(choices.length ? '选择一个' + label + '模型' : '没有可用' + label + '候选', ''));
|
|
127
|
+
for (const model of choices) $('#model').append(new Option(model.displayName + ' · ' + model.modelRef, model.modelRef));
|
|
128
|
+
if (selected && !choices.some(model => model.modelRef === selected)) $('#model').append(new Option(selected + ' · 原流程模型', selected));
|
|
129
|
+
$('#model').value = selected;
|
|
130
|
+
}
|
|
131
|
+
function cancelChain() { intent++; stopWatch(); }
|
|
132
|
+
function beginDraft({ file, artifactId, label, modelRef = '', mode = file || artifactId ? 'edit' : cap('generations.create') ? 'generate' : 'edit' } = {}) {
|
|
133
|
+
cancelChain(); loading = false; recoveryAfterLoad = null; retryAction = null; activeOperation = null; view = null; draft = { file, artifactId, mode };
|
|
134
|
+
releasePreview(); if (file) preview = URL.createObjectURL(file);
|
|
135
|
+
$('#input-preview').hidden = !preview || mode === 'transcribe'; $('#input-preview').removeAttribute('src'); if (preview && mode !== 'transcribe') $('#input-preview').src = preview;
|
|
136
|
+
if (preview && mode === 'transcribe') { $('#audio-input-preview').src = preview; $('#audio-input-preview').hidden = false; }
|
|
137
|
+
$('#audio-file').value = ''; $('#speech-voice').value = ''; $('#audio-file-name').textContent = label || file?.name || (artifactId ? '已保存的原音频' : 'WAV / MP3 / OGG / FLAC,最大20 MiB。');
|
|
138
|
+
$('#file-name').textContent = label || file?.name || (artifactId ? '已选作品' : '选择一张图片');
|
|
139
|
+
$('#image-file').value = ''; $('#edit-prompt').value = ''; $('#edit-size').value = ''; fillModels(modelRef); renderComfy(true);
|
|
140
|
+
$('#input-summary').textContent = mode === 'tts' ? '让文字成为声音' : mode === 'transcribe' ? '把声音留成文字' : mode === 'generate' ? '从一句想法开始' : file || artifactId ? '填写你的修改要求' : '从一张图片开始';
|
|
141
|
+
$('#creation-settings').open = true; $('#advanced-settings').open = false; feedback();
|
|
142
|
+
showDraft(preview, mode); controls();
|
|
143
|
+
}
|
|
144
|
+
async function chooseFile(file) {
|
|
145
|
+
if (!client || busy || loading || view || !file) return;
|
|
146
|
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > (session.limits?.uploadBytes || 20 * 1024 * 1024)) {
|
|
147
|
+
feedback('请选择不超过 20 MiB 的静态 PNG、JPEG 或 WebP 图片。', true); return;
|
|
148
|
+
}
|
|
149
|
+
const originalRetry = retryAction, modelRef = $('#model').value, prompt = $('#edit-prompt').value, size = $('#edit-size').value;
|
|
150
|
+
beginDraft({ file, modelRef }); $('#edit-prompt').value = prompt; $('#edit-size').value = size;
|
|
151
|
+
retryAction = originalRetry; controls();
|
|
152
|
+
}
|
|
153
|
+
function chooseAudio(file) {
|
|
154
|
+
if (!client || busy || loading || view || !file) return;
|
|
155
|
+
const types = { 'audio/x-wav': 'audio/wav', 'audio/mp3': 'audio/mpeg', 'audio/x-flac': 'audio/flac', 'application/ogg': 'audio/ogg' };
|
|
156
|
+
const byExtension = { wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg', flac: 'audio/flac' };
|
|
157
|
+
const type = types[file.type] || file.type || byExtension[file.name?.split('.').at(-1).toLowerCase()];
|
|
158
|
+
if (!['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'].includes(type) || file.size > 20 * 1024 * 1024) { feedback('请选择最多20 MiB的WAV、MP3、OGG或FLAC音频。', true); return; }
|
|
159
|
+
const original = retryAction;
|
|
160
|
+
beginDraft({ mode: 'transcribe', file: type === file.type ? file : new Blob([file], { type }), label: file.name, modelRef: $('#model').value });
|
|
161
|
+
retryAction = original; controls();
|
|
162
|
+
}
|
|
163
|
+
async function finishOperation(operation, at, ticket) {
|
|
164
|
+
if (!valid(at) || ticket !== watchTicket || !visible) return;
|
|
165
|
+
activeOperation = operation; renderOperation(operation);
|
|
166
|
+
if (ACTIVE.has(operation.state)) return;
|
|
167
|
+
stopWatch(); await recover();
|
|
168
|
+
if (!valid(at) || !visible || view?.id !== operation.runId) return;
|
|
169
|
+
await openRun(operation.runId, { fromAction: true });
|
|
170
|
+
if (!valid(at)) return;
|
|
171
|
+
if (view?.id === operation.runId && audio() && operation.state === 'succeeded') feedback();
|
|
172
|
+
await refreshHistory(); controls();
|
|
173
|
+
}
|
|
174
|
+
function watch(operation) {
|
|
175
|
+
stopWatch(); activeOperation = operation; renderOperation(operation); controls();
|
|
176
|
+
if (!ACTIVE.has(operation.state)) { void finishOperation(operation, epoch, watchTicket).catch(error => feedback(describe(error), true)); return; }
|
|
177
|
+
const at = epoch, ticket = watchTicket; let reads = 0;
|
|
178
|
+
const poll = async () => {
|
|
179
|
+
if (!valid(at) || ticket !== watchTicket || !visible || document.hidden || view?.id !== operation.runId) return;
|
|
180
|
+
try {
|
|
181
|
+
const result = await client.getOperation(operation.id);
|
|
182
|
+
if (!valid(at) || ticket !== watchTicket) return;
|
|
183
|
+
await finishOperation(result.operation, at, ticket);
|
|
184
|
+
if (valid(at) && ticket === watchTicket && ACTIVE.has(result.operation.state)) {
|
|
185
|
+
if (++reads < 40) timer = setTimeout(poll, 1500);
|
|
186
|
+
else feedback('自动读取已暂停。可点“刷新”核对原操作,不会重新提交。');
|
|
187
|
+
}
|
|
188
|
+
} catch (error) { if (valid(at) && ticket === watchTicket) { stopWatch(); feedback('进度读取已暂停。' + describe(error), true); } }
|
|
189
|
+
};
|
|
190
|
+
if (visible && !document.hidden) timer = setTimeout(poll, 350);
|
|
191
|
+
}
|
|
192
|
+
async function execute() {
|
|
193
|
+
if ($('#start-edit').disabled) return;
|
|
194
|
+
let parameters;
|
|
195
|
+
try { if (selectedComfy()) parameters = comfyForm.read(); } catch (error) { feedback(error.message, true); return; }
|
|
196
|
+
const task = { epoch, intent, runId: view?.id }; job = task; busy = true; controls();
|
|
197
|
+
const generate = generating(), currentMode = mode(), isAudio = audio(), transcribe = currentMode === 'transcribe';
|
|
198
|
+
const input = isAudio ? { modelRef: $('#model').value, ...(transcribe ? { sourceArtifactId: draft.artifactId }
|
|
199
|
+
: { text: $('#edit-prompt').value, ...($('#speech-voice').value.trim() ? { voice: $('#speech-voice').value.trim() } : {}) }) }
|
|
200
|
+
: { ...(!generate ? { sourceArtifactId: draft.artifactId } : {}), modelRef: $('#model').value, prompt: $('#edit-prompt').value,
|
|
201
|
+
...($('#edit-size').value.trim() ? { size: $('#edit-size').value.trim() } : {}),
|
|
202
|
+
...(parameters && Object.keys(parameters).length ? { workflowParameters: parameters } : {}) };
|
|
203
|
+
const file = draft.file, original = retryAction;
|
|
204
|
+
try {
|
|
205
|
+
if (original) {
|
|
206
|
+
const value = await media.retry(original.id, IMPORTS.includes(original.kind) ? file : CREATIONS.includes(original.kind) ? input : undefined);
|
|
207
|
+
if (!validJob(task)) return;
|
|
208
|
+
retryAction = null;
|
|
209
|
+
if (IMPORTS.includes(original.kind)) { draft.artifactId = value.artifact.artifactId; feedback('原素材已接回。再次明确开始时才会创建并执行。'); }
|
|
210
|
+
else if (CREATIONS.includes(original.kind)) { await openRun(value.flow.id, { fromAction: true }); feedback('原流程已接回,尚未自动开始。'); }
|
|
211
|
+
else { watch(value.operation); feedback('已接回原操作。'); }
|
|
212
|
+
await recover(); return;
|
|
213
|
+
}
|
|
214
|
+
if (!view) {
|
|
215
|
+
if ((transcribe || currentMode === 'edit') && !input.sourceArtifactId) {
|
|
216
|
+
feedback('正在保存素材…'); const imported = await media[transcribe ? 'uploadAudio' : 'uploadImage'](file);
|
|
217
|
+
if (!validJob(task)) return; draft.artifactId = input.sourceArtifactId = imported.artifact.artifactId;
|
|
218
|
+
}
|
|
219
|
+
feedback('正在建立流程…'); const created = await media[({ generate: 'createGeneration', edit: 'createRun', tts: 'createSpeech', transcribe: 'createTranscription' })[currentMode]](input);
|
|
220
|
+
if (!validJob(task)) return; task.runId = created.flow.id;
|
|
221
|
+
await openRun(created.flow.id, { fromAction: true });
|
|
222
|
+
if (!validJob(task)) return;
|
|
223
|
+
}
|
|
224
|
+
feedback('正在提交本次操作…');
|
|
225
|
+
const accepted = await media.continueRun(view.id, view.revision);
|
|
226
|
+
if (!validJob(task)) return;
|
|
227
|
+
feedback('已接收。正在读取本地进度…'); watch(accepted.operation); await refreshHistory();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (valid(task.epoch) && task.intent === intent) {
|
|
230
|
+
feedback('本次动作未确认完成,请先核对原结果。' + describe(error), true);
|
|
231
|
+
await recover();
|
|
232
|
+
}
|
|
233
|
+
} finally { if (job === task) { job = null; busy = false; if (valid(task.epoch)) controls(); } }
|
|
234
|
+
}
|
|
235
|
+
async function select(kind) {
|
|
236
|
+
if (!view || $('#select-' + kind).disabled) return;
|
|
237
|
+
const at = epoch, id = view.id, artifactId = view[kind].artifactId, revision = view.revision;
|
|
238
|
+
busy = true; controls(); $('#selection-feedback').textContent = '正在保存选择…';
|
|
239
|
+
try {
|
|
240
|
+
await media.selectRun(id, artifactId, revision);
|
|
241
|
+
if (!valid(at) || view?.id !== id || !visible) return;
|
|
242
|
+
await openRun(id, { fromAction: true });
|
|
243
|
+
if (!valid(at) || view?.id !== id) return;
|
|
244
|
+
$('#selection-feedback').textContent = '选择已保存,可以下载原始图片。'; await refreshHistory();
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!valid(at) || view?.id !== id) return;
|
|
247
|
+
await recover();
|
|
248
|
+
if (!valid(at) || !visible || view?.id !== id) return;
|
|
249
|
+
await openRun(id, { fromAction: true });
|
|
250
|
+
$('#selection-feedback').textContent = '已核对当前选择;请确认后再决定。' + describe(error);
|
|
251
|
+
} finally { if (valid(at)) { busy = false; controls(); } }
|
|
252
|
+
}
|
|
253
|
+
async function stop() {
|
|
254
|
+
if (!activeOperation || stopping) return;
|
|
255
|
+
const at = epoch, operation = activeOperation; stopping = true; controls();
|
|
256
|
+
try {
|
|
257
|
+
const result = await media.stopOperation(operation.id);
|
|
258
|
+
if (!valid(at)) return; activeOperation = result.operation;
|
|
259
|
+
stopWatch(); await recover();
|
|
260
|
+
if (view?.id === operation.runId && visible) { await openRun(operation.runId, { fromAction: true }); renderOperation(result.operation); }
|
|
261
|
+
feedback('已停止本地等待;远端取消未请求,不代表退款或未计费。');
|
|
262
|
+
} catch (error) { if (valid(at)) { feedback('停止结果待核对。' + describe(error), true); await recover(); } }
|
|
263
|
+
finally { if (valid(at)) { stopping = false; controls(); } }
|
|
264
|
+
}
|
|
265
|
+
async function reuse() {
|
|
266
|
+
if (!view?.selection || $('#reuse-selected').disabled) return;
|
|
267
|
+
const at = epoch, original = view, marker = intent; busy = true; controls();
|
|
268
|
+
try {
|
|
269
|
+
const file = await client.get('/api/v0/artifacts/' + original.selection.artifactId + '/content', { binary: true });
|
|
270
|
+
if (valid(at) && intent === marker && visible) beginDraft({ file, artifactId: original.selection.artifactId, modelRef: original.input.modelRef, label: '已选作品 · 新流程' });
|
|
271
|
+
} catch (error) { if (valid(at)) feedback(describe(error), true); }
|
|
272
|
+
finally { if (valid(at)) { busy = false; controls(); } }
|
|
273
|
+
}
|
|
274
|
+
function recovered(results) {
|
|
275
|
+
if (loading) { recoveryAfterLoad = results; controls(); return; }
|
|
276
|
+
if (!view) { controls(); return; }
|
|
277
|
+
const active = results.find(item => item.status === 'active' && item.result.operation.runId === view.id);
|
|
278
|
+
if (active) watch(active.result.operation); else controls();
|
|
279
|
+
}
|
|
280
|
+
$('#edit-form').addEventListener('submit', event => { event.preventDefault(); void execute(); });
|
|
281
|
+
for (const button of document.querySelectorAll('[data-creation-mode]')) button.addEventListener('click', () => {
|
|
282
|
+
if (busy || loading || view || button.disabled || draft.mode === button.dataset.creationMode) return;
|
|
283
|
+
const keepImageInput = !audio() && ['edit', 'generate'].includes(button.dataset.creationMode);
|
|
284
|
+
const prompt = $('#edit-prompt').value, size = $('#edit-size').value;
|
|
285
|
+
beginDraft({ mode: button.dataset.creationMode });
|
|
286
|
+
if (keepImageInput) { $('#edit-prompt').value = prompt; $('#edit-size').value = size; } controls();
|
|
287
|
+
});
|
|
288
|
+
$('#image-file').addEventListener('change', event => { void chooseFile(event.target.files[0]); event.target.value = ''; });
|
|
289
|
+
$('#model').addEventListener('change', () => { renderComfy(); controls(); });
|
|
290
|
+
for (const id of ['model', 'edit-prompt', 'edit-size', 'speech-voice']) $('#' + id).addEventListener('input', () => { if (id === 'model') renderComfy(); else invalidateInspection(); controls(); });
|
|
291
|
+
$('#audio-file').addEventListener('change', event => { const file = event.target.files[0]; event.target.value = ''; chooseAudio(file); });
|
|
292
|
+
$('#comfy-creation-check').addEventListener('click', () => { void checkComfy(); });
|
|
293
|
+
$('#image-drop').addEventListener('dragover', event => { event.preventDefault(); if (!view && !busy) $('#image-drop').classList.add('dragging'); });
|
|
294
|
+
$('#image-drop').addEventListener('dragleave', () => $('#image-drop').classList.remove('dragging'));
|
|
295
|
+
$('#image-drop').addEventListener('drop', event => { event.preventDefault(); $('#image-drop').classList.remove('dragging'); void chooseFile(event.dataTransfer.files[0]); });
|
|
296
|
+
$('#select-source').addEventListener('click', () => { void select('source'); });
|
|
297
|
+
$('#select-output').addEventListener('click', () => { void select('output'); });
|
|
298
|
+
$('#stop-operation').addEventListener('click', () => { void stop(); });
|
|
299
|
+
$('#reuse-selected').addEventListener('click', () => { void reuse(); });
|
|
300
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) stopWatch(); });
|
|
301
|
+
matchMedia('(max-width:640px)').addEventListener('change', event => { if (view) $('#creation-settings').open = !event.matches; });
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
connect(http, coordinator, info) { client = http; media = coordinator; session = info; visible = true; controls(); },
|
|
304
|
+
setModels(values) { const selected = $('#model').value; models = values; fillModels(selected); renderComfy(); controls(); },
|
|
305
|
+
beginDraft,
|
|
306
|
+
async prepareVerification({ capability, modelRef }) {
|
|
307
|
+
if (!client || busy || !cap('runs.continue')) return;
|
|
308
|
+
const at = epoch, next = capability === 'tts' ? 'tts' : capability === 'transcribe' ? 'transcribe' : cap('generations.create') ? 'generate' : 'edit';
|
|
309
|
+
if (!cap(createCapabilities[next])) { feedback('当前工作区没有此验证流程,请使用已接入的 CLI / DSH 功能。', true); return; }
|
|
310
|
+
let file;
|
|
311
|
+
if (next === 'edit') {
|
|
312
|
+
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 256;
|
|
313
|
+
const context = canvas.getContext('2d'); context.fillStyle = '#fff'; context.fillRect(0, 0, 256, 256);
|
|
314
|
+
context.fillStyle = '#e03131'; context.beginPath(); context.arc(128, 128, 64, 0, Math.PI * 2); context.fill();
|
|
315
|
+
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
|
316
|
+
if (!blob) { feedback('无法准备测试图片,请手动选择素材。', true); return; }
|
|
317
|
+
file = new File([blob], 'iris-test-circle.png', { type: 'image/png' });
|
|
318
|
+
}
|
|
319
|
+
if (!valid(at)) return;
|
|
320
|
+
beginDraft({ mode: next, modelRef, file });
|
|
321
|
+
$('#edit-prompt').value = next === 'tts' ? '你好' : next === 'edit' ? '将红色圆形改为蓝色,保留白色背景。' : next === 'generate' ? 'A simple red circle on a plain white background.' : '';
|
|
322
|
+
feedback(next === 'transcribe' ? '验证草稿已准备。请选择实际音频,确认后点击开始转写,可能产生费用。'
|
|
323
|
+
: '验证草稿已准备。请核对模型及参数,再明确点击生成;可能产生供应商费用。'); controls();
|
|
324
|
+
},
|
|
325
|
+
loading() { loading = true; recoveryAfterLoad = null; stopWatch(); invalidateInspection(); controls(); },
|
|
326
|
+
view(value, { sameRun = false } = {}) {
|
|
327
|
+
const changed = view?.id !== value.id;
|
|
328
|
+
loading = false; view = value; retryAction = null;
|
|
329
|
+
if (changed) { activeOperation = null; stopWatch(); }
|
|
330
|
+
if (!sameRun) { fillModels(value.input.modelRef); $('#edit-prompt').value = value.input.text ?? value.input.prompt ?? ''; $('#edit-size').value = value.input.size || ''; $('#speech-voice').value = value.input.voice || '';
|
|
331
|
+
$('#input-summary').textContent = value.input.modelRef; $('#file-name').textContent = '流程原图';
|
|
332
|
+
if (matchMedia('(max-width:640px)').matches) $('#creation-settings').open = false; }
|
|
333
|
+
renderComfy(); controls();
|
|
334
|
+
if (recoveryAfterLoad) { const results = recoveryAfterLoad; recoveryAfterLoad = null; recovered(results); }
|
|
335
|
+
},
|
|
336
|
+
source(url) { $('#input-preview').src = url; $('#input-preview').hidden = false; },
|
|
337
|
+
cancelChain,
|
|
338
|
+
show(value) { const returning = value && !visible; visible = value; if (!value) cancelChain(); else if (returning && client) void recover(); },
|
|
339
|
+
recovered,
|
|
340
|
+
retry(action) {
|
|
341
|
+
if (busy) return;
|
|
342
|
+
if (['continue', 'stop'].includes(action.kind)) { retryAction = action; feedback('将使用原身份接回此操作,请确认后点击。'); controls(); return; }
|
|
343
|
+
beginDraft({ artifactId: ['flow-create', 'transcription-create'].includes(action.kind) ? action.artifactId : undefined,
|
|
344
|
+
mode: ({ 'generation-create': 'generate', 'audio-import': 'transcribe', 'transcription-create': 'transcribe', 'speech-create': 'tts' })[action.kind] || 'edit' });
|
|
345
|
+
retryAction = action; feedback(IMPORTS.includes(action.kind) ? '请重新提供原素材字节,再按原 Key 接回。' : '请重新提供原模型、要求与参数,再按原 Key 接回。'); controls();
|
|
346
|
+
},
|
|
347
|
+
async refresh() { if (activeOperation && ACTIVE.has(activeOperation.state)) watch(activeOperation); controls(); },
|
|
348
|
+
disconnect() {
|
|
349
|
+
epoch++; intent++; stopWatch(); client = media = session = null; view = null; draft = {}; models = []; busy = stopping = loading = false; job = activeOperation = retryAction = recoveryAfterLoad = null;
|
|
350
|
+
releasePreview(); $('#input-preview').removeAttribute('src'); $('#input-preview').hidden = true; $('#image-file').value = '';
|
|
351
|
+
$('#audio-file').value = ''; $('#audio-file-name').textContent = 'WAV / MP3 / OGG / FLAC,最大20 MiB。'; $('#speech-voice').value = '';
|
|
352
|
+
$('#edit-prompt').value = ''; $('#edit-size').value = ''; fillModels(); renderComfy(true); feedback(); controls();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Consume the HTTP DTO, not private Core records or the prototype's in-memory transport.
|
|
2
|
+
export function presentReadOnlyFlow(view) {
|
|
3
|
+
if (view.contractVersion !== 0 || !['image-edit/v0', 'image-generate/v0', 'speech/v0', 'transcribe/v0'].includes(view.recipe)) throw new TypeError('不支持的流程版本');
|
|
4
|
+
switch (view.status) {
|
|
5
|
+
case 'not_started': return { title: '已保存,尚未开始', message: '输入已保存。当前页面只读,不会提交生成。', tone: 'neutral' };
|
|
6
|
+
case 'running': return { title: '原任务进行中', message: '这是本地保存的进展。页面刷新不会查询模型服务;可通过 CLI 查看原任务。', tone: 'working' };
|
|
7
|
+
case 'needs_attention': return { title: '需要核对原任务', message: view.task?.acceptance === 'unknown' || view.task?.outcome === 'unknown'
|
|
8
|
+
? '尚不能确认原请求的结果,可能已经计费。请核对原任务,避免重复生成。'
|
|
9
|
+
: '原任务已失败或取消。现有记录仍然保留。', tone: 'warning' };
|
|
10
|
+
case 'delivery_failed': return { title: '原结果待保存', message: '原任务已成功,本地保存尚未完成。请通过 CLI 恢复原结果。', tone: 'warning' };
|
|
11
|
+
case 'needs_comparison': return { title: '结果已保存', message: '本地比较尚未完成。可查看现有图片,通过 CLI 继续流程。', tone: 'neutral' };
|
|
12
|
+
case 'needs_result': return { title: '结果已保存', message: '原任务已交付,流程结果仍待接回。请通过 CLI 继续原流程。', tone: 'neutral' };
|
|
13
|
+
case 'ready': return { title: view.recipe === 'speech/v0' ? '语音已就绪' : '转写已完成',
|
|
14
|
+
message: view.recipe === 'speech/v0' ? '语音已保存,可以试听或下载音频。' : '转写文字已保存,可以查看或下载文本。', tone: 'success' };
|
|
15
|
+
case 'awaiting_selection': return { title: '图片已就绪', message: '图片已保存。请通过 CLI 明确选择作品,再回来下载。', tone: 'success' };
|
|
16
|
+
case 'selected': return { title: view.source && view.selection?.artifactId === view.source.artifactId ? '已选择原图' : '已选择结果',
|
|
17
|
+
message: '选择已记录,可以下载原始图片。', tone: 'success' };
|
|
18
|
+
default: throw new TypeError('不支持的流程状态,请核对服务版本');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function presentFlow(view, operations) {
|
|
23
|
+
const base = presentReadOnlyFlow(view), execute = operations.includes('runs.continue');
|
|
24
|
+
if (execute && view.status === 'not_started') return { ...base, message: '输入已固定。明确开始后才会调用模型,可能产生费用。' };
|
|
25
|
+
if (execute && view.status === 'running') return { ...base, message: '原任务仍在进行。点击“查看原任务”读取一次进展;刷新只读本地记录。' };
|
|
26
|
+
if (execute && view.status === 'delivery_failed') return { ...base, message: ['speech/v0', 'transcribe/v0'].includes(view.recipe)
|
|
27
|
+
? '原任务已成功。点击“恢复原结果”接回交付,不重复提交。' : '生成已经成功。点击“恢复原结果”接回交付,不新建生成。' };
|
|
28
|
+
if (execute && view.status === 'needs_comparison') return { ...base, message: '图片已保存。点击“完成比较”执行本地比较,不产生生成请求。' };
|
|
29
|
+
if (execute && view.status === 'needs_result') return { ...base, message: '原任务已交付。点击“接回结果”保存流程引用,不产生生成请求。' };
|
|
30
|
+
if (operations.includes('runs.select') && view.status === 'awaiting_selection') return { ...base, message: view.source
|
|
31
|
+
? '比较原图与结果,选择喜欢的版本,再下载原始图片。' : '查看生成结果,明确选择后下载原始图片。' };
|
|
32
|
+
return base;
|
|
33
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const $ = selector => document.querySelector(selector);
|
|
2
|
+
const element = (tag, text, className) => { const node = document.createElement(tag); node.textContent = text; if (className) node.className = className; return node; };
|
|
3
|
+
|
|
4
|
+
/** Read-only gallery. Preview bytes are fetched only while a card is visible. */
|
|
5
|
+
export function createGallery({ openRun, describe }) {
|
|
6
|
+
let client, visible = false, filter = 'all', kind = 'image', audioEnabled = false, cursor = null, busy = false, ticket = 0, controller, observer, activeAudio;
|
|
7
|
+
const previews = new Map();
|
|
8
|
+
function clearAudio() {
|
|
9
|
+
if (!activeAudio) return;
|
|
10
|
+
activeAudio.controller.abort(); activeAudio.player.pause(); activeAudio.player.removeAttribute('src'); activeAudio.player.load(); activeAudio.player.hidden = true;
|
|
11
|
+
if (activeAudio.url) URL.revokeObjectURL(activeAudio.url);
|
|
12
|
+
activeAudio.button.disabled = false; activeAudio.button.hidden = false; activeAudio = null;
|
|
13
|
+
}
|
|
14
|
+
function clear() {
|
|
15
|
+
controller?.abort(); observer?.disconnect(); observer = null;
|
|
16
|
+
clearAudio();
|
|
17
|
+
for (const item of previews.values()) { item.controller.abort(); if (item.url) URL.revokeObjectURL(item.url); }
|
|
18
|
+
previews.clear();
|
|
19
|
+
}
|
|
20
|
+
function observe() {
|
|
21
|
+
observer?.disconnect();
|
|
22
|
+
observer = new IntersectionObserver(entries => {
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const item = previews.get(entry.target); if (!item) continue;
|
|
25
|
+
if (!entry.isIntersecting) continue;
|
|
26
|
+
if (item.url || item.loading) continue;
|
|
27
|
+
const at = ticket, http = client, job = item.controller = new AbortController(); item.loading = true;
|
|
28
|
+
void http.get('/api/v0/artifacts/' + item.artifactId + '/content', { binary: true, signal: job.signal }).then(async blob => {
|
|
29
|
+
if (!visible || at !== ticket || job.signal.aborted) return;
|
|
30
|
+
item.url = URL.createObjectURL(blob); item.image.src = item.url; await item.image.decode();
|
|
31
|
+
if (!visible || at !== ticket || job.signal.aborted) return;
|
|
32
|
+
item.image.hidden = false; item.marker.hidden = true;
|
|
33
|
+
}).catch(error => {
|
|
34
|
+
if (!job.signal.aborted && at === ticket) item.marker.textContent = '预览暂不可用';
|
|
35
|
+
}).finally(() => { if (item.controller === job) item.loading = false; });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
for (const card of previews.keys()) observer.observe(card);
|
|
39
|
+
}
|
|
40
|
+
function card(run) {
|
|
41
|
+
if (['speech/v0', 'transcribe/v0'].includes(run.recipe)) return audioCard(run);
|
|
42
|
+
const button = element('button', '', 'gallery-card'); button.type = 'button'; button.dataset.galleryRun = run.id;
|
|
43
|
+
button.setAttribute('aria-label', '打开图片流程 · ' + (run.selection ? '已选择' : '未选择'));
|
|
44
|
+
const cover = element('div', '', 'gallery-cover'), image = document.createElement('img'), marker = element('span', '读取预览…');
|
|
45
|
+
const preview = run.selection || run.output || run.source;
|
|
46
|
+
image.alt = run.selection ? '此流程已选作品' : run.source ? '此流程原图或结果' : '此流程生成结果'; image.hidden = true; cover.append(image, marker);
|
|
47
|
+
const info = element('div', '', 'gallery-info');
|
|
48
|
+
info.append(element('strong', run.selection ? '已选择 · 图片流程' : '未选择 · 图片流程'),
|
|
49
|
+
element('p', new Date(run.updatedAt).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + ' · ' + run.modelRef));
|
|
50
|
+
button.append(cover, info); button.addEventListener('click', () => openRun(run.id));
|
|
51
|
+
if (preview) previews.set(button, { artifactId: preview.artifactId, image, marker, controller: new AbortController(), url: null, loading: false });
|
|
52
|
+
else marker.textContent = '尚未生成';
|
|
53
|
+
return button;
|
|
54
|
+
}
|
|
55
|
+
function audioCard(run) {
|
|
56
|
+
const speech = run.recipe === 'speech/v0', article = element('article', '', 'gallery-card gallery-audio'); article.dataset.galleryRun = run.id;
|
|
57
|
+
const cover = element('div', '', 'gallery-audio-cover');
|
|
58
|
+
cover.append(element('span', speech ? '♪' : '≋', 'gallery-audio-icon'), element('span', speech ? 'VOICE' : 'TRANSCRIPT', 'eyebrow'));
|
|
59
|
+
const info = element('div', '', 'gallery-info');
|
|
60
|
+
info.append(element('strong', (run.output ? '已保存 · ' : '待完成 · ') + (speech ? '语音合成' : '音频转写')),
|
|
61
|
+
element('p', new Date(run.updatedAt).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + ' · ' + run.modelRef));
|
|
62
|
+
const actions = element('div', '', 'export-actions'), open = element('button', speech ? '查看作品' : '查看文字', 'quiet'); open.type = 'button';
|
|
63
|
+
open.addEventListener('click', () => openRun(run.id)); actions.append(open);
|
|
64
|
+
const artifact = speech ? run.output : run.source;
|
|
65
|
+
if (artifact) {
|
|
66
|
+
const play = element('button', speech ? '载入试听' : '载入原音频', 'quiet'); play.type = 'button'; play.dataset.galleryPlay = run.id; actions.append(play);
|
|
67
|
+
const player = document.createElement('audio'); player.controls = true; player.preload = 'metadata'; player.hidden = true;
|
|
68
|
+
const note = element('p', '', 'field-note');
|
|
69
|
+
play.addEventListener('click', async () => {
|
|
70
|
+
clearAudio();
|
|
71
|
+
const at = ticket, http = client, job = { controller: new AbortController(), player, button: play, url: null };
|
|
72
|
+
activeAudio = job; play.disabled = true; note.textContent = '读取音频…';
|
|
73
|
+
try {
|
|
74
|
+
const blob = await http.get('/api/v0/artifacts/' + artifact.artifactId + '/audio', { binary: true, signal: job.controller.signal });
|
|
75
|
+
if (!visible || at !== ticket || job !== activeAudio) return;
|
|
76
|
+
job.url = URL.createObjectURL(blob); player.src = job.url; player.hidden = false; play.hidden = true; note.textContent = '点击播放器试听。';
|
|
77
|
+
} catch (error) { if (job === activeAudio && !job.controller.signal.aborted) note.textContent = describe(error); }
|
|
78
|
+
finally { if (job === activeAudio) play.disabled = false; }
|
|
79
|
+
});
|
|
80
|
+
info.append(actions, player, note);
|
|
81
|
+
} else info.append(actions);
|
|
82
|
+
article.append(cover, info); return article;
|
|
83
|
+
}
|
|
84
|
+
function counts(value) {
|
|
85
|
+
for (const button of document.querySelectorAll('#gallery-filters button')) {
|
|
86
|
+
button.setAttribute('aria-pressed', String(button.dataset.filter === filter));
|
|
87
|
+
button.querySelector('span').textContent = String(value[button.dataset.filter]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function load(more = false) {
|
|
91
|
+
if (!client || !visible || more && busy) return;
|
|
92
|
+
const at = ++ticket, http = client; busy = true;
|
|
93
|
+
if (!more) { clear(); $('#gallery-grid').replaceChildren(); }
|
|
94
|
+
else for (const item of previews.values()) { if (item.loading) { item.controller.abort(); item.loading = false; } }
|
|
95
|
+
controller = new AbortController(); const signal = controller.signal;
|
|
96
|
+
$('#gallery-grid').setAttribute('aria-busy', 'true'); $('#gallery-more').disabled = true;
|
|
97
|
+
$('#gallery-feedback').textContent = '正在读取作品…'; $('#gallery-empty').hidden = true;
|
|
98
|
+
try {
|
|
99
|
+
const value = await http.get('/api/v0/runs?' + new URLSearchParams({ limit: '20', selection: filter,
|
|
100
|
+
...(audioEnabled ? { kind } : {}), ...(more && cursor ? { cursor } : {}) }), { signal });
|
|
101
|
+
if (at !== ticket || client !== http || !visible) return;
|
|
102
|
+
if (value.contractVersion !== 0 || !value.counts || !Array.isArray(value.runs)) throw new TypeError('不支持的作品列表');
|
|
103
|
+
cursor = value.nextCursor; counts(value.counts);
|
|
104
|
+
$('#gallery-grid').append(...value.runs.map(card)); observe();
|
|
105
|
+
$('#gallery-more').hidden = !cursor; $('#gallery-empty').hidden = value.total !== 0;
|
|
106
|
+
$('#gallery-feedback').textContent = value.total ? '共 ' + value.total + ' 个流程 · 点击作品接回创作' : '';
|
|
107
|
+
} catch (error) { if (at === ticket && !signal.aborted) $('#gallery-feedback').textContent = describe(error); }
|
|
108
|
+
finally { if (at === ticket) { busy = false; $('#gallery-grid').setAttribute('aria-busy', 'false'); $('#gallery-more').disabled = false; } }
|
|
109
|
+
}
|
|
110
|
+
for (const button of document.querySelectorAll('#gallery-filters button')) button.addEventListener('click', () => {
|
|
111
|
+
if (button.dataset.filter === filter) return;
|
|
112
|
+
filter = button.dataset.filter; cursor = null;
|
|
113
|
+
for (const item of document.querySelectorAll('#gallery-filters button')) item.setAttribute('aria-pressed', String(item === button));
|
|
114
|
+
void load();
|
|
115
|
+
});
|
|
116
|
+
for (const button of document.querySelectorAll('[data-gallery-kind]')) button.addEventListener('click', () => {
|
|
117
|
+
if (!audioEnabled || button.dataset.galleryKind === kind) return;
|
|
118
|
+
kind = button.dataset.galleryKind; filter = 'all'; cursor = null;
|
|
119
|
+
for (const item of document.querySelectorAll('[data-gallery-kind]')) item.setAttribute('aria-pressed', String(item === button));
|
|
120
|
+
$('#gallery-filters').hidden = kind !== 'image'; void load();
|
|
121
|
+
});
|
|
122
|
+
$('#gallery-refresh').addEventListener('click', () => { void load(); });
|
|
123
|
+
$('#gallery-more').addEventListener('click', () => { void load(true); });
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
connect(http, session) { client = http; audioEnabled = session?.audio === true; $('#gallery-types').hidden = !audioEnabled; },
|
|
126
|
+
show(value) { if (visible === value) return; visible = value; if (value) void load(); else { ticket++; busy = false; clear(); $('#gallery-grid').replaceChildren(); } },
|
|
127
|
+
disconnect() { visible = false; client = null; ticket++; busy = false; cursor = null; filter = 'all'; kind = 'image'; audioEnabled = false; clear();
|
|
128
|
+
for (const item of document.querySelectorAll('[data-gallery-kind]')) item.setAttribute('aria-pressed', String(item.dataset.galleryKind === kind));
|
|
129
|
+
$('#gallery-types').hidden = true; $('#gallery-filters').hidden = false; $('#gallery-grid').replaceChildren(); counts({ all: 0, selected: 0, unselected: 0 }); $('#gallery-feedback').textContent = ''; },
|
|
130
|
+
refresh() { return load(); }
|
|
131
|
+
});
|
|
132
|
+
}
|