feat(workflows): 工作流批量导入向导与元数据管理列表 (HEL-682 S4)

- WorkflowBatchImportWizard:目录扫描/多文件 → dry-run 逐文件报告 → 确认导入;
  warn 可补用途、需确认与 fail 项不导入不标可运行、重复导入全量 conflict 跳过
- WorkflowLibrary:view=meta 轻量列表(搜索/分类/模型/媒介/收藏/最近/启停/
  去重/批量标注),完整 JSON 仅操作时按需加载;试运行复用 WorkflowTestDialog;
  保留用途确认、输出指定、单文件导入等既有能力
- api/types:listMeta、convert、importBatch、importScanDry/Commit、touch、
  dedupe 客户端与 WorkflowMeta/转换报告类型;update 支持 favorite/tags/
  category/model_family
- i18n 130 键 ×7 语言;changelog

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-10-11 12:13:52 +08:00
co-authored by multica-agent
parent 7c49250267
commit 1c00587a56
15 changed files with 3663 additions and 644 deletions
+58
View File
@@ -140,6 +140,11 @@ import type {
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
WorkflowMeta,
WorkflowUIFileInput,
WorkflowConvertFileReport,
WorkflowConvertReport,
WorkflowImportResult,
} from "./comfy/types";
export type {
Job,
@@ -154,6 +159,11 @@ export type {
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
WorkflowMeta,
WorkflowUIFileInput,
WorkflowConvertFileReport,
WorkflowConvertReport,
WorkflowImportResult,
};
async function api<T>(path: string, init?: RequestInit): Promise<T> {
@@ -462,6 +472,50 @@ export const comfy = {
export const workflows = {
list: () => api<Workflow[]>("/api/workflows"),
/** HEL-682: metadata-only list — 150+ rows without any workflow_json. */
listMeta: () => api<WorkflowMeta[]>("/api/workflows?view=meta"),
/** HEL-682: dry-run UI→API conversion for a batch of files (nothing stored). */
convert: (
files: WorkflowUIFileInput[],
opts?: { endpoint_id?: string | null; refresh?: boolean },
) =>
api<WorkflowConvertReport>("/api/workflows/convert", {
method: "POST",
body: JSON.stringify({
files,
...(opts?.endpoint_id ? { endpoint_id: opts.endpoint_id } : {}),
...(opts?.refresh ? { refresh: opts.refresh } : {}),
}),
}),
/** HEL-682: commit a batch import (converts server-side, conflicts skipped). */
importBatch: (files: WorkflowUIFileInput[]) =>
api<WorkflowImportResult>("/api/workflows/import-batch", {
method: "POST",
body: JSON.stringify({ files }),
}),
/** HEL-682: scan a server-side directory of UI-format JSONs — dry-run report. */
importScanDry: (root: string) =>
api<WorkflowConvertReport>("/api/workflows/import-scan", {
method: "POST",
body: JSON.stringify({ root, dry_run: true }),
}),
/** HEL-682: scan a server-side directory of UI-format JSONs — commit the import. */
importScanCommit: (root: string) =>
api<WorkflowImportResult>("/api/workflows/import-scan", {
method: "POST",
body: JSON.stringify({ root, dry_run: false }),
}),
/** HEL-682: record a use (最近使用). */
touch: (id: number) =>
api<{ ok: boolean }>(`/api/workflows/${id}/touch`, { method: "POST" }),
/** HEL-682: delete byte-identical duplicates, keeping the lowest id. */
dedupe: () =>
api<{
ok: boolean;
removed: number;
kept_ids: number[];
removed_ids: number[];
}>("/api/workflows/dedupe", { method: "POST" }),
analyze: (
workflow_json: Record<string, unknown>,
endpoint_id?: string | null,
@@ -537,6 +591,10 @@ export const workflows = {
input_schema: ComfyDynamicInput[];
output_map: WorkflowOutputMapEntry[];
output_map_source: "auto" | "user";
favorite: boolean;
tags: string[];
category: string;
model_family: string;
}>,
) =>
api<Workflow>(`/api/workflows/${id}`, {
+114
View File
@@ -175,6 +175,120 @@ export interface Workflow {
last_test?: WorkflowLastTest | null;
}
/**
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_source: 'auto' | 'user' | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: 'auto' | 'user' | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: 'error' | 'warning';
message: string;
}
export interface WorkflowConvertSuggested {
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: 'ok' | 'warn' | 'fail';
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: 'imported' | 'skipped' | 'failed';
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
files: WorkflowImportFileReport[];
}
export interface Job {
id: number;
project_id: number;
@@ -51,6 +51,7 @@ export const icons = {
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
// Status
check: '<path d="M20 6 9 17l-5-5"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
alert:
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
@@ -0,0 +1,908 @@
<script lang="ts">
import Drawer from '$lib/components/ui/Drawer.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import {
apiErrorMessage,
workflows,
type ComfyEndpoint,
type WorkflowConvertFileReport,
type WorkflowConvertReport,
type WorkflowImportResult,
type WorkflowUIFileInput,
type WorkflowUsage,
} from '$lib/api';
import { WORKFLOW_USAGES } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { toast } from '$lib/toast';
/**
* 批量导入向导 (HEL-682 / S4):扫描服务器目录或选择多个本地文件 →
* dry-run 逐文件预检报告(ok / warn / fail / 需人工确认,含建议元数据)→
* 确认后提交导入。warn 项可补充用途后以启用状态入库;fail 与需人工确认项
* 不会被导入,也不得被标作可运行。重复导入按内容去重,全部跳过。
*/
interface Props {
open?: boolean;
endpoints: ComfyEndpoint[];
onimported?: () => void;
}
let { open = $bindable(false), endpoints, onimported }: Props = $props();
type Step = 'source' | 'report' | 'result';
let step = $state<Step>('source');
let mode = $state<'scan' | 'files'>('scan');
let scanRoot = $state('example_ComfyUI_workflows');
let preflightId = $state('');
let inputFiles = $state<WorkflowUIFileInput[]>([]);
let nonUiSkipped = $state<string[]>([]);
let reading = $state(false);
let running = $state(false);
let importing = $state(false);
let error = $state('');
let report = $state<WorkflowConvertReport | null>(null);
let result = $state<WorkflowImportResult | null>(null);
let usageApplied = $state(0);
/** Per-file usage chosen in the report step, keyed by name\0source_path. */
let usageBy = $state<Record<string, WorkflowUsage>>({});
/** Files the user un-checked (file-picker mode only). */
let excludedKeys = $state<Set<string>>(new Set());
let rSearch = $state('');
let rFilter = $state<'all' | 'warn' | 'blocked'>('all');
let expandedIssues = $state<Set<string>>(new Set());
let visible = $state(60);
let fileInput = $state<HTMLInputElement | null>(null);
function rk(f: { name: string; source_path: string | null }): string {
return `${f.name}\u0000${f.source_path ?? ''}`;
}
function reset() {
step = 'source';
inputFiles = [];
nonUiSkipped = [];
report = null;
result = null;
usageApplied = 0;
usageBy = {};
excludedKeys = new Set();
rSearch = '';
rFilter = 'all';
expandedIssues = new Set();
visible = 60;
error = '';
}
function usageLabel(u: WorkflowUsage | null | undefined): string {
return u ? t(`usage.${u}`) : t('usage.pending');
}
function mediaIcon(kind: string): 'image' | 'film' | 'music' {
if (kind === 'video') return 'film';
if (kind === 'audio') return 'music';
return 'image';
}
function mediaTitle(kind: string): string {
if (kind === 'video') return t('wl.mediaVideo');
if (kind === 'audio') return t('wl.mediaAudio');
return t('wl.mediaImage');
}
async function readPickedFiles(list: FileList | null) {
if (!list?.length) return;
reading = true;
error = '';
try {
const items: WorkflowUIFileInput[] = [];
const skipped: string[] = [];
for (const file of Array.from(list)) {
try {
const data = JSON.parse(await file.text());
if (!(data && typeof data === 'object' && Array.isArray(data.nodes))) {
skipped.push(file.name);
continue;
}
items.push({
name: file.name.replace(/\.json$/i, ''),
source_path: file.name,
workflow_json: data,
});
} catch {
skipped.push(file.name);
}
}
inputFiles = items;
nonUiSkipped = skipped;
} finally {
reading = false;
if (fileInput) fileInput.value = '';
}
}
async function runDryRun() {
running = true;
error = '';
try {
let res: WorkflowConvertReport;
if (mode === 'scan') {
res = await workflows.importScanDry(scanRoot.trim() || 'example_ComfyUI_workflows');
inputFiles = [];
nonUiSkipped = [];
} else {
if (inputFiles.length === 0) {
error = t('wiz.noFiles');
return;
}
res = await workflows.convert(
inputFiles,
preflightId ? { endpoint_id: preflightId } : undefined,
);
}
report = res;
usageBy = {};
excludedKeys = new Set(res.files.filter((f) => !f.importable).map((f) => rk(f)));
rSearch = '';
rFilter = 'all';
expandedIssues = new Set();
visible = 60;
step = 'report';
} catch (err) {
error = apiErrorMessage(err);
} finally {
running = false;
}
}
const selectedCount = $derived(
report
? mode === 'files'
? report.files.filter((f) => f.importable && !excludedKeys.has(rk(f))).length
: report.importable
: 0,
);
const blockedCount = $derived(
report ? report.files.filter((f) => !f.importable).length : 0,
);
const filteredFiles = $derived.by(() => {
if (!report) return [];
const q = rSearch.trim().toLowerCase();
return report.files.filter((f) => {
if (rFilter === 'warn' && f.status !== 'warn') return false;
if (rFilter === 'blocked' && f.importable) return false;
if (!q) return true;
return (
f.name.toLowerCase().includes(q) ||
(f.source_path ?? '').toLowerCase().includes(q) ||
(f.suggested?.model_family ?? '').toLowerCase().includes(q)
);
});
});
function needsUsagePick(f: WorkflowConvertFileReport): boolean {
return Boolean(f.importable && (!f.suggested?.usage || f.suggested.usage_ambiguous));
}
function setUsage(f: WorkflowConvertFileReport, usage: string) {
const key = rk(f);
if (usage) usageBy = { ...usageBy, [key]: usage as WorkflowUsage };
else {
const next = { ...usageBy };
delete next[key];
usageBy = next;
}
}
function toggleIssue(f: WorkflowConvertFileReport) {
const key = rk(f);
const next = new Set(expandedIssues);
if (next.has(key)) next.delete(key);
else next.add(key);
expandedIssues = next;
}
function skipText(reason: string | null | undefined): string {
if (reason === 'conflict') return t('wiz.conflict');
return reason ?? '';
}
async function runImport() {
if (!report) return;
importing = true;
error = '';
try {
let res: WorkflowImportResult;
if (mode === 'files') {
const files = inputFiles
.map((item, i) => ({ item, f: report!.files[i] }))
.filter(({ f }) => f?.importable && !excludedKeys.has(rk(f)))
.map(({ item, f }) => {
const usage = usageBy[rk(f)];
return usage ? { ...item, usage } : item;
});
res = await workflows.importBatch(files);
} else {
res = await workflows.importScanCommit(scanRoot.trim() || 'example_ComfyUI_workflows');
}
result = res;
// Directory commits import everything found server-side; usage
// choices from the report step are applied to the fresh rows here.
if (mode === 'scan') {
let applied = 0;
for (const f of res.files) {
const usage = usageBy[rk(f)];
if (f.result === 'imported' && f.id != null && usage) {
try {
await workflows.update(f.id, {
usage,
usage_source: 'user',
is_enabled: true,
});
applied++;
} catch {
try {
await workflows.update(f.id, { usage, usage_source: 'user' });
applied++;
} catch {
/* keep the imported row, skip the override */
}
}
}
}
usageApplied = applied;
}
step = 'result';
onimported?.();
if (res.imported > 0) toast.success(t('wiz.importedToast', { n: res.imported }));
} catch (err) {
error = apiErrorMessage(err);
} finally {
importing = false;
}
}
</script>
<Drawer bind:open title={t('wiz.title')} width={780} onclose={reset}>
<ol class="wz-steps" aria-label={t('wiz.title')}>
<li class:active={step === 'source'} class:done={step !== 'source'}>
<span class="wz-step-n">1</span>{t('wiz.stepSource')}
</li>
<li class:active={step === 'report'} class:done={step === 'result'}>
<span class="wz-step-n">2</span>{t('wiz.stepReport')}
</li>
<li class:active={step === 'result'}>
<span class="wz-step-n">3</span>{t('wiz.stepResult')}
</li>
</ol>
{#if error}
<div class="wz-error" role="alert">
<Icon name="alert" size={14} />
<span>{error}</span>
</div>
{/if}
{#if step === 'source'}
<div class="seg wz-mode" role="radiogroup" aria-label={t('wiz.sourceLabel')}>
<button
type="button"
class="seg-item"
class:selected={mode === 'scan'}
aria-pressed={mode === 'scan'}
onclick={() => (mode = 'scan')}
>
<Icon name="folder" size={14} />
{t('wiz.modeScan')}
</button>
<button
type="button"
class="seg-item"
class:selected={mode === 'files'}
aria-pressed={mode === 'files'}
onclick={() => (mode = 'files')}
>
<Icon name="upload" size={14} />
{t('wiz.modeFiles')}
</button>
</div>
{#if mode === 'scan'}
<label class="wz-field">
<span class="wz-label">{t('wiz.rootLabel')}</span>
<input
class="a-input wz-root"
value={scanRoot}
spellcheck="false"
oninput={(e) => (scanRoot = e.currentTarget.value)}
placeholder="example_ComfyUI_workflows"
/>
<span class="wz-hint">{t('wiz.rootHint')}</span>
</label>
{:else}
<div class="wz-field">
<span class="wz-label">{t('wiz.pickFiles')}</span>
<button type="button" class="a-btn secondary" onclick={() => fileInput?.click()}>
<Icon name="upload" size={14} />
{t('wiz.pickFilesBtn')}
</button>
<input
bind:this={fileInput}
type="file"
accept=".json,application/json"
multiple
class="wz-file-hidden"
onchange={(e) => void readPickedFiles(e.currentTarget.files)}
/>
<span class="wz-hint">{t('wiz.pickFilesHint')}</span>
{#if reading}
<p class="wz-read">{t('common.loading')}…</p>
{/if}
{#if inputFiles.length}
<p class="wz-read ok">
<Icon name="check" size={13} />
{t('wiz.parsed', { n: inputFiles.length })}
</p>
{/if}
{#if nonUiSkipped.length}
<p class="wz-read bad">
<Icon name="alert" size={13} />
{t('wiz.notUi', { n: nonUiSkipped.length })}
</p>
{/if}
</div>
{/if}
{#if mode === 'files' && endpoints.length > 0}
<label class="wz-field">
<span class="wz-label">{t('wiz.preflight')}</span>
<select class="a-select wz-preflight" value={preflightId} onchange={(e) => (preflightId = e.currentTarget.value)}>
<option value="">{t('wiz.preflightNone')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
<span class="wz-hint">{t('wiz.preflightHint')}</span>
</label>
{/if}
{:else if step === 'report' && report}
<div class="wz-summary">
<span class="wz-chip">{t('wiz.total', { n: report.total })}</span>
<span class="wz-chip ok">{t('wiz.ok', { n: report.ok })}</span>
<span class="wz-chip warn">{t('wiz.warn', { n: report.warn })}</span>
<span class="wz-chip fail">{t('wiz.fail', { n: report.fail })}</span>
<span class="wz-chip confirm">{t('wiz.confirmCount', { n: report.needs_confirmation })}</span>
<span class="wz-chip importable">{t('wiz.importable', { n: report.importable })}</span>
</div>
<p class="wz-note">{t('wiz.reportHint')}</p>
<div class="wz-toolbar">
<div class="filters" role="group" aria-label={t('wiz.filterLabel')}>
<button type="button" class="a-chip" class:sel={rFilter === 'all'} aria-pressed={rFilter === 'all'} onclick={() => (rFilter = 'all')}>
{t('wl.filterAll')}
</button>
<button type="button" class="a-chip" class:sel={rFilter === 'warn'} aria-pressed={rFilter === 'warn'} onclick={() => (rFilter = 'warn')}>
{t('wiz.filterWarn')}
</button>
<button type="button" class="a-chip" class:sel={rFilter === 'blocked'} aria-pressed={rFilter === 'blocked'} onclick={() => (rFilter = 'blocked')}>
{t('wiz.filterBlocked')}
</button>
</div>
<input
class="a-input wz-search"
value={rSearch}
oninput={(e) => (rSearch = e.currentTarget.value)}
placeholder={t('wiz.searchPh')}
aria-label={t('wiz.searchPh')}
/>
</div>
<div class="wz-list">
{#each filteredFiles.slice(0, visible) as f (rk(f))}
<div class="wz-row" class:blocked={!f.importable}>
{#if mode === 'files'}
<input
type="checkbox"
class="wz-check"
aria-label={t('wiz.includeAria', { name: f.name })}
disabled={!f.importable}
checked={f.importable && !excludedKeys.has(rk(f))}
onchange={(e) => {
const next = new Set(excludedKeys);
if (e.currentTarget.checked) next.delete(rk(f));
else next.add(rk(f));
excludedKeys = next;
}}
/>
{/if}
<span class="wz-dot {f.status}" title={f.status}></span>
<div class="wz-main">
<div class="wz-line">
<span class="wz-name" title={f.suggested?.description ?? f.name}>{f.name}</span>
{#if f.suggested?.model_family}
<span class="tag tag-usage">{f.suggested.model_family}</span>
{/if}
{#if f.suggested}
<span class="tag tag-usage">{usageLabel(f.suggested.usage)}</span>
<span class="wz-media" aria-hidden="true">
{#each f.suggested.media_in as m (m)}
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
{/each}
{#if f.suggested.media_out.length}
<span class="wz-arrow">→</span>
{#each f.suggested.media_out as m (m)}
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
{/each}
{/if}
</span>
{/if}
{#if f.status === 'warn'}
<span class="tag tag-warn">{t('wiz.warnTag')}</span>
{/if}
{#if f.needs_confirmation}
<span class="tag tag-error">{t('wiz.confirmTag')}</span>
{/if}
{#if f.preflight && !f.preflight.ok}
<span class="tag tag-error" title={f.preflight.findings.map((x) => x.message).join('\n')}>
{t('wiz.preflightTag')}
</span>
{/if}
</div>
{#if f.source_path}
<div class="wz-path mono">{f.source_path}</div>
{/if}
{#if !f.importable && f.skip_reason}
<div class="wz-skip">{t('wiz.skipReason', { reason: skipText(f.skip_reason) })}</div>
{/if}
{#if needsUsagePick(f)}
<label class="wz-usage">
<span>{t('wiz.usagePick')}</span>
<select
class="a-select wz-usage-select"
value={usageBy[rk(f)] ?? ''}
onchange={(e) => setUsage(f, e.currentTarget.value)}
>
<option value="">{t('wiz.usageNone')}</option>
{#each WORKFLOW_USAGES as u (u)}
<option value={u}>{usageLabel(u)}</option>
{/each}
</select>
</label>
{/if}
{#if f.issues.length}
<button type="button" class="wz-issues-toggle" onclick={() => toggleIssue(f)} aria-expanded={expandedIssues.has(rk(f))}>
<Icon name="alert" size={12} />
{t('wiz.issues', { n: f.issues.length })}
</button>
{#if expandedIssues.has(rk(f))}
<ul class="wz-issues">
{#each f.issues as iss, i (`${rk(f)}#${i}`)}
<li class="lvl-{iss.level}">
<span class="mono">{iss.node_id}</span>
{iss.message}
</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{#if filteredFiles.length > visible}
<button type="button" class="a-btn ghost wz-more" onclick={() => (visible += 100)}>
{t('wl.moreCount', { n: Math.min(100, filteredFiles.length - visible), m: filteredFiles.length })}
</button>
{/if}
{:else if step === 'result' && result}
<div class="wz-summary">
<span class="wz-chip ok">{t('wiz.imported', { n: result.imported })}</span>
<span class="wz-chip">{t('wiz.skipped', { n: result.skipped })}</span>
{#if result.files.some((f) => f.result === 'failed')}
<span class="wz-chip fail">{t('wiz.fail', { n: result.files.filter((f) => f.result === 'failed').length })}</span>
{/if}
</div>
<p class="wz-note">{t('wiz.resultNote')}</p>
{#if usageApplied > 0}
<p class="wz-note ok">{t('wiz.usageApplied', { n: usageApplied })}</p>
{/if}
<div class="wz-list">
{#each result.files as f (rk(f))}
<div class="wz-row">
<span class="wz-dot {f.result === 'imported' ? 'ok' : f.result === 'failed' ? 'fail' : 'warn'}"></span>
<div class="wz-main">
<div class="wz-line">
<span class="wz-name">{f.name}</span>
{#if f.result === 'imported'}
<span class="tag tag-ok">
<Icon name="check" size={12} />
{t('wiz.importedTag', { id: f.id ?? '?' })}
</span>
{:else if f.skip_reason === 'conflict' || f.result === 'skipped'}
<span class="tag tag-usage">{skipText(f.skip_reason)}</span>
{:else}
<span class="tag tag-error">{skipText(f.skip_reason)}</span>
{/if}
</div>
{#if f.source_path}
<div class="wz-path mono">{f.source_path}</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#snippet footer()}
{#if step === 'source'}
<Button variant="secondary" onclick={() => (open = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={running} disabled={reading} onclick={() => void runDryRun()}>
{running ? t('wiz.running') : t('wiz.runDry')}
</Button>
{:else if step === 'report'}
<span class="wz-footer-note">
{t('wiz.selectedN', { n: selectedCount })}
{#if blockedCount}· {t('wiz.excludedM', { m: blockedCount })}{/if}
</span>
<Button variant="secondary" disabled={importing} onclick={() => (step = 'source')}>{t('wiz.back')}</Button>
<Button variant="primary" loading={importing} disabled={selectedCount === 0} onclick={() => void runImport()}>
{importing ? t('wiz.importing') : t('wiz.import')}
</Button>
{:else}
<Button variant="secondary" onclick={() => { reset(); step = 'source'; }}>{t('wiz.again')}</Button>
<Button variant="primary" onclick={() => (open = false)}>{t('wiz.done')}</Button>
{/if}
{/snippet}
</Drawer>
<style>
.wz-steps {
display: flex;
gap: 6px;
list-style: none;
padding: 0;
margin: 0 0 14px;
}
.wz-steps li {
flex: 1;
display: flex;
align-items: center;
gap: 7px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-3);
font-size: 12px;
}
.wz-steps li.active {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
font-weight: 600;
}
.wz-steps li.done {
color: var(--text-2);
}
.wz-step-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid currentColor;
font-size: 10.5px;
flex-shrink: 0;
}
.wz-error {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
padding: 9px 12px;
border-radius: 7px;
background: var(--error-dim);
color: var(--text-2);
font-size: 12px;
}
.wz-error :global(.icon) {
color: var(--error);
flex-shrink: 0;
margin-top: 1px;
}
.wz-mode {
margin-bottom: 14px;
}
.seg {
display: flex;
gap: 6px;
}
.seg-item {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
padding: 8px 6px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface-2);
font-size: 12.5px;
color: var(--text-2);
cursor: pointer;
font-family: inherit;
}
.seg-item.selected {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
font-weight: 600;
}
.wz-field {
display: block;
margin-bottom: 14px;
}
.wz-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 6px;
}
.wz-root,
.wz-preflight,
.wz-search {
width: 100%;
box-sizing: border-box;
}
.wz-hint {
display: block;
margin-top: 5px;
font-size: 11px;
color: var(--text-3);
line-height: 1.6;
}
.wz-file-hidden {
display: none;
}
.wz-read {
display: flex;
align-items: center;
gap: 6px;
margin: 8px 0 0;
font-size: 12px;
color: var(--text-2);
}
.wz-read.ok {
color: var(--success);
}
.wz-read.bad {
color: var(--warn);
}
.wz-summary {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.wz-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
color: var(--text-2);
background: var(--bg-4);
}
.wz-chip.ok,
.wz-chip.importable {
color: var(--success);
background: var(--success-dim);
}
.wz-chip.warn {
color: var(--warn);
background: var(--warn-dim, var(--bg-4));
}
.wz-chip.fail,
.wz-chip.confirm {
color: var(--error);
background: var(--error-dim);
}
.wz-note {
margin: 0 0 12px;
font-size: 11.5px;
line-height: 1.6;
color: var(--text-3);
}
.wz-note.ok {
color: var(--success);
}
.wz-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.wz-toolbar .filters {
display: flex;
gap: 6px;
}
.wz-search {
flex: 1;
min-width: 160px;
}
.wz-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.wz-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
}
.wz-row:hover {
background: var(--surface-2);
}
.wz-row.blocked .wz-name {
color: var(--text-3);
}
.wz-check {
margin-top: 3px;
accent-color: var(--accent);
flex-shrink: 0;
}
.wz-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
flex-shrink: 0;
}
.wz-dot.ok {
background: var(--success);
}
.wz-dot.warn {
background: var(--warn);
}
.wz-dot.fail {
background: var(--error);
}
.wz-main {
flex: 1;
min-width: 0;
}
.wz-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wz-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
}
.tag-usage {
color: var(--text-2);
background: var(--bg-4);
}
.tag-warn {
color: var(--warn);
background: var(--warn-dim, var(--bg-4));
}
.tag-error {
color: var(--error);
background: var(--error-dim);
}
.tag-ok {
color: var(--success);
background: var(--success-dim);
}
.wz-media {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--text-3);
}
.wz-m {
display: inline-flex;
align-items: center;
}
.wz-arrow {
font-size: 10px;
}
.wz-path {
margin-top: 2px;
font-size: 10.5px;
color: var(--text-3);
word-break: break-all;
}
.wz-skip {
margin-top: 4px;
font-size: 11.5px;
color: var(--error);
line-height: 1.5;
}
.wz-usage {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 11.5px;
color: var(--text-2);
}
.wz-usage-select {
width: 180px;
}
.wz-issues-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 5px;
padding: 0;
border: none;
background: none;
font-family: inherit;
font-size: 11.5px;
color: var(--warn);
cursor: pointer;
}
.wz-issues-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
.wz-issues {
margin: 6px 0 0;
padding: 8px 10px;
list-style: none;
border-radius: 7px;
background: var(--surface-2);
display: flex;
flex-direction: column;
gap: 4px;
}
.wz-issues li {
font-size: 11px;
color: var(--text-2);
line-height: 1.5;
}
.wz-issues li .mono {
color: var(--text-3);
margin-right: 6px;
}
.wz-issues li.lvl-error {
color: var(--error);
}
.wz-more {
margin-top: 10px;
width: 100%;
}
.wz-footer-note {
margin-right: auto;
font-size: 11.5px;
color: var(--text-3);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,259 @@
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { apiErrorMessage, workflows, type WorkflowMeta } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import { toast } from '$lib/toast';
/**
* 工作流标注 (HEL-682 / S4):单条编辑显示名/说明/标签/分类/模型家族;
* 批量模式对选中项做查找替换重命名、统一说明、追加标签与分类标注。
* 只有填写了的字段会被提交,空字段一律不动。
*/
interface Props {
open?: boolean;
/** Rows to update — length 1 = single edit (prefilled), >1 = batch. */
targets: WorkflowMeta[];
/** Known category keys / family names for the datalists. */
categories: string[];
families: string[];
onchanged?: () => void;
}
let { open = $bindable(false), targets, categories, families, onchanged }: Props = $props();
const client = useQueryClient();
let name = $state('');
let nameFind = $state('');
let nameReplace = $state('');
let description = $state('');
let tagsText = $state('');
let category = $state('');
let modelFamily = $state('');
let applying = $state(false);
const isBatch = $derived(targets.length > 1);
$effect(() => {
if (open) {
const first = targets[0];
name = targets.length === 1 ? (first?.name ?? '') : '';
nameFind = '';
nameReplace = '';
description = targets.length === 1 ? (first?.description ?? '') : '';
tagsText = targets.length === 1 ? (first?.tags?.join(', ') ?? '') : '';
category = targets.length === 1 ? (first?.category ?? '') : '';
modelFamily = targets.length === 1 ? (first?.model_family ?? '') : '';
}
});
function parseTags(text: string): string[] {
return text
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean);
}
function payloadFor(row: WorkflowMeta): Record<string, unknown> | null {
const payload: Record<string, unknown> = {};
if (isBatch) {
if (nameFind && row.name.includes(nameFind)) {
payload.name = row.name.split(nameFind).join(nameReplace);
}
if (description.trim()) payload.description = description.trim();
if (tagsText.trim()) {
const add = parseTags(tagsText);
const merged = [...row.tags, ...add.filter((x) => !row.tags.includes(x))];
if (merged.length !== row.tags.length) payload.tags = merged;
}
if (category.trim()) payload.category = category.trim();
if (modelFamily.trim()) payload.model_family = modelFamily.trim();
} else {
if (name.trim() && name.trim() !== row.name) payload.name = name.trim();
if (description !== (row.description ?? '')) payload.description = description.trim();
const nextTags = parseTags(tagsText);
if (JSON.stringify(nextTags) !== JSON.stringify(row.tags ?? [])) payload.tags = nextTags;
if (category.trim() !== (row.category ?? '')) payload.category = category.trim();
if (modelFamily.trim() !== (row.model_family ?? '')) payload.model_family = modelFamily.trim();
}
return Object.keys(payload).length ? payload : null;
}
async function apply() {
applying = true;
try {
let done = 0;
let failed = 0;
for (const row of targets) {
const payload = payloadFor(row);
if (!payload) {
done++; // nothing to change counts as OK (single mode no-op)
continue;
}
try {
await workflows.update(row.id, payload);
done++;
} catch {
failed++;
}
}
if (failed === 0) {
toast.success(isBatch ? t('md.applied', { n: done }) : t('md.appliedOne'));
} else {
toast.error(t('md.partial', { n: done, m: failed }));
}
open = false;
client.invalidateQueries({ queryKey: ['workflows'] });
onchanged?.();
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
applying = false;
}
}
</script>
<Modal bind:open title={isBatch ? t('md.batchTitle', { n: targets.length }) : t('md.title')} size="md">
<div class="md-form">
{#if isBatch}
<p class="md-hint">
<Icon name="info" size={14} />
{t('md.batchHint')}
</p>
<div class="md-grid2">
<label class="md-field">
<span class="md-label">{t('md.nameFind')}</span>
<input class="a-input" value={nameFind} oninput={(e) => (nameFind = e.currentTarget.value)} placeholder={t('md.nameFindPh')} />
</label>
<label class="md-field">
<span class="md-label">{t('md.nameReplace')}</span>
<input class="a-input" value={nameReplace} oninput={(e) => (nameReplace = e.currentTarget.value)} placeholder={t('md.nameReplacePh')} />
</label>
</div>
<p class="md-sub">{t('md.nameHint')}</p>
{:else}
<label class="md-field">
<span class="md-label">{t('md.name')}</span>
<input class="a-input" value={name} oninput={(e) => (name = e.currentTarget.value)} />
</label>
{/if}
<label class="md-field">
<span class="md-label">{t('md.description')}</span>
<textarea
class="a-textarea md-desc"
rows="2"
value={description}
oninput={(e) => (description = e.currentTarget.value)}
placeholder={t('md.descriptionPh')}
></textarea>
</label>
<label class="md-field">
<span class="md-label">{t('md.tags')}</span>
<input
class="a-input"
value={tagsText}
oninput={(e) => (tagsText = e.currentTarget.value)}
placeholder={t('md.tagsPh')}
/>
<span class="md-sub">{t('md.tagsHint')}</span>
</label>
<div class="md-grid2">
<label class="md-field">
<span class="md-label">{t('md.category')}</span>
<input
class="a-input"
value={category}
list="md-cat-list"
oninput={(e) => (category = e.currentTarget.value)}
/>
<datalist id="md-cat-list">
{#each categories as c (c)}
<option value={c}></option>
{/each}
</datalist>
</label>
<label class="md-field">
<span class="md-label">{t('md.modelFamily')}</span>
<input
class="a-input"
value={modelFamily}
list="md-family-list"
oninput={(e) => (modelFamily = e.currentTarget.value)}
/>
<datalist id="md-family-list">
{#each families as f (f)}
<option value={f}></option>
{/each}
</datalist>
</label>
</div>
</div>
{#snippet footer()}
<Button variant="secondary" disabled={applying} onclick={() => (open = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={applying} onclick={() => void apply()}>{t('md.apply')}</Button>
{/snippet}
</Modal>
<style>
.md-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.md-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
padding: 9px 12px;
border-radius: 7px;
background: var(--surface-2);
color: var(--text-2);
font-size: 11.5px;
line-height: 1.6;
}
.md-hint :global(.icon) {
color: var(--text-3);
flex-shrink: 0;
margin-top: 2px;
}
.md-grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.md-field {
display: block;
}
.md-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 6px;
}
.md-desc {
width: 100%;
box-sizing: border-box;
resize: vertical;
}
.md-sub {
display: block;
margin-top: 5px;
font-size: 11px;
color: var(--text-3);
line-height: 1.5;
}
@media (max-width: 760px) {
.md-grid2 {
grid-template-columns: 1fr;
}
}
</style>
+132
View File
@@ -2097,4 +2097,136 @@ export const de: Dict = {
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
'wfd.showRaw': 'Rohes JSON anzeigen',
'wfd.hideRaw': 'Rohes JSON ausblenden',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'UI-Format-Workflows stapelweise importieren; suchen, filtern, annotieren — volles JSON nur bei Bedarf',
'wl.batchImport': 'Stapelimport',
'wl.dedupe': 'Duplikate entfernen',
'wl.dedupeTitle': 'Duplizierte Workflows entfernen',
'wl.dedupeConfirm': 'Einträge mit identischem Namen UND Inhalt werden gelöscht (der früheste Import bleibt). Gleicher Name mit anderem Inhalt bleibt unangetastet. Fortfahren?',
'wl.dedupeDone': '{n} doppelte Workflows entfernt',
'wl.dedupeNone': 'Keine doppelten Workflows gefunden',
'wl.favCount': '{n} Favoriten',
'wl.searchPh': 'Name, Beschreibung, Tags, Modell suchen… (/ drücken)',
'wl.filterLabel': 'Filter',
'wl.filterAll': 'Alle',
'wl.filterFav': 'Favoriten',
'wl.filterPending': 'Offen',
'wl.filterDisabled': 'Deaktiviert',
'wl.favAria': 'Favorisieren',
'wl.unfavAria': 'Favorit entfernen',
'wl.categoryLabel': 'Kategorie',
'wl.categoryAll': 'Alle Kategorien',
'wl.familyLabel': 'Modellfamilie',
'wl.familyAll': 'Alle Familien',
'wl.mediaLabel': 'Medium',
'wl.mediaAll': 'Alle Medien',
'wl.mediaImage': 'Bild',
'wl.mediaVideo': 'Video',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Eingabe',
'wl.mediaOut': 'Ausgabe',
'wl.sortLabel': 'Sortierung',
'wl.sortRecent': 'Favoriten / zuletzt',
'wl.sortName': 'Name',
'wl.resetFilters': 'Filter zurücksetzen',
'wl.noMatch': 'Keine Workflows entsprechen den Filtern',
'wl.resultCount': '{n} von {m}',
'wl.selected': '{n} ausgewählt',
'wl.batchLabel': 'Stapelaktionen',
'wl.batchAnnotate': 'Annotieren',
'wl.batchEnable': 'Aktivieren',
'wl.batchDisable': 'Deaktivieren',
'wl.clearSel': 'Auswahl aufheben',
'wl.batchDone': '{n} Workflows aktualisiert',
'wl.batchPartial': '{n} erfolgreich, {m} fehlgeschlagen (zuerst Verwendung und Ausgabe klären)',
'wl.selectAll': 'Alle aufgelisteten Workflows auswählen',
'wl.rowCheckAria': '{name} auswählen',
'wl.headMeta': 'Metadaten',
'wl.headActions': 'Aktionen',
'wl.enableAria': '{name} aktivieren oder deaktivieren',
'wl.enableBlocked': 'Verwendung und Ausgabeknoten vor Aktivierung festlegen',
'wl.editMeta': 'Metadaten bearbeiten',
'wl.lastUsed': 'Zuletzt verwendet',
'wl.moreCount': '{n} mehr anzeigen ({m} gesamt)',
'wfCat.basic': 'Grundverarbeitung',
'wfCat.audioTts': 'Sprachgenerierung',
'wfCat.music': 'Musikgenerierung',
'wfCat.image': 'Bildgenerierung',
'wfCat.video': 'Videogenerierung',
'wfCat.digitalHuman': 'Digitaler Mensch',
'wfCat.motionTransfer': 'Bewegungsübertragung',
'md.title': 'Workflow-Metadaten',
'md.batchTitle': 'Stapel-Annotieren ({n})',
'md.batchHint': 'Nur ausgefüllte Felder werden angewendet; Namen per Suchen→Ersetzen, Tags werden ergänzt.',
'md.name': 'Anzeigename',
'md.nameFind': 'Suchen',
'md.nameFindPh': 'Zu ersetzender Text…',
'md.nameReplace': 'Ersetzen mit',
'md.nameReplacePh': 'Ersatztext…',
'md.nameHint': 'Leeres „Suchen“ lässt Namen unverändert.',
'md.description': 'Beschreibung',
'md.descriptionPh': 'Eine Zeile, was dieser Workflow tut…',
'md.tags': 'Tags',
'md.tagsPh': 'z. B. schnell, hohe Qualität',
'md.tagsHint': 'Kommagetrennt; im Stapelmodus werden sie ergänzt.',
'md.category': 'Kategorie',
'md.modelFamily': 'Modellfamilie',
'md.apply': 'Anwenden',
'md.applied': '{n} Workflows aktualisiert',
'md.appliedOne': 'Aktualisiert',
'md.partial': '{n} erfolgreich, {m} fehlgeschlagen',
'wiz.title': 'Workflows stapelweise importieren',
'wiz.stepSource': 'Quelle',
'wiz.stepReport': 'Prüfbericht',
'wiz.stepResult': 'Ergebnis',
'wiz.sourceLabel': 'Importquelle',
'wiz.modeScan': 'Serververzeichnis scannen',
'wiz.modeFiles': 'Lokale Dateien wählen',
'wiz.rootLabel': 'Serververzeichnis',
'wiz.rootHint': 'Ein Verzeichnispfad auf dem Server (z. B. example_ComfyUI_workflows des Repos); UI-Format-JSONs werden rekursiv gescannt, API-Format und andere übersprungen.',
'wiz.preflight': 'Preflight gegen echtes ComfyUI (optional)',
'wiz.preflightNone': 'Überspringen (Offline-Konvertierung)',
'wiz.preflightHint': 'Mit gewählter Verbindung werden konvertierte Graphen Knoten für Knoten geprüft.',
'wiz.pickFiles': 'Lokale Dateien',
'wiz.pickFilesBtn': 'JSON-Dateien wählen (mehrere)',
'wiz.pickFilesHint': 'ComfyUI-Workflows im UI-Format wählen; Mehrfachauswahl möglich.',
'wiz.parsed': '{n} Dateien gelesen',
'wiz.notUi': '{n} Dateien sind kein UI-Format (kein nodes-Array) und wurden übersprungen',
'wiz.noFiles': 'Zuerst Dateien zum Import auswählen',
'wiz.runDry': 'Probelauf starten (nichts wird gespeichert)',
'wiz.running': 'Prüfe…',
'wiz.total': '{n} Dateien',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Warnung {n}',
'wiz.fail': 'Fehler {n}',
'wiz.confirmCount': 'Prüfung nötig {n}',
'wiz.importable': 'Importierbar {n}',
'wiz.reportHint': 'Der Probelauf speichert nichts. Warnungen werden aktiviert importiert, sobald eine Verwendung angegeben ist; prüfungspflichtige und fehlgeschlagene Dateien werden nie importiert.',
'wiz.filterLabel': 'Berichtsfilter',
'wiz.filterWarn': 'Nur Warnungen',
'wiz.filterBlocked': 'Nicht importierbar',
'wiz.searchPh': 'Dateinamen suchen…',
'wiz.includeAria': '{name} importieren',
'wiz.skipReason': 'Nicht importierbar: {reason}',
'wiz.issues': '{n} Konvertierungshinweise',
'wiz.usagePick': 'Verwendung:',
'wiz.usageNone': 'Vorerst überspringen (nach Import offen)',
'wiz.selectedN': '{n} Dateien werden importiert',
'wiz.excludedM': '{m} nicht importierbar',
'wiz.import': 'Importieren',
'wiz.importing': 'Importiere…',
'wiz.back': 'Zurück',
'wiz.imported': '{n} importiert',
'wiz.skipped': '{n} übersprungen',
'wiz.conflict': 'Existiert bereits (Duplikat übersprungen)',
'wiz.usageApplied': 'Verwendung für {n} neue Importe gesetzt',
'wiz.done': 'Fertig',
'wiz.again': 'Weitere Charge importieren',
'wiz.resultNote': 'Dateien mit identischem Inhalt werden automatisch übersprungen — Wiederholung erzeugt keine Duplikate.',
'wiz.importedToast': '{n} Workflows importiert',
'wiz.warnTag': 'Warnung',
'wiz.confirmTag': 'Prüfung nötig',
'wiz.preflightTag': 'Preflight fehlgeschlagen',
'wiz.importedTag': 'Importiert #{id}',
};
+132
View File
@@ -2096,6 +2096,138 @@ export const en = {
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
'wfd.showRaw': 'View raw JSON',
'wfd.hideRaw': 'Hide raw JSON',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'Batch-import UI-format workflows; search, filter and annotate — full JSON loads only per action',
'wl.batchImport': 'Batch import',
'wl.dedupe': 'Dedupe',
'wl.dedupeTitle': 'Remove duplicate workflows',
'wl.dedupeConfirm': 'This deletes entries whose name AND content are identical (the earliest import is kept). Same name with different content is your variant and stays untouched. Continue?',
'wl.dedupeDone': 'Removed {n} duplicate workflows',
'wl.dedupeNone': 'No duplicate workflows found',
'wl.favCount': '{n} favorites',
'wl.searchPh': 'Search name, description, tags, model… (press /)',
'wl.filterLabel': 'Filters',
'wl.filterAll': 'All',
'wl.filterFav': 'Favorites',
'wl.filterPending': 'Pending',
'wl.filterDisabled': 'Disabled',
'wl.favAria': 'Favorite',
'wl.unfavAria': 'Unfavorite',
'wl.categoryLabel': 'Category',
'wl.categoryAll': 'All categories',
'wl.familyLabel': 'Model family',
'wl.familyAll': 'All families',
'wl.mediaLabel': 'Media',
'wl.mediaAll': 'All media',
'wl.mediaImage': 'Image',
'wl.mediaVideo': 'Video',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Input',
'wl.mediaOut': 'Output',
'wl.sortLabel': 'Sort',
'wl.sortRecent': 'Favorite / recent',
'wl.sortName': 'Name',
'wl.resetFilters': 'Clear filters',
'wl.noMatch': 'No workflows match the current filters',
'wl.resultCount': '{n} of {m} match',
'wl.selected': '{n} selected',
'wl.batchLabel': 'Batch actions',
'wl.batchAnnotate': 'Annotate',
'wl.batchEnable': 'Enable',
'wl.batchDisable': 'Disable',
'wl.clearSel': 'Clear selection',
'wl.batchDone': 'Updated {n} workflows',
'wl.batchPartial': '{n} succeeded, {m} failed (usage and output mapping must be settled first)',
'wl.selectAll': 'Select all listed workflows',
'wl.rowCheckAria': 'Select {name}',
'wl.headMeta': 'Metadata',
'wl.headActions': 'Actions',
'wl.enableAria': 'Enable or disable {name}',
'wl.enableBlocked': 'Settle usage and output nodes before enabling',
'wl.editMeta': 'Edit metadata',
'wl.lastUsed': 'Last used',
'wl.moreCount': 'Show {n} more ({m} total)',
'wfCat.basic': 'Basic processing',
'wfCat.audioTts': 'Speech generation',
'wfCat.music': 'Music generation',
'wfCat.image': 'Image generation',
'wfCat.video': 'Video generation',
'wfCat.digitalHuman': 'Digital human',
'wfCat.motionTransfer': 'Motion transfer',
'md.title': 'Workflow metadata',
'md.batchTitle': 'Batch annotate ({n})',
'md.batchHint': 'Only filled fields are applied; names are renamed via find→replace and tags are appended.',
'md.name': 'Display name',
'md.nameFind': 'Find',
'md.nameFindPh': 'Text to replace…',
'md.nameReplace': 'Replace with',
'md.nameReplacePh': 'Replacement text…',
'md.nameHint': 'Leave “Find” empty to keep names unchanged.',
'md.description': 'Description',
'md.descriptionPh': 'One line about what this workflow does…',
'md.tags': 'Tags',
'md.tagsPh': 'e.g. fast, high quality',
'md.tagsHint': 'Comma separated; appended in batch mode.',
'md.category': 'Category',
'md.modelFamily': 'Model family',
'md.apply': 'Apply',
'md.applied': 'Updated {n} workflows',
'md.appliedOne': 'Updated',
'md.partial': '{n} succeeded, {m} failed',
'wiz.title': 'Batch import workflows',
'wiz.stepSource': 'Source',
'wiz.stepReport': 'Dry-run report',
'wiz.stepResult': 'Result',
'wiz.sourceLabel': 'Import source',
'wiz.modeScan': 'Scan server directory',
'wiz.modeFiles': 'Pick local files',
'wiz.rootLabel': 'Server directory',
'wiz.rootHint': 'A directory path on the server (e.g. the repo’s example_ComfyUI_workflows); UI-format ComfyUI JSONs inside are scanned recursively — API-format and other JSONs are skipped.',
'wiz.preflight': 'Preflight against a live ComfyUI (optional)',
'wiz.preflightNone': 'Skip (offline conversion)',
'wiz.preflightHint': 'With a connection selected, converted graphs are checked node-by-node against that ComfyUI.',
'wiz.pickFiles': 'Local files',
'wiz.pickFilesBtn': 'Choose JSON files (multiple)',
'wiz.pickFilesHint': 'Pick ComfyUI UI-format workflow JSONs; multiple selection supported.',
'wiz.parsed': '{n} files read',
'wiz.notUi': '{n} files are not UI-format (no nodes array) and were skipped',
'wiz.noFiles': 'Choose files to import first',
'wiz.runDry': 'Run dry-run (nothing stored)',
'wiz.running': 'Checking…',
'wiz.total': '{n} files',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Warn {n}',
'wiz.fail': 'Fail {n}',
'wiz.confirmCount': 'Need review {n}',
'wiz.importable': 'Importable {n}',
'wiz.reportHint': 'The dry-run stores nothing. Warn items import enabled once a usage is supplied; needs-review and failed items are never imported nor marked runnable.',
'wiz.filterLabel': 'Report filters',
'wiz.filterWarn': 'Warnings only',
'wiz.filterBlocked': 'Not importable',
'wiz.searchPh': 'Search file name…',
'wiz.includeAria': 'Import {name}',
'wiz.skipReason': 'Not importable: {reason}',
'wiz.issues': '{n} conversion notes',
'wiz.usagePick': 'Usage:',
'wiz.usageNone': 'Skip for now (pending after import)',
'wiz.selectedN': '{n} files will be imported',
'wiz.excludedM': '{m} not importable',
'wiz.import': 'Import',
'wiz.importing': 'Importing…',
'wiz.back': 'Back',
'wiz.imported': 'Imported {n}',
'wiz.skipped': 'Skipped {n}',
'wiz.conflict': 'Already exists (duplicate skipped)',
'wiz.usageApplied': 'Usage supplied for {n} new imports',
'wiz.done': 'Done',
'wiz.again': 'Import another batch',
'wiz.resultNote': 'Files with identical content are skipped automatically — re-running never creates duplicates.',
'wiz.importedToast': 'Imported {n} workflows',
'wiz.warnTag': 'Warn',
'wiz.confirmTag': 'Needs review',
'wiz.preflightTag': 'Preflight failed',
'wiz.importedTag': 'Imported #{id}',
};
export type Dict = typeof en;
+132
View File
@@ -2097,4 +2097,136 @@ export const es: Dict = {
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
'wfd.showRaw': 'Ver JSON original',
'wfd.hideRaw': 'Ocultar JSON original',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'Importa por lotes flujos en formato UI; busca, filtra y anota — el JSON completo solo se carga al actuar',
'wl.batchImport': 'Importar por lotes',
'wl.dedupe': 'Deduplicar',
'wl.dedupeTitle': 'Eliminar flujos duplicados',
'wl.dedupeConfirm': 'Se eliminan las entradas con nombre Y contenido idénticos (se conserva la primera importada). Mismo nombre con contenido distinto no se toca. ¿Continuar?',
'wl.dedupeDone': 'Se eliminaron {n} flujos duplicados',
'wl.dedupeNone': 'No se encontraron flujos duplicados',
'wl.favCount': '{n} favoritos',
'wl.searchPh': 'Buscar nombre, descripción, etiquetas, modelo… (pulsa /)',
'wl.filterLabel': 'Filtros',
'wl.filterAll': 'Todos',
'wl.filterFav': 'Favoritos',
'wl.filterPending': 'Pendientes',
'wl.filterDisabled': 'Desactivados',
'wl.favAria': 'Marcar como favorito',
'wl.unfavAria': 'Quitar de favoritos',
'wl.categoryLabel': 'Categoría',
'wl.categoryAll': 'Todas las categorías',
'wl.familyLabel': 'Familia de modelo',
'wl.familyAll': 'Todas las familias',
'wl.mediaLabel': 'Medio',
'wl.mediaAll': 'Todos los medios',
'wl.mediaImage': 'Imagen',
'wl.mediaVideo': 'Vídeo',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Entrada',
'wl.mediaOut': 'Salida',
'wl.sortLabel': 'Orden',
'wl.sortRecent': 'Favoritos / recientes',
'wl.sortName': 'Nombre',
'wl.resetFilters': 'Limpiar filtros',
'wl.noMatch': 'Ningún flujo coincide con los filtros',
'wl.resultCount': '{n} de {m}',
'wl.selected': '{n} seleccionados',
'wl.batchLabel': 'Acciones por lotes',
'wl.batchAnnotate': 'Anotar',
'wl.batchEnable': 'Activar',
'wl.batchDisable': 'Desactivar',
'wl.clearSel': 'Limpiar selección',
'wl.batchDone': '{n} flujos actualizados',
'wl.batchPartial': '{n} con éxito, {m} fallidos (confirma antes uso y salida)',
'wl.selectAll': 'Seleccionar todos los flujos listados',
'wl.rowCheckAria': 'Seleccionar {name}',
'wl.headMeta': 'Metadatos',
'wl.headActions': 'Acciones',
'wl.enableAria': 'Activar o desactivar {name}',
'wl.enableBlocked': 'Confirma uso y nodos de salida antes de activar',
'wl.editMeta': 'Editar metadatos',
'wl.lastUsed': 'Último uso',
'wl.moreCount': 'Mostrar {n} más ({m} en total)',
'wfCat.basic': 'Procesamiento básico',
'wfCat.audioTts': 'Generación de voz',
'wfCat.music': 'Generación de música',
'wfCat.image': 'Generación de imágenes',
'wfCat.video': 'Generación de vídeo',
'wfCat.digitalHuman': 'Humano digital',
'wfCat.motionTransfer': 'Transferencia de movimiento',
'md.title': 'Metadatos del flujo',
'md.batchTitle': 'Anotar por lotes ({n})',
'md.batchHint': 'Solo se aplican los campos rellenados; los nombres se renombran con buscar→reemplazar y las etiquetas se añaden.',
'md.name': 'Nombre visible',
'md.nameFind': 'Buscar',
'md.nameFindPh': 'Texto a reemplazar…',
'md.nameReplace': 'Reemplazar por',
'md.nameReplacePh': 'Texto de reemplazo…',
'md.nameHint': 'Deja «Buscar» vacío para no cambiar nombres.',
'md.description': 'Descripción',
'md.descriptionPh': 'Una línea sobre qué hace este flujo…',
'md.tags': 'Etiquetas',
'md.tagsPh': 'p. ej. rápido, alta calidad',
'md.tagsHint': 'Separadas por comas; en lote se añaden.',
'md.category': 'Categoría',
'md.modelFamily': 'Familia de modelo',
'md.apply': 'Aplicar',
'md.applied': '{n} flujos actualizados',
'md.appliedOne': 'Actualizado',
'md.partial': '{n} con éxito, {m} fallidos',
'wiz.title': 'Importar flujos por lotes',
'wiz.stepSource': 'Origen',
'wiz.stepReport': 'Informe de prueba',
'wiz.stepResult': 'Resultado',
'wiz.sourceLabel': 'Origen de importación',
'wiz.modeScan': 'Escanear carpeta del servidor',
'wiz.modeFiles': 'Elegir archivos locales',
'wiz.rootLabel': 'Carpeta del servidor',
'wiz.rootHint': 'Ruta de carpeta en el servidor (p. ej. example_ComfyUI_workflows del repo); se escanean recursivamente los JSON en formato UI, el formato API y otros JSON se omiten.',
'wiz.preflight': 'Preflight contra un ComfyUI real (opcional)',
'wiz.preflightNone': 'Omitir (conversión sin conexión)',
'wiz.preflightHint': 'Con una conexión elegida, los grafos convertidos se verifican nodo a nodo.',
'wiz.pickFiles': 'Archivos locales',
'wiz.pickFilesBtn': 'Elegir archivos JSON (varios)',
'wiz.pickFilesHint': 'Elige JSON de flujos ComfyUI en formato UI; se admite selección múltiple.',
'wiz.parsed': '{n} archivos leídos',
'wiz.notUi': '{n} archivos no son formato UI (sin array nodes) y se omitieron',
'wiz.noFiles': 'Elige primero los archivos a importar',
'wiz.runDry': 'Ejecutar prueba (no se guarda nada)',
'wiz.running': 'Comprobando…',
'wiz.total': '{n} archivos',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Avisos {n}',
'wiz.fail': 'Fallos {n}',
'wiz.confirmCount': 'Requieren revisión {n}',
'wiz.importable': 'Importables {n}',
'wiz.reportHint': 'La prueba no guarda nada. Los avisos se importan activados al indicar un uso; los que requieren revisión o fallaron nunca se importan.',
'wiz.filterLabel': 'Filtros del informe',
'wiz.filterWarn': 'Solo avisos',
'wiz.filterBlocked': 'No importables',
'wiz.searchPh': 'Buscar nombre de archivo…',
'wiz.includeAria': 'Importar {name}',
'wiz.skipReason': 'No importable: {reason}',
'wiz.issues': '{n} notas de conversión',
'wiz.usagePick': 'Uso:',
'wiz.usageNone': 'Omitir por ahora (pendiente tras importar)',
'wiz.selectedN': 'Se importarán {n} archivos',
'wiz.excludedM': '{m} no importables',
'wiz.import': 'Importar',
'wiz.importing': 'Importando…',
'wiz.back': 'Atrás',
'wiz.imported': '{n} importados',
'wiz.skipped': '{n} omitidos',
'wiz.conflict': 'Ya existe (duplicado omitido)',
'wiz.usageApplied': 'Uso indicado para {n} importaciones nuevas',
'wiz.done': 'Hecho',
'wiz.again': 'Importar otro lote',
'wiz.resultNote': 'Los archivos con contenido idéntico se omiten automáticamente; repetir no crea duplicados.',
'wiz.importedToast': '{n} flujos importados',
'wiz.warnTag': 'Aviso',
'wiz.confirmTag': 'Requiere revisión',
'wiz.preflightTag': 'Preflight fallido',
'wiz.importedTag': 'Importado #{id}',
};
+132
View File
@@ -2097,4 +2097,136 @@ export const fr: Dict = {
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
'wfd.showRaw': 'Voir le JSON brut',
'wfd.hideRaw': 'Masquer le JSON brut',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'Importez par lots des flux au format UI ; cherchez, filtrez, annotez — le JSON complet ne se charge qu’à l’action',
'wl.batchImport': 'Import par lot',
'wl.dedupe': 'Dédupliquer',
'wl.dedupeTitle': 'Supprimer les flux dupliqués',
'wl.dedupeConfirm': 'Les entrées au nom ET contenu identiques seront supprimées (le premier import est conservé). Même nom mais contenu différent = votre variante, intact. Continuer ?',
'wl.dedupeDone': '{n} flux dupliqués supprimés',
'wl.dedupeNone': 'Aucun flux dupliqué trouvé',
'wl.favCount': '{n} favoris',
'wl.searchPh': 'Rechercher nom, description, tags, modèle… (touche /)',
'wl.filterLabel': 'Filtres',
'wl.filterAll': 'Tous',
'wl.filterFav': 'Favoris',
'wl.filterPending': 'À confirmer',
'wl.filterDisabled': 'Désactivés',
'wl.favAria': 'Mettre en favori',
'wl.unfavAria': 'Retirer des favoris',
'wl.categoryLabel': 'Catégorie',
'wl.categoryAll': 'Toutes les catégories',
'wl.familyLabel': 'Famille de modèle',
'wl.familyAll': 'Toutes les familles',
'wl.mediaLabel': 'Média',
'wl.mediaAll': 'Tous les médias',
'wl.mediaImage': 'Image',
'wl.mediaVideo': 'Vidéo',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Entrée',
'wl.mediaOut': 'Sortie',
'wl.sortLabel': 'Tri',
'wl.sortRecent': 'Favoris / récents',
'wl.sortName': 'Nom',
'wl.resetFilters': 'Effacer les filtres',
'wl.noMatch': 'Aucun flux ne correspond aux filtres',
'wl.resultCount': '{n} sur {m}',
'wl.selected': '{n} sélectionnés',
'wl.batchLabel': 'Actions groupées',
'wl.batchAnnotate': 'Annoter',
'wl.batchEnable': 'Activer',
'wl.batchDisable': 'Désactiver',
'wl.clearSel': 'Effacer la sélection',
'wl.batchDone': '{n} flux mis à jour',
'wl.batchPartial': '{n} réussis, {m} échoués (usage et sortie à confirmer d’abord)',
'wl.selectAll': 'Sélectionner tous les flux listés',
'wl.rowCheckAria': 'Sélectionner {name}',
'wl.headMeta': 'Métadonnées',
'wl.headActions': 'Actions',
'wl.enableAria': 'Activer ou désactiver {name}',
'wl.enableBlocked': 'Confirmez l’usage et les nœuds de sortie avant activation',
'wl.editMeta': 'Modifier les métadonnées',
'wl.lastUsed': 'Dernière utilisation',
'wl.moreCount': 'Afficher {n} de plus ({m} au total)',
'wfCat.basic': 'Traitement de base',
'wfCat.audioTts': 'Génération de voix',
'wfCat.music': 'Génération de musique',
'wfCat.image': 'Génération d’images',
'wfCat.video': 'Génération de vidéo',
'wfCat.digitalHuman': 'Humain numérique',
'wfCat.motionTransfer': 'Transfert de mouvement',
'md.title': 'Métadonnées du flux',
'md.batchTitle': 'Annotation groupée ({n})',
'md.batchHint': 'Seuls les champs remplis sont appliqués ; renommage par rechercher→remplacer, tags ajoutés.',
'md.name': 'Nom affiché',
'md.nameFind': 'Rechercher',
'md.nameFindPh': 'Texte à remplacer…',
'md.nameReplace': 'Remplacer par',
'md.nameReplacePh': 'Texte de remplacement…',
'md.nameHint': 'Laissez « Rechercher » vide pour ne pas renommer.',
'md.description': 'Description',
'md.descriptionPh': 'Une ligne sur ce que fait ce flux…',
'md.tags': 'Tags',
'md.tagsPh': 'ex. rapide, haute qualité',
'md.tagsHint': 'Séparés par des virgules ; ajoutés en mode groupé.',
'md.category': 'Catégorie',
'md.modelFamily': 'Famille de modèle',
'md.apply': 'Appliquer',
'md.applied': '{n} flux mis à jour',
'md.appliedOne': 'Mis à jour',
'md.partial': '{n} réussis, {m} échoués',
'wiz.title': 'Import de flux par lot',
'wiz.stepSource': 'Source',
'wiz.stepReport': 'Rapport de test',
'wiz.stepResult': 'Résultat',
'wiz.sourceLabel': 'Source d’import',
'wiz.modeScan': 'Scanner un dossier serveur',
'wiz.modeFiles': 'Choisir des fichiers locaux',
'wiz.rootLabel': 'Dossier serveur',
'wiz.rootHint': 'Chemin d’un dossier sur le serveur (ex. example_ComfyUI_workflows du dépôt) ; les JSON au format UI sont scannés récursivement, le format API et les autres sont ignorés.',
'wiz.preflight': 'Pré-vérification sur un ComfyUI réel (facultatif)',
'wiz.preflightNone': 'Passer (conversion hors ligne)',
'wiz.preflightHint': 'Avec une connexion choisie, les graphes convertis sont vérifiés nœud par nœud.',
'wiz.pickFiles': 'Fichiers locaux',
'wiz.pickFilesBtn': 'Choisir des fichiers JSON (plusieurs)',
'wiz.pickFilesHint': 'Choisissez des flux ComfyUI au format UI ; sélection multiple possible.',
'wiz.parsed': '{n} fichiers lus',
'wiz.notUi': '{n} fichiers ne sont pas au format UI (pas de tableau nodes), ignorés',
'wiz.noFiles': 'Choisissez d’abord des fichiers à importer',
'wiz.runDry': 'Lancer le test (rien n’est enregistré)',
'wiz.running': 'Vérification…',
'wiz.total': '{n} fichiers',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Avertissements {n}',
'wiz.fail': 'Échecs {n}',
'wiz.confirmCount': 'À vérifier {n}',
'wiz.importable': 'Importables {n}',
'wiz.reportHint': 'Le test n’écrit rien. Les avertissements s’importent activés dès qu’un usage est fourni ; les fichiers à vérifier ou en échec ne sont jamais importés.',
'wiz.filterLabel': 'Filtres du rapport',
'wiz.filterWarn': 'Avertissements seuls',
'wiz.filterBlocked': 'Non importables',
'wiz.searchPh': 'Rechercher un nom de fichier…',
'wiz.includeAria': 'Importer {name}',
'wiz.skipReason': 'Non importable : {reason}',
'wiz.issues': '{n} notes de conversion',
'wiz.usagePick': 'Usage :',
'wiz.usageNone': 'Passer pour l’instant (à confirmer après import)',
'wiz.selectedN': '{n} fichiers seront importés',
'wiz.excludedM': '{m} non importables',
'wiz.import': 'Importer',
'wiz.importing': 'Import…',
'wiz.back': 'Retour',
'wiz.imported': '{n} importés',
'wiz.skipped': '{n} ignorés',
'wiz.conflict': 'Existe déjà (doublon ignoré)',
'wiz.usageApplied': 'Usage fourni pour {n} nouveaux imports',
'wiz.done': 'Terminé',
'wiz.again': 'Importer un autre lot',
'wiz.resultNote': 'Les fichiers au contenu identique sont ignorés automatiquement — relancer ne crée jamais de doublons.',
'wiz.importedToast': '{n} flux importés',
'wiz.warnTag': 'Avertissement',
'wiz.confirmTag': 'À vérifier',
'wiz.preflightTag': 'Pré-vérification échouée',
'wiz.importedTag': 'Importé #{id}',
};
+132
View File
@@ -2095,4 +2095,136 @@ export const ja: Dict = {
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
'wfd.showRaw': '元の JSON を見る',
'wfd.hideRaw': '元の JSON を閉じる',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'UI 形式ワークフローを一括取り込み、検索・絞り込み・注釈管理。完全な JSON は操作時のみ読み込み',
'wl.batchImport': '一括取り込み',
'wl.dedupe': '重複を削除',
'wl.dedupeTitle': '重複ワークフローの削除',
'wl.dedupeConfirm': '名前と内容が完全に同一の項目を削除します(最初に取り込んだものを残す)。名前が同じでも内容が異なるものは触れません。実行しますか?',
'wl.dedupeDone': '重複 {n} 件を削除しました',
'wl.dedupeNone': '重複するワークフローはありません',
'wl.favCount': 'お気に入り {n} 件',
'wl.searchPh': '名前・説明・タグ・モデルを検索…(/ で移動)',
'wl.filterLabel': '絞り込み',
'wl.filterAll': 'すべて',
'wl.filterFav': 'お気に入り',
'wl.filterPending': '要確認',
'wl.filterDisabled': '無効',
'wl.favAria': 'お気に入りに追加',
'wl.unfavAria': 'お気に入りから外す',
'wl.categoryLabel': 'カテゴリ',
'wl.categoryAll': 'すべてのカテゴリ',
'wl.familyLabel': 'モデルファミリー',
'wl.familyAll': 'すべてのファミリー',
'wl.mediaLabel': 'メディア',
'wl.mediaAll': 'すべてのメディア',
'wl.mediaImage': '画像',
'wl.mediaVideo': '動画',
'wl.mediaAudio': '音声',
'wl.mediaIn': '入力',
'wl.mediaOut': '出力',
'wl.sortLabel': '並び替え',
'wl.sortRecent': 'お気に入り / 最近',
'wl.sortName': '名前',
'wl.resetFilters': '絞り込みを解除',
'wl.noMatch': '条件に合うワークフローがありません',
'wl.resultCount': '{n} / {m} 件',
'wl.selected': '{n} 件選択中',
'wl.batchLabel': '一括操作',
'wl.batchAnnotate': '一括注釈',
'wl.batchEnable': '有効化',
'wl.batchDisable': '無効化',
'wl.clearSel': '選択を解除',
'wl.batchDone': '{n} 件を更新しました',
'wl.batchPartial': '{n} 件成功、{m} 件失敗(用途と出力の確認が必要)',
'wl.selectAll': '表示中のワークフローをすべて選択',
'wl.rowCheckAria': '{name} を選択',
'wl.headMeta': 'メタデータ',
'wl.headActions': '操作',
'wl.enableAria': '{name} を有効化・無効化',
'wl.enableBlocked': '用途と出力ノードを確定してから有効化してください',
'wl.editMeta': 'メタデータを編集',
'wl.lastUsed': '最終使用',
'wl.moreCount': 'さらに {n} 件表示(全 {m} 件)',
'wfCat.basic': '基本処理',
'wfCat.audioTts': '音声生成',
'wfCat.music': '音楽生成',
'wfCat.image': '画像生成',
'wfCat.video': '動画生成',
'wfCat.digitalHuman': 'デジタルヒューマン',
'wfCat.motionTransfer': 'モーション転送',
'md.title': 'ワークフローの注釈',
'md.batchTitle': '一括注釈({n} 件)',
'md.batchHint': '入力したフィールドのみ適用。名前は検索→置換で変更、タグは追加されます。',
'md.name': '表示名',
'md.nameFind': '検索',
'md.nameFindPh': '置換する文字…',
'md.nameReplace': '置換後',
'md.nameReplacePh': '置換する文字…',
'md.nameHint': '「検索」が空なら名前は変更されません。',
'md.description': '説明',
'md.descriptionPh': 'このワークフローの用途を一行で…',
'md.tags': 'タグ',
'md.tagsPh': '例: 高速, 高品質',
'md.tagsHint': 'カンマ区切り。一括モードでは追加されます。',
'md.category': 'カテゴリ',
'md.modelFamily': 'モデルファミリー',
'md.apply': '適用',
'md.applied': '{n} 件を更新しました',
'md.appliedOne': '更新しました',
'md.partial': '{n} 件成功、{m} 件失敗',
'wiz.title': 'ワークフローの一括取り込み',
'wiz.stepSource': '取り込み元',
'wiz.stepReport': '事前チェック',
'wiz.stepResult': '結果',
'wiz.sourceLabel': '取り込み元',
'wiz.modeScan': 'サーバーディレクトリをスキャン',
'wiz.modeFiles': 'ローカルファイルを選択',
'wiz.rootLabel': 'サーバーディレクトリ',
'wiz.rootHint': 'サーバー上のディレクトリパス(例: リポジトリの example_ComfyUI_workflows)。UI 形式の JSON を再帰的にスキャンし、API 形式などは自動的にスキップします。',
'wiz.preflight': '実機 ComfyUI で事前チェック(任意)',
'wiz.preflightNone': 'スキップ(オフライン変換)',
'wiz.preflightHint': '接続を選ぶと、変換結果をその ComfyUI のノート・モデルと照合します。',
'wiz.pickFiles': 'ローカルファイル',
'wiz.pickFilesBtn': 'JSON ファイルを選択(複数可)',
'wiz.pickFilesHint': 'ComfyUI UI 形式のワークフロー JSON を選んでください。複数選択できます。',
'wiz.parsed': '{n} ファイルを読み込みました',
'wiz.notUi': '{n} ファイルは UI 形式でないため(nodes 配列なし)スキップしました',
'wiz.noFiles': '先に取り込むファイルを選んでください',
'wiz.runDry': '事前チェックを実行(保存しません)',
'wiz.running': 'チェック中…',
'wiz.total': '全体 {n} ファイル',
'wiz.ok': '正常 {n}',
'wiz.warn': '警告 {n}',
'wiz.fail': '失敗 {n}',
'wiz.confirmCount': '要確認 {n}',
'wiz.importable': '取り込み可 {n}',
'wiz.reportHint': '事前チェックは何も書き込みません。警告は用途を補えば有効な状態で取り込まれ、要確認・失敗のファイルは取り込まれません。',
'wiz.filterLabel': 'レポートの絞り込み',
'wiz.filterWarn': '警告のみ',
'wiz.filterBlocked': '取り込み不可',
'wiz.searchPh': 'ファイル名を検索…',
'wiz.includeAria': '{name} を取り込む',
'wiz.skipReason': '取り込み不可: {reason}',
'wiz.issues': '変換メモ {n} 件',
'wiz.usagePick': '用途:',
'wiz.usageNone': '今はスキップ(取り込み後に要確認)',
'wiz.selectedN': '{n} ファイルを取り込みます',
'wiz.excludedM': '{m} 件は取り込み不可',
'wiz.import': '取り込む',
'wiz.importing': '取り込み中…',
'wiz.back': '戻る',
'wiz.imported': '{n} 件取り込み完了',
'wiz.skipped': '{n} 件スキップ',
'wiz.conflict': '既に存在(重複は自動スキップ)',
'wiz.usageApplied': '新規取り込み {n} 件に用途を設定しました',
'wiz.done': '完了',
'wiz.again': '続けて取り込む',
'wiz.resultNote': '内容が同一のファイルは自動スキップされ、再実行しても重複は作られません。',
'wiz.importedToast': '{n} 件のワークフローを取り込みました',
'wiz.warnTag': '警告',
'wiz.confirmTag': '要確認',
'wiz.preflightTag': '事前チェック不合格',
'wiz.importedTag': '取り込み済み #{id}',
};
+132
View File
@@ -2095,4 +2095,136 @@ export const ko: Dict = {
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
'wfd.showRaw': '원본 JSON 보기',
'wfd.hideRaw': '원본 JSON 접기',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'UI 형식 워크플로 일괄 가져오기, 검색·필터·주석 관리. 전체 JSON은 작업 시에만 로드',
'wl.batchImport': '일괄 가져오기',
'wl.dedupe': '중복 정리',
'wl.dedupeTitle': '중복 워크플로 삭제',
'wl.dedupeConfirm': '이름과 내용이 완전히 같은 항목을 삭제합니다(가장 먼저 가져온 것 유지). 이름이 같아도 내용이 다르면 그대로 둡니다. 계속할까요?',
'wl.dedupeDone': '중복 워크플로 {n}건 삭제',
'wl.dedupeNone': '중복 워크플로가 없습니다',
'wl.favCount': '즐겨찾기 {n}건',
'wl.searchPh': '이름·설명·태그·모델 검색… (/ 키)',
'wl.filterLabel': '필터',
'wl.filterAll': '전체',
'wl.filterFav': '즐겨찾기',
'wl.filterPending': '확인 필요',
'wl.filterDisabled': '비활성',
'wl.favAria': '즐겨찾기 추가',
'wl.unfavAria': '즐겨찾기 해제',
'wl.categoryLabel': '분류',
'wl.categoryAll': '전체 분류',
'wl.familyLabel': '모델 계열',
'wl.familyAll': '전체 계열',
'wl.mediaLabel': '미디어',
'wl.mediaAll': '전체 미디어',
'wl.mediaImage': '이미지',
'wl.mediaVideo': '영상',
'wl.mediaAudio': '오디오',
'wl.mediaIn': '입력',
'wl.mediaOut': '출력',
'wl.sortLabel': '정렬',
'wl.sortRecent': '즐겨찾기 / 최근',
'wl.sortName': '이름',
'wl.resetFilters': '필터 해제',
'wl.noMatch': '조건에 맞는 워크플로가 없습니다',
'wl.resultCount': '{n} / {m}',
'wl.selected': '{n}건 선택됨',
'wl.batchLabel': '일괄 작업',
'wl.batchAnnotate': '일괄 주석',
'wl.batchEnable': '활성화',
'wl.batchDisable': '비활성화',
'wl.clearSel': '선택 해제',
'wl.batchDone': '{n}건 업데이트',
'wl.batchPartial': '{n}건 성공, {m}건 실패(용도와 출력 먼저 확정)',
'wl.selectAll': '나열된 워크플로 모두 선택',
'wl.rowCheckAria': '{name} 선택',
'wl.headMeta': '메타데이터',
'wl.headActions': '작업',
'wl.enableAria': '{name} 활성화/비활성화',
'wl.enableBlocked': '활성화 전에 용도와 출력 노드를 확정하세요',
'wl.editMeta': '메타데이터 편집',
'wl.lastUsed': '마지막 사용',
'wl.moreCount': '{n}건 더 보기(전체 {m}건)',
'wfCat.basic': '기본 처리',
'wfCat.audioTts': '음성 생성',
'wfCat.music': '음악 생성',
'wfCat.image': '이미지 생성',
'wfCat.video': '영상 생성',
'wfCat.digitalHuman': '디지털 휴먼',
'wfCat.motionTransfer': '모션 전이',
'md.title': '워크플로 주석',
'md.batchTitle': '일괄 주석({n}건)',
'md.batchHint': '입력한 필드만 적용됩니다. 이름은 찾기→바꾸기로 변경, 태그는 추가됩니다.',
'md.name': '표시 이름',
'md.nameFind': '찾기',
'md.nameFindPh': '바꿀 문자…',
'md.nameReplace': '바꾸기',
'md.nameReplacePh': '대체 문자…',
'md.nameHint': '「찾기」를 비우면 이름이 유지됩니다.',
'md.description': '설명',
'md.descriptionPh': '이 워크플로가 하는 일을 한 줄로…',
'md.tags': '태그',
'md.tagsPh': '예: 빠름, 고품질',
'md.tagsHint': '쉼표로 구분. 일괄 모드에서는 추가됩니다.',
'md.category': '분류',
'md.modelFamily': '모델 계열',
'md.apply': '적용',
'md.applied': '{n}건 업데이트',
'md.appliedOne': '업데이트됨',
'md.partial': '{n}건 성공, {m}건 실패',
'wiz.title': '워크플로 일괄 가져오기',
'wiz.stepSource': '원본',
'wiz.stepReport': '사전 검사',
'wiz.stepResult': '결과',
'wiz.sourceLabel': '가져오기 원본',
'wiz.modeScan': '서버 디렉터리 스캔',
'wiz.modeFiles': '로컬 파일 선택',
'wiz.rootLabel': '서버 디렉터리',
'wiz.rootHint': '서버의 디렉터리 경로(예: 저장소의 example_ComfyUI_workflows). UI 형식 JSON을 재귀적으로 스캔하며 API 형식 등은 건너뜁니다.',
'wiz.preflight': '실제 ComfyUI 사전 검사(선택)',
'wiz.preflightNone': '건너뛰기(오프라인 변환)',
'wiz.preflightHint': '연결을 선택하면 변환 결과를 해당 ComfyUI의 노드·모델과 대조합니다.',
'wiz.pickFiles': '로컬 파일',
'wiz.pickFilesBtn': 'JSON 파일 선택(여러 개)',
'wiz.pickFilesHint': 'ComfyUI UI 형식 워크플로 JSON을 선택하세요. 다중 선택이 가능합니다.',
'wiz.parsed': '{n}개 파일 읽음',
'wiz.notUi': '{n}개 파일이 UI 형식이 아니어서(nodes 배열 없음) 건너뛰었습니다',
'wiz.noFiles': '먼저 가져올 파일을 선택하세요',
'wiz.runDry': '사전 검사 실행(저장 안 함)',
'wiz.running': '검사 중…',
'wiz.total': '총 {n}개 파일',
'wiz.ok': '정상 {n}',
'wiz.warn': '경고 {n}',
'wiz.fail': '실패 {n}',
'wiz.confirmCount': '확인 필요 {n}',
'wiz.importable': '가져오기 가능 {n}',
'wiz.reportHint': '사전 검사는 아무것도 저장하지 않습니다. 경고 항목은 용도를 지정하면 활성화 상태로 가져오며, 확인 필요·실패 항목은 절대 가져오지 않습니다.',
'wiz.filterLabel': '보고서 필터',
'wiz.filterWarn': '경고만',
'wiz.filterBlocked': '가져올 수 없음',
'wiz.searchPh': '파일 이름 검색…',
'wiz.includeAria': '{name} 가져오기',
'wiz.skipReason': '가져올 수 없음: {reason}',
'wiz.issues': '변환 메모 {n}건',
'wiz.usagePick': '용도:',
'wiz.usageNone': '지금은 건너뜀(가져온 뒤 확인 필요)',
'wiz.selectedN': '{n}개 파일을 가져옵니다',
'wiz.excludedM': '{m}개 가져올 수 없음',
'wiz.import': '가져오기',
'wiz.importing': '가져오는 중…',
'wiz.back': '이전',
'wiz.imported': '{n}건 가져옴',
'wiz.skipped': '{n}건 건너뜀',
'wiz.conflict': '이미 있음(중복 자동 건너뜀)',
'wiz.usageApplied': '새로 가져온 {n}건에 용도 지정',
'wiz.done': '완료',
'wiz.again': '다시 가져오기',
'wiz.resultNote': '내용이 같은 파일은 자동으로 건너뛰며, 다시 실행해도 중복이 생기지 않습니다.',
'wiz.importedToast': '워크플로 {n}건을 가져왔습니다',
'wiz.warnTag': '경고',
'wiz.confirmTag': '확인 필요',
'wiz.preflightTag': '사전 검사 실패',
'wiz.importedTag': '가져옴 #{id}',
};
+132
View File
@@ -2095,4 +2095,136 @@ export const zh: Dict = {
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
'wfd.showRaw': '查看原始 JSON',
'wfd.hideRaw': '收起原始 JSON',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': '批量导入 UI 格式工作流,搜索筛选与标注管理;完整 JSON 只在试运行等操作时按需加载',
'wl.batchImport': '批量导入',
'wl.dedupe': '去重',
'wl.dedupeTitle': '清理重复工作流',
'wl.dedupeConfirm': '将删除名称与内容完全相同的重复条目(保留最早导入的一条)。同名但内容不同的是你的变体,不会被动。确定执行?',
'wl.dedupeDone': '已删除 {n} 个重复工作流',
'wl.dedupeNone': '没有发现重复工作流',
'wl.favCount': '{n} 个收藏',
'wl.searchPh': '搜索名称、说明、标签、模型…(按 / 聚焦)',
'wl.filterLabel': '筛选',
'wl.filterAll': '全部',
'wl.filterFav': '收藏',
'wl.filterPending': '待确认',
'wl.filterDisabled': '已停用',
'wl.favAria': '收藏',
'wl.unfavAria': '取消收藏',
'wl.categoryLabel': '分类',
'wl.categoryAll': '全部分类',
'wl.familyLabel': '模型家族',
'wl.familyAll': '全部模型',
'wl.mediaLabel': '媒介',
'wl.mediaAll': '全部媒介',
'wl.mediaImage': '图像',
'wl.mediaVideo': '视频',
'wl.mediaAudio': '音频',
'wl.mediaIn': '输入',
'wl.mediaOut': '输出',
'wl.sortLabel': '排序',
'wl.sortRecent': '收藏 / 最近使用',
'wl.sortName': '名称',
'wl.resetFilters': '清除筛选',
'wl.noMatch': '没有符合条件的工作流',
'wl.resultCount': '匹配 {n} / {m}',
'wl.selected': '已选 {n} 项',
'wl.batchLabel': '批量操作',
'wl.batchAnnotate': '批量标注',
'wl.batchEnable': '启用',
'wl.batchDisable': '停用',
'wl.clearSel': '清除选择',
'wl.batchDone': '已更新 {n} 项',
'wl.batchPartial': '{n} 项成功,{m} 项失败(需先确认用途与输出映射)',
'wl.selectAll': '选择当前列出的全部工作流',
'wl.rowCheckAria': '选择 {name}',
'wl.headMeta': '标注',
'wl.headActions': '操作',
'wl.enableAria': '启用或停用 {name}',
'wl.enableBlocked': '需先确认用途并指定输出节点,才能启用',
'wl.editMeta': '编辑标注',
'wl.lastUsed': '最近使用',
'wl.moreCount': '再显示 {n} 项(共 {m} 项)',
'wfCat.basic': '基础处理',
'wfCat.audioTts': '语音生成',
'wfCat.music': '音乐生成',
'wfCat.image': '图像生成',
'wfCat.video': '视频生成',
'wfCat.digitalHuman': '数字人',
'wfCat.motionTransfer': '动作迁移',
'md.title': '标注工作流',
'md.batchTitle': '批量标注({n} 项)',
'md.batchHint': '只有填写了的字段会被应用;显示名按「查找→替换」批量重命名,标签为追加。',
'md.name': '显示名',
'md.nameFind': '查找',
'md.nameFindPh': '要替换的文字…',
'md.nameReplace': '替换为',
'md.nameReplacePh': '替换后的文字…',
'md.nameHint': '留空「查找」则不改名。',
'md.description': '说明',
'md.descriptionPh': '一句话说明这个工作流做什么…',
'md.tags': '标签',
'md.tagsPh': '如:快速, 高质量',
'md.tagsHint': '用逗号分隔;批量模式下为追加标签。',
'md.category': '分类',
'md.modelFamily': '模型家族',
'md.apply': '应用',
'md.applied': '已更新 {n} 项',
'md.appliedOne': '已更新',
'md.partial': '{n} 项成功,{m} 项失败',
'wiz.title': '批量导入工作流',
'wiz.stepSource': '选择来源',
'wiz.stepReport': '预检报告',
'wiz.stepResult': '导入结果',
'wiz.sourceLabel': '导入来源',
'wiz.modeScan': '扫描服务器目录',
'wiz.modeFiles': '选择本地文件',
'wiz.rootLabel': '服务器目录',
'wiz.rootHint': '服务器上的目录路径(如仓库的 example_ComfyUI_workflows),将递归扫描其中的 ComfyUI 界面格式 JSON;API 格式与其他 JSON 自动跳过。',
'wiz.preflight': '对真实 ComfyUI 预检(可选)',
'wiz.preflightNone': '跳过(离线转换)',
'wiz.preflightHint': '选择连接后,转换结果会对照该 ComfyUI 的节点与模型做对象级检查。',
'wiz.pickFiles': '本地文件',
'wiz.pickFilesBtn': '选择 JSON 文件(可多选)',
'wiz.pickFilesHint': '选择 ComfyUI 界面格式(UI 格式)的工作流 JSON,可一次选多个。',
'wiz.parsed': '已读取 {n} 个文件',
'wiz.notUi': '{n} 个文件不是 UI 格式(缺少 nodes 数组),已忽略',
'wiz.noFiles': '请先选择要导入的文件',
'wiz.runDry': '开始预检(不写入)',
'wiz.running': '预检中…',
'wiz.total': '共 {n} 个文件',
'wiz.ok': '正常 {n}',
'wiz.warn': '警告 {n}',
'wiz.fail': '失败 {n}',
'wiz.confirmCount': '需人工确认 {n}',
'wiz.importable': '可导入 {n}',
'wiz.reportHint': '预检不写入数据库。warn 项可补充用途后以启用状态导入;「需人工确认」与转换失败项不会被导入,也不会被标为可运行。',
'wiz.filterLabel': '报告筛选',
'wiz.filterWarn': '仅看警告',
'wiz.filterBlocked': '不可导入',
'wiz.searchPh': '搜索文件名…',
'wiz.includeAria': '导入 {name}',
'wiz.skipReason': '不可导入:{reason}',
'wiz.issues': '{n} 条转换提示',
'wiz.usagePick': '补充用途:',
'wiz.usageNone': '暂不补充(导入后待确认)',
'wiz.selectedN': '将导入 {n} 个文件',
'wiz.excludedM': '{m} 个不可导入',
'wiz.import': '确认导入',
'wiz.importing': '导入中…',
'wiz.back': '上一步',
'wiz.imported': '导入成功 {n}',
'wiz.skipped': '跳过 {n}',
'wiz.conflict': '已存在(重复导入自动跳过)',
'wiz.usageApplied': '已为 {n} 个新导入项补充用途',
'wiz.done': '完成',
'wiz.again': '再导一批',
'wiz.resultNote': '内容相同的重复文件会被自动跳过,重复执行不会产生重复条目。',
'wiz.importedToast': '已导入 {n} 个工作流',
'wiz.warnTag': '警告',
'wiz.confirmTag': '需人工确认',
'wiz.preflightTag': '预检未通过',
'wiz.importedTag': '已导入 #{id}',
};
+16 -644
View File
@@ -7,10 +7,8 @@
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
import { WORKFLOW_USAGES } from '$lib/comfy/types';
import WorkflowLibrary from '$lib/components/workflows/WorkflowLibrary.svelte';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type WorkflowMeta } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { classifyEndpoint, endpointTypeLabel, type EndpointKind } from '$lib/endpointKind';
@@ -25,12 +23,17 @@
queryKey: ['settings'],
queryFn: settings.get,
});
const workflowsQuery = createQuery({
queryKey: ['workflows'],
queryFn: workflows.list,
// HEL-682: the library browses metadata only (view=meta) — the heavy
// workflow_json payloads are fetched per-row inside WorkflowLibrary
// exactly when an action needs them.
const wfMetaQuery = createQuery({
queryKey: ['workflows', 'meta'],
queryFn: workflows.listMeta,
});
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
const endpoints = $derived(
$settingsQuery.data?.comfyui_endpoints ?? [],
);
const activeId = $derived(
$settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '',
);
@@ -281,7 +284,7 @@
}
function workflowsBound(id: string): number {
return ($workflowsQuery.data ?? []).filter((wf) => wf.endpoint_id === id).length;
return ($wfMetaQuery.data ?? []).filter((wf) => wf.endpoint_id === id).length;
}
const deleteMessage = $derived.by(() => {
@@ -327,188 +330,6 @@
},
});
// ------------------------------------------------------------------
// Workflow library (binding + per-row recheck / test). The import
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
// ------------------------------------------------------------------
let wfImportOpen = $state(false);
function startWfImport() {
wfImportOpen = true;
}
function usageLabel(u: WorkflowUsage | null | undefined): string {
return u ? t(`usage.${u}`) : t('usage.pending');
}
function wfProblems(wf: Workflow): string[] {
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
}
// HEL-643 L2: per-row 重新检查 / 测试 in the workflow library.
let wfRecheckId = $state<number | null>(null);
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
async function recheckRow(wf: Workflow) {
wfRecheckId = wf.id;
try {
const res = await workflows.check(wf.id, { refresh: true });
wfRecheckResult[wf.id] = res;
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
wfRecheckId = null;
}
}
function recheckLine(wf: Workflow): { tone: 'ok' | 'bad' | 'warn'; text: string } | null {
const check = wfRecheckResult[wf.id];
if (!check) return null;
if (!check.reachable) return { tone: 'warn', text: t('conn.wfCheckUnreachable') };
if (check.ok) return { tone: 'ok', text: t('conn.wfCheckOk') };
const n = check.findings.length;
return { tone: 'bad', text: t('conn.wfCheckProblems', { n }) };
}
let testWf = $state<Workflow | null>(null);
let testOpen = $state(false);
function openTest(wf: Workflow) {
testWf = wf;
testOpen = true;
}
function lastTestLine(wf: Workflow): string | null {
const lt = wf.last_test;
if (!lt) return null;
if (lt.status === 'done' && lt.output_count > 0) {
return t('conn.wfTestPassed', { n: lt.output_count, time: formatDbTime(lt.completed_at ?? lt.created_at) });
}
if (lt.status === 'pending' || lt.status === 'running') {
return t('conn.wfTestRunning', { time: formatDbTime(lt.created_at) });
}
return t('conn.wfTestFailed', { time: formatDbTime(lt.completed_at ?? lt.created_at) });
}
function formatDbTime(at: string | null): string {
if (!at) return '—';
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
const d = new Date(Number.isFinite(ts) ? ts : at);
if (Number.isNaN(d.getTime())) return '—';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const wfConfirmMutation = createMutation({
mutationFn: ({ id, usage }: { id: number; usage: WorkflowUsage }) =>
workflows.update(id, { usage, usage_source: 'user' }),
onSuccess: (wf) => {
client.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('conn.wfConfirmed', { usage: usageLabel(wf.usage) }));
},
onError: (err) => {
toast.error(
t('conn.wfImportFailed', {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
// ------------------------------------------------------------------
// HEL-638: per-row output-node designation for pending workflows with
// unknown custom output nodes (imported earlier or parked by migration).
// ------------------------------------------------------------------
let wfMapEditId = $state<number | null>(null);
let wfMapPicked = $state<Record<string, boolean>>({});
let wfMapKind = $state<Record<string, 'image' | 'video'>>({});
function rowOutputCandidates(wf: Workflow) {
return (wf.output_schema ?? []).filter((o) => o.evidence === 'unknown_node');
}
function rowNeedsOutputMap(wf: Workflow): boolean {
return Boolean(wf.needs_mapping) && !(wf.output_map ?? []).length;
}
function toggleWfMapEdit(wf: Workflow) {
if (wfMapEditId === wf.id) {
wfMapEditId = null;
return;
}
const picked: Record<string, boolean> = {};
const kinds: Record<string, 'image' | 'video'> = {};
const mapped = new Map((wf.output_map ?? []).map((e) => [e.nodeId, e.kind]));
for (const c of rowOutputCandidates(wf)) {
const known = mapped.get(c.nodeId);
if (known) picked[c.nodeId] = true;
kinds[c.nodeId] = known ?? c.suggested_kind ?? 'image';
}
wfMapPicked = picked;
wfMapKind = kinds;
wfMapEditId = wf.id;
}
const wfMapMutation = createMutation({
mutationFn: ({ id, outputMap }: { id: number; outputMap: WorkflowOutputMapEntry[] }) =>
workflows.update(
id,
outputMap.length
? { output_map: outputMap, output_map_source: 'user' }
: { output_map: [] },
),
onSuccess: (wf) => {
client.invalidateQueries({ queryKey: ['workflows'] });
wfMapEditId = null;
const nodes = (wf.output_map ?? []).map((e) => e.nodeId).join('、');
toast.success(
wf.output_map?.length
? t('conn.wfOutputApplied', { nodes })
: t('conn.wfOutputCleared'),
);
},
onError: (err) => {
toast.error(
t('conn.wfImportFailed', {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitWfMap(wf: Workflow) {
if ($wfMapMutation.isPending) return;
const outputMap = rowOutputCandidates(wf)
.filter((c) => wfMapPicked[c.nodeId])
.map((c) => ({
nodeId: c.nodeId,
kind: wfMapKind[c.nodeId] ?? c.suggested_kind ?? 'image',
label: c.label,
...(c.class_type ? { class_type: c.class_type } : {}),
}));
$wfMapMutation.mutate({ id: wf.id, outputMap });
}
const wfBindMutation = createMutation({
mutationFn: ({ id, endpointId }: { id: number; endpointId: string | null }) =>
workflows.update(id, { endpoint_id: endpointId }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['workflows'] });
},
});
const wfDeleteMutation = createMutation({
mutationFn: (id: number) => workflows.delete(id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('conn.wfDeleted'));
},
onError: (err) => {
// e.g. 409 "该工作流还有 N 个排队或执行中的任务…" (HEL-619)
toast.error(apiErrorMessage(err));
},
});
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
@@ -529,7 +350,9 @@
// 任务使用目标 (a6): where each kind of job actually runs — a workflow's
// own binding first, otherwise the default connection.
function kindTarget(kind: 'image' | 'video' | null): { value: string; kind: EndpointKind | null } {
const wfs = ($workflowsQuery.data ?? []).filter((w) => kind === null || w.kind === kind);
const wfs: WorkflowMeta[] = ($wfMetaQuery.data ?? []).filter(
(w) => kind === null || w.kind === kind,
);
const ids = new Set(wfs.map((w) => w.endpoint_id || activeId).filter(Boolean));
if (wfs.length === 0) ids.add(activeId);
if (ids.size === 1) {
@@ -710,208 +533,7 @@
</p>
</div>
<!-- ---------------- workflow library ---------------- -->
<section class="wf-panel" id="wf-library">
<div class="panel-head">
<h2>{t('conn.wfTitle')}</h2>
<span class="panel-hint">{t('conn.wfHint')}</span>
<span class="panel-actions">
<Button variant="secondary" size="sm" onclick={startWfImport}>
<Icon name="upload" size={13} />
{t('conn.wfImportAction')}
</Button>
</span>
</div>
{#if ($workflowsQuery.data ?? []).length === 0}
<EmptyState title={t('conn.wfEmpty')} />
<p class="missing-note">{t('conn.missingMaterial')}</p>
{:else}
{@const wfs = $workflowsQuery.data ?? []}
{@const pending = wfs.filter((w) => !w.usage).length}
<p class="wf-count">
{t('conn.wfCount', {
n: wfs.length,
m: wfs.filter((w) => w.kind === 'image').length,
k: wfs.filter((w) => w.kind === 'video').length,
})}
{#if pending}
<span class="wf-pending-note">· {t('conn.wfPendingCount', { n: pending })}</span>
{/if}
</p>
<div class="wf-table">
{#each wfs as wf (wf.id)}
<div class="wf-row" class:pending={!wf.usage}>
<span class="wf-kind {wf.kind}">
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
</span>
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
{#if wf.usage}
<span
class="tag tag-usage"
title={wf.usage_source === 'user' ? t('conn.wfSourceUser') : t('conn.wfSourceAuto')}
>
{usageLabel(wf.usage)}
</span>
{:else}
<select
class="a-select wf-confirm-select"
aria-label={t('conn.wfConfirmAria')}
disabled={$wfConfirmMutation.isPending}
onchange={(e) => {
const usage = e.currentTarget.value as WorkflowUsage;
if (usage) $wfConfirmMutation.mutate({ id: wf.id, usage });
e.currentTarget.value = '';
}}
>
<option value="">{t('usage.pending')}…</option>
{#each WORKFLOW_USAGES as u (u)}
<option value={u}>{usageLabel(u)}</option>
{/each}
</select>
{/if}
{#if rowNeedsOutputMap(wf)}
<button
type="button"
class="tag tag-mapbtn"
class:open={wfMapEditId === wf.id}
title={t('conn.wfOutputNeedMap')}
onclick={() => toggleWfMapEdit(wf)}
>
<Icon name="alert" size={12} />
{t('conn.wfOutputPendingBadge')}
</button>
{/if}
{#if !wf.is_enabled && wf.usage && !rowNeedsOutputMap(wf)}
<span class="tag tag-disabled">{t('conn.wfDisabledBadge')}</span>
{/if}
{#if wfProblems(wf).length}
<span class="tag tag-error" title={wfProblems(wf).join('\n')}>
{t('conn.wfBrokenBadge')}
</span>
{/if}
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
<button
type="button"
class="link-accent wf-test-btn"
title={t('wft.title')}
onclick={() => openTest(wf)}
>
<Icon name="play" size={13} />
{t('conn.wfTestBtn')}
</button>
<button
type="button"
class="link-accent wf-test-btn"
disabled={wfRecheckId === wf.id}
title={t('conn.wfCheckBtn')}
onclick={() => recheckRow(wf)}
>
<Icon name="retry" size={13} />
{wfRecheckId === wf.id ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
</button>
<select
class="a-select"
value={wf.endpoint_id ?? ''}
onchange={(e) =>
$wfBindMutation.mutate({
id: wf.id,
endpointId: e.currentTarget.value || null,
})}
>
<option value="">{t('conn.wfConnectionActive')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
<button
type="button"
class="link-danger"
title={t('conn.delete')}
onclick={() => $wfDeleteMutation.mutate(wf.id)}
>
<Icon name="trash" size={14} />
</button>
</div>
{@const lastLine = lastTestLine(wf)}
{@const recheck = recheckLine(wf)}
{#if lastLine || recheck}
<div class="wf-meta-row">
{#if lastLine}<span class="wf-meta-item">{t('conn.wfLastTest')}:{lastLine}</span>{/if}
{#if recheck}
<span class="wf-meta-item recheck" class:ok={recheck.tone === 'ok'} class:bad={recheck.tone === 'bad'} class:warn={recheck.tone === 'warn'}>
{#if recheck.tone === 'bad'}
{@const check = wfRecheckResult[wf.id]}
<span class="wf-recheck-text" title={(check?.findings ?? []).map((f) => f.message).join('\n')}>
{recheck.text}
</span>
{:else}
<span class="wf-recheck-text">{recheck.text}</span>
{/if}
</span>
{/if}
</div>
{/if}
{#if wfMapEditId === wf.id}
<div class="wf-map-editor">
<p class="wf-map-hint">{t('conn.wfOutputHint')}</p>
<div class="wf-slot-rows">
{#each rowOutputCandidates(wf) as c (c.nodeId)}
<div class="wf-slot-row" class:off={!wfMapPicked[c.nodeId]}>
<label class="wf-out-check">
<input
type="checkbox"
checked={wfMapPicked[c.nodeId] ?? false}
onchange={(e) =>
(wfMapPicked = {
...wfMapPicked,
[c.nodeId]: e.currentTarget.checked,
})}
/>
<span class="wf-slot-id mono">{c.nodeId}</span>
<span class="wf-slot-name" title={c.class_type}>{c.label}</span>
</label>
<select
class="a-select"
aria-label={t('conn.wfOutputKindAria')}
disabled={!wfMapPicked[c.nodeId]}
value={wfMapKind[c.nodeId] ?? c.suggested_kind ?? 'image'}
onchange={(e) =>
(wfMapKind = {
...wfMapKind,
[c.nodeId]: e.currentTarget.value as 'image' | 'video',
})}
>
<option value="image">{t('conn.wfKindImage')}</option>
<option value="video">{t('conn.wfKindVideo')}</option>
</select>
</div>
{/each}
</div>
<div class="form-actions">
<Button
variant="secondary"
size="sm"
onclick={() => (wfMapEditId = null)}
>
{t('conn.wfMapCancel')}
</Button>
<Button
variant="primary"
size="sm"
disabled={!Object.values(wfMapPicked).some(Boolean)}
loading={$wfMapMutation.isPending}
onclick={() => submitWfMap(wf)}
>
{t('conn.wfOutputApply')}
</Button>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</section>
<WorkflowLibrary rows={$wfMetaQuery.data ?? []} {endpoints} />
</section>
{#if formMode !== 'idle'}
@@ -1104,17 +726,6 @@
</div>
{/if}
</div>
<WorkflowImportDrawer
bind:open={wfImportOpen}
{endpoints}
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
/>
<WorkflowTestDialog
bind:open={testOpen}
workflow={testWf}
{endpoints}
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
/>
</AppShell>
<ConfirmDialog
@@ -1376,231 +987,6 @@
color: var(--text);
}
/* ---------------- workflow panel ---------------- */
.wf-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.panel-head h2 {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.panel-hint {
font-size: 11.5px;
color: var(--text-3);
}
.panel-actions {
display: inline-flex;
gap: 8px;
}
.wf-count {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--text-3);
}
.missing-note {
margin: 10px 0 0;
font-size: 11.5px;
color: var(--text-3);
border-left: 2px solid var(--accent);
padding-left: 10px;
line-height: 1.6;
}
.wf-test-btn {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.wf-test-btn:disabled {
opacity: 0.6;
cursor: default;
}
.wf-meta-row {
display: flex;
gap: 14px;
flex-wrap: wrap;
padding: 0 10px 8px;
margin-top: -2px;
}
.wf-meta-item {
font-size: 10.5px;
color: var(--text-3);
}
.wf-meta-item.recheck .wf-recheck-text {
color: var(--text-3);
}
.wf-meta-item.recheck.ok .wf-recheck-text {
color: var(--success);
}
.wf-meta-item.recheck.bad .wf-recheck-text {
color: var(--error);
cursor: help;
}
.wf-meta-item.recheck.warn .wf-recheck-text {
color: var(--warn);
}
.wf-slot-rows {
display: flex;
flex-direction: column;
gap: 5px;
}
.wf-slot-row {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 8px;
border-radius: 7px;
background: var(--surface);
border: 1px solid var(--border);
}
.wf-slot-id {
font-size: 10.5px;
color: var(--text-3);
min-width: 64px;
}
.wf-slot-name {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wf-slot-row .a-select {
width: 130px;
}
.wf-slot-row.off {
opacity: 0.55;
}
.wf-out-check {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.wf-out-check input[type='checkbox'] {
accent-color: var(--accent);
}
.tag-mapbtn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--error);
background: var(--error-dim);
border: 1px solid transparent;
}
.tag-mapbtn.open {
color: var(--accent);
background: var(--accent-dim);
border-color: var(--accent);
}
.wf-map-editor {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
margin: 2px 0 6px;
border-radius: 7px;
border: 1px dashed var(--border);
background: var(--surface);
}
.wf-map-hint {
margin: 0;
font-size: 11.5px;
color: var(--text-3);
}
.wf-map-editor .form-actions {
justify-content: flex-end;
}
.wf-params {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--text-3);
}
.tag-usage {
color: var(--text-2);
background: var(--bg-4);
}
.tag-disabled {
color: var(--text-3);
background: var(--surface-3);
}
.wf-pending-note {
color: var(--warn);
}
.wf-row.pending .wf-name {
color: var(--text-3);
}
.wf-confirm-select {
max-width: 150px;
font-size: 11px;
}
.wf-table {
display: flex;
flex-direction: column;
gap: 4px;
}
.wf-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background-color 150ms;
}
.wf-row:hover {
background: var(--surface-2);
}
.wf-kind {
width: 28px;
height: 28px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.wf-kind.image,
.wf-kind.video {
color: var(--text-2);
background: var(--bg-4);
}
.wf-name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wf-params {
font-size: 11px;
color: var(--text-3);
flex-shrink: 0;
}
.wf-row .a-select {
width: 220px;
}
/* ---------------- form column ---------------- */
.side-col {
width: 384px;
@@ -1879,9 +1265,6 @@
z-index: 790;
background: var(--overlay);
}
.wf-import-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1023px) {
@@ -1910,15 +1293,4 @@
.card-foot {
margin-left: 0;
}
.wf-row {
flex-wrap: wrap;
}
.wf-name {
flex-basis: 100%;
order: 5;
}
.wf-row .a-select {
flex: 1;
min-width: 160px;
}
}</style>
@@ -0,0 +1,40 @@
# S4:工作流批量导入向导与管理页(HEL-682)
复用 `/connections` 工作流库,接入 S2(HEL-681)的数据层端点:批量导入向导 +
元数据轻量管理列表。本批只动 `calliope-web`,后端零改动。
## 批量导入向导(`WorkflowBatchImportWizard.svelte`)
- 三步式抽屉(780px):选择来源 → 预检报告 → 导入结果。
- 来源两种:扫描服务器目录(`POST /api/workflows/import-scan`,默认
`example_ComfyUI_workflows`,可选对真实 ComfyUI 做 L2 预检)或本地多选
文件(客户端解析,非 UI 格式自动忽略,`POST /api/workflows/convert`)。
- 预检报告逐文件给出 ok/warn/fail、转换提示、建议元数据(用途/分类/模型
家族/输入输出媒介);warn 项可就地补充用途(目录模式在导入后 PATCH 到
新行);「需人工确认」与转换失败项不可勾选、不会被导入、永远不被标作
可运行。
- 重复导入按内容去重:重复执行全部 conflict 跳过,界面明示「已存在」。
## 管理列表改元数据驱动(`WorkflowLibrary.svelte`,自 connections 页抽出)
- 列表数据源换成 `GET /api/workflows?view=meta`(无 workflow_json /
input_schema,152 条约 90KB),完整 JSON 只在试运行、输出指定等操作时
按需单条加载并缓存。
- 工具栏:搜索(`/` 聚焦)、类型/收藏/待确认/停用筛选、分类/模型家族/
媒介下拉、收藏/最近与名称排序、清除筛选;行内:分类与模型家族标签、
输入→输出媒介图标、收藏星标、启停开关(用途/输出未 settle 时禁用并
提示)、试运行(复用 `WorkflowTestDialog`)、L2 复查、绑定连接、删除;
分页渲染(60/批,「再显示」)。
- 批量:勾选 + 全选 → 批量标注(查找替换重命名、统一说明、追加标签、
分类/模型家族)与批量启停。
- 新增去重入口(`POST /api/workflows/dedupe`,字节级相同才删,保留最早)。
- 原有能力保留:用途确认下拉、输出节点指定(HEL-638)、单个 API 格式
导入抽屉(HEL-655)。
## 其他
- `api.ts`/`comfy/types.ts`:`listMeta/convert/importBatch/importScanDry/
importScanCommit/touch/dedupe` 客户端与 `WorkflowMeta` 等类型;`update`
载荷支持 favorite/tags/category/model_family。
- i18n:`wl.* / md.* / wiz.* / wfCat.*` 共 130 键 × 7 语言;分类展示用
标签映射,不把 30 字目录名暴露给用户。