Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef28facee | ||
|
|
316fba97e1 | ||
|
|
1c00587a56 |
@@ -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}`, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkflowMediaIO } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
|
||||
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
|
||||
*/
|
||||
interface Props {
|
||||
io: WorkflowMediaIO | null | undefined;
|
||||
/** Compact form for row summaries (no out side when empty-in). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { io, compact = false }: Props = $props();
|
||||
|
||||
type Medium = 'image' | 'video' | 'audio';
|
||||
|
||||
const LABELS: Record<Medium, string> = {
|
||||
image: 'wl.mediaImage',
|
||||
video: 'wl.mediaVideo',
|
||||
audio: 'wl.mediaAudio',
|
||||
};
|
||||
|
||||
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
|
||||
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
|
||||
</script>
|
||||
|
||||
{#if inList.length || outList.length}
|
||||
<span class="req" aria-label={t('desk.bayReqAria')}>
|
||||
{#each inList as m (m)}
|
||||
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
|
||||
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
|
||||
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
|
||||
{#each outList as m (m)}
|
||||
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
|
||||
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
|
||||
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else if !compact}
|
||||
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.req {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-mini);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.in {
|
||||
color: var(--text);
|
||||
}
|
||||
.pill.none .lbl {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
|
||||
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
|
||||
*/
|
||||
interface Props {
|
||||
search: string;
|
||||
source: 'local' | 'cloud';
|
||||
favOnly: boolean;
|
||||
resultHint: string;
|
||||
searchEl?: HTMLInputElement | null;
|
||||
onSetSearch: (v: string) => void;
|
||||
onSetSource: (s: 'local' | 'cloud') => void;
|
||||
onToggleFav: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
search,
|
||||
source,
|
||||
favOnly,
|
||||
resultHint,
|
||||
searchEl = $bindable(null),
|
||||
onSetSearch,
|
||||
onSetSource,
|
||||
onToggleFav,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="filters">
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
bind:this={searchEl}
|
||||
class="search-input"
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder={t('desk.baySearchPh')}
|
||||
aria-label={t('desk.baySearchPh')}
|
||||
aria-keyshortcuts="/"
|
||||
oninput={(e) => onSetSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
|
||||
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
|
||||
{t('desk.baySourceLocal')}
|
||||
</button>
|
||||
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
|
||||
{t('desk.baySourceCloud')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip fav"
|
||||
class:on={favOnly}
|
||||
aria-pressed={favOnly}
|
||||
title={t('desk.bayFavOnly')}
|
||||
onclick={onToggleFav}
|
||||
>
|
||||
<Icon name="star" size={13} />
|
||||
<span class="lbl">{t('desk.bayFavOnly')}</span>
|
||||
</button>
|
||||
<span class="hint">{resultHint}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.source {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fav.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
|
||||
/**
|
||||
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
|
||||
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
|
||||
*/
|
||||
interface Props {
|
||||
meta: WorkflowMeta;
|
||||
current: boolean;
|
||||
active: boolean;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: () => void;
|
||||
onActive: () => void;
|
||||
}
|
||||
|
||||
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
|
||||
|
||||
const endpointName = $derived(
|
||||
meta.endpoint_id
|
||||
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
|
||||
: t('desk.wfDefaultConn'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
class:active
|
||||
class:current
|
||||
role="option"
|
||||
aria-selected={active ? 'true' : undefined}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
onclick={onPick}
|
||||
onpointermove={onActive}
|
||||
onfocus={onActive}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="name">
|
||||
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
|
||||
<span class="name-txt">{meta.name}</span>
|
||||
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
|
||||
</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{usageLabel(meta.usage!)}</span>
|
||||
<InputRequirementBadge io={meta.media_io} compact />
|
||||
</span>
|
||||
</span>
|
||||
<span class="side">
|
||||
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
/* 长列表性能:视口外的行不参与布局/绘制。 */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 52px;
|
||||
transition: background-color var(--d-state) ease;
|
||||
}
|
||||
.row:hover,
|
||||
.row.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.row:focus-visible {
|
||||
outline: none;
|
||||
background: var(--bg-3);
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.name-txt {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fav {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
.cur-mark {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.side {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.conn {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
|
||||
.row.current {
|
||||
position: relative;
|
||||
}
|
||||
.row.current::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
+428
-100
@@ -2,7 +2,7 @@
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { page } from '$app/state';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
@@ -20,6 +20,7 @@
|
||||
listPaths,
|
||||
missingMedia,
|
||||
schemaDefaults,
|
||||
usageLabel,
|
||||
validateForm,
|
||||
type BlockReason,
|
||||
type DeskKind,
|
||||
@@ -27,8 +28,18 @@
|
||||
type FormValues,
|
||||
} from '$lib/components/generate/schema';
|
||||
import { matchesFilter, type FeedFilter } from '$lib/components/generate/takes';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
import TaskModeRail from './TaskModeRail.svelte';
|
||||
import WorkflowBay from './WorkflowBay.svelte';
|
||||
import WorkflowPreview from './WorkflowPreview.svelte';
|
||||
import { apiErrorMessage, assetUrl, comfy, jobsApi, playgroundApi, projects, settings, workflows } from '$lib/api';
|
||||
import { USAGE_BY_KIND, type Job, type Workflow } from '$lib/comfy/types';
|
||||
import {
|
||||
USAGE_BY_KIND,
|
||||
type Job,
|
||||
type Workflow,
|
||||
type WorkflowMeta,
|
||||
type WorkflowUsage,
|
||||
} from '$lib/comfy/types';
|
||||
import { compactInputValues } from '$lib/comfy/promptInput';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { handleJobEvent } from '$lib/jobProgress';
|
||||
@@ -38,11 +49,11 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台 (spec-v2 §3.3, HEL-650 C 批): one desk for images and video — the
|
||||
* take feed with the composer below and the inspector on the right.
|
||||
* `/image` and `/video` only preselect: the default workflow comes from
|
||||
* that kind's usages and the feed opens filtered to it. Slots and params
|
||||
* follow the selected workflow's JSON (see generate/schema.ts).
|
||||
* 三段式生成台 (HEL-683, HEL-674 §二 / HEL-679 融合样稿): 左栏任务
|
||||
* 模式、中部工作流挑选台(挑选时展开,选定后收成摘要带)、右侧沿用
|
||||
* 参数与结果组件。`/image` 与 `/video` 共用本台,`kind` 决定任务模式,
|
||||
* `?wf=<id>` 深链指定工作流、`?mode=<usage>` 预选任务模式。项目五阶段
|
||||
* 的上下文条与返回路径保持不变(回流属 S5,不在本卡)。
|
||||
*/
|
||||
interface Props {
|
||||
kind: DeskKind;
|
||||
@@ -56,7 +67,10 @@
|
||||
// Data
|
||||
// ------------------------------------------------------------------
|
||||
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
// HEL-683: the desk browses S2's lightweight metadata (view=meta, no
|
||||
// workflow_json). Full records are fetched per id on demand — the whole
|
||||
// list (~7.6MB at 150+ rows) never loads here.
|
||||
const metaQuery = createQuery({ queryKey: ['workflows-meta'], queryFn: workflows.listMeta });
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ['playground-jobs'],
|
||||
queryFn: playgroundApi.jobs,
|
||||
@@ -77,13 +91,75 @@
|
||||
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
|
||||
const dryRun = $derived($settingsQuery.data?.dry_run === true);
|
||||
|
||||
// HEL-636/638: only confirmed usages with a settled output mapping.
|
||||
const deskWorkflows = $derived(
|
||||
($workflowsQuery.data ?? []).filter((w) => w.is_enabled && (w.usage ?? null) !== null && !w.needs_mapping),
|
||||
// Picker-side metadata: runnable rows of this kind + honest pending count.
|
||||
const allMetas = $derived($metaQuery.data ?? []);
|
||||
const metaById = $derived(new Map(allMetas.map((m) => [m.id, m])));
|
||||
const deskMetas = $derived(
|
||||
allMetas.filter((m) => m.is_enabled && m.usage !== null && !m.needs_mapping && m.kind === kind),
|
||||
);
|
||||
const workflowById = $derived(new Map(($workflowsQuery.data ?? []).map((w) => [w.id, w])));
|
||||
const workflowOf = (job: Job): Workflow | undefined =>
|
||||
job.workflow_id != null ? workflowById.get(job.workflow_id) : undefined;
|
||||
const pendingCount = $derived(allMetas.filter((m) => m.needs_mapping && m.kind === kind).length);
|
||||
|
||||
// Full records, lazily fetched per id (selected workflow, feed jobs'
|
||||
// workflows, the bay's preview row) and cached until invalidated.
|
||||
const details = new SvelteMap<number, Workflow>();
|
||||
const inflight = new Set<number>();
|
||||
|
||||
async function ensureDetail(id: number): Promise<Workflow | null> {
|
||||
const hit = details.get(id);
|
||||
if (hit) return hit;
|
||||
if (inflight.has(id)) return null;
|
||||
inflight.add(id);
|
||||
try {
|
||||
const wf = await workflows.get(id);
|
||||
details.set(id, wf);
|
||||
return wf;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
inflight.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const neededIds = $derived.by(() => {
|
||||
const ids = new Set<number>();
|
||||
if (workflowId != null) ids.add(workflowId);
|
||||
if (bayOpen && bayActiveId != null) ids.add(bayActiveId);
|
||||
for (const j of allJobs) if (j.workflow_id != null) ids.add(j.workflow_id);
|
||||
return ids;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
for (const id of neededIds) {
|
||||
if (!details.has(id)) void ensureDetail(id);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A job's workflow: the full record when loaded, else a metadata stub
|
||||
* (name/kind/usage render instantly; input-dependent views stay empty
|
||||
* until the record lands). Undefined only when the row is truly gone —
|
||||
* the 工作流已删除 tag stays honest.
|
||||
*/
|
||||
function stubFromMeta(m: WorkflowMeta): Workflow {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
kind: m.kind,
|
||||
usage: m.usage,
|
||||
is_enabled: m.is_enabled,
|
||||
input_schema: [],
|
||||
workflow_json: {},
|
||||
description: m.description,
|
||||
prompt_profile: m.prompt_profile,
|
||||
} as unknown as Workflow;
|
||||
}
|
||||
const workflowOf = (job: Job): Workflow | undefined => {
|
||||
if (job.workflow_id == null) return undefined;
|
||||
const full = details.get(job.workflow_id);
|
||||
if (full) return full;
|
||||
const m = metaById.get(job.workflow_id);
|
||||
return m ? stubFromMeta(m) : undefined;
|
||||
};
|
||||
|
||||
const allJobs = $derived($jobsQuery.data ?? []);
|
||||
|
||||
@@ -136,6 +212,61 @@
|
||||
return off;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Bay (挑选台) state — mode, deep links, open/collapse
|
||||
// ------------------------------------------------------------------
|
||||
type TaskMode = WorkflowUsage | 'all';
|
||||
let mode = $state<TaskMode>(untrack(initMode));
|
||||
function initMode(): TaskMode {
|
||||
const q = page.url.searchParams.get('mode');
|
||||
return q && USAGE_BY_KIND[kind].includes(q as WorkflowUsage) ? (q as WorkflowUsage) : 'all';
|
||||
}
|
||||
|
||||
let bayOpen = $state(false);
|
||||
let bayActiveId = $state<number | null>(null);
|
||||
let bay = $state<WorkflowBay | null>(null);
|
||||
|
||||
const modeMetas = $derived(mode === 'all' ? deskMetas : deskMetas.filter((m) => m.usage === mode));
|
||||
|
||||
function openBay() {
|
||||
bayActiveId = workflowId;
|
||||
bayOpen = true;
|
||||
}
|
||||
|
||||
function closeBay() {
|
||||
bayOpen = false;
|
||||
}
|
||||
|
||||
function pickWorkflow(id: number) {
|
||||
bayOpen = false;
|
||||
if (id !== workflowId) workflowId = id;
|
||||
void tick().then(() => promptEl?.focus());
|
||||
}
|
||||
|
||||
// ?wf=<id> deep link: preselect once the metadata is in (invalid → ignored).
|
||||
let wfDeepLinkDone = false;
|
||||
$effect(() => {
|
||||
if (wfDeepLinkDone) return;
|
||||
const raw = Number(page.url.searchParams.get('wf'));
|
||||
if (!Number.isInteger(raw) || raw <= 0) {
|
||||
wfDeepLinkDone = true;
|
||||
return;
|
||||
}
|
||||
if ($metaQuery.isLoading) return;
|
||||
wfDeepLinkDone = true;
|
||||
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
|
||||
});
|
||||
|
||||
async function toggleFavorite(id: number, fav: boolean) {
|
||||
try {
|
||||
await workflows.update(id, { favorite: fav });
|
||||
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
|
||||
await client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Form state
|
||||
// ------------------------------------------------------------------
|
||||
@@ -150,7 +281,7 @@
|
||||
let promptEl = $state<HTMLTextAreaElement | null>(null);
|
||||
let pickerFor = $state<string | null>(null);
|
||||
|
||||
const currentWorkflow = $derived(deskWorkflows.find((w) => w.id === workflowId) ?? null);
|
||||
const currentWorkflow = $derived(workflowId != null ? (details.get(workflowId) ?? null) : null);
|
||||
const schema = $derived(currentWorkflow?.input_schema ?? []);
|
||||
const ds = $derived(classifySchema(schema, currentWorkflow?.kind ?? kind));
|
||||
|
||||
@@ -161,16 +292,13 @@
|
||||
endpointId = currentWorkflow?.endpoint_id ?? activeId ?? '';
|
||||
});
|
||||
|
||||
// Default workflow: this entry's usages in priority order, runnable ones first.
|
||||
// Default workflow: this entry's usages in priority order (metadata-side;
|
||||
// error-level warnings surface through the block reason once loaded).
|
||||
$effect(() => {
|
||||
if (workflowId != null && deskWorkflows.some((w) => w.id === workflowId)) return;
|
||||
if (workflowId != null && (details.has(workflowId) || deskMetas.some((m) => m.id === workflowId))) return;
|
||||
const usages = USAGE_BY_KIND[kind];
|
||||
const runnable = deskWorkflows.filter((w) => !(w.warnings ?? []).some((x) => x.level === 'error'));
|
||||
const pick =
|
||||
usages.map((u) => runnable.find((w) => w.usage === u)).find(Boolean) ??
|
||||
deskWorkflows.find((w) => usages.includes(w.usage!)) ??
|
||||
runnable[0] ??
|
||||
deskWorkflows[0];
|
||||
const candidates = deskMetas.filter((m) => m.usage != null && usages.includes(m.usage));
|
||||
const pick = usages.map((u) => candidates.find((m) => m.usage === u)).find(Boolean) ?? candidates[0] ?? null;
|
||||
workflowId = pick?.id ?? null;
|
||||
});
|
||||
|
||||
@@ -328,6 +456,10 @@
|
||||
openParams();
|
||||
return;
|
||||
}
|
||||
if (b.target === 'desk-wf') {
|
||||
openBay();
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
const el = b.target ? document.getElementById(b.target) : null;
|
||||
if (el && !el.getClientRects().length) {
|
||||
@@ -372,6 +504,8 @@
|
||||
else if (!narrow) inspOpen = true;
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
// 最近使用由后端在入队时记录 (queue/manager);这里只刷新轻量列表。
|
||||
void client.invalidateQueries({ queryKey: ['workflows-meta'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.submitFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
@@ -494,9 +628,14 @@
|
||||
}
|
||||
|
||||
/** Fill the composer back with a past take's values (on its own workflow). */
|
||||
function reuse(job: Job) {
|
||||
const wf = deskWorkflows.find((w) => w.id === job.workflow_id);
|
||||
if (!wf) {
|
||||
async function reuse(job: Job) {
|
||||
const id = job.workflow_id;
|
||||
if (id == null) {
|
||||
toast.error(t('studio.reuseMissingWf'));
|
||||
return;
|
||||
}
|
||||
const wf = (details.get(id) ?? (await ensureDetail(id))) as Workflow | null;
|
||||
if (!wf || !(wf.input_schema ?? []).length) {
|
||||
toast.error(t('studio.reuseMissingWf'));
|
||||
return;
|
||||
}
|
||||
@@ -594,8 +733,9 @@
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
|
||||
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
|
||||
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦搜索/提示词 · ↑↓/J/K 切换
|
||||
// (挑选台打开时游走挑选行,否则切换记录)· 空格播放/暂停 · Delete 删
|
||||
// 选中 · ] 检视区 · Esc 关挑选台/浮层
|
||||
// ------------------------------------------------------------------
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
@@ -615,6 +755,19 @@
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
|
||||
if (e.key === 'Escape' && bayOpen) {
|
||||
e.preventDefault();
|
||||
closeBay();
|
||||
return;
|
||||
}
|
||||
// 挑选台开着时,键盘归挑选台(其容器自己处理 ↑↓/Enter/Esc)。
|
||||
if (bayOpen) {
|
||||
if (e.key === '/' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
bay?.focusSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
@@ -652,6 +805,14 @@
|
||||
inspOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Band (选定后的摘要带) helpers
|
||||
// ------------------------------------------------------------------
|
||||
const currentMeta = $derived(allMetas.find((m) => m.id === workflowId) ?? null);
|
||||
const previewMeta = $derived(allMetas.find((m) => m.id === bayActiveId) ?? null);
|
||||
const previewFull = $derived(bayActiveId != null ? (details.get(bayActiveId) ?? null) : null);
|
||||
const picking = $derived(bayOpen && wide);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
@@ -685,82 +846,131 @@
|
||||
{/snippet}
|
||||
|
||||
<AppShell active={kind} title={t('desk.title')}>
|
||||
<div class="desk" class:insp-off={inspOff}>
|
||||
<section class="center" aria-label={t('desk.title')}>
|
||||
<TakeFeed
|
||||
bind:this={feed}
|
||||
jobs={allJobs}
|
||||
{workflowOf}
|
||||
bind:filter
|
||||
selectedId={selectedJob?.id ?? null}
|
||||
{nowMs}
|
||||
{busyId}
|
||||
loading={$jobsQuery.isLoading}
|
||||
{pendingIds}
|
||||
{flashIds}
|
||||
{shakeIds}
|
||||
bind:orderedIds
|
||||
banner={ctxBanner}
|
||||
{inspectorToggle}
|
||||
onSelect={(job) => selectTake(job, 'pointer')}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onCancel={cancel}
|
||||
onDelete={requestDelete}
|
||||
<div class="studio-desk" class:picking>
|
||||
<TaskModeRail {kind} metas={deskMetas} active={mode} onSelect={(m) => (mode = m)} onPickWorkflow={pickWorkflow} />
|
||||
|
||||
<div class="bay-col">
|
||||
<WorkflowBay
|
||||
bind:this={bay}
|
||||
open={bayOpen}
|
||||
metas={modeMetas}
|
||||
{pendingCount}
|
||||
selectedId={workflowId}
|
||||
bind:activeId={bayActiveId}
|
||||
{endpoints}
|
||||
onPick={pickWorkflow}
|
||||
onClose={closeBay}
|
||||
/>
|
||||
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
|
||||
<div class="desk-empty" role="note">
|
||||
<Icon name="playground" size={16} />
|
||||
<span>{t('desk.noWorkflows')}</span>
|
||||
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
|
||||
</div>
|
||||
|
||||
<section class="main-col">
|
||||
{#if !bayOpen}
|
||||
<div class="bay-band">
|
||||
<button type="button" class="band-hit" onclick={openBay} title={t('desk.bandChange')}>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="band-mode">{mode === 'all' ? t('desk.modeAll') : usageLabel(mode)}</span>
|
||||
<span class="band-sep" aria-hidden="true">·</span>
|
||||
<span class="band-name">
|
||||
{currentWorkflow?.name ?? currentMeta?.name ?? t('desk.pickWorkflow')}
|
||||
</span>
|
||||
{#if currentMeta?.usage}<span class="a-tag">{usageLabel(currentMeta.usage)}</span>{/if}
|
||||
<InputRequirementBadge io={currentMeta?.media_io} compact />
|
||||
<span class="band-change">
|
||||
<span class="band-change-txt">{t('desk.bandChange')}</span>
|
||||
<Icon name="chevron-right" size={13} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<Composer
|
||||
{ds}
|
||||
{values}
|
||||
workflows={deskWorkflows}
|
||||
current={currentWorkflow}
|
||||
{endpoints}
|
||||
{endpointId}
|
||||
conn={connState}
|
||||
{errors}
|
||||
{showErrors}
|
||||
{block}
|
||||
{changedCount}
|
||||
{submitting}
|
||||
{sent}
|
||||
bind:slotBusy
|
||||
bind:promptEl
|
||||
onSet={setVal}
|
||||
onPickWorkflow={(wf) => (workflowId = wf.id)}
|
||||
onPickEndpoint={(id) => {
|
||||
endpointTouched = true;
|
||||
endpointId = id;
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onReveal={reveal}
|
||||
onOpenParams={openParams}
|
||||
onFromResults={(nodeId) => (pickerFor = nodeId)}
|
||||
/>
|
||||
|
||||
{#if picking}
|
||||
<WorkflowPreview
|
||||
meta={previewMeta}
|
||||
full={previewFull}
|
||||
currentId={workflowId}
|
||||
{endpoints}
|
||||
onUse={() => pickWorkflow(bayActiveId ?? workflowId ?? 0)}
|
||||
onToggleFav={(fav) => previewMeta && toggleFavorite(previewMeta.id, fav)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="desk" class:insp-off={inspOff} class:off={picking}>
|
||||
<section class="center" aria-label={t('desk.title')}>
|
||||
<TakeFeed
|
||||
bind:this={feed}
|
||||
jobs={allJobs}
|
||||
{workflowOf}
|
||||
bind:filter
|
||||
selectedId={selectedJob?.id ?? null}
|
||||
{nowMs}
|
||||
{busyId}
|
||||
loading={$jobsQuery.isLoading}
|
||||
{pendingIds}
|
||||
{flashIds}
|
||||
{shakeIds}
|
||||
bind:orderedIds
|
||||
banner={ctxBanner}
|
||||
{inspectorToggle}
|
||||
onSelect={(job) => selectTake(job, 'pointer')}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onCancel={cancel}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
{#if !$metaQuery.isLoading && !deskMetas.length}
|
||||
<div class="desk-empty" role="note">
|
||||
<Icon name="playground" size={16} />
|
||||
<span>{t('desk.noWorkflows')}</span>
|
||||
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
<Composer
|
||||
{ds}
|
||||
{values}
|
||||
current={currentWorkflow}
|
||||
{endpoints}
|
||||
{endpointId}
|
||||
conn={connState}
|
||||
{errors}
|
||||
{showErrors}
|
||||
{block}
|
||||
{changedCount}
|
||||
{submitting}
|
||||
{sent}
|
||||
bind:slotBusy
|
||||
bind:promptEl
|
||||
onSet={setVal}
|
||||
onOpenBay={openBay}
|
||||
onPickEndpoint={(id) => {
|
||||
endpointTouched = true;
|
||||
endpointId = id;
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onReveal={reveal}
|
||||
onOpenParams={openParams}
|
||||
onFromResults={(nodeId) => (pickerFor = nodeId)}
|
||||
/>
|
||||
</section>
|
||||
<Inspector
|
||||
bind:tab
|
||||
job={selectedJob}
|
||||
wf={selectedJob ? workflowOf(selectedJob) : undefined}
|
||||
{nowMs}
|
||||
open={inspOpen}
|
||||
inactive={wide ? inspOff : !inspOpen}
|
||||
canSendToSlot={!!imageSlotTarget || !!imageList}
|
||||
{crossfade}
|
||||
{playSignal}
|
||||
bind:videoEl
|
||||
params={paramsTab}
|
||||
onClose={closeInspector}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onSendToSlot={sendToSlot}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<Inspector
|
||||
bind:tab
|
||||
job={selectedJob}
|
||||
wf={selectedJob ? workflowOf(selectedJob) : undefined}
|
||||
{nowMs}
|
||||
open={inspOpen}
|
||||
inactive={wide ? inspOff : !inspOpen}
|
||||
canSendToSlot={!!imageSlotTarget || !!imageList}
|
||||
{crossfade}
|
||||
{playSignal}
|
||||
bind:videoEl
|
||||
params={paramsTab}
|
||||
onClose={closeInspector}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onSendToSlot={sendToSlot}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
@@ -779,17 +989,48 @@
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.studio-desk {
|
||||
display: grid;
|
||||
grid-template-columns: 204px 0px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.studio-desk.picking {
|
||||
grid-template-columns: 204px clamp(340px, 28vw, 440px) minmax(0, 1fr);
|
||||
}
|
||||
:global(:root[data-reduce='1']) .studio-desk {
|
||||
transition: none;
|
||||
}
|
||||
.bay-col {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.main-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.desk {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.desk.insp-off {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
.desk.off {
|
||||
display: none;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -808,6 +1049,65 @@
|
||||
color: var(--warn);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
/* 摘要带(选定后):整条可点,展开挑选台。 */
|
||||
.bay-band {
|
||||
flex-shrink: 0;
|
||||
padding: var(--sp-2) var(--sp-7);
|
||||
}
|
||||
.band-hit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
.band-hit:hover {
|
||||
border-color: var(--line-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.band-hit:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.band-mode {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.band-sep {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.band-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.band-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.band-change-txt {
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
@@ -838,7 +1138,12 @@
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
/* 中屏:挑选台为全屏浮层(组件内处理),列宽归零。 */
|
||||
@media (max-width: 1179px) {
|
||||
.studio-desk,
|
||||
.studio-desk.picking {
|
||||
grid-template-columns: 204px 0px minmax(0, 1fr);
|
||||
}
|
||||
.desk,
|
||||
.desk.insp-off {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -846,10 +1151,33 @@
|
||||
.desk-empty {
|
||||
margin: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
.bay-band {
|
||||
padding: var(--sp-2) var(--sp-6);
|
||||
}
|
||||
}
|
||||
/* 窄屏:任务模式变横条,摘要带仍可用。 */
|
||||
@media (max-width: 760px) {
|
||||
.studio-desk,
|
||||
.studio-desk.picking {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
.bay-col {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.main-col {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.desk-empty {
|
||||
margin: 0 var(--sp-5) var(--sp-2);
|
||||
}
|
||||
.bay-band {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-2);
|
||||
}
|
||||
.band-change-txt {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
|
||||
/**
|
||||
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
|
||||
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
|
||||
*/
|
||||
interface Props {
|
||||
kind: 'image' | 'video';
|
||||
metas: WorkflowMeta[];
|
||||
active: WorkflowUsage | 'all';
|
||||
onSelect: (mode: WorkflowUsage | 'all') => void;
|
||||
onPickWorkflow: (id: number) => void;
|
||||
}
|
||||
|
||||
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
|
||||
|
||||
interface ModeEntry {
|
||||
usage: WorkflowUsage;
|
||||
count: number;
|
||||
}
|
||||
const modes = $derived.by(() => {
|
||||
const counts = new Map<WorkflowUsage, number>();
|
||||
for (const m of metas) {
|
||||
if (!m.usage) continue;
|
||||
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
|
||||
}
|
||||
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
|
||||
});
|
||||
|
||||
const favorites = $derived(
|
||||
metas
|
||||
.filter((m) => m.favorite)
|
||||
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
|
||||
.slice(0, 5),
|
||||
);
|
||||
const recents = $derived(
|
||||
metas
|
||||
.filter((m) => m.last_used_at && !m.favorite)
|
||||
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
|
||||
.slice(0, 5),
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label={t('desk.modeRailAria')}>
|
||||
<p class="rail-label">{t('desk.modeRailTitle')}</p>
|
||||
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
class:on={active === 'all'}
|
||||
aria-pressed={active === 'all'}
|
||||
onclick={() => onSelect('all')}
|
||||
>
|
||||
<span class="mode-lbl">{t('desk.modeAll')}</span>
|
||||
<span class="mode-n">{metas.length}</span>
|
||||
</button>
|
||||
{#each modes as m (m.usage)}
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
class:on={active === m.usage}
|
||||
class:off={m.count === 0}
|
||||
disabled={m.count === 0}
|
||||
aria-pressed={active === m.usage}
|
||||
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
|
||||
onclick={() => onSelect(m.usage)}
|
||||
>
|
||||
<span class="mode-lbl">{usageLabel(m.usage)}</span>
|
||||
<span class="mode-n">{m.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="lists">
|
||||
{#if recents.length}
|
||||
<section class="rail-sec">
|
||||
<p class="rail-label">
|
||||
<Icon name="clock" size={12} />
|
||||
{t('desk.railRecent')}
|
||||
</p>
|
||||
<ul class="pick-list">
|
||||
{#each recents as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
|
||||
<span class="pick-name">{m.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if favorites.length}
|
||||
<section class="rail-sec">
|
||||
<p class="rail-label">
|
||||
<Icon name="star" size={12} />
|
||||
{t('desk.railFav')}
|
||||
</p>
|
||||
<ul class="pick-list">
|
||||
{#each favorites as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
|
||||
<span class="pick-name">{m.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin: 0 0 var(--sp-2);
|
||||
padding: 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.mode:hover:not(:disabled) {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.mode:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.mode.on {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
|
||||
.mode.on {
|
||||
position: relative;
|
||||
}
|
||||
.mode.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.mode.off {
|
||||
color: var(--text-3);
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mode-n {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.rail-sec {
|
||||
min-width: 0;
|
||||
}
|
||||
.pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.pick {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.pick:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.pick:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.pick-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
|
||||
@media (max-width: 760px) {
|
||||
.rail {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
.rail-label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modes {
|
||||
flex-direction: row;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mode {
|
||||
width: auto;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mode.on::after {
|
||||
inset: 1px;
|
||||
}
|
||||
.lists {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,488 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import LibraryFilters from './LibraryFilters.svelte';
|
||||
import LibraryRow from './LibraryRow.svelte';
|
||||
|
||||
/**
|
||||
* 工作流挑选台 (HEL-683, HEL-674 §二):中部可搜索筛选、按模型家族
|
||||
* 分组的挑选列。数据来自 S2 轻量元数据(view=meta,不含完整 JSON);
|
||||
* 云端来源只做诚实占位(未接入,不可点击使用)。键盘:↑↓/J/K 游走、
|
||||
* Enter 使用、Esc 关闭。长列表增量渲染 + content-visibility。
|
||||
*/
|
||||
interface Props {
|
||||
open: boolean;
|
||||
metas: WorkflowMeta[];
|
||||
pendingCount: number;
|
||||
selectedId: number | null;
|
||||
activeId: number | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (id: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, metas, pendingCount, selectedId, activeId = $bindable(null), endpoints, onPick, onClose }: Props =
|
||||
$props();
|
||||
|
||||
let search = $state('');
|
||||
let source = $state<'local' | 'cloud'>('local');
|
||||
let favOnly = $state(false);
|
||||
let visibleCount = $state(48);
|
||||
let searchEl = $state<HTMLInputElement | null>(null);
|
||||
let listEl = $state<HTMLElement | null>(null);
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
basic: 'wfCat.basic',
|
||||
audio_tts: 'wfCat.audioTts',
|
||||
music: 'wfCat.music',
|
||||
image: 'wfCat.image',
|
||||
video: 'wfCat.video',
|
||||
digital_human: 'wfCat.digitalHuman',
|
||||
motion_transfer: 'wfCat.motionTransfer',
|
||||
};
|
||||
function categoryLabel(key: string | null | undefined): string {
|
||||
if (!key) return '';
|
||||
const k = CATEGORY_LABELS[key];
|
||||
return k ? t(k) : key;
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return metas.filter((m) => {
|
||||
if (favOnly && !m.favorite) return false;
|
||||
if (!q) return true;
|
||||
const hay = [
|
||||
m.name,
|
||||
m.description ?? '',
|
||||
(m.tags ?? []).join(' '),
|
||||
m.model_family ?? '',
|
||||
m.usage ? usageLabel(m.usage) : '',
|
||||
categoryLabel(m.category),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: WorkflowMeta[];
|
||||
}
|
||||
const groups = $derived.by(() => {
|
||||
const byFamily = new Map<string, WorkflowMeta[]>();
|
||||
for (const m of filtered) {
|
||||
const key = m.model_family ?? '';
|
||||
const list = byFamily.get(key) ?? [];
|
||||
list.push(m);
|
||||
byFamily.set(key, list);
|
||||
}
|
||||
const out: Group[] = [];
|
||||
for (const [key, rows] of byFamily) {
|
||||
rows.sort((a, b) => {
|
||||
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
|
||||
const la = a.last_used_at ?? '';
|
||||
const lb = b.last_used_at ?? '';
|
||||
if (la !== lb) return lb.localeCompare(la);
|
||||
return a.name.localeCompare(b.name, 'zh');
|
||||
});
|
||||
out.push({ key, label: key || t('desk.bayNoFamily'), rows });
|
||||
}
|
||||
out.sort((a, b) =>
|
||||
a.key === '' ? 1 : b.key === '' ? -1 : a.label.localeCompare(b.label, 'zh'),
|
||||
);
|
||||
return out;
|
||||
});
|
||||
|
||||
const flat = $derived(groups.flatMap((g) => g.rows));
|
||||
|
||||
/** Flattened rows cut at the incremental window, per group. */
|
||||
const windowed = $derived.by(() => {
|
||||
let left = visibleCount;
|
||||
return groups.map((g) => {
|
||||
const take = Math.max(0, Math.min(g.rows.length, left));
|
||||
left -= take;
|
||||
return { ...g, visible: g.rows.slice(0, take) };
|
||||
});
|
||||
});
|
||||
|
||||
// Any filter change restarts the incremental window.
|
||||
$effect(() => {
|
||||
void search;
|
||||
void favOnly;
|
||||
visibleCount = 48;
|
||||
listEl?.scrollTo({ top: 0 });
|
||||
});
|
||||
|
||||
// Keep the active row real: follow the selection, clamp on filter changes.
|
||||
$effect(() => {
|
||||
void flat;
|
||||
if (activeId != null && flat.some((m) => m.id === activeId)) return;
|
||||
activeId = flat.find((m) => m.id === selectedId)?.id ?? flat[0]?.id ?? null;
|
||||
});
|
||||
|
||||
// Opening the bay: the selected workflow's row must be inside the
|
||||
// incremental window and in view — otherwise the highlight is invisible.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const target = activeId ?? selectedId;
|
||||
const idx = flat.findIndex((m) => m.id === target);
|
||||
if (idx < 0) return;
|
||||
if (idx >= visibleCount) visibleCount = idx + 48;
|
||||
const id = setTimeout(() => {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-wf-row="${target}"]`)
|
||||
?.scrollIntoView({ block: 'center' });
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
const activeMeta = $derived(metas.find((m) => m.id === activeId) ?? null);
|
||||
const resultHint = $derived(
|
||||
source === 'cloud' ? '' : t('desk.bayCount', { n: filtered.length, total: metas.length }),
|
||||
);
|
||||
|
||||
// Autofocus the search whenever the bay opens.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const id = setTimeout(() => searchEl?.focus(), 0);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((m) => m.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
i = Math.max(0, Math.min(flat.length - 1, i));
|
||||
const next = flat[i];
|
||||
activeId = next.id;
|
||||
// Keyboard roving may step past the incremental window — extend it so
|
||||
// the row exists, then reveal it.
|
||||
if (i >= visibleCount) visibleCount = i + 24;
|
||||
void tick().then(() => {
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-wf-row="${next.id}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
if (activeMeta) onPick(activeMeta.id);
|
||||
}
|
||||
|
||||
export function focusSearch() {
|
||||
searchEl?.focus();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const inSearch = e.target === searchEl;
|
||||
if (e.key === 'ArrowDown' || (!inSearch && e.key === 'j')) {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp' || (!inSearch && e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = listEl;
|
||||
if (!el) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) {
|
||||
if (visibleCount < flat.length) visibleCount += 48;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="bay-backdrop" onclick={onClose} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<section
|
||||
class="bay"
|
||||
class:open
|
||||
aria-label={t('desk.pickWorkflow')}
|
||||
inert={!open}
|
||||
onkeydown={onKeydown}
|
||||
>
|
||||
<header class="bay-head">
|
||||
<h2 class="bay-title">
|
||||
<Icon name="playground" size={15} />
|
||||
<span>{t('desk.pickWorkflow')}</span>
|
||||
</h2>
|
||||
<button type="button" class="a-btn ghost sm close" onclick={onClose} title={t('desk.keyClose')}>
|
||||
<Icon name="close" size={14} />
|
||||
<span class="lbl">{t('desk.keyClose')}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="bay-filters">
|
||||
<LibraryFilters
|
||||
bind:searchEl
|
||||
{search}
|
||||
{source}
|
||||
{favOnly}
|
||||
{resultHint}
|
||||
onSetSearch={(v) => (search = v)}
|
||||
onSetSource={(s) => (source = s)}
|
||||
onToggleFav={() => (favOnly = !favOnly)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if source === 'cloud'}
|
||||
<div class="cloud-note" role="note">
|
||||
<Icon name="alert" size={16} />
|
||||
<p class="cloud-title">{t('desk.bayCloudTitle')}</p>
|
||||
<p class="cloud-body">{t('desk.bayCloudBody')}</p>
|
||||
</div>
|
||||
{:else if !metas.length}
|
||||
<div class="bay-empty">
|
||||
<p>{t('desk.noWorkflows')}</p>
|
||||
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="bay-list" bind:this={listEl} role="listbox" aria-label={t('desk.pickWorkflow')} onscroll={onScroll}>
|
||||
{#each windowed as g (g.key)}
|
||||
{#if g.visible.length}
|
||||
<div class="group-label" aria-hidden="true">{g.label} · {g.rows.length}</div>
|
||||
{#each g.visible as m (m.id)}
|
||||
<div data-wf-row={m.id}>
|
||||
<LibraryRow
|
||||
meta={m}
|
||||
current={m.id === selectedId}
|
||||
active={m.id === activeId}
|
||||
{endpoints}
|
||||
onPick={() => onPick(m.id)}
|
||||
onActive={() => (activeId = m.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="bay-none">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
{#if visibleCount < flat.length}
|
||||
<button type="button" class="more a-btn ghost sm" onclick={() => (visibleCount += 48)}>
|
||||
{t('desk.bayMore', { n: Math.min(48, flat.length - visibleCount) })}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="bay-foot">
|
||||
{#if pendingCount > 0}
|
||||
<a class="pending-link" href="/connections">
|
||||
{t('desk.bayPending', { n: pendingCount })}
|
||||
<Icon name="chevron-right" size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
{#if activeMeta}
|
||||
<span class="active-name" title={activeMeta.name}>{activeMeta.name}</span>
|
||||
{/if}
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.bay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bay-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-2) var(--sp-4);
|
||||
}
|
||||
.bay-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bay-title :global(svg) {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
}
|
||||
.close .lbl {
|
||||
display: none;
|
||||
}
|
||||
.bay-filters {
|
||||
padding: 0 var(--sp-1) var(--sp-3);
|
||||
}
|
||||
.bay-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--sp-1);
|
||||
}
|
||||
.group-label {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-1);
|
||||
background: linear-gradient(var(--bg-2) 82%, transparent);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.more {
|
||||
align-self: center;
|
||||
margin: var(--sp-3) 0;
|
||||
}
|
||||
.bay-none,
|
||||
.bay-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
text-align: center;
|
||||
}
|
||||
.bay-empty {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.bay-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-2) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.pending-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pending-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.active-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
.cloud-note {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.cloud-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cloud-body {
|
||||
margin: 0;
|
||||
font-size: var(--fs-aux);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 窄/中屏:挑选台改为全屏浮层(计划中的最简可用降级)。 */
|
||||
.bay-backdrop {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.bay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: var(--topbar-h);
|
||||
bottom: 0;
|
||||
z-index: 60;
|
||||
background: var(--bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
opacity var(--d-state) ease;
|
||||
}
|
||||
.bay.open {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.bay-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: var(--topbar-h) 0 0 0;
|
||||
z-index: 59;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.close .lbl {
|
||||
display: inline;
|
||||
}
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .bay {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.bay {
|
||||
top: 0;
|
||||
padding: var(--sp-4) var(--sp-4) calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
}
|
||||
.bay-backdrop {
|
||||
inset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
|
||||
/**
|
||||
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
|
||||
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
|
||||
* 完整记录槽位摘要),确认后进入创作区。
|
||||
*/
|
||||
interface Props {
|
||||
meta: WorkflowMeta | null;
|
||||
full: Workflow | null;
|
||||
currentId: number | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onUse: () => void;
|
||||
onToggleFav: (fav: boolean) => void;
|
||||
}
|
||||
|
||||
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
|
||||
|
||||
const isCurrent = $derived(meta != null && meta.id === currentId);
|
||||
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
|
||||
const endpointName = $derived.by(() => {
|
||||
const id = meta?.endpoint_id;
|
||||
if (!id) return t('desk.wfDefaultConn');
|
||||
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
|
||||
});
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
basic: 'wfCat.basic',
|
||||
audio_tts: 'wfCat.audioTts',
|
||||
music: 'wfCat.music',
|
||||
image: 'wfCat.image',
|
||||
video: 'wfCat.video',
|
||||
digital_human: 'wfCat.digitalHuman',
|
||||
motion_transfer: 'wfCat.motionTransfer',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="preview">
|
||||
{#if meta}
|
||||
<div class="head">
|
||||
<h3 class="name" title={meta.name}>{meta.name}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="fav"
|
||||
class:on={meta.favorite}
|
||||
aria-pressed={meta.favorite}
|
||||
aria-label={t('desk.bayFavOnly')}
|
||||
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
|
||||
onclick={() => onToggleFav(!meta.favorite)}
|
||||
>
|
||||
<Icon name="star" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
|
||||
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
|
||||
{#if meta.category && CATEGORY_LABELS[meta.category]}
|
||||
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
|
||||
{/if}
|
||||
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
|
||||
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if meta.description}
|
||||
<p class="desc">{meta.description}</p>
|
||||
{/if}
|
||||
|
||||
<section class="sec">
|
||||
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
|
||||
<div class="req-line">
|
||||
<InputRequirementBadge io={meta.media_io} />
|
||||
</div>
|
||||
{#if full}
|
||||
<p class="slot-summary">{slotSummary(full)}</p>
|
||||
{:else}
|
||||
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<dl class="kv">
|
||||
<div class="kv-row">
|
||||
<dt>{t('studio.connLabel')}</dt>
|
||||
<dd>{endpointName}</dd>
|
||||
</div>
|
||||
{#if meta.last_used_at}
|
||||
<div class="kv-row">
|
||||
<dt>{t('desk.pvLastUsed')}</dt>
|
||||
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if (full?.input_schema ?? []).length}
|
||||
<div class="kv-row">
|
||||
<dt>{t('desk.pvParamCount')}</dt>
|
||||
<dd>{(full?.input_schema ?? []).length}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="use">
|
||||
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
|
||||
<Icon name="check" size={13} />
|
||||
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
|
||||
</button>
|
||||
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-empty">
|
||||
<Icon name="search" size={20} />
|
||||
<p>{t('desk.pvEmpty')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: var(--sp-7);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fav {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.fav.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.fav:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.desc {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.sec {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.sec-title {
|
||||
margin: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.req-line {
|
||||
display: flex;
|
||||
}
|
||||
.slot-summary {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.slot-summary.dim {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
margin: 0;
|
||||
}
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv-row dt {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv-row dd {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.use {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-4);
|
||||
}
|
||||
.use-hint {
|
||||
margin: 0;
|
||||
color: var(--err);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: 100%;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,6 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import ParamChip from './ParamChip.svelte';
|
||||
import SlotTray from './SlotTray.svelte';
|
||||
import WorkflowPicker from './WorkflowPicker.svelte';
|
||||
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
@@ -13,11 +12,12 @@
|
||||
* row of chips — workflow, connection, common params, 参数 — ending in
|
||||
* 生成 with its reason line. The whole composer takes dropped files.
|
||||
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
|
||||
* HEL-683: the workflow chip no longer opens a dropdown — it expands the
|
||||
* desk's middle picker bay (StudioDesk WorkflowBay), the single picker.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
endpointId: string;
|
||||
@@ -32,7 +32,7 @@
|
||||
slotBusy?: boolean;
|
||||
promptEl?: HTMLTextAreaElement | null;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onPickWorkflow: (wf: Workflow) => void;
|
||||
onOpenBay: () => void;
|
||||
onPickEndpoint: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
onReveal: () => void;
|
||||
@@ -43,7 +43,6 @@
|
||||
let {
|
||||
ds,
|
||||
values,
|
||||
workflows,
|
||||
current,
|
||||
endpoints,
|
||||
endpointId,
|
||||
@@ -57,7 +56,7 @@
|
||||
slotBusy = $bindable(false),
|
||||
promptEl = $bindable(null),
|
||||
onSet,
|
||||
onPickWorkflow,
|
||||
onOpenBay,
|
||||
onPickEndpoint,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
@@ -165,7 +164,18 @@
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={onOpenBay}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="conn opt">
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,6 +322,10 @@
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
}
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.conn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { slotSummary, usageLabel } from './schema';
|
||||
|
||||
/**
|
||||
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
|
||||
* pop grouped by usage with search; each row shows what the workflow
|
||||
* really takes (slot summary from its JSON), its connection and whether
|
||||
* it last ran. Picking keeps the prompt and carried-over images.
|
||||
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
|
||||
* active row follows ↑↓ (and the pointer), Enter uses it, and the
|
||||
* search's Enter picks the active row instead of always the first.
|
||||
*/
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (wf: Workflow) => void;
|
||||
}
|
||||
|
||||
let { workflows, current, endpoints, onPick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
function statusOf(wf: Workflow): Status {
|
||||
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return { tone: '', label: t('desk.wfUntested') };
|
||||
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
|
||||
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
|
||||
return { tone: '', label: t('desk.wfTesting') };
|
||||
}
|
||||
|
||||
function endpointLabel(wf: Workflow): string {
|
||||
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
|
||||
return ep ? ep.name : t('desk.wfDefaultConn');
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
workflows.map((wf) => ({
|
||||
wf,
|
||||
summary: slotSummary(wf),
|
||||
conn: endpointLabel(wf),
|
||||
status: statusOf(wf),
|
||||
usage: wf.usage ? usageLabel(wf.usage) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (r: (typeof rows)[number]) =>
|
||||
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
|
||||
return WORKFLOW_USAGES.map((u) => ({
|
||||
usage: u,
|
||||
items: rows.filter((r) => r.wf.usage === u && hit(r)),
|
||||
})).filter((g) => g.items.length);
|
||||
});
|
||||
|
||||
/** Flattened rows in display order — the roving selection walks these. */
|
||||
const flat = $derived(groups.flatMap((g) => g.items));
|
||||
|
||||
// Keep the active row real: default to the current workflow, clamp on
|
||||
// filter changes, never point at a row that is no longer listed.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
|
||||
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((r) => r.wf.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
|
||||
if (row) pick(row.wf);
|
||||
}
|
||||
|
||||
function pick(wf: Workflow) {
|
||||
open = false;
|
||||
query = '';
|
||||
if (wf.id !== current?.id) onPick(wf);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
<ShellPop
|
||||
bind:open
|
||||
label={t('desk.pickWorkflow')}
|
||||
placement="top"
|
||||
width="520px"
|
||||
arrowRoving={false}
|
||||
onclose={() => (query = '')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pop-body" onkeydown={onKeydown}>
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('desk.wfSearchPh')}
|
||||
aria-label={t('desk.wfSearchPh')}
|
||||
/>
|
||||
</div>
|
||||
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
|
||||
{#each groups as g (g.usage)}
|
||||
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
|
||||
{#each g.items as r (r.wf.id)}
|
||||
{@const cur = r.wf.id === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item row"
|
||||
class:off={r.status.tone === 'err'}
|
||||
class:active={r.wf.id === activeId}
|
||||
role="option"
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
aria-selected={r.wf.id === activeId ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
onpointermove={() => (activeId = r.wf.id)}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{r.summary}</span>
|
||||
<span class="a-tag">{r.conn}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<a class="a-btn ghost sm" href="/connections">
|
||||
<Icon name="upload" size={13} />
|
||||
{t('desk.wfImport')}
|
||||
</a>
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
|
||||
.list {
|
||||
max-height: min(380px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.main .a-menu-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.row.off .a-menu-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -2081,6 +2081,36 @@ export const de: Dict = {
|
||||
'desk.wfUntested': 'Nicht getestet',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Gestern',
|
||||
// --- Dreispaltige Desk (HEL-683) ---
|
||||
'desk.bandChange': "Workflow wechseln",
|
||||
'desk.bayCloudBody': "Cloud-Modelle für Bild / Video (gpt-image2, nano-banana, Jimeng, Kling, …) sind noch nicht angebunden. Nach der Anbindung erscheinen sie mit ihren echten Fähigkeiten — nie als unbenutzbare Einträge.",
|
||||
'desk.bayCloudTitle': "Cloud-Generierung nicht angebunden",
|
||||
'desk.bayCount': "{n} / {total} Workflows",
|
||||
'desk.bayFavOnly': "Favoriten",
|
||||
'desk.bayMore': "{n} weitere anzeigen",
|
||||
'desk.bayNoFamily': "Modell unbekannt",
|
||||
'desk.bayPending': "{n} offen (Zweck / Ausgabe ungeklärt)",
|
||||
'desk.bayReqAria': "Eingabeanforderungen",
|
||||
'desk.bayReqTitle': "Eingabeanforderungen",
|
||||
'desk.baySearchPh': "Name, Beschreibung, Modell, Zweck suchen…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Generierungsquelle",
|
||||
'desk.baySourceLocal': "Lokal",
|
||||
'desk.modeAll': "Alle",
|
||||
'desk.modeEmpty': "Für diesen Modus gibt es noch keinen nutzbaren Workflow",
|
||||
'desk.modeRailAria': "Aufgabenmodi",
|
||||
'desk.modeRailTitle': "Aufgabenmodi",
|
||||
'desk.pvBackCreate': "Zurück zum Erstellen",
|
||||
'desk.pvCurrent': "Aktueller Workflow",
|
||||
'desk.pvEmpty': "Links einen Workflow wählen, um die Eingaben zu sehen",
|
||||
'desk.pvFav': "Zu Favoriten",
|
||||
'desk.pvLastUsed': "Zuletzt genutzt",
|
||||
'desk.pvParamCount': "Parameter",
|
||||
'desk.pvSlotsLoading': "Eingabeslots werden gelesen…",
|
||||
'desk.pvUnfav': "Aus Favoriten entfernen",
|
||||
'desk.pvUse': "Diesen Workflow verwenden",
|
||||
'desk.railFav': "Favoriten",
|
||||
'desk.railRecent': "Zuletzt",
|
||||
'wfd.stepParse': 'JSON parsen',
|
||||
'wfd.stepNodes': 'Knoten erkennen',
|
||||
'wfd.stepModels': 'Modelle prüfen',
|
||||
@@ -2097,4 +2127,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}',
|
||||
};
|
||||
|
||||
@@ -2080,6 +2080,36 @@ export const en = {
|
||||
'desk.wfUntested': 'Not tested',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Yesterday',
|
||||
// --- Three-section desk (HEL-683) ---
|
||||
'desk.bandChange': "Change workflow",
|
||||
'desk.bayCloudBody': "Cloud image / video models (gpt-image2, nano-banana, Jimeng, Kling, …) are not connected to this product yet. Once integrated they will be listed with their real capabilities — never as unusable entries.",
|
||||
'desk.bayCloudTitle': "Cloud generation not connected",
|
||||
'desk.bayCount': "{n} / {total} workflows",
|
||||
'desk.bayFavOnly': "Favorites",
|
||||
'desk.bayMore': "Show {n} more",
|
||||
'desk.bayNoFamily': "Unrecognized model",
|
||||
'desk.bayPending': "{n} pending confirmation (usage / output unsettled)",
|
||||
'desk.bayReqAria': "Input requirements",
|
||||
'desk.bayReqTitle': "Input requirements",
|
||||
'desk.baySearchPh': "Search name, description, model, usage…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Generation source",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "All",
|
||||
'desk.modeEmpty': "No runnable workflow for this mode yet",
|
||||
'desk.modeRailAria': "Task modes",
|
||||
'desk.modeRailTitle': "Task modes",
|
||||
'desk.pvBackCreate': "Back to creating",
|
||||
'desk.pvCurrent': "Current workflow",
|
||||
'desk.pvEmpty': "Pick a workflow on the left to see its input requirements",
|
||||
'desk.pvFav': "Add to favorites",
|
||||
'desk.pvLastUsed': "Last used",
|
||||
'desk.pvParamCount': "Parameters",
|
||||
'desk.pvSlotsLoading': "Loading input slots…",
|
||||
'desk.pvUnfav': "Remove from favorites",
|
||||
'desk.pvUse': "Use this workflow",
|
||||
'desk.railFav': "Favorites",
|
||||
'desk.railRecent': "Recent",
|
||||
'wfd.stepParse': 'Parse JSON',
|
||||
'wfd.stepNodes': 'Identify nodes',
|
||||
'wfd.stepModels': 'Verify models',
|
||||
@@ -2096,6 +2126,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;
|
||||
|
||||
@@ -2081,6 +2081,36 @@ export const es: Dict = {
|
||||
'desk.wfUntested': 'Sin probar',
|
||||
'desk.workflow': 'Flujo',
|
||||
'desk.yesterday': 'Ayer',
|
||||
// --- Escritorio de tres secciones (HEL-683) ---
|
||||
'desk.bandChange': "Cambiar flujo",
|
||||
'desk.bayCloudBody': "Los modelos de nube para imagen / vídeo (gpt-image2, nano-banana, Jimeng, Kling, …) aún no están conectados a este producto. Al integrarse se listarán con sus capacidades reales, nunca como entradas inutilizables.",
|
||||
'desk.bayCloudTitle': "Generación en la nube no conectada",
|
||||
'desk.bayCount': "{n} / {total} flujos",
|
||||
'desk.bayFavOnly': "Favoritos",
|
||||
'desk.bayMore': "Mostrar {n} más",
|
||||
'desk.bayNoFamily': "Modelo no identificado",
|
||||
'desk.bayPending': "{n} pendientes de confirmar (uso / salida sin fijar)",
|
||||
'desk.bayReqAria': "Requisitos de entrada",
|
||||
'desk.bayReqTitle': "Requisitos de entrada",
|
||||
'desk.baySearchPh': "Buscar nombre, descripción, modelo, uso…",
|
||||
'desk.baySourceCloud': "Nube",
|
||||
'desk.baySourceLabel': "Origen de generación",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "Todos",
|
||||
'desk.modeEmpty': "Aún no hay flujo utilizable para este modo",
|
||||
'desk.modeRailAria': "Modos de tarea",
|
||||
'desk.modeRailTitle': "Modos de tarea",
|
||||
'desk.pvBackCreate': "Volver a crear",
|
||||
'desk.pvCurrent': "Flujo actual",
|
||||
'desk.pvEmpty': "Elige un flujo a la izquierda para ver sus requisitos",
|
||||
'desk.pvFav': "Añadir a favoritos",
|
||||
'desk.pvLastUsed': "Último uso",
|
||||
'desk.pvParamCount': "Parámetros",
|
||||
'desk.pvSlotsLoading': "Leyendo ranuras de entrada…",
|
||||
'desk.pvUnfav': "Quitar de favoritos",
|
||||
'desk.pvUse': "Usar este flujo",
|
||||
'desk.railFav': "Favoritos",
|
||||
'desk.railRecent': "Recientes",
|
||||
'wfd.stepParse': 'Analizar JSON',
|
||||
'wfd.stepNodes': 'Identificar nodos',
|
||||
'wfd.stepModels': 'Verificar modelos',
|
||||
@@ -2097,4 +2127,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}',
|
||||
};
|
||||
|
||||
@@ -2081,6 +2081,36 @@ export const fr: Dict = {
|
||||
'desk.wfUntested': 'Non testé',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Hier',
|
||||
// --- Bureau en trois sections (HEL-683) ---
|
||||
'desk.bandChange': "Changer de flux",
|
||||
'desk.bayCloudBody': "Les modèles cloud d'image / vidéo (gpt-image2, nano-banana, Jimeng, Kling, …) ne sont pas encore connectés à ce produit. Une fois intégrés, ils seront listés avec leurs capacités réelles — jamais en entrées inutilisables.",
|
||||
'desk.bayCloudTitle': "Génération cloud non connectée",
|
||||
'desk.bayCount': "{n} / {total} flux",
|
||||
'desk.bayFavOnly': "Favoris",
|
||||
'desk.bayMore': "Afficher {n} de plus",
|
||||
'desk.bayNoFamily': "Modèle non identifié",
|
||||
'desk.bayPending': "{n} à confirmer (usage / sortie non fixés)",
|
||||
'desk.bayReqAria': "Exigences d'entrée",
|
||||
'desk.bayReqTitle': "Exigences d'entrée",
|
||||
'desk.baySearchPh': "Rechercher nom, description, modèle, usage…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Source de génération",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "Tous",
|
||||
'desk.modeEmpty': "Aucun flux utilisable pour ce mode pour l'instant",
|
||||
'desk.modeRailAria': "Modes de tâche",
|
||||
'desk.modeRailTitle': "Modes de tâche",
|
||||
'desk.pvBackCreate': "Retour à la création",
|
||||
'desk.pvCurrent': "Flux actuel",
|
||||
'desk.pvEmpty': "Choisissez un flux à gauche pour voir ses exigences d'entrée",
|
||||
'desk.pvFav': "Ajouter aux favoris",
|
||||
'desk.pvLastUsed': "Dernière utilisation",
|
||||
'desk.pvParamCount': "Paramètres",
|
||||
'desk.pvSlotsLoading': "Lecture des emplacements d’entrée…",
|
||||
'desk.pvUnfav': "Retirer des favoris",
|
||||
'desk.pvUse': "Utiliser ce flux",
|
||||
'desk.railFav': "Favoris",
|
||||
'desk.railRecent': "Récents",
|
||||
'wfd.stepParse': 'Analyser le JSON',
|
||||
'wfd.stepNodes': 'Identifier les nœuds',
|
||||
'wfd.stepModels': 'Vérifier les modèles',
|
||||
@@ -2097,4 +2127,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}',
|
||||
};
|
||||
|
||||
@@ -2079,6 +2079,36 @@ export const ja: Dict = {
|
||||
'desk.wfUntested': '未テスト',
|
||||
'desk.workflow': 'ワークフロー',
|
||||
'desk.yesterday': '昨日',
|
||||
// --- 三段構成デスク (HEL-683) ---
|
||||
'desk.bandChange': "ワークフローを変更",
|
||||
'desk.bayCloudBody': "クラウドの画像/動画モデル(gpt-image2、nano-banana、即夢、Kling など)はまだ本製品に接続されていません。接続後は実際の機能とともに一覧表示され、使えない項目が並ぶことはありません。",
|
||||
'desk.bayCloudTitle': "クラウド生成は未接続",
|
||||
'desk.bayCount': "{n} / {total} 件のワークフロー",
|
||||
'desk.bayFavOnly': "お気に入り",
|
||||
'desk.bayMore': "さらに {n} 件表示",
|
||||
'desk.bayNoFamily': "モデル未識別",
|
||||
'desk.bayPending': "確認待ち {n} 件(用途/出力未確定)",
|
||||
'desk.bayReqAria': "入力要件",
|
||||
'desk.bayReqTitle': "入力要件",
|
||||
'desk.baySearchPh': "名前・説明・モデル・用途を検索…",
|
||||
'desk.baySourceCloud': "クラウド",
|
||||
'desk.baySourceLabel': "生成ソース",
|
||||
'desk.baySourceLocal': "ローカル",
|
||||
'desk.modeAll': "すべて",
|
||||
'desk.modeEmpty': "このモードで利用できるワークフローはまだありません",
|
||||
'desk.modeRailAria': "タスクモード",
|
||||
'desk.modeRailTitle': "タスクモード",
|
||||
'desk.pvBackCreate': "作成に戻る",
|
||||
'desk.pvCurrent': "現在のワークフロー",
|
||||
'desk.pvEmpty': "左でワークフローを選ぶと入力要件が表示されます",
|
||||
'desk.pvFav': "お気に入りに追加",
|
||||
'desk.pvLastUsed': "最終使用",
|
||||
'desk.pvParamCount': "パラメータ数",
|
||||
'desk.pvSlotsLoading': "入力スロットを読み込み中…",
|
||||
'desk.pvUnfav': "お気に入りから外す",
|
||||
'desk.pvUse': "このワークフローを使う",
|
||||
'desk.railFav': "お気に入り",
|
||||
'desk.railRecent': "最近の使用",
|
||||
'wfd.stepParse': 'JSON を解析',
|
||||
'wfd.stepNodes': 'ノードを識別',
|
||||
'wfd.stepModels': 'モデルを確認',
|
||||
@@ -2095,4 +2125,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}',
|
||||
};
|
||||
|
||||
@@ -2079,6 +2079,36 @@ export const ko: Dict = {
|
||||
'desk.wfUntested': '테스트 안 함',
|
||||
'desk.workflow': '워크플로',
|
||||
'desk.yesterday': '어제',
|
||||
// --- 3단 구성 데스크 (HEL-683) ---
|
||||
'desk.bandChange': "워크플로 변경",
|
||||
'desk.bayCloudBody': "클라우드 이미지 / 동영상 모델(gpt-image2, nano-banana, 지멍, 클링 등)은 아직 이 제품에 연결되지 않았습니다. 연결 후 실제 기능과 함께 표시되며, 사용할 수 없는 항목은 나타나지 않습니다.",
|
||||
'desk.bayCloudTitle': "클라우드 생성 미연결",
|
||||
'desk.bayCount': "{n} / {total}개 워크플로",
|
||||
'desk.bayFavOnly': "즐겨찾기",
|
||||
'desk.bayMore': "{n}개 더 보기",
|
||||
'desk.bayNoFamily': "미식별 모델",
|
||||
'desk.bayPending': "확인 대기 {n}개(용도 / 출력 미확정)",
|
||||
'desk.bayReqAria': "입력 요구 사항",
|
||||
'desk.bayReqTitle': "입력 요구 사항",
|
||||
'desk.baySearchPh': "이름, 설명, 모델, 용도 검색…",
|
||||
'desk.baySourceCloud': "클라우드",
|
||||
'desk.baySourceLabel': "생성 소스",
|
||||
'desk.baySourceLocal': "로컬",
|
||||
'desk.modeAll': "전체",
|
||||
'desk.modeEmpty': "이 모드에서 사용 가능한 워크플로가 아직 없습니다",
|
||||
'desk.modeRailAria': "작업 모드",
|
||||
'desk.modeRailTitle': "작업 모드",
|
||||
'desk.pvBackCreate': "만들기로 돌아가기",
|
||||
'desk.pvCurrent': "현재 워크플로",
|
||||
'desk.pvEmpty': "왼쪽에서 워크플로를 선택하면 입력 요구 사항이 표시됩니다",
|
||||
'desk.pvFav': "즐겨찾기에 추가",
|
||||
'desk.pvLastUsed': "최근 사용",
|
||||
'desk.pvParamCount': "매개변수 수",
|
||||
'desk.pvSlotsLoading': "입력 슬롯 읽는 중…",
|
||||
'desk.pvUnfav': "즐겨찾기에서 제거",
|
||||
'desk.pvUse': "이 워크플로 사용",
|
||||
'desk.railFav': "즐겨찾기",
|
||||
'desk.railRecent': "최근 사용",
|
||||
'wfd.stepParse': 'JSON 파싱',
|
||||
'wfd.stepNodes': '노드 식별',
|
||||
'wfd.stepModels': '모델 확인',
|
||||
@@ -2095,4 +2125,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}',
|
||||
};
|
||||
|
||||
@@ -2079,6 +2079,36 @@ export const zh: Dict = {
|
||||
'desk.wfUntested': '未试运行',
|
||||
'desk.workflow': '工作流',
|
||||
'desk.yesterday': '昨天',
|
||||
// --- 三段式生成台 (HEL-683) ---
|
||||
'desk.bandChange': "更换工作流",
|
||||
'desk.bayCloudBody': "云端生图 / 生视频模型(gpt-image2、nano-banana、即梦、可灵等)尚未接入本产品。接入后这里会按真实能力列出,不会展示无法使用的条目。",
|
||||
'desk.bayCloudTitle': "云端生成未接入",
|
||||
'desk.bayCount': "{n} / {total} 个工作流",
|
||||
'desk.bayFavOnly': "收藏",
|
||||
'desk.bayMore': "再显示 {n} 个",
|
||||
'desk.bayNoFamily': "未识别模型",
|
||||
'desk.bayPending': "{n} 个待确认(用途 / 输出未定)",
|
||||
'desk.bayReqAria': "输入要求",
|
||||
'desk.bayReqTitle': "输入要求",
|
||||
'desk.baySearchPh': "搜索名称、说明、模型、用途…",
|
||||
'desk.baySourceCloud': "云端",
|
||||
'desk.baySourceLabel': "生成来源",
|
||||
'desk.baySourceLocal': "本机",
|
||||
'desk.modeAll': "全部",
|
||||
'desk.modeEmpty': "该模式暂无可用工作流",
|
||||
'desk.modeRailAria': "任务模式",
|
||||
'desk.modeRailTitle': "任务模式",
|
||||
'desk.pvBackCreate': "返回创作",
|
||||
'desk.pvCurrent': "当前工作流",
|
||||
'desk.pvEmpty': "在左侧选择一个工作流查看输入要求",
|
||||
'desk.pvFav': "加入收藏",
|
||||
'desk.pvLastUsed': "最近使用",
|
||||
'desk.pvParamCount': "参数项",
|
||||
'desk.pvSlotsLoading': "正在读取输入槽位…",
|
||||
'desk.pvUnfav': "取消收藏",
|
||||
'desk.pvUse': "使用此工作流",
|
||||
'desk.railFav': "收藏",
|
||||
'desk.railRecent': "最近使用",
|
||||
'wfd.stepParse': '解析 JSON',
|
||||
'wfd.stepNodes': '识别节点',
|
||||
'wfd.stepModels': '核对模型',
|
||||
@@ -2095,4 +2125,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}',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
|
||||
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
|
||||
</script>
|
||||
|
||||
<StudioGenerate kind="image" />
|
||||
<StudioDesk kind="image" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
|
||||
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
|
||||
</script>
|
||||
|
||||
<StudioGenerate kind="video" />
|
||||
<StudioDesk kind="video" />
|
||||
|
||||
@@ -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 字目录名暴露给用户。
|
||||
@@ -0,0 +1,46 @@
|
||||
# S4:三段式图片 / 视频生成台(HEL-683)
|
||||
|
||||
按 HEL-679 已确认的三张融合样稿,把 `/image` 与 `/video` 两个入口接入同一
|
||||
个三段式生成台。本批只动 `calliope-web`,生成后端与项目回流(S5)零改动。
|
||||
|
||||
## 三段式布局(新增 `components/desk/`)
|
||||
|
||||
- `StudioDesk.svelte` 取代 `StudioGenerate.svelte` 成为 `/image`、`/video`
|
||||
的共用页面(薄壳不变,`kind` 决定任务模式);右栏沿用既有
|
||||
`Composer / SlotTray / Inspector / TakeFeed / ContextBar`,仅重排布局。
|
||||
- 左栏 `TaskModeRail.svelte`:任务模式按真实工作流统计计数,未覆盖模式置灰;
|
||||
底部收藏 / 最近使用直达;≤760px 退化为横向模式条。
|
||||
- 中栏挑选台 `WorkflowBay.svelte`(含 `LibraryFilters / LibraryRow /
|
||||
InputRequirementBadge`):搜索、本机 / 云端来源分段、按模型家族分组、
|
||||
收藏过滤、待确认计数与入口;长列表增量渲染(48/批)+ `content-visibility`,
|
||||
键盘 ↑↓/J/K 游走、Enter 使用、Esc 关闭。
|
||||
- 挑选时中栏展开(grid 列宽动画,`data-reduce` 下直接跳变),右侧显示
|
||||
`WorkflowPreview.svelte` 输入要求预览(S2 元数据 + 按需加载的槽位摘要);
|
||||
选定后收成摘要带(band),一键重新展开。≤1179px 挑选台为全屏浮层。
|
||||
- 云端来源是诚实占位:明确标注「云端生成未接入」,不造可点击的虚假能力;
|
||||
数据结构按 `source: 'comfy' | 'cloud'` 预留。
|
||||
|
||||
## 数据与路由
|
||||
|
||||
- 挑选台 / 任务模式 / 摘要带全部走 `GET /api/workflows?view=meta` 轻量元数据
|
||||
(150+ 条不下载 workflow_json);完整记录改为按 id 惰性加载(当前工作流、
|
||||
记录流里出现的工作流、预览行),feed 名称先用元数据桩、schema 到位后补全,
|
||||
「工作流已删除」判定以元数据为准,不在加载期误报。
|
||||
- 新增查询参数:`?wf=<id>` 深链指定工作流、`?mode=<task-mode>` 预选任务模式;
|
||||
五阶段 `deskHref` 的 `from=workbench` 上下文条与返回路径保持不变。
|
||||
- Composer 的工作流芯片从 380px 下拉弹层改为展开挑选台(`#desk-wf` 语义
|
||||
不变,仍是缺工作流时的定位目标);`WorkflowPicker.svelte` 删除。
|
||||
- 最近使用沿用后端入队时写入的 `last_used_at`(queue/manager 原有逻辑),
|
||||
提交后刷新轻量列表即可。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run check`(0 错误,2 条基线既有警告)、`npm run test`、`npm run build`
|
||||
通过;prettier 对本批文件通过(仓库 lint 基线因 prettier 3.9 全量重排 +
|
||||
无 eslint 配置本就不可用,未扩面修复)。
|
||||
- `e2e/shoot.mjs` 全绿:挑选台键盘链(展开→搜索→↑↓→Enter→调参→提交)、
|
||||
SSE 状态语义、删除撤销、任务页、窄屏无溢出、减动效;同时把 7 处 S1 灰阶
|
||||
化后未更新的旧色值断言修正为 theme-1 实际值(定影文案 + 灰阶信号色)。
|
||||
- `e2e/lastframe.mjs` 全绿(视频检视、尾帧导出、手机弹层无回归)。
|
||||
- 手工走查:`?wf` / `?mode` / `from=workbench` 上下文预填与返回、1024/390
|
||||
无横向溢出,截图见 `e2e/shots/hel683-*.png`。
|
||||
+23
-18
@@ -69,14 +69,15 @@ const run = async () => {
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
|
||||
|
||||
// ---------- 工作流选择器:↑↓ 高亮 + Enter 使用 ----------
|
||||
// ---------- 挑选台(HEL-683 三段式):摘要带展开 → ↑↓ 高亮 + Enter 使用 ----------
|
||||
await page.goto('http://127.0.0.1:5173/image');
|
||||
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
|
||||
await page.click('#desk-wf');
|
||||
await page.waitForSelector('.shell-pop');
|
||||
const activeRows = page.locator('.shell-pop .row.active');
|
||||
await page.click('.bay-band .band-hit');
|
||||
await page.waitForSelector('.bay .row.active', { timeout: 10000 });
|
||||
await page.waitForSelector('.bay .search-input');
|
||||
const activeRows = page.locator('.bay .row.active');
|
||||
const nActive = await activeRows.count();
|
||||
ok('工作流面板有默认高亮行', nActive === 1);
|
||||
ok('挑选台有默认高亮行', nActive === 1);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
const label1 = await activeRows.first().textContent();
|
||||
@@ -86,15 +87,19 @@ const run = async () => {
|
||||
const chipBefore = await page.locator('#desk-wf .lbl').textContent();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(400);
|
||||
ok('Enter 使用高亮的工作流(挑选台收起)', (await page.locator('.bay.open').count()) === 0);
|
||||
const chipAfter = await page.locator('#desk-wf .lbl').textContent();
|
||||
ok('Enter 使用高亮的工作流', chipBefore !== chipAfter || (await activeRows.count()) === 0, `${chipBefore} → ${chipAfter}`);
|
||||
await page.screenshot({ path: `${SHOTS}/01-picker-roving.png` });
|
||||
ok('输入台芯片跟随选中工作流', chipBefore !== chipAfter, `${chipBefore} → ${chipAfter}`);
|
||||
await page.screenshot({ path: `${SHOTS}/01-bay-roving.png` });
|
||||
|
||||
// 回到文生图工作流
|
||||
await page.click('#desk-wf');
|
||||
await page.fill('.shell-pop .search-input', '文生图');
|
||||
// 回到文生图工作流(搜索唯一命中「回归」)
|
||||
await page.click('.bay-band .band-hit');
|
||||
await page.waitForSelector('.bay .search-input');
|
||||
await page.fill('.bay .search-input', '回归');
|
||||
await page.waitForSelector('.bay .row.active');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(300);
|
||||
ok('搜索后 Enter 选中文生图工作流', (await page.locator('#desk-wf .lbl').textContent()).includes('文生图'));
|
||||
|
||||
// ---------- 提交 → 完成(绿标签 + 自动切结果页) ----------
|
||||
setMode({ mode: 'ok', delay_sec: 0 });
|
||||
@@ -116,9 +121,9 @@ const run = async () => {
|
||||
// 绿标签
|
||||
const doneCard = page.locator('.take', { hasText: '柴犬' }).first();
|
||||
const doneTag = await doneCard.locator('.state .a-tag').first().textContent();
|
||||
ok('完成态为绿色「完成」标签(非金色)', (doneTag || '').includes('完成'), doneTag);
|
||||
ok('完成态为「定影」标签(HEL-680 灰阶语义)', (doneTag || '').includes('定影'), doneTag);
|
||||
const tagColor = await doneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('完成标签用信号绿 rgb(73,203,144)', tagColor === 'rgb(73, 203, 144)', tagColor);
|
||||
ok('完成标签用灰阶信号色 rgb(221,221,215)', tagColor === 'rgb(221, 221, 215)', tagColor);
|
||||
await page.screenshot({ path: `${SHOTS}/02-desk-done.png` });
|
||||
|
||||
// ---------- 慢任务:排队灰 + 运行青 + 进度条 ----------
|
||||
@@ -130,9 +135,9 @@ const run = async () => {
|
||||
await page.click('.a-btn.primary'); // 第二个:queued 灰
|
||||
await page.waitForTimeout(4000);
|
||||
const runTag = await stateTag(page, '观察进度').textContent();
|
||||
ok('运行中为青色「生成中」', /生成中|执行/.test(runTag || ''), runTag);
|
||||
ok('运行中为「生成中/执行中」', /生成中|执行/.test(runTag || ''), runTag);
|
||||
const runTagColor = await stateTag(page, '观察进度').evaluate((el) => getComputedStyle(el).color);
|
||||
ok('运行标签用信号青 rgb(84,223,210)', runTagColor === 'rgb(84, 223, 210)', runTagColor);
|
||||
ok('运行标签用灰阶信号色 rgb(184,182,172)', runTagColor === 'rgb(184, 182, 172)', runTagColor);
|
||||
const hasBar = (await page.locator('.take', { hasText: '观察进度' }).locator('.a-progress').count()) > 0;
|
||||
ok('运行中卡片有进度条', hasBar);
|
||||
const queueTag = await stateTag(page, '第二个任务').textContent();
|
||||
@@ -175,7 +180,7 @@ const run = async () => {
|
||||
const failTag = await failCard.locator('.state .a-tag').first().textContent();
|
||||
ok('失败为红色「失败」标签', (failTag || '').includes('失败'), failTag);
|
||||
const failColor = await failCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('失败标签用信号红 rgb(240,100,90)', failColor === 'rgb(240, 100, 90)', failColor);
|
||||
ok('失败标签用信号红 rgb(208,122,107)(灰阶底上的红)', failColor === 'rgb(208, 122, 107)', failColor);
|
||||
const errLine = failCard.locator('.err-txt');
|
||||
ok('失败自动展开一行中文结论', (await errLine.count()) === 1 && (await errLine.textContent()).length > 4);
|
||||
await errLine.click();
|
||||
@@ -199,7 +204,7 @@ const run = async () => {
|
||||
const undoBtn = page.locator('.toast .action', { hasText: '撤销' });
|
||||
ok('出现带「撤销」的 Toast', (await undoBtn.count()) === 1);
|
||||
const undoColor = await undoBtn.evaluate((el) => getComputedStyle(el).color);
|
||||
ok('撤销动作按钮为金色文字 rgb(242,199,132)', undoColor === 'rgb(242, 199, 132)', undoColor);
|
||||
ok('撤销动作按钮为强调白文字 rgb(245,244,238)', undoColor === 'rgb(245, 244, 238)', undoColor);
|
||||
await page.screenshot({ path: `${SHOTS}/05-undo-toast.png` });
|
||||
const countDeleted = await cardCount(page);
|
||||
ok('删除后行立即收起', countDeleted === countBefore - 1, `${countBefore} → ${countDeleted}`);
|
||||
@@ -237,7 +242,7 @@ const run = async () => {
|
||||
const goneTag = await goneCard.locator('.state .a-tag').first().textContent();
|
||||
ok('文件缺失为琥珀「文件缺失」', (goneTag || '').includes('文件缺失'), goneTag);
|
||||
const goneColor = await goneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('缺失标签用琥珀 rgb(240,180,92)', goneColor === 'rgb(240, 180, 92)', goneColor);
|
||||
ok('缺失标签用信号琥珀 rgb(163,145,95)', goneColor === 'rgb(163, 145, 95)', goneColor);
|
||||
ok('缺失态给「用相同参数重做」', (await goneCard.getByRole('button', { name: '用相同参数重做' }).count()) === 1);
|
||||
ok('缺失态给「移除记录」', (await goneCard.getByRole('button', { name: '移除记录' }).count()) === 1);
|
||||
await goneCard.getByRole('button', { name: '用相同参数重做' }).click();
|
||||
@@ -284,7 +289,7 @@ const run = async () => {
|
||||
ok('任务页筛选芯片组(含失败/进行中)', (await chips.count()) === 5);
|
||||
// 默认「全部」选中即金色文字(accent-soft 底)
|
||||
const selChipColor = await chips.first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('选中筛选芯片为金色文字', selChipColor === 'rgb(242, 199, 132)', selChipColor);
|
||||
ok('选中筛选芯片为强调白文字', selChipColor === 'rgb(245, 244, 238)', selChipColor);
|
||||
const failedChipColor = await chips.nth(3).evaluate((el) => getComputedStyle(el).color);
|
||||
await chips.nth(3).click(); // 失败
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
Reference in New Issue
Block a user