Compare commits

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

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 12:13:52 +08:00
总工 7c49250267 HEL-687: merge S2 workflow data layer (agent/agent/c85587840139) 2026-10-11 11:32:05 +08:00
leeferandmultica-agent c5aa575ac3 feat(workflows): UI→API 转换器 + 批量导入 + 元数据轻量列表 (HEL-681 S2)
- 新增 comfyui/ui_convert.py:离线确定性转换(reroute/bypass/mute 透传、
  子图展开为 packed:inner 限定 id、Set/Get 变量解析、控件三种映射来源,
  无法可靠映射的文件进入需人工确认,绝不静默猜测)
- 新增 convert / import-batch / import-scan 端点:dry-run 逐文件
  ok/warn/fail 报告 + 建议元数据;导入端冲突跳过、重复执行幂等
- workflows 表加列 category/model_family/media_io/tags/favorite/
  last_used_at/source/source_path(纯加列迁移,旧行为零影响)
- GET /api/workflows?view=meta 轻量列表不含全量 JSON;旧接口行为不变
- 元数据建议:分类/模型家族(主加载器加权+目录回退,规避 qwen_image_vae
  误标)/输入输出媒介/MarkdownNote 描述素材
- enqueue 时记录 last_used_at;PATCH 支持 favorite/tags 等字段
- 测试 23 项新增(转换语义单测+146 文件语料+对拍+端点/迁移/幂等)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 11:20:23 +08:00
总工 6623ffa04c HEL-680: merge S1 grayscale visual foundation (feat/hel-680-grayscale-foundation) 2026-10-11 11:17:30 +08:00
施工员andmultica-agent 0ee8280ddb feat(theme): HEL-680 S1 全站无彩灰阶视觉底座——theme-1 灰阶 token 换值、取景框角线选中、相纸白边与「定影」完成态
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 11:01:21 +08:00
leefer 1fc8bdb8bb Add 146 ComfyUI workflow JSONs organized by category (0-基础处理/1-语音/2-音乐/3-图像/4-视频/5-数字人/6-动作迁移) 2026-10-10 20:30:14 +08:00
184 changed files with 12556 additions and 771 deletions
@@ -0,0 +1,128 @@
"""Regenerate ``src/calliope/comfyui/ui_widget_orders.py`` (HEL-681).
Offline widget-order registry for the UI→API converter. Sources, in priority:
1. ``widgets_values_named`` instances across ``example_ComfyUI_workflows``
(including subgraph inner nodes) — the frontend serializes named widgets
in widget order, so the key order IS the serialization order;
2. the repo's 10 ground-truth API exports (root ``*_API.json``) — scalar
input names in export order (control_after_generate entries are absent
there; the converter's walk skips them positionally).
Run from anywhere inside the repo:
python scripts/build_widget_orders.py
"""
from __future__ import annotations
import collections
import json
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
REPO = BACKEND.parent
EXAMPLES = REPO / "example_ComfyUI_workflows"
OUT = BACKEND / "src" / "calliope" / "comfyui" / "ui_widget_orders.py"
BUTTONS = {"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
MAX_SIGNATURES = 3
MAX_ORDER_LEN = 48
# Hand-curated fallbacks for node classes the corpus never serializes with
# names (classic core/custom widgets whose order is stable and public).
CURATED: dict[str, list[list[str]]] = {
"PrimitiveNode": [["value"]],
"Seed (rgthree)": [["seed"]],
}
def _iter_ui_nodes() -> list[dict]:
nodes: list[dict] = []
for path in sorted(EXAMPLES.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue
nodes.extend(n for n in data["nodes"] if isinstance(n, dict))
for sub in data.get("definitions", {}).get("subgraphs", []) or []:
if isinstance(sub, dict):
nodes.extend(n for n in sub.get("nodes", []) or [] if isinstance(n, dict))
return nodes
def _iter_api_workflows() -> list[dict]:
out: list[dict] = []
for path in sorted(EXAMPLES.glob("*_API.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if isinstance(data, dict) and "nodes" not in data:
out.append(data)
return out
def main() -> None:
learned: dict[str, collections.Counter[tuple[str, ...]]] = collections.defaultdict(
collections.Counter
)
for node in _iter_ui_nodes():
named = node.get("widgets_values_named")
if not isinstance(named, dict) or not named:
continue
keys = tuple(
str(k) for k in named.keys() if k and k not in BUTTONS
)
if keys and len(keys) <= MAX_ORDER_LEN:
learned[str(node.get("type") or "")][keys] += 1
for workflow in _iter_api_workflows():
for node in workflow.values():
if not isinstance(node, dict):
continue
names = [
str(k)
for k, v in (node.get("inputs") or {}).items()
if not isinstance(v, list) and k and k not in BUTTONS
]
if len(names) > MAX_ORDER_LEN:
continue
# Display nodes (PreviewAny…) carry zero scalar inputs — that
# empty order is real signal: their positional arrays are pure
# button leftovers and map to {}.
learned[str(node.get("class_type") or "")][tuple(names)] += 1
registry: dict[str, list[list[str]]] = {}
for node_type, counter in sorted(learned.items()):
if not node_type:
continue
orders: list[list[str]] = []
for signature, _count in counter.most_common(MAX_SIGNATURES):
orders.append(list(signature))
if orders:
registry[node_type] = orders
for node_type, orders in CURATED.items():
registry.setdefault(node_type, orders)
body = json.dumps(registry, ensure_ascii=False, indent=1, sort_keys=True)
OUT.write_text(
'"""GENERATED by scripts/build_widget_orders.py — do not edit by hand.\n'
"\n"
"Widget-order registry for the UI→API converter: class_type → candidate\n"
"widget name orders, learned from the repo's named-widget UI files and\n"
"ground-truth API exports (HEL-681).\n"
'"""\n'
"\n"
"from __future__ import annotations\n"
"\n"
f"WIDGET_ORDERS: dict[str, list[list[str]]] = {body}\n",
encoding="utf-8",
)
total = sum(len(o) for orders in registry.values() for o in orders)
print(f"wrote {OUT.name}: {len(registry)} node types, {total} orders total")
if __name__ == "__main__":
main()
@@ -0,0 +1,170 @@
"""Generate the HEL-681 dry-run report over the repo's UI workflow library.
Walks ``example_ComfyUI_workflows`` (or ``--root``), runs every file through
the production converter + lint + parser pipeline (exactly what
``POST /api/workflows/convert`` does, minus HTTP), and writes a JSON detail
file plus a human-readable Markdown summary. Read-only: nothing is imported,
no DB is touched.
Usage:
python scripts/ui_import_dryrun.py [--root DIR] [--out PREFIX]
"""
from __future__ import annotations
import argparse
import collections
import json
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND / "src"))
from calliope.comfyui.lint import workflow_warnings # noqa: E402
from calliope.comfyui.metadata import suggested_metadata # noqa: E402
from calliope.comfyui.parser import classify_usage # noqa: E402
from calliope.comfyui.ui_convert import convert_ui_workflow # noqa: E402
REPO = BACKEND.parent
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", default=str(REPO / "example_ComfyUI_workflows"))
parser.add_argument("--out", default=str(REPO / "dryrun_report"))
args = parser.parse_args()
root = Path(args.root)
out_prefix = Path(args.out)
entries = []
for path in sorted(root.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
entries.append(
{
"source_path": str(path.relative_to(root)),
"status": "fail",
"issues": [{"code": "unreadable", "message": str(exc)}],
}
)
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue # API-format / non-workflow files are out of scope
rel = str(path.relative_to(root))
result = convert_ui_workflow(data)
entry: dict = {
"source_path": rel,
"status": result.status,
"needs_confirmation": result.needs_confirmation,
"issues": [
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
for i in result.issues
],
}
if result.workflow is not None:
entry["node_count"] = len(result.workflow)
entry["suggested"] = suggested_metadata(result.workflow, rel, data)
errors = [w for w in workflow_warnings(result.workflow) if w["level"] == "error"]
entry["lint_errors"] = [e["code"] for e in errors]
entry["importable"] = not result.needs_confirmation and not errors
else:
entry["importable"] = False
entries.append(entry)
counts = collections.Counter(e["status"] for e in entries)
code_counts = collections.Counter(
i["code"] for e in entries for i in e["issues"]
)
category_counts = collections.Counter(
(e.get("suggested") or {}).get("category") or "?" for e in entries
)
family_counts = collections.Counter(
(e.get("suggested") or {}).get("model_family") or "?" for e in entries
)
importable = sum(1 for e in entries if e.get("importable"))
needs_confirmation = sum(1 for e in entries if e["needs_confirmation"])
lint_blocked = [e for e in entries if e.get("lint_errors")]
detail = {
"root": str(root),
"total": len(entries),
"status_counts": dict(counts),
"issue_code_counts": dict(code_counts),
"importable": importable,
"needs_confirmation": needs_confirmation,
"lint_blocked": [e["source_path"] for e in lint_blocked],
"files": entries,
}
out_prefix.parent.mkdir(parents=True, exist_ok=True)
(out_prefix.with_suffix(".json")).write_text(
json.dumps(detail, ensure_ascii=False, indent=1), encoding="utf-8"
)
lines = [
"# HEL-681 工作流 UI→API 转换 dry-run 报告",
"",
f"- 扫描目录:`{root}`(根目录 10 个 API 格式旧文件不在本管线范围)",
f"- UI 格式文件总数:**{len(entries)}**",
f"- 转换结果:ok **{counts.get('ok', 0)}** / warn **{counts.get('warn', 0)}** / fail **{counts.get('fail', 0)}**",
f"- 其中「需人工确认」(控件无法可靠映射,禁止直接导入):**{needs_confirmation}**",
f"- 通过转换+lint 后可直接导入:**{importable}**",
f"- 转换成功但被现有 lint 拦截(导入时按阻止处理):**{len(lint_blocked)}**",
"",
"## 分类分布(自动提取)",
"",
"| 分类 | 数量 |",
"|---|---|",
]
for cat, n in sorted(category_counts.items()):
lines.append(f"| {cat} | {n} |")
lines += ["", "## 模型家族分布(自动提取)", "", "| 家族 | 数量 |", "|---|---|"]
for fam, n in sorted(family_counts.items(), key=lambda kv: -kv[1]):
lines.append(f"| {fam} | {n} |")
lines += ["", "## 问题代码统计", "", "| 代码 | 次数 | 含义 |", "|---|---|---|"]
meaning = {
"muted_input_dropped": "上游节点被静音,输入按未连接处理",
"variable_upstream_dropped": "Set/Get 变量上游被旁路,取值按未连接处理",
"get_without_set": "GetNode 没有同名 SetNode",
"widget_unmapped": "控件值无法可靠映射到输入名(需人工确认)",
"widget_order_mismatch": "控件数量与已知顺序不符(需人工确认)",
"no_output_node": "图中无已知输出节点,全部活跃节点保留待确认",
"cyclic_variable": "Set/Get 循环(FOR 循环流),该连线按未连接处理",
"dangling_link": "连线指向不存在的节点",
}
for code, n in code_counts.most_common():
lines.append(f"| `{code}` | {n} | {meaning.get(code, '')} |")
lines += ["", "## 需人工确认文件清单", ""]
for e in entries:
if e["needs_confirmation"]:
reasons = sorted({i["code"] for i in e["issues"]})[:3]
lines.append(f"- `{e['source_path']}`({', '.join(reasons)})")
lines += ["", "## lint 拦截文件清单", ""]
for e in lint_blocked:
lines.append(f"- `{e['source_path']}`({', '.join(e['lint_errors'])})")
lines += [
"",
"## 逐文件结果",
"",
"| 文件 | 状态 | 节点数 | 分类 | 模型家族 | 用途 | 问题 |",
"|---|---|---|---|---|---|---|",
]
for e in entries:
sug = e.get("suggested") or {}
issues = collections.Counter(i["code"] for i in e["issues"])
issue_text = ", ".join(f"{c}×{n}" for c, n in issues.most_common(2)) or "-"
usage = sug.get("usage") or ("待定" if sug.get("usage_ambiguous") else "-")
lines.append(
f"| `{e['source_path']}` | {e['status']} | {e.get('node_count', '-')} "
f"| {sug.get('category') or '-'} | {sug.get('model_family') or '-'} "
f"| {usage} | {issue_text} |"
)
(out_prefix.with_suffix(".md")).write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"{len(entries)} files: {dict(counts)}, importable={importable}, nc={needs_confirmation}")
print(f"report → {out_prefix}.md / {out_prefix}.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,160 @@
"""Suggested workflow metadata for imports (HEL-681 / S2 data layer).
Everything here derives from two honest sources: the repo directory the file
came from (category) and the converted API graph itself (usage, media in/out,
model family, note text). Per HEL-675's inventory, clean display names and
tags stay human work — the suggestion is a starting point, never a guess that
hides uncertainty.
"""
from __future__ import annotations
import re
from typing import Any
from calliope.comfyui.parser import (
classify_usage,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
# Repo top-level directory → (category key, display label). Directory names
# are mapping sources only — they never surface to users verbatim.
CATEGORY_DIRS: dict[str, tuple[str, str]] = {
"0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大": ("basic", "基础处理"),
"1-语音生成": ("audio_tts", "语音生成"),
"2-音乐生成": ("music", "音乐生成"),
"3-图像生成": ("image", "图像生成"),
"4-视频生成": ("video", "视频生成"),
"5-数字人": ("digital_human", "数字人"),
"6-动作迁移": ("motion_transfer", "动作迁移"),
}
# Evidence patterns → model family. Shared VAE/text-encoder files must NOT
# count (HEL-675: Anima/Z-Image graphs reference qwen_image_vae and get
# mislabeled Qwen — `qwen.?image(?!_vae)` is deliberate).
_FAMILY_PATTERNS: tuple[tuple[str, str], ...] = (
(r"minimax[_\-]?(music|_audio)|MiniMaxMusic", "MiniMax-Music"),
(r"minimax.?h3|minimax_h3|h3[-_]realism|fl2v[aa]?", "MiniMax-H3"),
(r"ltx[-_.]?2", "LTX-2.5"),
(r"ltx", "LTX"),
(r"krea", "Krea2"),
(r"qwen.?image(?!_vae)|qwen_image_2|qwen2vl|qwen3vl|Qwen3?[-.]?ASR|qwen3?[-_.]?tts", "Qwen"),
(r"z[._-]?image", "Z-Image"),
(r"flux", "Flux"),
(r"\bwan", "Wan"),
(r"ace[_-]?step", "ACE-Step"),
(r"index[_-]?tts", "IndexTTS2"),
(r"anima[-_.]?(turbo|klein)", "Anima"),
(r"sdpose", "SDPose"),
(r"gemma", "Gemma"),
(r"instantmesh|instant_mesh", "InstantMesh"),
(r"hidream", "HiDream"),
)
_FAMILY_RE = [(re.compile(pat, re.IGNORECASE), fam) for pat, fam in _FAMILY_PATTERNS]
# Main-model loaders dominate the vote: a UNET/checkpoint reference says far
# more about the workflow's family than a shared VAE or text encoder does.
_PRIMARY_LOADER_RE = re.compile(r"UNETLoader|CheckpointLoader|unet|diffusion_model", re.IGNORECASE)
# Directory names that carry family evidence the files' own widget values
# lack (HEL-675: 14 krea2 files reference no "krea" string at all).
_FAMILY_DIRS = (
(re.compile(r"krea2", re.IGNORECASE), "Krea2"),
(re.compile(r"Minimax-h3|uncensored", re.IGNORECASE), "MiniMax-H3"),
(re.compile(r"Z-Image", re.IGNORECASE), "Z-Image"),
(re.compile(r"Qwen-Image", re.IGNORECASE), "Qwen"),
(re.compile(r"^Anima$", re.IGNORECASE), "Anima"),
(re.compile(r"Flux", re.IGNORECASE), "Flux"),
)
_NOTE_TYPES = frozenset({"Note", "MarkdownNote"})
def category_from_source_path(source_path: str | None) -> tuple[str | None, str | None]:
"""(category key, label) derived from the file's repo-relative path."""
if not source_path:
return None, None
head = source_path.replace("\\", "/").split("/", 1)[0]
return CATEGORY_DIRS.get(head, (None, None))
def model_family_from(workflow: dict[str, Any], source_path: str | None = None) -> str | None:
"""The graph's dominant model family: main-model loader references weigh
triple, other node evidence single, and the repo sub-directory adds a
strong vote for families the widget values don't spell out."""
votes: dict[str, int] = {}
for node in workflow.values():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type") or "")
blob = " ".join(
[class_type]
+ [str(v) for v in (node.get("inputs") or {}).values() if isinstance(v, str)]
)
weight = 3 if _PRIMARY_LOADER_RE.search(blob) else 1
for pattern, family in _FAMILY_RE:
if pattern.search(blob):
votes[family] = votes.get(family, 0) + weight
break # one vote per node: strongest family that node shows
if source_path:
parts = source_path.replace("\\", "/").split("/")
for part in parts[1:2]:
for pattern, family in _FAMILY_DIRS:
if pattern.search(part):
votes[family] = votes.get(family, 0) + 2
if not votes:
return None
return max(votes.items(), key=lambda kv: kv[1])[0]
def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | None:
"""First non-trivial author note as description material (never final copy)."""
for node in ui_workflow.get("nodes") or []:
if not isinstance(node, dict) or str(node.get("type")) not in _NOTE_TYPES:
continue
values = node.get("widgets_values")
text = values[0] if isinstance(values, list) and values else None
if isinstance(values, dict):
text = values.get("text") or next(iter(values.values()), None)
if isinstance(text, str):
text = text.strip()
if len(text) >= 8:
return text[:limit].strip()
return None
def suggested_metadata(
converted: dict[str, Any],
source_path: str | None = None,
ui_workflow: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""The import suggestion bundle for one converted workflow."""
judgment = classify_usage(converted)
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
media_in = sorted(
{
entry["kind"].replace("_list", "")
for entry in parse_dynamic_inputs(converted)
if entry.get("kind") in media_kinds
}
)
media_out = sorted(
{
str(entry.get("kind"))
for entry in parse_dynamic_outputs(converted)
if entry.get("kind") in ("image", "video", "audio")
}
)
category, category_label = category_from_source_path(source_path)
return {
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
"usage": judgment["usage"],
"usage_ambiguous": judgment["ambiguous"],
"category": category,
"category_label": category_label,
"model_family": model_family_from(converted, source_path),
"media_in": media_in,
"media_out": media_out,
"description": note_description(ui_workflow or {}),
}
@@ -0,0 +1,801 @@
"""UI→API converter for ComfyUI workflow JSON (HEL-681 / S2 data layer).
ComfyUI saves two formats: the editor ("UI") format with ``nodes``/``links``
arrays, and the API ("prompt") format ``{node_id: {class_type, inputs}}`` the
product executes. This module converts UI→API offline and deterministically:
* links are rewritten through ``Reroute`` chains and bypassed (mode 4) nodes;
* packed subgraph instances (frontend ≥1.x ``definitions.subgraphs``) expand
into their inner nodes under qualified ids ``<packed>:<inner>`` — the same
scheme ComfyUI itself exports;
* widget values map through, in priority order: the node's serialized input
list (entries carrying a ``widget`` marker — authoritative, this corpus's
frontend version always writes them), the ``widgets_values_named`` dict,
then a generated registry of widget orders learned from the corpus and the
repo's ground-truth API exports (``ui_widget_orders``).
Anything that cannot be mapped with certainty is reported as an issue and the
file is flagged ``needs_confirmation`` — never silently guessed: a wrong
widget order produces a runnable-looking workflow with swapped seed/steps,
which is worse than an honest「需人工确认」.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from calliope.comfyui.ui_widget_orders import WIDGET_ORDERS
# Values of the frontend-only "control_after_generate" control that serializes
# right after every seed-like INT widget (and is not a real server input).
_CONTROL_MODES = frozenset({"fixed", "increment", "decrement", "randomize"})
# Widget names that are pure UI buttons/previews — never serialized in
# widgets_values (named dicts skip them too) and never API inputs.
_BUTTON_WIDGETS = frozenset(
{"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
)
# Leftover positional values that are provably action buttons, not data:
# known labels, self-describing ids, or emoji-prefixed actions. Anything
# else left over keeps the file in the needs-confirmation queue.
_BUTTON_LEFTOVER_LABELS = frozenset({"Edit", "lanpaint_star_button"})
_BUTTON_LEFTOVER_EMOJI = ("🖼", "🎬", "🎵", "➕", "🔁", "🗑", "📂", "🔎", "✨")
def _is_button_leftover(value: Any) -> bool:
if value is None or value == "":
return True
if isinstance(value, str):
return (
value in _BUTTON_LEFTOVER_LABELS
or "button" in value.lower()
or value.startswith(_BUTTON_LEFTOVER_EMOJI)
)
return False
# Virtual editor-only nodes. Links pass *through* reroute-style ones (see
# _resolve_source); the rest are dropped from the graph entirely.
_REROUTE_TYPES = frozenset({"Reroute", "Reroute (rgthree)"})
# Frontend-virtual variable nodes: SetNode captures a value under a name,
# GetNode reads it. Neither reaches the server — links through a Get resolve
# to whatever its same-named Set captured (scope-local).
_SET_TYPES = frozenset({"SetNode"})
_GET_TYPES = frozenset({"GetNode"})
_UI_ONLY_TYPES = frozenset(
{
"Note",
"MarkdownNote",
"Image Comparer (rgthree)",
"GH_ImageVideoComparer",
"Label (rgthree)",
"Fast Groups Bypasser (rgthree)",
"Fast Groups Muter (rgthree)",
"Fast Muter (rgthree)",
"FastGroupsBypassSwitch",
"Mute / Bypass Repeater (rgthree)",
}
)
# Server-side OUTPUT nodes: the closure roots. Learned from the repo's
# ground-truth API exports (their unreferenced classes) + the corpus's
# real save/preview sinks (SaveVideo/SaveImageAdvanced/… custom nodes).
_OUTPUT_CLASSES = frozenset(
{
"SaveImage",
"PreviewImage",
"SaveImageAdvanced",
"SaveImageKJ",
"SaveAnimatedWEBP",
"SaveAnimatedPNG",
"SaveAnimatedGIF",
"SaveVideo",
"SaveAudio",
"SaveAudioMP3",
"SaveAudioAdvanced",
"PreviewAudio",
"MaskPreview",
"MaskPreview+",
"ImageAndMaskPreview",
"VHS_VideoCombine",
"easy saveVideo",
"easy multitrackProjectVideoCombine",
"easy multiTrackInfoOutput",
"easy multiTrackTaskOutput",
"easy compareVideos",
"easy compareImages",
"easy showText",
"easy imageWall",
"CR Prompt Text",
"PreviewAny",
"easy clearGpuUsed",
"easy clearCacheAll",
"easy showSpentTime",
}
)
MODE_ACTIVE = 0
MODE_MUTED = 2
MODE_BYPASSED = 4
_SUBGRAPH_IN = -10 # pseudo-node id of a subgraph's boundary input
_SUBGRAPH_OUT = -20 # pseudo-node id of a subgraph's boundary output
@dataclass
class ConvertIssue:
node_id: str
code: str
message: str
level: str = "warning" # 'error' | 'warning'
@dataclass
class ConvertResult:
workflow: dict[str, Any] | None
status: str # 'ok' | 'warn' | 'fail'
issues: list[ConvertIssue] = field(default_factory=list)
@property
def needs_confirmation(self) -> bool:
"""Widget mapping uncertain — must not be imported as runnable."""
return any(
i.code in ("widget_unmapped", "widget_order_mismatch") for i in self.issues
)
def _norm_link(link: Any) -> tuple[int, int, int, int, int, str] | None:
"""(id, origin_id, origin_slot, target_id, target_slot, type) from either form."""
if isinstance(link, dict):
try:
return (
int(link["id"]),
int(link["origin_id"]),
int(link["origin_slot"]),
int(link["target_id"]),
int(link["target_slot"]),
str(link.get("type") or "*"),
)
except (KeyError, TypeError, ValueError):
return None
if isinstance(link, (list, tuple)) and len(link) >= 5:
try:
return (
int(link[0]),
int(link[1]),
int(link[2]),
int(link[3]),
int(link[4]),
str(link[5]) if len(link) > 5 and link[5] else "*",
)
except (TypeError, ValueError):
return None
return None
def _is_seed_like(name: str) -> bool:
lowered = name.lower()
return lowered in ("seed", "noise_seed", "value", "rand_seed", "random_seed") or (
lowered.endswith("seed")
)
def _is_upload_entry(name: str, type_: str) -> bool:
return type_.upper().endswith("UPLOAD") or name == "upload" or "UPLOAD" in type_.upper()
class _Converter:
"""One conversion run over a single UI-format document."""
def __init__(self, ui: dict[str, Any]) -> None:
self.ui = ui
self.issues: list[ConvertIssue] = []
self.subgraphs: dict[str, dict[str, Any]] = {
str(s.get("id")): s
for s in self.ui.get("definitions", {}).get("subgraphs", []) or []
if isinstance(s, dict)
}
# qualified id → UI node + owning scope
self.nodes: dict[str, dict[str, Any]] = {}
# scope key ('' for top level) → link table / node-id index / owner
self.scope_links: dict[str, dict[int, tuple[int, int, int, int, str]]] = {}
self.scope_ids: dict[str, dict[str, str]] = {}
self.scope_owner: dict[str, str] = {}
self._set_capture_cache: dict[str, dict[str, tuple[str, int]]] = {}
self._set_names: dict[str, set[str]] = {}
self._depth = 0
self._cycle_warned = False
self._set_building: set[str] = set()
# -------------------------------------------------------------- indexing
def _index_scope(self, scope: str, links: list[Any], owner: str | None) -> None:
table = self.scope_links.setdefault(scope, {})
for link in links or []:
norm = _norm_link(link)
if norm is not None:
table[norm[0]] = norm[1:]
self.scope_ids.setdefault(scope, {})
if owner is not None:
self.scope_owner[scope] = owner
def _flatten(
self, node: dict[str, Any], scope: str, prefix: str
) -> None:
qualified = f"{prefix}{node.get('id')}"
self.nodes[qualified] = {"node": node, "scope": scope}
self.scope_ids.setdefault(scope, {})[str(node.get("id"))] = qualified
sub = self.subgraphs.get(str(node.get("type") or ""))
if sub is None:
return
if not isinstance(sub.get("nodes"), list):
self.issues.append(
ConvertIssue(qualified, "subgraph_bad_definition", "子图缺少内部节点定义")
)
return
sub_scope = f"{scope}|{node.get('type')}" if scope else f"{node.get('type')}"
if sub_scope not in self.scope_links:
self._index_scope(sub_scope, sub.get("links") or [], qualified)
for inner in sub["nodes"]:
if isinstance(inner, dict):
self._flatten(inner, sub_scope, f"{qualified}:")
# --------------------------------------------------------------- lookups
def _node(self, qualified: str) -> dict[str, Any] | None:
entry = self.nodes.get(qualified)
return entry["node"] if entry else None
def _scope(self, qualified: str) -> str:
entry = self.nodes.get(qualified)
return entry["scope"] if entry else ""
def _link(self, scope: str, link_id: Any) -> tuple[int, int, int, int, str] | None:
if link_id is None:
return None
try:
key = int(link_id)
except (TypeError, ValueError):
return None
return self.scope_links.get(scope, {}).get(key)
def _boundary_source(self, scope: str, link_id: Any) -> tuple[str, int] | None:
"""(packed qualified id, boundary slot) when this link leaves a
subgraph through its -10 input boundary."""
link = self._link(scope, link_id)
if link is None or link[0] != _SUBGRAPH_IN:
return None
packed_q = self.scope_owner.get(scope)
if packed_q is None:
return None
return packed_q, link[1]
def _subgraph_of(self, qualified: str) -> dict[str, Any] | None:
node = self._node(qualified)
if node is None:
return None
return self.subgraphs.get(str(node.get("type") or ""))
@staticmethod
def _virtual_name(node: dict[str, Any]) -> str:
"""A Set/Get node's captured variable name (its first widget value)."""
named = node.get("widgets_values_named")
if isinstance(named, dict):
for key in ("const_name", "name", "value"):
if key in named:
return str(named[key])
values = node.get("widgets_values")
if isinstance(values, list) and values and isinstance(values[0], str):
return values[0]
return ""
def _set_captures(self, scope: str) -> dict[str, tuple[str, int]]:
"""{variable name → resolved source} for the Set nodes in one scope."""
table = self._set_capture_cache.get(scope)
if table is not None:
return table
table = {}
# Seed before resolving: a Set feeding a same-named Get (FOR-loop
# graphs) would otherwise recurse into this builder forever. A miss
# while building is not a missing Set — never warn mid-construction.
self._set_capture_cache[scope] = table
self._set_building.add(scope)
try:
return self._build_set_captures(scope, table)
finally:
self._set_building.discard(scope)
def _build_set_captures(
self, scope: str, table: dict[str, tuple[str, int]]
) -> dict[str, tuple[str, int]]:
names: set[str] = set()
for qualified, entry in self.nodes.items():
if entry["scope"] != scope:
continue
node = entry["node"]
if str(node.get("type") or "") not in _SET_TYPES:
continue
name = self._virtual_name(node)
if not name:
continue
names.add(name)
ins = self._inputs_of(node)
link = self._link(scope, ins[0].get("link")) if ins else None
resolved = self._resolve_link(scope, link)
if resolved is not None:
table.setdefault(name, resolved)
self._set_names[scope] = names
self._set_capture_cache[scope] = table
return table
@staticmethod
def _inputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
return [i for i in node.get("inputs") or [] if isinstance(i, dict)]
@staticmethod
def _outputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
return [o for o in node.get("outputs") or [] if isinstance(o, dict)]
# ------------------------------------------------------------- resolution
def _resolve_link(
self, scope: str, link: tuple[int, int, int, int, str] | None
) -> tuple[str, int] | None:
"""Resolve one link (in scope) to an API source (qualified id, slot)."""
if link is None:
return None
if self._depth > 200: # Set/Get cycles (FOR-loop graphs) never unwind
if not self._cycle_warned:
self._cycle_warned = True
self.issues.append(
ConvertIssue(
"",
"cyclic_variable",
"变量/Set-Get 连线存在循环(常见于 FOR 循环流),该连线按未连接处理",
)
)
return None
self._depth += 1
try:
return self._resolve_link_inner(scope, link)
finally:
self._depth -= 1
def _resolve_link_inner(
self, scope: str, link: tuple[int, int, int, int, str]
) -> tuple[str, int] | None:
origin_id, origin_slot = link[0], link[1]
if origin_id == _SUBGRAPH_IN:
# Boundary: hand off to the packed instance's own input slot.
packed_q = self.scope_owner.get(scope)
if packed_q is None:
return None
node = self._node(packed_q)
if node is None:
return None
entries = self._inputs_of(node)
if origin_slot >= len(entries):
return None
entry = entries[origin_slot]
if entry.get("link") is None:
return None # unlinked boundary → inner widget value applies
outer = self._scope(packed_q)
return self._resolve_link(outer, self._link(outer, entry.get("link")))
qualified = self.scope_ids.get(scope, {}).get(str(origin_id))
if qualified is None:
return None
return self._resolve_source(qualified, origin_slot)
def _resolve_source(self, qualified: str, slot: int) -> tuple[str, int] | None:
"""What an outgoing edge from (node, output slot) really connects to."""
node = self._node(qualified)
if node is None:
return None
node_type = str(node.get("type") or "")
mode = node.get("mode") or 0
scope = self._scope(qualified)
if node_type in _UI_ONLY_TYPES:
return None
if node_type in _REROUTE_TYPES:
ins = self._inputs_of(node)
if not ins:
return None
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
if node_type in _SET_TYPES:
ins = self._inputs_of(node)
if not ins:
return None
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
if node_type in _GET_TYPES:
name = self._virtual_name(node)
captured = self._set_captures(scope).get(name)
if captured is None and scope not in self._set_building:
if name in self._set_names.get(scope, set()):
self.issues.append(
ConvertIssue(
qualified,
"variable_upstream_dropped",
f"变量「{name}」的上游被旁路或未连接,取值按未连接处理",
)
)
else:
self.issues.append(
ConvertIssue(
qualified,
"get_without_set",
f"变量节点「{name}」没有对应的 Set 节点",
)
)
return captured
sub = self._subgraph_of(qualified)
if sub is not None:
outputs = [o for o in sub.get("outputs") or [] if isinstance(o, dict)]
if slot >= len(outputs):
return None
sub_scope = f"{scope}|{node_type}" if scope else node_type
for link in self.scope_links.get(sub_scope, {}).values():
if link[2] == _SUBGRAPH_OUT and link[3] == slot:
inner_q = self.scope_ids.get(sub_scope, {}).get(str(link[0]))
return self._resolve_source(inner_q, link[1]) if inner_q else None
return None # boundary output with no inner source
if mode == MODE_MUTED:
return None
if mode == MODE_BYPASSED:
return self._bypass_through(qualified, slot)
return (qualified, slot)
def _bypass_through(self, qualified: str, slot: int) -> tuple[str, int] | None:
"""Pass-through of a bypassed node: same-name input first, then same-type."""
node = self._node(qualified)
if node is None:
return None
outputs = self._outputs_of(node)
out = outputs[slot] if slot < len(outputs) else {}
out_name = str(out.get("name") or "")
out_type = str(out.get("type") or "*")
linkable = [
e for e in self._inputs_of(node) if not e.get("widget") and e.get("link") is not None
]
pick = None
if out_name:
pick = next((e for e in linkable if str(e.get("name") or "") == out_name), None)
if pick is None:
pick = next(
(
e
for e in linkable
if out_type == "*" or str(e.get("type") or "*") in (out_type, "*")
),
None,
)
if pick is None:
return None
scope = self._scope(qualified)
return self._resolve_link(scope, self._link(scope, pick.get("link")))
# --------------------------------------------------------- widget mapping
def _widget_entries(self, node: dict[str, Any]) -> list[tuple[str, str]]:
"""[(name, type)] of entries carrying the ``widget`` marker, in order."""
out: list[tuple[str, str]] = []
for entry in self._inputs_of(node):
marker = entry.get("widget")
if isinstance(marker, dict):
name = str(marker.get("name") or entry.get("name") or "")
if name:
out.append((name, str(entry.get("type") or "")))
return out
def _widgets_from_entries(
self,
node_id: str,
entries: list[tuple[str, str]],
values: Any,
) -> tuple[dict[str, Any], list[ConvertIssue]]:
"""Map widget values using the node's serialized input list."""
inputs: dict[str, Any] = {}
issues: list[ConvertIssue] = []
if isinstance(values, dict):
for name, type_ in entries:
if name in _BUTTON_WIDGETS or self._is_upload(name, type_):
continue
if name in values:
inputs[name] = values[name]
else:
issues.append(
ConvertIssue(node_id, "widget_unmapped", f"控件「{name}」没有序列化值")
)
return inputs, issues
if not isinstance(values, list):
return inputs, issues
if not values:
return inputs, issues # nothing serialized — server defaults apply
i = 0
for name, type_ in entries:
if name in _BUTTON_WIDGETS:
continue # button widgets never serialize
if i >= len(values):
issues.append(
ConvertIssue(node_id, "widget_order_mismatch", f"控件值数量不足({name})")
)
break
value = values[i]
i += 1
if self._is_upload(name, type_):
continue # consumes a slot, never an API input
inputs[name] = value
if (
type_.upper() == "INT"
and _is_seed_like(name)
and i < len(values)
and values[i] in _CONTROL_MODES
):
i += 1 # control_after_generate rides right after its seed
leftover_ok = all(_is_button_leftover(v) for v in values[i:])
if not issues and i != len(values) and not leftover_ok:
issues.append(
ConvertIssue(
node_id,
"widget_order_mismatch",
f"有 {len(values)} 个控件值,输入清单只覆盖 {i} 个",
)
)
return inputs, issues
@staticmethod
def _is_upload(name: str, type_: str) -> bool:
return _is_upload_entry(name, type_)
def _widgets_from_registry(
self, node: dict[str, Any], values: list[Any]
) -> tuple[dict[str, Any], list[ConvertIssue]]:
node_type = str(node.get("type") or "")
if not values:
return {}, [] # nothing serialized — server defaults apply
for order in WIDGET_ORDERS.get(node_type) or []:
inputs: dict[str, Any] = {}
i = 0
for name in order:
if name in _BUTTON_WIDGETS:
continue
if i >= len(values):
break
value = values[i]
i += 1
if _is_upload_entry(name, ""):
continue
inputs[name] = value
if _is_seed_like(name) and i < len(values) and values[i] in _CONTROL_MODES:
i += 1
if i == len(values) or all(_is_button_leftover(v) for v in values[i:]):
return inputs, [] # exact fit, or trailing button leftovers
return {}, [
ConvertIssue(
node.get("id", ""),
"widget_unmapped" if not WIDGET_ORDERS.get(node_type) else "widget_order_mismatch",
f"「{node_type}」的 {len(values)} 个控件值无法可靠对应到输入名",
)
]
def _packed_widget_values(self, qualified: str) -> Any:
"""Exposed widget values of a packed instance as {boundary name: value}."""
node = self._node(qualified)
sub = self._subgraph_of(qualified)
if node is None or sub is None:
return None
boundary = [i for i in sub.get("inputs") or [] if isinstance(i, dict)]
named = node.get("widgets_values_named")
if isinstance(named, dict):
return named
values = node.get("widgets_values")
if not isinstance(values, list):
return None
out: dict[str, Any] = {}
i = 0
for entry in boundary:
name = str(entry.get("name") or "")
type_ = str(entry.get("type") or "")
if name and i < len(values):
out[name] = values[i]
i += 1
if (
type_.upper() == "INT"
and _is_seed_like(name)
and i < len(values)
and values[i] in _CONTROL_MODES
):
i += 1
return out
# -------------------------------------------------------------- serialize
def _serialize_node(self, qualified: str) -> dict[str, Any] | None:
node = self._node(qualified)
if node is None:
return None
node_type = str(node.get("type") or "")
if (
not node_type
or node_type in _UI_ONLY_TYPES
or node_type in _REROUTE_TYPES
or node_type in _SET_TYPES
or node_type in _GET_TYPES
):
return None
scope = self._scope(qualified)
inputs: dict[str, Any] = {}
widget_values = node.get("widgets_values_named")
if not isinstance(widget_values, dict):
widget_values = node.get("widgets_values")
if self._subgraph_of(qualified) is not None:
widget_values = self._packed_widget_values(qualified)
widget_inputs: dict[str, Any] = {}
entries = self._widget_entries(node)
if widget_values is not None:
if entries:
widget_inputs, w_issues = self._widgets_from_entries(
qualified, entries, widget_values
)
self.issues.extend(w_issues)
elif isinstance(widget_values, dict):
widget_inputs = {
k: v for k, v in widget_values.items() if k not in _BUTTON_WIDGETS
}
elif isinstance(widget_values, list):
widget_inputs, w_issues = self._widgets_from_registry(node, widget_values)
self.issues.extend(w_issues)
for entry in self._inputs_of(node):
name = str(entry.get("name") or "")
if not name or name in inputs:
continue
if entry.get("link") is None:
continue # unconnected optional input; widget values applied below
# Widget entries fed from the subgraph boundary (-10): when the
# packed instance left that boundary slot unlinked, the value is
# the packed node's own exposed widget value, not the inner one.
boundary = self._boundary_source(scope, entry.get("link"))
if boundary is not None:
packed_q, boundary_slot = boundary
packed_node = self._node(packed_q)
packed_entries = self._inputs_of(packed_node) if packed_node else []
if boundary_slot < len(packed_entries) and packed_entries[
boundary_slot
].get("link") is None:
values = self._packed_widget_values(packed_q)
boundary_name = str(packed_entries[boundary_slot].get("name") or "")
if isinstance(values, dict) and boundary_name in values:
inputs[name] = values[boundary_name]
continue
# linked boundary falls through to normal resolution
link = self._link(scope, entry.get("link"))
if link is None:
self.issues.append(
ConvertIssue(qualified, "dangling_link", f"输入「{name}」的连线不存在")
)
continue
resolved = self._resolve_link(scope, link)
if resolved is not None:
inputs[name] = [resolved[0], resolved[1]]
elif entry.get("widget"):
pass # muted upstream: the widget value below takes over
else:
self.issues.append(
ConvertIssue(
qualified,
"muted_input_dropped",
f"输入「{name}」上游被静音,已按未连接处理",
)
)
for name, value in widget_inputs.items():
inputs.setdefault(name, value)
api_node: dict[str, Any] = {"class_type": node_type, "inputs": inputs}
title = str(node.get("title") or "").strip()
if title:
api_node["_meta"] = {"title": title}
return api_node
# ------------------------------------------------------------------- main
def run(self) -> ConvertResult:
nodes = self.ui.get("nodes")
if not isinstance(nodes, list) or not nodes:
return ConvertResult(
None,
"fail",
[ConvertIssue("", "no_nodes", "文件不含 nodes 数组,不是 UI 格式工作流", "error")],
)
try:
self._index_scope("", self.ui.get("links") or [], None)
for node in nodes:
if isinstance(node, dict):
self._flatten(node, "", "")
except RecursionError:
return ConvertResult(
None, "fail", [ConvertIssue("", "subgraph_recursion", "子图嵌套过深", "error")]
)
# Closure from known output nodes (ComfyUI's own graphToPrompt roots).
roots = [
q
for q, entry in self.nodes.items()
if str(entry["node"].get("type") or "") in _OUTPUT_CLASSES
and (entry["node"].get("mode") or 0) == MODE_ACTIVE
]
keep: dict[str, None] = {}
stack = list(roots)
while stack:
qualified = stack.pop()
if qualified in keep:
continue
entry = self.nodes.get(qualified)
if entry is None:
continue
keep[qualified] = None
node = entry["node"]
scope = entry["scope"]
for e in self._inputs_of(node):
if e.get("link") is None:
continue
link = self._link(scope, e.get("link"))
resolved = self._resolve_link(scope, link)
if resolved is not None:
stack.append(resolved[0])
if not keep:
# No known output node: keep every active computed node and warn —
# the import lint will report the missing output separately.
for qualified, entry in self.nodes.items():
node = entry["node"]
node_type = str(node.get("type") or "")
if (
node_type in _UI_ONLY_TYPES
or node_type in _REROUTE_TYPES
or (node.get("mode") or 0) != MODE_ACTIVE
or self._subgraph_of(qualified) is not None
):
continue
keep[qualified] = None
self.issues.append(
ConvertIssue(
"",
"no_output_node",
"图中没有已知输出节点(SaveImage / 视频合成等),"
"全部活跃节点已保留,需人工确认",
)
)
workflow: dict[str, Any] = {}
for qualified in keep:
api_node = self._serialize_node(qualified)
if api_node is not None and api_node["class_type"]:
workflow[qualified] = api_node
if not workflow:
return ConvertResult(
None,
"fail",
self.issues or [ConvertIssue("", "empty", "转换后没有任何节点", "error")],
)
status = (
"fail"
if any(i.level == "error" for i in self.issues)
else ("ok" if not self.issues else "warn")
)
return ConvertResult(workflow, status, self.issues)
def convert_ui_workflow(ui: dict[str, Any]) -> ConvertResult:
"""Convert one ComfyUI UI-format workflow dict into API format."""
if not isinstance(ui, dict) or not isinstance(ui.get("nodes"), list):
return ConvertResult(
None,
"fail",
[ConvertIssue("", "not_ui_format", "不是 UI 格式(缺少 nodes 数组)", "error")],
)
return _Converter(ui).run()
File diff suppressed because it is too large Load Diff
+21
View File
@@ -506,6 +506,27 @@ async def migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
if "output_map_source" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
# HEL-681 (S2): library metadata for 150+ scale workflow browsing. Purely
# additive columns — old rows stay NULL/0 and every legacy path ignores
# them; the lightweight list view (?view=meta) is their only reader.
if "category" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN category TEXT")
if "model_family" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN model_family TEXT")
if "media_io" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN media_io TEXT")
if "tags" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN tags TEXT")
if "favorite" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0")
if "last_used_at" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN last_used_at TIMESTAMP")
if "source" not in cols:
# where the row came from: 'ui_convert' (HEL-681 batch import) vs
# NULL = manual/API import as before.
conn.execute("ALTER TABLE workflows ADD COLUMN source TEXT")
if "source_path" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN source_path TEXT")
# Idempotent backfill: pending rows are re-judged on every boot (parser
# upgrades can settle them); confirmed rows are never touched.
_backfill_workflow_usage(conn)
@@ -243,6 +243,55 @@ class WorkflowUpdate(BaseModel):
# resets the workflow to pending confirmation.
output_map: list[dict[str, Any]] | None = None
output_map_source: Literal["auto", "user"] | None = None
# HEL-681 (S2): library metadata fields.
favorite: bool | None = None
tags: list[str] | None = None
category: str | None = None
model_family: str | None = None
class WorkflowUIFileItem(BaseModel):
"""One UI-format file for the HEL-681 convert/import pipeline."""
name: str | None = None
# repo-relative path (category/model-family evidence); display name
# defaults to the file stem.
source_path: str | None = None
workflow_json: dict[str, Any]
# optional human confirmations from the import wizard (S3): a settled
# usage and a corrected schema let warn files import enabled.
usage: WorkflowUsageKind | None = None
input_schema: list[dict[str, Any]] | None = None
output_map: list[dict[str, Any]] | None = None
description: str | None = None
tags: list[str] | None = None
class WorkflowConvertRequest(BaseModel):
"""Dry-run: per-file ok/warn/fail + suggested metadata, nothing stored."""
files: list[WorkflowUIFileItem]
# Include the converted API JSON per file (default on for small batches;
# the 146-file report leaves it off to keep the payload sane).
include_workflows: bool = False
# L2 preflight against a real ComfyUI (opt-in; needs a connection).
endpoint_id: str | None = None
refresh: bool = False
class WorkflowImportBatchRequest(BaseModel):
"""Commit: convert UI files and insert, skipping conflicts. Idempotent."""
files: list[WorkflowUIFileItem]
class WorkflowImportScanRequest(BaseModel):
"""Scan a server-side directory of UI JSONs (repo example library)."""
root: str = "example_ComfyUI_workflows"
# Set to preview what would import (same shape as /convert) — the safe
# default for a first run against the 146-file library.
dry_run: bool = True
class WorkflowAnalyze(BaseModel):
@@ -49,6 +49,12 @@ class QueueManager:
""",
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
)
# HEL-681: every enqueued job counts as a use (最近使用).
if workflow_id is not None:
conn.execute(
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(workflow_id,),
)
conn.commit()
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
return row_to_dict(row)
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.metadata import suggested_metadata
from calliope.comfyui.output_map import (
auto_output_map,
load_output_map,
@@ -23,6 +24,7 @@ from calliope.comfyui.parser import (
from calliope.comfyui.preflight import check_workflow_against
from calliope.comfyui.profiles import detect_prompt_profile
from calliope.comfyui.roles import usage_to_kind
from calliope.comfyui.ui_convert import convert_ui_workflow
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from calliope.db import get_db, row_to_dict
@@ -30,8 +32,12 @@ from calliope.events.bus import event_bus
from calliope.models.schemas import (
WorkflowAnalyze,
WorkflowCheckRequest,
WorkflowConvertRequest,
WorkflowCreate,
WorkflowImportBatchRequest,
WorkflowImportScanRequest,
WorkflowTestRequest,
WorkflowUIFileItem,
WorkflowUpdate,
)
from calliope.queue.manager import queue_manager
@@ -44,6 +50,14 @@ from calliope.queue.workflow_test import (
router = APIRouter()
# HEL-681: metadata columns the lightweight list view exposes (everything
# except the heavy workflow_json / input_schema / output_map payloads).
_META_COLUMNS = (
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
" favorite, last_used_at, description, source, source_path, prompt_profile,"
" created_at, endpoint_id, usage_source, output_map_source"
)
def _serialize_workflow(row: Any) -> dict[str, Any]:
data = row_to_dict(row)
@@ -61,9 +75,21 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
data["needs_mapping"] = (
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
)
# HEL-681: structured library metadata (raw JSON columns → values).
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["tags"] = _load_json_col(data.get("tags"), [])
data["favorite"] = bool(data.get("favorite"))
return data
def _load_json_col(raw: Any, default: Any) -> Any:
try:
value = json.loads(raw) if raw else None
except (json.JSONDecodeError, TypeError):
value = None
return value if value is not None else default
async def _connection_check(
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
) -> dict[str, Any]:
@@ -216,9 +242,31 @@ def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
@router.get("")
async def list_workflows() -> list[dict[str, Any]]:
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
if view == "meta":
# HEL-681: metadata-only list for 150+ scale browsing — no
# workflow_json, no per-row re-parse. `needs_mapping` here is the
# cheap pending check (usage/output_map not yet settled); rows
# that never generated keep showing as pending exactly like the
# full view reports them.
rows = conn.execute(
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
" last_used_at DESC, id DESC"
).fetchall()
out = []
for row in rows:
data = row_to_dict(row)
data["is_enabled"] = bool(data.get("is_enabled"))
data["favorite"] = bool(data.get("favorite"))
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
data["tags"] = _load_json_col(data.get("tags"), [])
data["needs_mapping"] = data.get("usage") is None or data.get(
"output_map_source"
) is None
out.append(data)
return out
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
out = [_serialize_workflow(r) for r in rows]
# 上次测试 state is read live from the newest test job per workflow —
@@ -395,6 +443,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
data = payload.model_dump(exclude_unset=True)
if "is_enabled" in data and data["is_enabled"] is not None:
data["is_enabled"] = 1 if data["is_enabled"] else 0
# HEL-681: library metadata patch fields (favorite/tags/category/
# model_family) — booleans to ints, lists to JSON text.
if "favorite" in data and data["favorite"] is not None:
data["favorite"] = 1 if data["favorite"] else 0
if data.get("tags") is not None:
data["tags"] = json.dumps(data["tags"])
workflow_json = json.loads(existing["workflow_json"])
# HEL-638: output_map payload. A non-empty list validates against the
# stored JSON and becomes the user decision; an explicit empty list
@@ -600,3 +654,280 @@ async def delete_workflow(workflow_id: int) -> dict[str, bool]:
return {"ok": True}
finally:
conn.close()
# ---------------------------------------------------------------------------
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
# The convert endpoint never writes; import-batch converts again server-side
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
# — repeated runs import nothing new, so the batch is idempotent by design.
# ---------------------------------------------------------------------------
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""(report, converted workflow) for one UI file — shared by every
convert/import path so the analysis can never drift between them."""
result = convert_ui_workflow(item.workflow_json)
stem = (
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
if item.source_path
else None
)
name = item.name or stem or "未命名工作流"
report: dict[str, Any] = {
"name": name,
"source_path": item.source_path,
"status": result.status,
"needs_confirmation": result.needs_confirmation,
"issues": [
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
for i in result.issues
],
}
if result.workflow is None:
report["importable"] = False
report["skip_reason"] = "转换失败:" + (
result.issues[0].message if result.issues else "未知错误"
)
return report, None
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
report["suggested"] = suggested
# The import gate re-runs the product's own checks — a converted graph
# that the lint would block (missing model link etc.) is reported, not
# silently imported.
broken = blocking_problem(result.workflow)
if broken:
report["importable"] = False
report["skip_reason"] = broken
report["status"] = "warn"
return report, result.workflow
if result.needs_confirmation:
report["importable"] = False
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
return report, result.workflow
report["importable"] = True
return report, result.workflow
@router.post("/convert")
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
issues, suggested metadata and importability. Nothing is stored."""
files = []
object_info: dict[str, Any] | None = None
client = None
if payload.endpoint_id:
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
if endpoint is not None:
client = client_for(endpoint)
try:
object_info = await client.object_info(force_refresh=payload.refresh)
except Exception:
object_info = None
try:
for item in payload.files:
report, converted = _convert_one(item)
if object_info is not None and report.get("importable") and converted:
findings = check_workflow_against(object_info, converted)
errors = [f for f in findings if f.get("level") == "error"]
report["preflight"] = {
"endpoint_id": payload.endpoint_id,
"ok": not errors,
"findings": findings,
}
if errors:
report["importable"] = False
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
if payload.include_workflows:
report["workflow_json"] = converted
files.append(report)
finally:
if client is not None:
await client.close()
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
return {
"total": len(files),
**counts,
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
"importable": sum(1 for f in files if f.get("importable")),
"files": files,
}
def _insert_converted(
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
) -> dict[str, Any]:
"""Insert one converted workflow row (import-batch's write path).
Mirrors create_workflow's enable gate: usage must be settled (auto or the
wizard's confirmation) and unknown outputs need a mapping; anything less
lands disabled exactly like a manual import would.
"""
suggested = report["suggested"]
judgment = classify_usage(converted)
usage = item.usage or judgment["usage"]
usage_source = "user" if item.usage else "auto"
kind = usage_to_kind(usage) or suggested["kind"]
profile = detect_prompt_profile(converted)
if item.output_map:
problems = validate_output_map(converted, item.output_map)
if problems:
raise HTTPException(status_code=422, detail=";".join(problems))
output_map = json.dumps(item.output_map)
output_map_source = "user"
else:
auto_map = auto_output_map(converted)
output_map = json.dumps(auto_map) if auto_map else None
output_map_source = "auto" if auto_map else None
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
workflow_text = json.dumps(converted)
existing = conn.execute(
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
(report["name"], kind, workflow_text),
).fetchone()
if existing:
return {
"result": "skipped",
"skip_reason": "conflict",
"existing_id": existing["id"],
}
is_enabled = 1 if (usage and mapping_ok) else 0
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
description, prompt_profile, endpoint_id, is_enabled,
usage, usage_source, output_map, output_map_source,
category, model_family, media_io, tags, favorite,
source, source_path)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
""",
(
report["name"],
kind,
workflow_text,
json.dumps(
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
),
json.dumps(parse_dynamic_outputs(converted)),
item.description if item.description is not None else suggested["description"],
profile,
is_enabled,
usage,
usage_source,
output_map,
output_map_source,
suggested["category"],
suggested["model_family"],
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
json.dumps(item.tags or []),
item.source_path,
),
)
return {"result": "imported", "id": cur.lastrowid}
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
reports: list[dict[str, Any]] = []
conn = get_db(settings.db_path)
try:
for item in items:
report, converted = _convert_one(item)
if not report.get("importable"):
report["result"] = "skipped"
reports.append(report)
continue
try:
outcome = _insert_converted(conn, item, report, converted)
except HTTPException as exc:
report["result"] = "failed"
report["skip_reason"] = str(exc.detail)
reports.append(report)
continue
conn.commit()
report.update(outcome)
reports.append(report)
finally:
conn.close()
imported = [r for r in reports if r.get("result") == "imported"]
skipped = [
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
for r in reports
if r.get("result") != "imported"
]
return {
"total": len(reports),
"imported": len(imported),
"skipped": len(skipped),
"imported_ids": [r["id"] for r in imported],
"skipped_files": skipped,
"files": reports,
}
@router.post("/import-batch")
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
"""Batch-import UI-format workflows: convert server-side, insert what is
cleanly convertible, skip conflicts and anything uncertain. Re-running
with the same files creates zero new rows."""
return await _run_import(payload.files)
@router.post("/import-scan")
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
"""Scan a server-side directory for UI-format workflow JSONs and run the
convert (dry_run, default) or import pipeline over every file found."""
from pathlib import Path
root = Path(payload.root)
if not root.is_absolute():
candidates = [
Path.cwd() / root,
Path(__file__).resolve().parents[4] / root,
]
root = next((c for c in candidates if c.is_dir()), root)
if not root.is_dir():
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
items: list[WorkflowUIFileItem] = []
for path in sorted(root.rglob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
continue # API-format or non-workflow files: not this pipeline's job
items.append(
WorkflowUIFileItem(
source_path=str(path.relative_to(root)), workflow_json=data
)
)
if payload.dry_run:
files = [_convert_one(item)[0] for item in items]
counts = {
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
}
return {
"total": len(files),
**counts,
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
"importable": sum(1 for f in files if f.get("importable")),
"files": files,
}
return await _run_import(items)
@router.post("/{workflow_id}/touch")
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
conn = get_db(settings.db_path)
try:
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
conn.execute(
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(workflow_id,),
)
conn.commit()
return {"ok": True}
finally:
conn.close()
+449
View File
@@ -0,0 +1,449 @@
"""HEL-681: UI→API converter unit tests + ground-truth cross-checks.
The synthetic graphs pin the structural semantics (reroute / bypass / mute /
subgraph expansion / Set-Get variables / control_after_generate pairing);
the corpus section runs the real 146-file library and the repo's API exports.
"""
from __future__ import annotations
import copy
import json
from pathlib import Path
import pytest
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.ui_convert import convert_ui_workflow
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _node(nid, ntype, *, mode=0, inputs=None, outputs=None, wv=None, wvn=None, title=None):
node = {"id": nid, "type": ntype, "mode": mode, "inputs": inputs or [], "order": 0}
if outputs is not None:
node["outputs"] = outputs
if wv is not None:
node["widgets_values"] = wv
if wvn is not None:
node["widgets_values_named"] = wvn
if title:
node["title"] = title
return node
def _in_slot(name, type_, link=None, widget=None):
entry = {"name": name, "type": type_, "link": link}
if widget:
entry["widget"] = {"name": widget}
return entry
def _out(name, type_, links=None):
return {"name": name, "type": type_, "links": links or [], "slot_index": 0}
def _base_graph():
"""text2image: loader → prompts/latent → KSampler → VAE → SaveImage."""
nodes = [
_node(
1,
"CheckpointLoaderSimple",
outputs=[_out("MODEL", "MODEL"), _out("CLIP", "CLIP"), _out("VAE", "VAE")],
wv=["model.safetensors"],
inputs=[_in_slot("ckpt_name", "COMBO", widget="ckpt_name")],
),
_node(
2,
"CLIPTextEncode",
inputs=[
_in_slot("clip", "CLIP", link=1),
_in_slot("text", "STRING", widget="text"),
],
outputs=[_out("CONDITIONING", "CONDITIONING")],
wv=["a cat"],
),
_node(
3,
"CLIPTextEncode",
inputs=[
_in_slot("clip", "CLIP", link=2),
_in_slot("text", "STRING", widget="text"),
],
outputs=[_out("CONDITIONING", "CONDITIONING")],
wv=["bad quality"],
),
_node(
4,
"EmptyLatentImage",
inputs=[
_in_slot("width", "INT", widget="width"),
_in_slot("height", "INT", widget="height"),
_in_slot("batch_size", "INT", widget="batch_size"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[512, 512, 1],
),
_node(
5,
"KSampler",
inputs=[
_in_slot("model", "MODEL", link=3),
_in_slot("positive", "CONDITIONING", link=4),
_in_slot("negative", "CONDITIONING", link=5),
_in_slot("latent_image", "LATENT", link=6),
_in_slot("seed", "INT", widget="seed"),
_in_slot("steps", "INT", widget="steps"),
_in_slot("cfg", "FLOAT", widget="cfg"),
_in_slot("sampler_name", "COMBO", widget="sampler_name"),
_in_slot("scheduler", "COMBO", widget="scheduler"),
_in_slot("denoise", "FLOAT", widget="denoise"),
],
outputs=[_out("LATENT", "LATENT")],
# seed 42 + control "fixed" rides together, then five widgets
wv=[42, "fixed", 20, 7, "euler", "normal", 1.0],
),
_node(
6,
"VAEDecode",
inputs=[
_in_slot("samples", "LATENT", link=7),
_in_slot("vae", "VAE", link=8),
],
outputs=[_out("IMAGE", "IMAGE")],
),
_node(
7,
"SaveImage",
inputs=[
_in_slot("images", "IMAGE", link=9),
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
],
wv=["test"],
),
]
links = [
[1, 1, 1, 2, 0, "CLIP"],
[2, 1, 1, 3, 0, "CLIP"],
[3, 1, 0, 5, 0, "MODEL"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
]
return {"nodes": nodes, "links": links, "last_node_id": 7, "last_link_id": 9}
def _convert(graph):
return convert_ui_workflow(graph)
def test_clean_conversion_maps_everything():
result = _convert(_base_graph())
assert result.status == "ok"
assert result.needs_confirmation is False
wf = result.workflow
assert set(wf) == {"1", "2", "3", "4", "5", "6", "7"}
ks = wf["5"]
assert ks["class_type"] == "KSampler"
assert ks["inputs"]["seed"] == 42 # control_after_generate consumed silently
assert ks["inputs"]["steps"] == 20
assert ks["inputs"]["denoise"] == 1.0
assert ks["inputs"]["model"] == ["1", 0]
assert ks["inputs"]["latent_image"] == ["4", 0]
# lint on the converted graph must be error-free
assert not [w for w in workflow_warnings(wf) if w["level"] == "error"]
def test_upload_button_widgets_are_consumed_but_never_emitted():
graph = _base_graph()
load = _node(
8,
"LoadImage",
inputs=[
_in_slot("image", "COMBO", widget="image"),
_in_slot("upload", "IMAGEUPLOAD", widget="upload"),
],
outputs=[_out("IMAGE", "IMAGE")],
wv=["sample.png", "image"],
)
save = _node(
9,
"PreviewImage",
inputs=[
_in_slot("images", "IMAGE", link=20),
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
],
wv=["preview"],
)
graph["nodes"] += [load, save]
graph["links"].append([20, 8, 0, 9, 0, "IMAGE"])
result = _convert(graph)
assert result.workflow["8"]["inputs"] == {"image": "sample.png"}
def test_reroute_pass_through():
graph = _base_graph()
reroute = _node(8, "Reroute", inputs=[_in_slot("(in)", "*", link=9)])
graph["nodes"].append(reroute)
# SaveImage now fed by the reroute instead of VAEDecode directly
graph["nodes"][6]["inputs"][0]["link"] = 10
graph["links"].append([10, 8, 0, 7, 0, "IMAGE"])
result = _convert(graph)
assert result.workflow["7"]["inputs"]["images"] == ["6", 0]
assert "8" not in result.workflow
def test_bypass_pass_through_and_mute_drop():
graph = _base_graph()
# bypass the KSampler: VAEDecode.samples must fall back to the latent
graph["nodes"][4]["mode"] = 4
result = _convert(graph)
assert result.workflow["6"]["inputs"]["samples"] == ["4", 0]
# mute the negative prompt: the link input is dropped with a warning
graph = _base_graph()
graph["nodes"][2]["mode"] = 2
result = _convert(graph)
assert "negative" not in result.workflow["5"]["inputs"]
assert any(i.code == "muted_input_dropped" for i in result.issues)
def test_widget_entry_with_muted_upstream_falls_back_to_value():
graph = _base_graph()
# feed seed from a muted PrimitiveInt via a converted widget input
prim = _node(
9,
"PrimitiveInt",
mode=2,
inputs=[_in_slot("value", "INT", widget="value"), _in_slot("control", "COMBO", widget="control")],
wv=[7],
)
graph["nodes"].append(prim)
graph["nodes"][4]["inputs"][4]["link"] = 11 # seed ← primitive
graph["links"].append([11, 9, 0, 5, 4, "INT"])
result = _convert(graph)
assert result.workflow["5"]["inputs"]["seed"] == 42
def test_subgraph_expansion_and_boundary_wiring():
graph = _base_graph()
# pack the EmptyLatentImage into a subgraph exposing width/height widgets
del graph["nodes"][3]
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != 4]
sub_id = "aaaa-bbbb-cccc-dddd-eeee"
packed = _node(
20,
sub_id,
inputs=[
_in_slot("width", "INT", widget="width"),
_in_slot("height", "INT", widget="height"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[640, 384],
)
graph["nodes"].append(packed)
# KSampler latent_image ← packed output
graph["links"] = [l for l in graph["links"] if l[0] != 6]
graph["links"].append([6, 20, 0, 5, 3, "LATENT"])
graph["definitions"] = {
"subgraphs": [
{
"id": sub_id,
"name": "Latent",
"inputs": [
{"name": "width", "type": "INT"},
{"name": "height", "type": "INT"},
],
"outputs": [{"name": "LATENT", "type": "LATENT"}],
"inputNode": {"id": -10},
"outputNode": {"id": -20},
"nodes": [
_node(
5,
"EmptyLatentImage",
inputs=[
_in_slot("width", "INT", widget="width", link=101),
_in_slot("height", "INT", widget="height", link=102),
_in_slot("batch_size", "INT", widget="batch_size"),
],
outputs=[_out("LATENT", "LATENT")],
wv=[512, 512, 1],
),
],
"links": [
{"id": 101, "origin_id": -10, "origin_slot": 0, "target_id": 5, "target_slot": 0, "type": "INT"},
{"id": 102, "origin_id": -10, "origin_slot": 1, "target_id": 5, "target_slot": 1, "type": "INT"},
{"id": 103, "origin_id": 5, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "LATENT"},
],
}
]
}
result = _convert(graph)
wf = result.workflow
assert "20:5" in wf # qualified packed:inner id, ComfyUI's own scheme
inner = wf["20:5"]
assert inner["class_type"] == "EmptyLatentImage"
# exposed widget values from the packed instance, not the inner defaults
assert inner["inputs"]["width"] == 640
assert inner["inputs"]["height"] == 384
assert inner["inputs"]["batch_size"] == 1
assert wf["5"]["inputs"]["latent_image"] == ["20:5", 0]
# now wire the packed width slot from outside: boundary becomes a link
res_node = _node(
21,
"ResolutionSelector",
inputs=[_in_slot("aspect_ratio", "COMBO", widget="aspect_ratio")],
outputs=[_out("width", "INT"), _out("height", "INT")],
wv=["1:1"],
)
graph["nodes"].append(res_node)
graph["nodes"][-2]["inputs"][0]["link"] = 12 # packed width ← selector
graph["links"].append([12, 21, 0, 20, 0, "INT"])
wf2 = _convert(graph).workflow
assert wf2["20:5"]["inputs"]["width"] == ["21", 0]
assert wf2["20:5"]["inputs"]["height"] == 384
def test_set_get_variables_resolve_to_captured_source():
graph = _base_graph()
setter = _node(8, "SetNode", inputs=[_in_slot("*", "*", link=8)], wv=["the_vae"])
getter = _node(9, "GetNode", wv=["the_vae"], outputs=[_out("VAE", "VAE")])
graph["nodes"] += [setter, getter]
# VAEDecode.vae now fed via the variable
graph["nodes"][5]["inputs"][1]["link"] = 13
graph["links"] = [l for l in graph["links"] if l[0] != 8]
graph["links"] += [
[8, 1, 2, 8, 0, "VAE"],
[13, 9, 0, 6, 1, "VAE"],
]
result = _convert(graph)
assert "8" not in result.workflow and "9" not in result.workflow
assert result.workflow["6"]["inputs"]["vae"] == ["1", 2]
def test_unknown_widget_order_lands_in_needs_confirmation():
graph = _base_graph()
# an old-frontend node: positional widgets, no widget markers, no registry
graph["nodes"].append(
_node(10, "MysteryCustomNode", inputs=[], outputs=[_out("IMAGE", "IMAGE")], wv=[1, 2, 3])
)
save = _node(
11,
"PreviewImage",
inputs=[_in_slot("images", "IMAGE", link=30)],
wv=["preview"],
)
graph["nodes"].append(save)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
result = _convert(graph)
assert result.needs_confirmation is True
assert any(i.code == "widget_unmapped" for i in result.issues)
# the node is still converted structurally — the report carries the doubt
assert "10" in result.workflow
def test_not_ui_format_fails_fast():
result = convert_ui_workflow({"3": {"class_type": "SaveImage", "inputs": {}}})
assert result.status == "fail"
assert result.workflow is None
# ---------------------------------------------------------------------------
# Real corpus: the 146 UI files + ground-truth API cross-checks (对拍).
# ---------------------------------------------------------------------------
pytestmark_convert = pytest.mark.skipif(
not EXAMPLES.is_dir(), reason="example_ComfyUI_workflows not checked out"
)
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_corpus_146_files_all_convert():
ui_files = [
p
for p in sorted(EXAMPLES.rglob("*.json"))
if isinstance(json.loads(p.read_text(encoding="utf-8")), dict)
and isinstance(json.loads(p.read_text(encoding="utf-8")).get("nodes"), list)
]
assert len(ui_files) == 146
statuses = {"ok": 0, "warn": 0, "fail": 0}
needs_confirmation = 0
for path in ui_files:
data = json.loads(path.read_text(encoding="utf-8"))
result = convert_ui_workflow(data)
statuses[result.status] += 1
needs_confirmation += result.needs_confirmation
assert result.workflow, f"{path.name} produced no workflow"
# structural sanity: no dangling links in any conversion
nodes = result.workflow
for nid, node in nodes.items():
for name, val in node.get("inputs", {}).items():
if isinstance(val, list):
assert str(val[0]) in nodes, f"{path.name}: {nid}.{name} dangles"
assert statuses["fail"] == 0
assert statuses["ok"] + statuses["warn"] == 146
# files whose widget order cannot be trusted are flagged, never hidden
assert 0 < needs_confirmation < 10
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_cross_check_against_ground_truth_exports():
"""The H3 t2v UI export converts to the same node classes the author's
own API export carries; shared classes agree on scalar input names."""
ui = json.loads(
(EXAMPLES / "4-视频生成/Minimax-h3/minimax_h3_t2v_文生视频.json").read_text(encoding="utf-8")
)
api = json.loads(
(EXAMPLES / "minimax_h3-Turbo_Text2Video_20260813_API.json").read_text(encoding="utf-8")
)
result = convert_ui_workflow(ui)
assert result.status in ("ok", "warn")
converted = result.workflow
api_classes = {n["class_type"] for n in api.values()}
# Every scalar name the conversion emits for a shared class must be an
# input name the author's own export knows — wiring may differ between
# versions (a muted branch falls back to its widget value), but the NAME
# mapping must never invent or swap inputs.
for cls in api_classes & {n["class_type"] for n in converted.values()}:
api_names = {
key
for n in api.values()
if n["class_type"] == cls
for key in n.get("inputs", {})
}
conv_names = {
key
for n in converted.values()
if n["class_type"] == cls
for key, v in n.get("inputs", {}).items()
if not isinstance(v, list)
}
assert conv_names <= api_names, f"class {cls} invents inputs: {conv_names - api_names}"
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_packed_krea2_conversion_shape_matches_export_scheme():
"""The krea2 turbo file (packed subgraph) converts ok, expands inner nodes
under `<packed>:<inner>` ids and maps the sampler widgets exactly — the
same scheme the author's own Krea2 API export uses (ids like "171:153")."""
ui = json.loads(
(EXAMPLES / "3-图像生成/krea2/krea2_turbo_文生图.json").read_text(encoding="utf-8")
)
api = json.loads(
(EXAMPLES / "Krea2_t2i_20260818_API.json").read_text(encoding="utf-8")
)
result = convert_ui_workflow(ui)
assert result.status == "ok"
assert any(":" in nid for nid in result.workflow) # expanded subgraph nodes
assert any("171:" in nid for nid in api) # ground truth uses the same scheme
ks = [
n
for n in result.workflow.values()
if n["class_type"] == "KSampler" and not isinstance(n["inputs"].get("seed"), list)
]
assert ks, "inner KSampler with widget-mapped seed expected"
assert {"seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"} <= set(
ks[0]["inputs"]
)
@@ -0,0 +1,421 @@
"""HEL-681 (S2): batch import endpoints, meta list view, migration safety.
Everything runs on a temp DB via the shared client fixture — the real
146-file library is never touched, matching the card's「不导入生产数据」boundary.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from calliope.db import migrate_db
import pytest
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _ui_graph():
"""Minimal clean t2i UI graph (same shape as test_ui_convert's)."""
return {
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [{"name": "ckpt_name", "type": "COMBO", "widget": {"name": "ckpt_name"}, "link": None}],
"outputs": [
{"name": "MODEL", "type": "MODEL", "links": [3], "slot_index": 0},
{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 1},
{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 2},
],
"widgets_values": ["qwen_image_2.safetensors"],
},
{
"id": 2,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 1},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [4], "slot_index": 0}],
"widgets_values": ["a cat"],
},
{
"id": 3,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [
{"name": "clip", "type": "CLIP", "link": 2},
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
],
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}],
"widgets_values": ["bad"],
},
{
"id": 4,
"type": "EmptyLatentImage",
"mode": 0,
"inputs": [
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": None},
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": None},
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": None},
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [6], "slot_index": 0}],
"widgets_values": [512, 512, 1],
},
{
"id": 5,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "type": "MODEL", "link": 3},
{"name": "positive", "type": "CONDITIONING", "link": 4},
{"name": "negative", "type": "CONDITIONING", "link": 5},
{"name": "latent_image", "type": "LATENT", "link": 6},
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": None},
{"name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": None},
{"name": "cfg", "type": "FLOAT", "widget": {"name": "cfg"}, "link": None},
{"name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": None},
{"name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": None},
{"name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": None},
],
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}],
"widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1.0],
},
{
"id": 6,
"type": "VAEDecode",
"mode": 0,
"inputs": [
{"name": "samples", "type": "LATENT", "link": 7},
{"name": "vae", "type": "VAE", "link": 8},
],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0}],
},
{
"id": 7,
"type": "SaveImage",
"mode": 0,
"inputs": [
{"name": "images", "type": "IMAGE", "link": 9},
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": None},
],
"widgets_values": ["test"],
},
],
"links": [
[1, 1, 1, 2, 0, "CLIP"],
[2, 1, 1, 3, 0, "CLIP"],
[3, 1, 0, 5, 0, "MODEL"],
[4, 2, 0, 5, 1, "CONDITIONING"],
[5, 3, 0, 5, 2, "CONDITIONING"],
[6, 4, 0, 5, 3, "LATENT"],
[7, 5, 0, 6, 0, "LATENT"],
[8, 1, 2, 6, 1, "VAE"],
[9, 6, 0, 7, 0, "IMAGE"],
],
}
def _api_workflow():
return json.loads(
(EXAMPLES / "video_minimax_h3_r2v_2ref_API.json").read_text(encoding="utf-8")
)
def test_convert_dry_run_reports_and_stores_nothing(client):
payload = {
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
}
],
"include_workflows": True,
}
resp = client.post("/api/workflows/convert", json=payload)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1 and body["ok"] == 1 and body["importable"] == 1
entry = body["files"][0]
assert entry["name"] == "test_t2i"
assert entry["workflow_json"]["5"]["inputs"]["seed"] == 42
assert entry["suggested"]["usage"] == "text2image"
assert entry["suggested"]["category"] == "image"
assert entry["suggested"]["model_family"] == "Qwen"
assert entry["suggested"]["media_out"] == ["image"]
# dry-run must not write anything
assert client.get("/api/workflows").json() == []
def test_convert_needs_confirmation_is_flagged_not_runnable(client):
graph = _ui_graph()
graph["nodes"].append(
{
"id": 10,
"type": "MysteryCustomNode",
"mode": 0,
"inputs": [],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
"widgets_values": [1, 2, 3],
}
)
graph["nodes"].append(
{
"id": 11,
"type": "PreviewImage",
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
"widgets_values": ["p"],
}
)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
resp = client.post(
"/api/workflows/convert",
json={"files": [{"source_path": "x/mystery.json", "workflow_json": graph}]},
)
body = resp.json()
assert body["needs_confirmation"] == 1
assert body["importable"] == 0
assert "需人工确认" in body["files"][0]["skip_reason"]
def test_import_batch_then_rerun_is_idempotent(client):
files = [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
}
]
first = client.post("/api/workflows/import-batch", json={"files": files})
assert first.status_code == 200
body = first.json()
assert body["imported"] == 1 and body["skipped"] == 0
row_id = body["imported_ids"][0]
# second run with identical file: conflict skip, no duplicates
second = client.post("/api/workflows/import-batch", json={"files": files})
body2 = second.json()
assert body2["imported"] == 0 and body2["skipped"] == 1
assert body2["skipped_files"][0]["reason"] == "conflict"
listing = client.get("/api/workflows").json()
assert len(listing) == 1
detail = client.get(f"/api/workflows/{row_id}").json()
assert detail["usage"] == "text2image"
assert detail["is_enabled"] is True
assert detail["category"] == "image"
assert detail["model_family"] == "Qwen"
assert detail["media_io"] == {"in": [], "out": ["image"]}
assert detail["source"] == "ui_convert"
assert detail["source_path"] == "3-图像生成/Qwen-Image-2.1/test_t2i.json"
def test_import_batch_skips_needs_confirmation_files(client):
graph = _ui_graph()
graph["nodes"].append(
{
"id": 10,
"type": "MysteryCustomNode",
"mode": 0,
"inputs": [],
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
"widgets_values": [1, 2, 3],
}
)
graph["nodes"].append(
{
"id": 11,
"type": "PreviewImage",
"mode": 0,
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
"widgets_values": ["p"],
}
)
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
resp = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "mystery", "workflow_json": graph}]},
)
body = resp.json()
assert body["imported"] == 0
assert "需人工确认" in body["skipped_files"][0]["reason"]
assert client.get("/api/workflows").json() == []
def test_warn_sample_imports_with_human_confirmation(client):
"""A warn file (muted branch → input dropped) still imports when the
wizard supplies the settled usage — the manual-correction path."""
graph = _ui_graph()
graph["nodes"][2]["mode"] = 2 # mute the negative prompt branch
dry = client.post(
"/api/workflows/convert",
json={"files": [{"name": "warned", "workflow_json": graph}]},
).json()
assert dry["warn"] == 1 and dry["importable"] == 1
resp = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "warned", "workflow_json": graph, "usage": "text2image"}]},
)
body = resp.json()
assert body["imported"] == 1
detail = client.get(f"/api/workflows/{body['imported_ids'][0]}").json()
assert detail["is_enabled"] is True
assert "negative" not in detail["workflow_json"]["5"]["inputs"]
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
def test_import_scan_dry_run_over_repo_library(client):
resp = client.post(
"/api/workflows/import-scan",
json={"root": str(EXAMPLES), "dry_run": True},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 146
assert body["fail"] == 0
assert body["ok"] + body["warn"] == 146
assert body["needs_confirmation"] > 0 # the 3 uncensored files stay honest
assert body["importable"] < body["total"]
# a dry scan must leave the DB untouched
assert client.get("/api/workflows").json() == []
def test_import_scan_commit_from_tmp_directory(client, tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "demo.json").write_text(json.dumps(_ui_graph()), encoding="utf-8")
(tmp_path / "plain_api.json").write_text(json.dumps(_api_workflow()), encoding="utf-8")
(tmp_path / "not_a_workflow.json").write_text(json.dumps({"hello": 1}), encoding="utf-8")
resp = client.post(
"/api/workflows/import-scan",
json={"root": str(tmp_path), "dry_run": False},
)
body = resp.json()
# only the one UI file went through this pipeline; API-format and junk skipped
assert body["imported"] == 1
listing = client.get("/api/workflows").json()
assert len(listing) == 1
assert listing[0]["source_path"] == "sub/demo.json"
# rerun: idempotent
again = client.post(
"/api/workflows/import-scan",
json={"root": str(tmp_path), "dry_run": False},
).json()
assert again["imported"] == 0
assert len(client.get("/api/workflows").json()) == 1
def test_meta_view_has_metadata_but_no_full_json(client):
client.post(
"/api/workflows/import-batch",
json={
"files": [
{
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
"workflow_json": _ui_graph(),
"tags": ["极速"],
}
]
},
)
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert len(meta) == 1
row = meta[0]
assert "workflow_json" not in row
assert "input_schema" not in row
assert "output_schema" not in row
assert row["category"] == "image"
assert row["model_family"] == "Qwen"
assert row["media_io"] == {"in": [], "out": ["image"]}
assert row["tags"] == ["极速"]
assert row["favorite"] is False
assert row["needs_mapping"] is False
def test_legacy_list_shape_and_old_records_unchanged(client):
"""Old imports (API format via POST /api/workflows) keep working; the
legacy list still carries workflow_json and the new columns default."""
created = client.post(
"/api/workflows",
json={"name": "旧导入", "workflow_json": _api_workflow()},
)
assert created.status_code == 200
legacy = client.get("/api/workflows").json()
assert len(legacy) == 1
row = legacy[0]
assert isinstance(row["workflow_json"], dict)
assert "usage" in row and "output_map" in row
assert row["favorite"] is False
assert row["tags"] == []
assert row["media_io"] == {"in": [], "out": []}
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
assert meta[0]["source"] is None
def test_favorite_tags_patch_and_touch(client):
wid = client.post(
"/api/workflows/import-batch",
json={"files": [{"name": "fav", "workflow_json": _ui_graph()}]},
).json()["imported_ids"][0]
resp = client.patch(f"/api/workflows/{wid}", json={"favorite": True, "tags": ["高质量"]})
assert resp.status_code == 200
assert resp.json()["favorite"] is True
assert resp.json()["tags"] == ["高质量"]
touch = client.post(f"/api/workflows/{wid}/touch")
assert touch.status_code == 200
meta = client.get("/api/workflows", params={"view": "meta"}).json()
assert meta[0]["last_used_at"] is not None
assert meta[0]["favorite"] is True
def test_migration_adds_columns_on_old_database(tmp_path):
"""A pre-S2 database (workflows table without the metadata columns)
migrates in place; existing rows survive with defaults."""
db_path = tmp_path / "old.db"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
workflow_json TEXT NOT NULL,
description TEXT,
prompt_profile TEXT NOT NULL DEFAULT 'prose',
is_enabled INTEGER NOT NULL DEFAULT 1,
usage TEXT,
usage_source TEXT,
output_map TEXT,
output_map_source TEXT
);
INSERT INTO workflows (name, kind, workflow_json)
VALUES ('legacy', 'video', '{"1": {"class_type": "SaveImage", "inputs": {}}}');
"""
)
conn.commit()
conn.close()
import asyncio
asyncio.run(migrate_db(db_path))
conn = sqlite3.connect(db_path)
cols = {r[1] for r in conn.execute("PRAGMA table_info(workflows)").fetchall()}
assert {
"category",
"model_family",
"media_io",
"tags",
"favorite",
"last_used_at",
"source",
"source_path",
} <= cols
row = conn.execute("SELECT name, favorite, tags, category FROM workflows").fetchone()
assert row == ("legacy", 0, None, None)
conn.close()
+58
View File
@@ -140,6 +140,11 @@ import type {
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
WorkflowMeta,
WorkflowUIFileInput,
WorkflowConvertFileReport,
WorkflowConvertReport,
WorkflowImportResult,
} from "./comfy/types";
export type {
Job,
@@ -154,6 +159,11 @@ export type {
WorkflowOutputMapEntry,
WorkflowConnectionCheck,
WorkflowLastTest,
WorkflowMeta,
WorkflowUIFileInput,
WorkflowConvertFileReport,
WorkflowConvertReport,
WorkflowImportResult,
};
async function api<T>(path: string, init?: RequestInit): Promise<T> {
@@ -462,6 +472,50 @@ export const comfy = {
export const workflows = {
list: () => api<Workflow[]>("/api/workflows"),
/** HEL-682: metadata-only list — 150+ rows without any workflow_json. */
listMeta: () => api<WorkflowMeta[]>("/api/workflows?view=meta"),
/** HEL-682: dry-run UI→API conversion for a batch of files (nothing stored). */
convert: (
files: WorkflowUIFileInput[],
opts?: { endpoint_id?: string | null; refresh?: boolean },
) =>
api<WorkflowConvertReport>("/api/workflows/convert", {
method: "POST",
body: JSON.stringify({
files,
...(opts?.endpoint_id ? { endpoint_id: opts.endpoint_id } : {}),
...(opts?.refresh ? { refresh: opts.refresh } : {}),
}),
}),
/** HEL-682: commit a batch import (converts server-side, conflicts skipped). */
importBatch: (files: WorkflowUIFileInput[]) =>
api<WorkflowImportResult>("/api/workflows/import-batch", {
method: "POST",
body: JSON.stringify({ files }),
}),
/** HEL-682: scan a server-side directory of UI-format JSONs — dry-run report. */
importScanDry: (root: string) =>
api<WorkflowConvertReport>("/api/workflows/import-scan", {
method: "POST",
body: JSON.stringify({ root, dry_run: true }),
}),
/** HEL-682: scan a server-side directory of UI-format JSONs — commit the import. */
importScanCommit: (root: string) =>
api<WorkflowImportResult>("/api/workflows/import-scan", {
method: "POST",
body: JSON.stringify({ root, dry_run: false }),
}),
/** HEL-682: record a use (最近使用). */
touch: (id: number) =>
api<{ ok: boolean }>(`/api/workflows/${id}/touch`, { method: "POST" }),
/** HEL-682: delete byte-identical duplicates, keeping the lowest id. */
dedupe: () =>
api<{
ok: boolean;
removed: number;
kept_ids: number[];
removed_ids: number[];
}>("/api/workflows/dedupe", { method: "POST" }),
analyze: (
workflow_json: Record<string, unknown>,
endpoint_id?: string | null,
@@ -537,6 +591,10 @@ export const workflows = {
input_schema: ComfyDynamicInput[];
output_map: WorkflowOutputMapEntry[];
output_map_source: "auto" | "user";
favorite: boolean;
tags: string[];
category: string;
model_family: string;
}>,
) =>
api<Workflow>(`/api/workflows/${id}`, {
+114
View File
@@ -175,6 +175,120 @@ export interface Workflow {
last_test?: WorkflowLastTest | null;
}
/**
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_source: 'auto' | 'user' | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: 'auto' | 'user' | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: 'error' | 'warning';
message: string;
}
export interface WorkflowConvertSuggested {
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: 'ok' | 'warn' | 'fail';
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: 'imported' | 'skipped' | 'failed';
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
files: WorkflowImportFileReport[];
}
export interface Job {
id: number;
project_id: number;
@@ -167,28 +167,34 @@
<div class="stage-media" in:xfade out:xfade>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
</div>
{:else if output}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
@@ -362,14 +368,24 @@
inset: 0;
display: grid;
place-items: center;
padding: var(--sp-5);
box-sizing: border-box;
}
.stage-media > img,
.stage-media > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
/* 相纸白边 (HEL-680):检视舞台上的结果按相纸呈现,媒体自适应内含 */
.print {
position: relative;
display: grid;
max-width: 100%;
max-height: 100%;
--paper-w: 5px;
}
.print > img,
.print > video {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.stage-empty {
display: grid;
@@ -183,7 +183,8 @@
<div class="out">
{#if output}
<div class="thumb" class:portrait>
<div class="thumb a-photo" class:portrait>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
{#if isVideoOutput(job, output)}
<video
src={assetUrl(output)}
@@ -238,15 +239,42 @@
background-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
HEL-680 四角短线取代整框描边) */
.sel-mark {
position: absolute;
inset: -1px;
z-index: 3;
display: none;
border: 1px solid var(--accent-line);
border-radius: inherit;
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:
14px 2px,
2px 14px,
14px 2px,
2px 14px,
14px 2px,
2px 14px,
14px 2px,
2px 14px;
background-repeat: no-repeat;
}
.take.selected {
border-color: transparent;
@@ -458,6 +486,12 @@
background: var(--bg-4);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
/* 相纸白边 (HEL-680):有产出的记录缩略图按相纸呈现,压过占位态样式 */
.thumb.a-photo {
border-radius: 2px;
background: var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.thumb.portrait {
width: 96px;
aspect-ratio: 3 / 4;
@@ -52,7 +52,12 @@
'var(--avatar-4)',
'var(--avatar-5)',
];
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
const placeholderIconColor = $derived(
placeholderIdx <= 1 ? 'var(--accent-ink)' : 'var(--text)',
);
const coverUrl = $derived(assetUrl(project.cover_path));
@@ -174,7 +179,12 @@
onerror={() => (coverFailed = true)}
/>
{:else}
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
<span
class="cover-ph"
style:background={placeholder}
style:color={placeholderIconColor}
aria-hidden="true"
>
<Icon name="film" size={22} />
</span>
{/if}
@@ -402,6 +402,7 @@
flex-shrink: 0;
margin-bottom: var(--sp-5);
border-radius: var(--r-md);
border-bottom: 2px solid var(--accent-lo);
display: grid;
place-items: center;
background: var(--accent-grad);
@@ -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"/>',
@@ -132,9 +132,7 @@ let {
min-height: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at center, rgba(139, 92, 246, 0.08), transparent 55%),
#050508;
background: var(--stage-bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
@@ -79,14 +79,14 @@
let criticNotes = $state<string[]>([]);
const preview = createMutation({
mutationFn: async (vars?: { force?: boolean }) => {
mutationFn: async (vars: { force?: boolean } | void) => {
if (!clip && !scene) throw new Error(t('promptPreview.noClipSelected'));
return jobsApi.previewPrompt(projectId, {
clip_id: clip?.id,
scene_id: clip ? undefined : scene?.id,
workflow_id: workflow?.id,
input_values: compactInputValues(inputValues),
force: Boolean(vars?.force),
force: Boolean((vars as { force?: boolean } | undefined)?.force),
});
},
onSuccess: (data) => {
@@ -203,7 +203,7 @@ title={t('filmstrip.clipTitle', {
inset: 0 0 auto 0;
height: 3px;
z-index: 1;
background: #3f3f46;
background: var(--bg-4);
}
.clip.status-done .bar {
@@ -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>
+133 -1
View File
@@ -1902,7 +1902,7 @@ export const de: Dict = {
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
'tasks.sourceWorkbench': 'Kreativ-Fluss',
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
'jobst.done': 'Fertig',
'jobst.done': 'Fixiert',
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
'jobst.dryRun': 'Platzhalter',
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
@@ -2097,4 +2097,136 @@ export const de: Dict = {
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
'wfd.showRaw': 'Rohes JSON anzeigen',
'wfd.hideRaw': 'Rohes JSON ausblenden',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'UI-Format-Workflows stapelweise importieren; suchen, filtern, annotieren — volles JSON nur bei Bedarf',
'wl.batchImport': 'Stapelimport',
'wl.dedupe': 'Duplikate entfernen',
'wl.dedupeTitle': 'Duplizierte Workflows entfernen',
'wl.dedupeConfirm': 'Einträge mit identischem Namen UND Inhalt werden gelöscht (der früheste Import bleibt). Gleicher Name mit anderem Inhalt bleibt unangetastet. Fortfahren?',
'wl.dedupeDone': '{n} doppelte Workflows entfernt',
'wl.dedupeNone': 'Keine doppelten Workflows gefunden',
'wl.favCount': '{n} Favoriten',
'wl.searchPh': 'Name, Beschreibung, Tags, Modell suchen… (/ drücken)',
'wl.filterLabel': 'Filter',
'wl.filterAll': 'Alle',
'wl.filterFav': 'Favoriten',
'wl.filterPending': 'Offen',
'wl.filterDisabled': 'Deaktiviert',
'wl.favAria': 'Favorisieren',
'wl.unfavAria': 'Favorit entfernen',
'wl.categoryLabel': 'Kategorie',
'wl.categoryAll': 'Alle Kategorien',
'wl.familyLabel': 'Modellfamilie',
'wl.familyAll': 'Alle Familien',
'wl.mediaLabel': 'Medium',
'wl.mediaAll': 'Alle Medien',
'wl.mediaImage': 'Bild',
'wl.mediaVideo': 'Video',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Eingabe',
'wl.mediaOut': 'Ausgabe',
'wl.sortLabel': 'Sortierung',
'wl.sortRecent': 'Favoriten / zuletzt',
'wl.sortName': 'Name',
'wl.resetFilters': 'Filter zurücksetzen',
'wl.noMatch': 'Keine Workflows entsprechen den Filtern',
'wl.resultCount': '{n} von {m}',
'wl.selected': '{n} ausgewählt',
'wl.batchLabel': 'Stapelaktionen',
'wl.batchAnnotate': 'Annotieren',
'wl.batchEnable': 'Aktivieren',
'wl.batchDisable': 'Deaktivieren',
'wl.clearSel': 'Auswahl aufheben',
'wl.batchDone': '{n} Workflows aktualisiert',
'wl.batchPartial': '{n} erfolgreich, {m} fehlgeschlagen (zuerst Verwendung und Ausgabe klären)',
'wl.selectAll': 'Alle aufgelisteten Workflows auswählen',
'wl.rowCheckAria': '{name} auswählen',
'wl.headMeta': 'Metadaten',
'wl.headActions': 'Aktionen',
'wl.enableAria': '{name} aktivieren oder deaktivieren',
'wl.enableBlocked': 'Verwendung und Ausgabeknoten vor Aktivierung festlegen',
'wl.editMeta': 'Metadaten bearbeiten',
'wl.lastUsed': 'Zuletzt verwendet',
'wl.moreCount': '{n} mehr anzeigen ({m} gesamt)',
'wfCat.basic': 'Grundverarbeitung',
'wfCat.audioTts': 'Sprachgenerierung',
'wfCat.music': 'Musikgenerierung',
'wfCat.image': 'Bildgenerierung',
'wfCat.video': 'Videogenerierung',
'wfCat.digitalHuman': 'Digitaler Mensch',
'wfCat.motionTransfer': 'Bewegungsübertragung',
'md.title': 'Workflow-Metadaten',
'md.batchTitle': 'Stapel-Annotieren ({n})',
'md.batchHint': 'Nur ausgefüllte Felder werden angewendet; Namen per Suchen→Ersetzen, Tags werden ergänzt.',
'md.name': 'Anzeigename',
'md.nameFind': 'Suchen',
'md.nameFindPh': 'Zu ersetzender Text…',
'md.nameReplace': 'Ersetzen mit',
'md.nameReplacePh': 'Ersatztext…',
'md.nameHint': 'Leeres „Suchen“ lässt Namen unverändert.',
'md.description': 'Beschreibung',
'md.descriptionPh': 'Eine Zeile, was dieser Workflow tut…',
'md.tags': 'Tags',
'md.tagsPh': 'z. B. schnell, hohe Qualität',
'md.tagsHint': 'Kommagetrennt; im Stapelmodus werden sie ergänzt.',
'md.category': 'Kategorie',
'md.modelFamily': 'Modellfamilie',
'md.apply': 'Anwenden',
'md.applied': '{n} Workflows aktualisiert',
'md.appliedOne': 'Aktualisiert',
'md.partial': '{n} erfolgreich, {m} fehlgeschlagen',
'wiz.title': 'Workflows stapelweise importieren',
'wiz.stepSource': 'Quelle',
'wiz.stepReport': 'Prüfbericht',
'wiz.stepResult': 'Ergebnis',
'wiz.sourceLabel': 'Importquelle',
'wiz.modeScan': 'Serververzeichnis scannen',
'wiz.modeFiles': 'Lokale Dateien wählen',
'wiz.rootLabel': 'Serververzeichnis',
'wiz.rootHint': 'Ein Verzeichnispfad auf dem Server (z. B. example_ComfyUI_workflows des Repos); UI-Format-JSONs werden rekursiv gescannt, API-Format und andere übersprungen.',
'wiz.preflight': 'Preflight gegen echtes ComfyUI (optional)',
'wiz.preflightNone': 'Überspringen (Offline-Konvertierung)',
'wiz.preflightHint': 'Mit gewählter Verbindung werden konvertierte Graphen Knoten für Knoten geprüft.',
'wiz.pickFiles': 'Lokale Dateien',
'wiz.pickFilesBtn': 'JSON-Dateien wählen (mehrere)',
'wiz.pickFilesHint': 'ComfyUI-Workflows im UI-Format wählen; Mehrfachauswahl möglich.',
'wiz.parsed': '{n} Dateien gelesen',
'wiz.notUi': '{n} Dateien sind kein UI-Format (kein nodes-Array) und wurden übersprungen',
'wiz.noFiles': 'Zuerst Dateien zum Import auswählen',
'wiz.runDry': 'Probelauf starten (nichts wird gespeichert)',
'wiz.running': 'Prüfe…',
'wiz.total': '{n} Dateien',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Warnung {n}',
'wiz.fail': 'Fehler {n}',
'wiz.confirmCount': 'Prüfung nötig {n}',
'wiz.importable': 'Importierbar {n}',
'wiz.reportHint': 'Der Probelauf speichert nichts. Warnungen werden aktiviert importiert, sobald eine Verwendung angegeben ist; prüfungspflichtige und fehlgeschlagene Dateien werden nie importiert.',
'wiz.filterLabel': 'Berichtsfilter',
'wiz.filterWarn': 'Nur Warnungen',
'wiz.filterBlocked': 'Nicht importierbar',
'wiz.searchPh': 'Dateinamen suchen…',
'wiz.includeAria': '{name} importieren',
'wiz.skipReason': 'Nicht importierbar: {reason}',
'wiz.issues': '{n} Konvertierungshinweise',
'wiz.usagePick': 'Verwendung:',
'wiz.usageNone': 'Vorerst überspringen (nach Import offen)',
'wiz.selectedN': '{n} Dateien werden importiert',
'wiz.excludedM': '{m} nicht importierbar',
'wiz.import': 'Importieren',
'wiz.importing': 'Importiere…',
'wiz.back': 'Zurück',
'wiz.imported': '{n} importiert',
'wiz.skipped': '{n} übersprungen',
'wiz.conflict': 'Existiert bereits (Duplikat übersprungen)',
'wiz.usageApplied': 'Verwendung für {n} neue Importe gesetzt',
'wiz.done': 'Fertig',
'wiz.again': 'Weitere Charge importieren',
'wiz.resultNote': 'Dateien mit identischem Inhalt werden automatisch übersprungen — Wiederholung erzeugt keine Duplikate.',
'wiz.importedToast': '{n} Workflows importiert',
'wiz.warnTag': 'Warnung',
'wiz.confirmTag': 'Prüfung nötig',
'wiz.preflightTag': 'Preflight fehlgeschlagen',
'wiz.importedTag': 'Importiert #{id}',
};
+133 -1
View File
@@ -1901,7 +1901,7 @@ export const en = {
'home.stageProgress': 'Five-stage progress',
'tasks.sourceWorkbench': 'Creation flow',
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
'jobst.done': 'Done',
'jobst.done': 'Fixed',
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
'jobst.dryRun': 'Placeholder',
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
@@ -2096,6 +2096,138 @@ export const en = {
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
'wfd.showRaw': 'View raw JSON',
'wfd.hideRaw': 'Hide raw JSON',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'Batch-import UI-format workflows; search, filter and annotate — full JSON loads only per action',
'wl.batchImport': 'Batch import',
'wl.dedupe': 'Dedupe',
'wl.dedupeTitle': 'Remove duplicate workflows',
'wl.dedupeConfirm': 'This deletes entries whose name AND content are identical (the earliest import is kept). Same name with different content is your variant and stays untouched. Continue?',
'wl.dedupeDone': 'Removed {n} duplicate workflows',
'wl.dedupeNone': 'No duplicate workflows found',
'wl.favCount': '{n} favorites',
'wl.searchPh': 'Search name, description, tags, model… (press /)',
'wl.filterLabel': 'Filters',
'wl.filterAll': 'All',
'wl.filterFav': 'Favorites',
'wl.filterPending': 'Pending',
'wl.filterDisabled': 'Disabled',
'wl.favAria': 'Favorite',
'wl.unfavAria': 'Unfavorite',
'wl.categoryLabel': 'Category',
'wl.categoryAll': 'All categories',
'wl.familyLabel': 'Model family',
'wl.familyAll': 'All families',
'wl.mediaLabel': 'Media',
'wl.mediaAll': 'All media',
'wl.mediaImage': 'Image',
'wl.mediaVideo': 'Video',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Input',
'wl.mediaOut': 'Output',
'wl.sortLabel': 'Sort',
'wl.sortRecent': 'Favorite / recent',
'wl.sortName': 'Name',
'wl.resetFilters': 'Clear filters',
'wl.noMatch': 'No workflows match the current filters',
'wl.resultCount': '{n} of {m} match',
'wl.selected': '{n} selected',
'wl.batchLabel': 'Batch actions',
'wl.batchAnnotate': 'Annotate',
'wl.batchEnable': 'Enable',
'wl.batchDisable': 'Disable',
'wl.clearSel': 'Clear selection',
'wl.batchDone': 'Updated {n} workflows',
'wl.batchPartial': '{n} succeeded, {m} failed (usage and output mapping must be settled first)',
'wl.selectAll': 'Select all listed workflows',
'wl.rowCheckAria': 'Select {name}',
'wl.headMeta': 'Metadata',
'wl.headActions': 'Actions',
'wl.enableAria': 'Enable or disable {name}',
'wl.enableBlocked': 'Settle usage and output nodes before enabling',
'wl.editMeta': 'Edit metadata',
'wl.lastUsed': 'Last used',
'wl.moreCount': 'Show {n} more ({m} total)',
'wfCat.basic': 'Basic processing',
'wfCat.audioTts': 'Speech generation',
'wfCat.music': 'Music generation',
'wfCat.image': 'Image generation',
'wfCat.video': 'Video generation',
'wfCat.digitalHuman': 'Digital human',
'wfCat.motionTransfer': 'Motion transfer',
'md.title': 'Workflow metadata',
'md.batchTitle': 'Batch annotate ({n})',
'md.batchHint': 'Only filled fields are applied; names are renamed via find→replace and tags are appended.',
'md.name': 'Display name',
'md.nameFind': 'Find',
'md.nameFindPh': 'Text to replace…',
'md.nameReplace': 'Replace with',
'md.nameReplacePh': 'Replacement text…',
'md.nameHint': 'Leave “Find” empty to keep names unchanged.',
'md.description': 'Description',
'md.descriptionPh': 'One line about what this workflow does…',
'md.tags': 'Tags',
'md.tagsPh': 'e.g. fast, high quality',
'md.tagsHint': 'Comma separated; appended in batch mode.',
'md.category': 'Category',
'md.modelFamily': 'Model family',
'md.apply': 'Apply',
'md.applied': 'Updated {n} workflows',
'md.appliedOne': 'Updated',
'md.partial': '{n} succeeded, {m} failed',
'wiz.title': 'Batch import workflows',
'wiz.stepSource': 'Source',
'wiz.stepReport': 'Dry-run report',
'wiz.stepResult': 'Result',
'wiz.sourceLabel': 'Import source',
'wiz.modeScan': 'Scan server directory',
'wiz.modeFiles': 'Pick local files',
'wiz.rootLabel': 'Server directory',
'wiz.rootHint': 'A directory path on the server (e.g. the repo’s example_ComfyUI_workflows); UI-format ComfyUI JSONs inside are scanned recursively — API-format and other JSONs are skipped.',
'wiz.preflight': 'Preflight against a live ComfyUI (optional)',
'wiz.preflightNone': 'Skip (offline conversion)',
'wiz.preflightHint': 'With a connection selected, converted graphs are checked node-by-node against that ComfyUI.',
'wiz.pickFiles': 'Local files',
'wiz.pickFilesBtn': 'Choose JSON files (multiple)',
'wiz.pickFilesHint': 'Pick ComfyUI UI-format workflow JSONs; multiple selection supported.',
'wiz.parsed': '{n} files read',
'wiz.notUi': '{n} files are not UI-format (no nodes array) and were skipped',
'wiz.noFiles': 'Choose files to import first',
'wiz.runDry': 'Run dry-run (nothing stored)',
'wiz.running': 'Checking…',
'wiz.total': '{n} files',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Warn {n}',
'wiz.fail': 'Fail {n}',
'wiz.confirmCount': 'Need review {n}',
'wiz.importable': 'Importable {n}',
'wiz.reportHint': 'The dry-run stores nothing. Warn items import enabled once a usage is supplied; needs-review and failed items are never imported nor marked runnable.',
'wiz.filterLabel': 'Report filters',
'wiz.filterWarn': 'Warnings only',
'wiz.filterBlocked': 'Not importable',
'wiz.searchPh': 'Search file name…',
'wiz.includeAria': 'Import {name}',
'wiz.skipReason': 'Not importable: {reason}',
'wiz.issues': '{n} conversion notes',
'wiz.usagePick': 'Usage:',
'wiz.usageNone': 'Skip for now (pending after import)',
'wiz.selectedN': '{n} files will be imported',
'wiz.excludedM': '{m} not importable',
'wiz.import': 'Import',
'wiz.importing': 'Importing…',
'wiz.back': 'Back',
'wiz.imported': 'Imported {n}',
'wiz.skipped': 'Skipped {n}',
'wiz.conflict': 'Already exists (duplicate skipped)',
'wiz.usageApplied': 'Usage supplied for {n} new imports',
'wiz.done': 'Done',
'wiz.again': 'Import another batch',
'wiz.resultNote': 'Files with identical content are skipped automatically — re-running never creates duplicates.',
'wiz.importedToast': 'Imported {n} workflows',
'wiz.warnTag': 'Warn',
'wiz.confirmTag': 'Needs review',
'wiz.preflightTag': 'Preflight failed',
'wiz.importedTag': 'Imported #{id}',
};
export type Dict = typeof en;
+133 -1
View File
@@ -1902,7 +1902,7 @@ export const es: Dict = {
'home.stageProgress': 'Progreso de cinco etapas',
'tasks.sourceWorkbench': 'Flujo de creación',
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
'jobst.done': 'Listo',
'jobst.done': 'Fijado',
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
'jobst.dryRun': 'Marcador',
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
@@ -2097,4 +2097,136 @@ export const es: Dict = {
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
'wfd.showRaw': 'Ver JSON original',
'wfd.hideRaw': 'Ocultar JSON original',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'Importa por lotes flujos en formato UI; busca, filtra y anota — el JSON completo solo se carga al actuar',
'wl.batchImport': 'Importar por lotes',
'wl.dedupe': 'Deduplicar',
'wl.dedupeTitle': 'Eliminar flujos duplicados',
'wl.dedupeConfirm': 'Se eliminan las entradas con nombre Y contenido idénticos (se conserva la primera importada). Mismo nombre con contenido distinto no se toca. ¿Continuar?',
'wl.dedupeDone': 'Se eliminaron {n} flujos duplicados',
'wl.dedupeNone': 'No se encontraron flujos duplicados',
'wl.favCount': '{n} favoritos',
'wl.searchPh': 'Buscar nombre, descripción, etiquetas, modelo… (pulsa /)',
'wl.filterLabel': 'Filtros',
'wl.filterAll': 'Todos',
'wl.filterFav': 'Favoritos',
'wl.filterPending': 'Pendientes',
'wl.filterDisabled': 'Desactivados',
'wl.favAria': 'Marcar como favorito',
'wl.unfavAria': 'Quitar de favoritos',
'wl.categoryLabel': 'Categoría',
'wl.categoryAll': 'Todas las categorías',
'wl.familyLabel': 'Familia de modelo',
'wl.familyAll': 'Todas las familias',
'wl.mediaLabel': 'Medio',
'wl.mediaAll': 'Todos los medios',
'wl.mediaImage': 'Imagen',
'wl.mediaVideo': 'Vídeo',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Entrada',
'wl.mediaOut': 'Salida',
'wl.sortLabel': 'Orden',
'wl.sortRecent': 'Favoritos / recientes',
'wl.sortName': 'Nombre',
'wl.resetFilters': 'Limpiar filtros',
'wl.noMatch': 'Ningún flujo coincide con los filtros',
'wl.resultCount': '{n} de {m}',
'wl.selected': '{n} seleccionados',
'wl.batchLabel': 'Acciones por lotes',
'wl.batchAnnotate': 'Anotar',
'wl.batchEnable': 'Activar',
'wl.batchDisable': 'Desactivar',
'wl.clearSel': 'Limpiar selección',
'wl.batchDone': '{n} flujos actualizados',
'wl.batchPartial': '{n} con éxito, {m} fallidos (confirma antes uso y salida)',
'wl.selectAll': 'Seleccionar todos los flujos listados',
'wl.rowCheckAria': 'Seleccionar {name}',
'wl.headMeta': 'Metadatos',
'wl.headActions': 'Acciones',
'wl.enableAria': 'Activar o desactivar {name}',
'wl.enableBlocked': 'Confirma uso y nodos de salida antes de activar',
'wl.editMeta': 'Editar metadatos',
'wl.lastUsed': 'Último uso',
'wl.moreCount': 'Mostrar {n} más ({m} en total)',
'wfCat.basic': 'Procesamiento básico',
'wfCat.audioTts': 'Generación de voz',
'wfCat.music': 'Generación de música',
'wfCat.image': 'Generación de imágenes',
'wfCat.video': 'Generación de vídeo',
'wfCat.digitalHuman': 'Humano digital',
'wfCat.motionTransfer': 'Transferencia de movimiento',
'md.title': 'Metadatos del flujo',
'md.batchTitle': 'Anotar por lotes ({n})',
'md.batchHint': 'Solo se aplican los campos rellenados; los nombres se renombran con buscar→reemplazar y las etiquetas se añaden.',
'md.name': 'Nombre visible',
'md.nameFind': 'Buscar',
'md.nameFindPh': 'Texto a reemplazar…',
'md.nameReplace': 'Reemplazar por',
'md.nameReplacePh': 'Texto de reemplazo…',
'md.nameHint': 'Deja «Buscar» vacío para no cambiar nombres.',
'md.description': 'Descripción',
'md.descriptionPh': 'Una línea sobre qué hace este flujo…',
'md.tags': 'Etiquetas',
'md.tagsPh': 'p. ej. rápido, alta calidad',
'md.tagsHint': 'Separadas por comas; en lote se añaden.',
'md.category': 'Categoría',
'md.modelFamily': 'Familia de modelo',
'md.apply': 'Aplicar',
'md.applied': '{n} flujos actualizados',
'md.appliedOne': 'Actualizado',
'md.partial': '{n} con éxito, {m} fallidos',
'wiz.title': 'Importar flujos por lotes',
'wiz.stepSource': 'Origen',
'wiz.stepReport': 'Informe de prueba',
'wiz.stepResult': 'Resultado',
'wiz.sourceLabel': 'Origen de importación',
'wiz.modeScan': 'Escanear carpeta del servidor',
'wiz.modeFiles': 'Elegir archivos locales',
'wiz.rootLabel': 'Carpeta del servidor',
'wiz.rootHint': 'Ruta de carpeta en el servidor (p. ej. example_ComfyUI_workflows del repo); se escanean recursivamente los JSON en formato UI, el formato API y otros JSON se omiten.',
'wiz.preflight': 'Preflight contra un ComfyUI real (opcional)',
'wiz.preflightNone': 'Omitir (conversión sin conexión)',
'wiz.preflightHint': 'Con una conexión elegida, los grafos convertidos se verifican nodo a nodo.',
'wiz.pickFiles': 'Archivos locales',
'wiz.pickFilesBtn': 'Elegir archivos JSON (varios)',
'wiz.pickFilesHint': 'Elige JSON de flujos ComfyUI en formato UI; se admite selección múltiple.',
'wiz.parsed': '{n} archivos leídos',
'wiz.notUi': '{n} archivos no son formato UI (sin array nodes) y se omitieron',
'wiz.noFiles': 'Elige primero los archivos a importar',
'wiz.runDry': 'Ejecutar prueba (no se guarda nada)',
'wiz.running': 'Comprobando…',
'wiz.total': '{n} archivos',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Avisos {n}',
'wiz.fail': 'Fallos {n}',
'wiz.confirmCount': 'Requieren revisión {n}',
'wiz.importable': 'Importables {n}',
'wiz.reportHint': 'La prueba no guarda nada. Los avisos se importan activados al indicar un uso; los que requieren revisión o fallaron nunca se importan.',
'wiz.filterLabel': 'Filtros del informe',
'wiz.filterWarn': 'Solo avisos',
'wiz.filterBlocked': 'No importables',
'wiz.searchPh': 'Buscar nombre de archivo…',
'wiz.includeAria': 'Importar {name}',
'wiz.skipReason': 'No importable: {reason}',
'wiz.issues': '{n} notas de conversión',
'wiz.usagePick': 'Uso:',
'wiz.usageNone': 'Omitir por ahora (pendiente tras importar)',
'wiz.selectedN': 'Se importarán {n} archivos',
'wiz.excludedM': '{m} no importables',
'wiz.import': 'Importar',
'wiz.importing': 'Importando…',
'wiz.back': 'Atrás',
'wiz.imported': '{n} importados',
'wiz.skipped': '{n} omitidos',
'wiz.conflict': 'Ya existe (duplicado omitido)',
'wiz.usageApplied': 'Uso indicado para {n} importaciones nuevas',
'wiz.done': 'Hecho',
'wiz.again': 'Importar otro lote',
'wiz.resultNote': 'Los archivos con contenido idéntico se omiten automáticamente; repetir no crea duplicados.',
'wiz.importedToast': '{n} flujos importados',
'wiz.warnTag': 'Aviso',
'wiz.confirmTag': 'Requiere revisión',
'wiz.preflightTag': 'Preflight fallido',
'wiz.importedTag': 'Importado #{id}',
};
+133 -1
View File
@@ -1902,7 +1902,7 @@ export const fr: Dict = {
'home.stageProgress': 'Progression en cinq étapes',
'tasks.sourceWorkbench': 'Flux de création',
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
'jobst.done': 'Terminé',
'jobst.done': 'Fixé',
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
'jobst.dryRun': 'Substitut',
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
@@ -2097,4 +2097,136 @@ export const fr: Dict = {
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
'wfd.showRaw': 'Voir le JSON brut',
'wfd.hideRaw': 'Masquer le JSON brut',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'Importez par lots des flux au format UI ; cherchez, filtrez, annotez — le JSON complet ne se charge qu’à l’action',
'wl.batchImport': 'Import par lot',
'wl.dedupe': 'Dédupliquer',
'wl.dedupeTitle': 'Supprimer les flux dupliqués',
'wl.dedupeConfirm': 'Les entrées au nom ET contenu identiques seront supprimées (le premier import est conservé). Même nom mais contenu différent = votre variante, intact. Continuer ?',
'wl.dedupeDone': '{n} flux dupliqués supprimés',
'wl.dedupeNone': 'Aucun flux dupliqué trouvé',
'wl.favCount': '{n} favoris',
'wl.searchPh': 'Rechercher nom, description, tags, modèle… (touche /)',
'wl.filterLabel': 'Filtres',
'wl.filterAll': 'Tous',
'wl.filterFav': 'Favoris',
'wl.filterPending': 'À confirmer',
'wl.filterDisabled': 'Désactivés',
'wl.favAria': 'Mettre en favori',
'wl.unfavAria': 'Retirer des favoris',
'wl.categoryLabel': 'Catégorie',
'wl.categoryAll': 'Toutes les catégories',
'wl.familyLabel': 'Famille de modèle',
'wl.familyAll': 'Toutes les familles',
'wl.mediaLabel': 'Média',
'wl.mediaAll': 'Tous les médias',
'wl.mediaImage': 'Image',
'wl.mediaVideo': 'Vidéo',
'wl.mediaAudio': 'Audio',
'wl.mediaIn': 'Entrée',
'wl.mediaOut': 'Sortie',
'wl.sortLabel': 'Tri',
'wl.sortRecent': 'Favoris / récents',
'wl.sortName': 'Nom',
'wl.resetFilters': 'Effacer les filtres',
'wl.noMatch': 'Aucun flux ne correspond aux filtres',
'wl.resultCount': '{n} sur {m}',
'wl.selected': '{n} sélectionnés',
'wl.batchLabel': 'Actions groupées',
'wl.batchAnnotate': 'Annoter',
'wl.batchEnable': 'Activer',
'wl.batchDisable': 'Désactiver',
'wl.clearSel': 'Effacer la sélection',
'wl.batchDone': '{n} flux mis à jour',
'wl.batchPartial': '{n} réussis, {m} échoués (usage et sortie à confirmer d’abord)',
'wl.selectAll': 'Sélectionner tous les flux listés',
'wl.rowCheckAria': 'Sélectionner {name}',
'wl.headMeta': 'Métadonnées',
'wl.headActions': 'Actions',
'wl.enableAria': 'Activer ou désactiver {name}',
'wl.enableBlocked': 'Confirmez l’usage et les nœuds de sortie avant activation',
'wl.editMeta': 'Modifier les métadonnées',
'wl.lastUsed': 'Dernière utilisation',
'wl.moreCount': 'Afficher {n} de plus ({m} au total)',
'wfCat.basic': 'Traitement de base',
'wfCat.audioTts': 'Génération de voix',
'wfCat.music': 'Génération de musique',
'wfCat.image': 'Génération d’images',
'wfCat.video': 'Génération de vidéo',
'wfCat.digitalHuman': 'Humain numérique',
'wfCat.motionTransfer': 'Transfert de mouvement',
'md.title': 'Métadonnées du flux',
'md.batchTitle': 'Annotation groupée ({n})',
'md.batchHint': 'Seuls les champs remplis sont appliqués ; renommage par rechercher→remplacer, tags ajoutés.',
'md.name': 'Nom affiché',
'md.nameFind': 'Rechercher',
'md.nameFindPh': 'Texte à remplacer…',
'md.nameReplace': 'Remplacer par',
'md.nameReplacePh': 'Texte de remplacement…',
'md.nameHint': 'Laissez « Rechercher » vide pour ne pas renommer.',
'md.description': 'Description',
'md.descriptionPh': 'Une ligne sur ce que fait ce flux…',
'md.tags': 'Tags',
'md.tagsPh': 'ex. rapide, haute qualité',
'md.tagsHint': 'Séparés par des virgules ; ajoutés en mode groupé.',
'md.category': 'Catégorie',
'md.modelFamily': 'Famille de modèle',
'md.apply': 'Appliquer',
'md.applied': '{n} flux mis à jour',
'md.appliedOne': 'Mis à jour',
'md.partial': '{n} réussis, {m} échoués',
'wiz.title': 'Import de flux par lot',
'wiz.stepSource': 'Source',
'wiz.stepReport': 'Rapport de test',
'wiz.stepResult': 'Résultat',
'wiz.sourceLabel': 'Source d’import',
'wiz.modeScan': 'Scanner un dossier serveur',
'wiz.modeFiles': 'Choisir des fichiers locaux',
'wiz.rootLabel': 'Dossier serveur',
'wiz.rootHint': 'Chemin d’un dossier sur le serveur (ex. example_ComfyUI_workflows du dépôt) ; les JSON au format UI sont scannés récursivement, le format API et les autres sont ignorés.',
'wiz.preflight': 'Pré-vérification sur un ComfyUI réel (facultatif)',
'wiz.preflightNone': 'Passer (conversion hors ligne)',
'wiz.preflightHint': 'Avec une connexion choisie, les graphes convertis sont vérifiés nœud par nœud.',
'wiz.pickFiles': 'Fichiers locaux',
'wiz.pickFilesBtn': 'Choisir des fichiers JSON (plusieurs)',
'wiz.pickFilesHint': 'Choisissez des flux ComfyUI au format UI ; sélection multiple possible.',
'wiz.parsed': '{n} fichiers lus',
'wiz.notUi': '{n} fichiers ne sont pas au format UI (pas de tableau nodes), ignorés',
'wiz.noFiles': 'Choisissez d’abord des fichiers à importer',
'wiz.runDry': 'Lancer le test (rien n’est enregistré)',
'wiz.running': 'Vérification…',
'wiz.total': '{n} fichiers',
'wiz.ok': 'OK {n}',
'wiz.warn': 'Avertissements {n}',
'wiz.fail': 'Échecs {n}',
'wiz.confirmCount': 'À vérifier {n}',
'wiz.importable': 'Importables {n}',
'wiz.reportHint': 'Le test n’écrit rien. Les avertissements s’importent activés dès qu’un usage est fourni ; les fichiers à vérifier ou en échec ne sont jamais importés.',
'wiz.filterLabel': 'Filtres du rapport',
'wiz.filterWarn': 'Avertissements seuls',
'wiz.filterBlocked': 'Non importables',
'wiz.searchPh': 'Rechercher un nom de fichier…',
'wiz.includeAria': 'Importer {name}',
'wiz.skipReason': 'Non importable : {reason}',
'wiz.issues': '{n} notes de conversion',
'wiz.usagePick': 'Usage :',
'wiz.usageNone': 'Passer pour l’instant (à confirmer après import)',
'wiz.selectedN': '{n} fichiers seront importés',
'wiz.excludedM': '{m} non importables',
'wiz.import': 'Importer',
'wiz.importing': 'Import…',
'wiz.back': 'Retour',
'wiz.imported': '{n} importés',
'wiz.skipped': '{n} ignorés',
'wiz.conflict': 'Existe déjà (doublon ignoré)',
'wiz.usageApplied': 'Usage fourni pour {n} nouveaux imports',
'wiz.done': 'Terminé',
'wiz.again': 'Importer un autre lot',
'wiz.resultNote': 'Les fichiers au contenu identique sont ignorés automatiquement — relancer ne crée jamais de doublons.',
'wiz.importedToast': '{n} flux importés',
'wiz.warnTag': 'Avertissement',
'wiz.confirmTag': 'À vérifier',
'wiz.preflightTag': 'Pré-vérification échouée',
'wiz.importedTag': 'Importé #{id}',
};
+133 -1
View File
@@ -1900,7 +1900,7 @@ export const ja: Dict = {
'home.stageProgress': '五段階の進捗',
'tasks.sourceWorkbench': '制作フロー',
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
'jobst.done': '完了',
'jobst.done': '定着',
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
'jobst.dryRun': 'プレースホルダー',
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
@@ -2095,4 +2095,136 @@ export const ja: Dict = {
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
'wfd.showRaw': '元の JSON を見る',
'wfd.hideRaw': '元の JSON を閉じる',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'UI 形式ワークフローを一括取り込み、検索・絞り込み・注釈管理。完全な JSON は操作時のみ読み込み',
'wl.batchImport': '一括取り込み',
'wl.dedupe': '重複を削除',
'wl.dedupeTitle': '重複ワークフローの削除',
'wl.dedupeConfirm': '名前と内容が完全に同一の項目を削除します(最初に取り込んだものを残す)。名前が同じでも内容が異なるものは触れません。実行しますか?',
'wl.dedupeDone': '重複 {n} 件を削除しました',
'wl.dedupeNone': '重複するワークフローはありません',
'wl.favCount': 'お気に入り {n} 件',
'wl.searchPh': '名前・説明・タグ・モデルを検索…(/ で移動)',
'wl.filterLabel': '絞り込み',
'wl.filterAll': 'すべて',
'wl.filterFav': 'お気に入り',
'wl.filterPending': '要確認',
'wl.filterDisabled': '無効',
'wl.favAria': 'お気に入りに追加',
'wl.unfavAria': 'お気に入りから外す',
'wl.categoryLabel': 'カテゴリ',
'wl.categoryAll': 'すべてのカテゴリ',
'wl.familyLabel': 'モデルファミリー',
'wl.familyAll': 'すべてのファミリー',
'wl.mediaLabel': 'メディア',
'wl.mediaAll': 'すべてのメディア',
'wl.mediaImage': '画像',
'wl.mediaVideo': '動画',
'wl.mediaAudio': '音声',
'wl.mediaIn': '入力',
'wl.mediaOut': '出力',
'wl.sortLabel': '並び替え',
'wl.sortRecent': 'お気に入り / 最近',
'wl.sortName': '名前',
'wl.resetFilters': '絞り込みを解除',
'wl.noMatch': '条件に合うワークフローがありません',
'wl.resultCount': '{n} / {m} 件',
'wl.selected': '{n} 件選択中',
'wl.batchLabel': '一括操作',
'wl.batchAnnotate': '一括注釈',
'wl.batchEnable': '有効化',
'wl.batchDisable': '無効化',
'wl.clearSel': '選択を解除',
'wl.batchDone': '{n} 件を更新しました',
'wl.batchPartial': '{n} 件成功、{m} 件失敗(用途と出力の確認が必要)',
'wl.selectAll': '表示中のワークフローをすべて選択',
'wl.rowCheckAria': '{name} を選択',
'wl.headMeta': 'メタデータ',
'wl.headActions': '操作',
'wl.enableAria': '{name} を有効化・無効化',
'wl.enableBlocked': '用途と出力ノードを確定してから有効化してください',
'wl.editMeta': 'メタデータを編集',
'wl.lastUsed': '最終使用',
'wl.moreCount': 'さらに {n} 件表示(全 {m} 件)',
'wfCat.basic': '基本処理',
'wfCat.audioTts': '音声生成',
'wfCat.music': '音楽生成',
'wfCat.image': '画像生成',
'wfCat.video': '動画生成',
'wfCat.digitalHuman': 'デジタルヒューマン',
'wfCat.motionTransfer': 'モーション転送',
'md.title': 'ワークフローの注釈',
'md.batchTitle': '一括注釈({n} 件)',
'md.batchHint': '入力したフィールドのみ適用。名前は検索→置換で変更、タグは追加されます。',
'md.name': '表示名',
'md.nameFind': '検索',
'md.nameFindPh': '置換する文字…',
'md.nameReplace': '置換後',
'md.nameReplacePh': '置換する文字…',
'md.nameHint': '「検索」が空なら名前は変更されません。',
'md.description': '説明',
'md.descriptionPh': 'このワークフローの用途を一行で…',
'md.tags': 'タグ',
'md.tagsPh': '例: 高速, 高品質',
'md.tagsHint': 'カンマ区切り。一括モードでは追加されます。',
'md.category': 'カテゴリ',
'md.modelFamily': 'モデルファミリー',
'md.apply': '適用',
'md.applied': '{n} 件を更新しました',
'md.appliedOne': '更新しました',
'md.partial': '{n} 件成功、{m} 件失敗',
'wiz.title': 'ワークフローの一括取り込み',
'wiz.stepSource': '取り込み元',
'wiz.stepReport': '事前チェック',
'wiz.stepResult': '結果',
'wiz.sourceLabel': '取り込み元',
'wiz.modeScan': 'サーバーディレクトリをスキャン',
'wiz.modeFiles': 'ローカルファイルを選択',
'wiz.rootLabel': 'サーバーディレクトリ',
'wiz.rootHint': 'サーバー上のディレクトリパス(例: リポジトリの example_ComfyUI_workflows)。UI 形式の JSON を再帰的にスキャンし、API 形式などは自動的にスキップします。',
'wiz.preflight': '実機 ComfyUI で事前チェック(任意)',
'wiz.preflightNone': 'スキップ(オフライン変換)',
'wiz.preflightHint': '接続を選ぶと、変換結果をその ComfyUI のノート・モデルと照合します。',
'wiz.pickFiles': 'ローカルファイル',
'wiz.pickFilesBtn': 'JSON ファイルを選択(複数可)',
'wiz.pickFilesHint': 'ComfyUI UI 形式のワークフロー JSON を選んでください。複数選択できます。',
'wiz.parsed': '{n} ファイルを読み込みました',
'wiz.notUi': '{n} ファイルは UI 形式でないため(nodes 配列なし)スキップしました',
'wiz.noFiles': '先に取り込むファイルを選んでください',
'wiz.runDry': '事前チェックを実行(保存しません)',
'wiz.running': 'チェック中…',
'wiz.total': '全体 {n} ファイル',
'wiz.ok': '正常 {n}',
'wiz.warn': '警告 {n}',
'wiz.fail': '失敗 {n}',
'wiz.confirmCount': '要確認 {n}',
'wiz.importable': '取り込み可 {n}',
'wiz.reportHint': '事前チェックは何も書き込みません。警告は用途を補えば有効な状態で取り込まれ、要確認・失敗のファイルは取り込まれません。',
'wiz.filterLabel': 'レポートの絞り込み',
'wiz.filterWarn': '警告のみ',
'wiz.filterBlocked': '取り込み不可',
'wiz.searchPh': 'ファイル名を検索…',
'wiz.includeAria': '{name} を取り込む',
'wiz.skipReason': '取り込み不可: {reason}',
'wiz.issues': '変換メモ {n} 件',
'wiz.usagePick': '用途:',
'wiz.usageNone': '今はスキップ(取り込み後に要確認)',
'wiz.selectedN': '{n} ファイルを取り込みます',
'wiz.excludedM': '{m} 件は取り込み不可',
'wiz.import': '取り込む',
'wiz.importing': '取り込み中…',
'wiz.back': '戻る',
'wiz.imported': '{n} 件取り込み完了',
'wiz.skipped': '{n} 件スキップ',
'wiz.conflict': '既に存在(重複は自動スキップ)',
'wiz.usageApplied': '新規取り込み {n} 件に用途を設定しました',
'wiz.done': '完了',
'wiz.again': '続けて取り込む',
'wiz.resultNote': '内容が同一のファイルは自動スキップされ、再実行しても重複は作られません。',
'wiz.importedToast': '{n} 件のワークフローを取り込みました',
'wiz.warnTag': '警告',
'wiz.confirmTag': '要確認',
'wiz.preflightTag': '事前チェック不合格',
'wiz.importedTag': '取り込み済み #{id}',
};
+133 -1
View File
@@ -1900,7 +1900,7 @@ export const ko: Dict = {
'home.stageProgress': '5단계 진행',
'tasks.sourceWorkbench': '제작 흐름',
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
'jobst.done': '완료',
'jobst.done': '정착',
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
'jobst.dryRun': '자리표시자',
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
@@ -2095,4 +2095,136 @@ export const ko: Dict = {
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
'wfd.showRaw': '원본 JSON 보기',
'wfd.hideRaw': '원본 JSON 접기',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': 'UI 형식 워크플로 일괄 가져오기, 검색·필터·주석 관리. 전체 JSON은 작업 시에만 로드',
'wl.batchImport': '일괄 가져오기',
'wl.dedupe': '중복 정리',
'wl.dedupeTitle': '중복 워크플로 삭제',
'wl.dedupeConfirm': '이름과 내용이 완전히 같은 항목을 삭제합니다(가장 먼저 가져온 것 유지). 이름이 같아도 내용이 다르면 그대로 둡니다. 계속할까요?',
'wl.dedupeDone': '중복 워크플로 {n}건 삭제',
'wl.dedupeNone': '중복 워크플로가 없습니다',
'wl.favCount': '즐겨찾기 {n}건',
'wl.searchPh': '이름·설명·태그·모델 검색… (/ 키)',
'wl.filterLabel': '필터',
'wl.filterAll': '전체',
'wl.filterFav': '즐겨찾기',
'wl.filterPending': '확인 필요',
'wl.filterDisabled': '비활성',
'wl.favAria': '즐겨찾기 추가',
'wl.unfavAria': '즐겨찾기 해제',
'wl.categoryLabel': '분류',
'wl.categoryAll': '전체 분류',
'wl.familyLabel': '모델 계열',
'wl.familyAll': '전체 계열',
'wl.mediaLabel': '미디어',
'wl.mediaAll': '전체 미디어',
'wl.mediaImage': '이미지',
'wl.mediaVideo': '영상',
'wl.mediaAudio': '오디오',
'wl.mediaIn': '입력',
'wl.mediaOut': '출력',
'wl.sortLabel': '정렬',
'wl.sortRecent': '즐겨찾기 / 최근',
'wl.sortName': '이름',
'wl.resetFilters': '필터 해제',
'wl.noMatch': '조건에 맞는 워크플로가 없습니다',
'wl.resultCount': '{n} / {m}',
'wl.selected': '{n}건 선택됨',
'wl.batchLabel': '일괄 작업',
'wl.batchAnnotate': '일괄 주석',
'wl.batchEnable': '활성화',
'wl.batchDisable': '비활성화',
'wl.clearSel': '선택 해제',
'wl.batchDone': '{n}건 업데이트',
'wl.batchPartial': '{n}건 성공, {m}건 실패(용도와 출력 먼저 확정)',
'wl.selectAll': '나열된 워크플로 모두 선택',
'wl.rowCheckAria': '{name} 선택',
'wl.headMeta': '메타데이터',
'wl.headActions': '작업',
'wl.enableAria': '{name} 활성화/비활성화',
'wl.enableBlocked': '활성화 전에 용도와 출력 노드를 확정하세요',
'wl.editMeta': '메타데이터 편집',
'wl.lastUsed': '마지막 사용',
'wl.moreCount': '{n}건 더 보기(전체 {m}건)',
'wfCat.basic': '기본 처리',
'wfCat.audioTts': '음성 생성',
'wfCat.music': '음악 생성',
'wfCat.image': '이미지 생성',
'wfCat.video': '영상 생성',
'wfCat.digitalHuman': '디지털 휴먼',
'wfCat.motionTransfer': '모션 전이',
'md.title': '워크플로 주석',
'md.batchTitle': '일괄 주석({n}건)',
'md.batchHint': '입력한 필드만 적용됩니다. 이름은 찾기→바꾸기로 변경, 태그는 추가됩니다.',
'md.name': '표시 이름',
'md.nameFind': '찾기',
'md.nameFindPh': '바꿀 문자…',
'md.nameReplace': '바꾸기',
'md.nameReplacePh': '대체 문자…',
'md.nameHint': '「찾기」를 비우면 이름이 유지됩니다.',
'md.description': '설명',
'md.descriptionPh': '이 워크플로가 하는 일을 한 줄로…',
'md.tags': '태그',
'md.tagsPh': '예: 빠름, 고품질',
'md.tagsHint': '쉼표로 구분. 일괄 모드에서는 추가됩니다.',
'md.category': '분류',
'md.modelFamily': '모델 계열',
'md.apply': '적용',
'md.applied': '{n}건 업데이트',
'md.appliedOne': '업데이트됨',
'md.partial': '{n}건 성공, {m}건 실패',
'wiz.title': '워크플로 일괄 가져오기',
'wiz.stepSource': '원본',
'wiz.stepReport': '사전 검사',
'wiz.stepResult': '결과',
'wiz.sourceLabel': '가져오기 원본',
'wiz.modeScan': '서버 디렉터리 스캔',
'wiz.modeFiles': '로컬 파일 선택',
'wiz.rootLabel': '서버 디렉터리',
'wiz.rootHint': '서버의 디렉터리 경로(예: 저장소의 example_ComfyUI_workflows). UI 형식 JSON을 재귀적으로 스캔하며 API 형식 등은 건너뜁니다.',
'wiz.preflight': '실제 ComfyUI 사전 검사(선택)',
'wiz.preflightNone': '건너뛰기(오프라인 변환)',
'wiz.preflightHint': '연결을 선택하면 변환 결과를 해당 ComfyUI의 노드·모델과 대조합니다.',
'wiz.pickFiles': '로컬 파일',
'wiz.pickFilesBtn': 'JSON 파일 선택(여러 개)',
'wiz.pickFilesHint': 'ComfyUI UI 형식 워크플로 JSON을 선택하세요. 다중 선택이 가능합니다.',
'wiz.parsed': '{n}개 파일 읽음',
'wiz.notUi': '{n}개 파일이 UI 형식이 아니어서(nodes 배열 없음) 건너뛰었습니다',
'wiz.noFiles': '먼저 가져올 파일을 선택하세요',
'wiz.runDry': '사전 검사 실행(저장 안 함)',
'wiz.running': '검사 중…',
'wiz.total': '총 {n}개 파일',
'wiz.ok': '정상 {n}',
'wiz.warn': '경고 {n}',
'wiz.fail': '실패 {n}',
'wiz.confirmCount': '확인 필요 {n}',
'wiz.importable': '가져오기 가능 {n}',
'wiz.reportHint': '사전 검사는 아무것도 저장하지 않습니다. 경고 항목은 용도를 지정하면 활성화 상태로 가져오며, 확인 필요·실패 항목은 절대 가져오지 않습니다.',
'wiz.filterLabel': '보고서 필터',
'wiz.filterWarn': '경고만',
'wiz.filterBlocked': '가져올 수 없음',
'wiz.searchPh': '파일 이름 검색…',
'wiz.includeAria': '{name} 가져오기',
'wiz.skipReason': '가져올 수 없음: {reason}',
'wiz.issues': '변환 메모 {n}건',
'wiz.usagePick': '용도:',
'wiz.usageNone': '지금은 건너뜀(가져온 뒤 확인 필요)',
'wiz.selectedN': '{n}개 파일을 가져옵니다',
'wiz.excludedM': '{m}개 가져올 수 없음',
'wiz.import': '가져오기',
'wiz.importing': '가져오는 중…',
'wiz.back': '이전',
'wiz.imported': '{n}건 가져옴',
'wiz.skipped': '{n}건 건너뜀',
'wiz.conflict': '이미 있음(중복 자동 건너뜀)',
'wiz.usageApplied': '새로 가져온 {n}건에 용도 지정',
'wiz.done': '완료',
'wiz.again': '다시 가져오기',
'wiz.resultNote': '내용이 같은 파일은 자동으로 건너뛰며, 다시 실행해도 중복이 생기지 않습니다.',
'wiz.importedToast': '워크플로 {n}건을 가져왔습니다',
'wiz.warnTag': '경고',
'wiz.confirmTag': '확인 필요',
'wiz.preflightTag': '사전 검사 실패',
'wiz.importedTag': '가져옴 #{id}',
};
+134 -2
View File
@@ -1900,8 +1900,8 @@ export const zh: Dict = {
'home.stageProgress': '五段进度',
'tasks.sourceWorkbench': '创作流程',
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
'jobst.done': '完成',
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
'jobst.done': '定影',
'jobst.doneDetail': '生成完成,结果已定影保存',
'jobst.dryRun': '占位结果',
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
'jobst.noOutput': '无产出',
@@ -2095,4 +2095,136 @@ export const zh: Dict = {
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
'wfd.showRaw': '查看原始 JSON',
'wfd.hideRaw': '收起原始 JSON',
// --- Workflow library · batch import & management (HEL-682) ---
'wl.hint': '批量导入 UI 格式工作流,搜索筛选与标注管理;完整 JSON 只在试运行等操作时按需加载',
'wl.batchImport': '批量导入',
'wl.dedupe': '去重',
'wl.dedupeTitle': '清理重复工作流',
'wl.dedupeConfirm': '将删除名称与内容完全相同的重复条目(保留最早导入的一条)。同名但内容不同的是你的变体,不会被动。确定执行?',
'wl.dedupeDone': '已删除 {n} 个重复工作流',
'wl.dedupeNone': '没有发现重复工作流',
'wl.favCount': '{n} 个收藏',
'wl.searchPh': '搜索名称、说明、标签、模型…(按 / 聚焦)',
'wl.filterLabel': '筛选',
'wl.filterAll': '全部',
'wl.filterFav': '收藏',
'wl.filterPending': '待确认',
'wl.filterDisabled': '已停用',
'wl.favAria': '收藏',
'wl.unfavAria': '取消收藏',
'wl.categoryLabel': '分类',
'wl.categoryAll': '全部分类',
'wl.familyLabel': '模型家族',
'wl.familyAll': '全部模型',
'wl.mediaLabel': '媒介',
'wl.mediaAll': '全部媒介',
'wl.mediaImage': '图像',
'wl.mediaVideo': '视频',
'wl.mediaAudio': '音频',
'wl.mediaIn': '输入',
'wl.mediaOut': '输出',
'wl.sortLabel': '排序',
'wl.sortRecent': '收藏 / 最近使用',
'wl.sortName': '名称',
'wl.resetFilters': '清除筛选',
'wl.noMatch': '没有符合条件的工作流',
'wl.resultCount': '匹配 {n} / {m}',
'wl.selected': '已选 {n} 项',
'wl.batchLabel': '批量操作',
'wl.batchAnnotate': '批量标注',
'wl.batchEnable': '启用',
'wl.batchDisable': '停用',
'wl.clearSel': '清除选择',
'wl.batchDone': '已更新 {n} 项',
'wl.batchPartial': '{n} 项成功,{m} 项失败(需先确认用途与输出映射)',
'wl.selectAll': '选择当前列出的全部工作流',
'wl.rowCheckAria': '选择 {name}',
'wl.headMeta': '标注',
'wl.headActions': '操作',
'wl.enableAria': '启用或停用 {name}',
'wl.enableBlocked': '需先确认用途并指定输出节点,才能启用',
'wl.editMeta': '编辑标注',
'wl.lastUsed': '最近使用',
'wl.moreCount': '再显示 {n} 项(共 {m} 项)',
'wfCat.basic': '基础处理',
'wfCat.audioTts': '语音生成',
'wfCat.music': '音乐生成',
'wfCat.image': '图像生成',
'wfCat.video': '视频生成',
'wfCat.digitalHuman': '数字人',
'wfCat.motionTransfer': '动作迁移',
'md.title': '标注工作流',
'md.batchTitle': '批量标注({n} 项)',
'md.batchHint': '只有填写了的字段会被应用;显示名按「查找→替换」批量重命名,标签为追加。',
'md.name': '显示名',
'md.nameFind': '查找',
'md.nameFindPh': '要替换的文字…',
'md.nameReplace': '替换为',
'md.nameReplacePh': '替换后的文字…',
'md.nameHint': '留空「查找」则不改名。',
'md.description': '说明',
'md.descriptionPh': '一句话说明这个工作流做什么…',
'md.tags': '标签',
'md.tagsPh': '如:快速, 高质量',
'md.tagsHint': '用逗号分隔;批量模式下为追加标签。',
'md.category': '分类',
'md.modelFamily': '模型家族',
'md.apply': '应用',
'md.applied': '已更新 {n} 项',
'md.appliedOne': '已更新',
'md.partial': '{n} 项成功,{m} 项失败',
'wiz.title': '批量导入工作流',
'wiz.stepSource': '选择来源',
'wiz.stepReport': '预检报告',
'wiz.stepResult': '导入结果',
'wiz.sourceLabel': '导入来源',
'wiz.modeScan': '扫描服务器目录',
'wiz.modeFiles': '选择本地文件',
'wiz.rootLabel': '服务器目录',
'wiz.rootHint': '服务器上的目录路径(如仓库的 example_ComfyUI_workflows),将递归扫描其中的 ComfyUI 界面格式 JSON;API 格式与其他 JSON 自动跳过。',
'wiz.preflight': '对真实 ComfyUI 预检(可选)',
'wiz.preflightNone': '跳过(离线转换)',
'wiz.preflightHint': '选择连接后,转换结果会对照该 ComfyUI 的节点与模型做对象级检查。',
'wiz.pickFiles': '本地文件',
'wiz.pickFilesBtn': '选择 JSON 文件(可多选)',
'wiz.pickFilesHint': '选择 ComfyUI 界面格式(UI 格式)的工作流 JSON,可一次选多个。',
'wiz.parsed': '已读取 {n} 个文件',
'wiz.notUi': '{n} 个文件不是 UI 格式(缺少 nodes 数组),已忽略',
'wiz.noFiles': '请先选择要导入的文件',
'wiz.runDry': '开始预检(不写入)',
'wiz.running': '预检中…',
'wiz.total': '共 {n} 个文件',
'wiz.ok': '正常 {n}',
'wiz.warn': '警告 {n}',
'wiz.fail': '失败 {n}',
'wiz.confirmCount': '需人工确认 {n}',
'wiz.importable': '可导入 {n}',
'wiz.reportHint': '预检不写入数据库。warn 项可补充用途后以启用状态导入;「需人工确认」与转换失败项不会被导入,也不会被标为可运行。',
'wiz.filterLabel': '报告筛选',
'wiz.filterWarn': '仅看警告',
'wiz.filterBlocked': '不可导入',
'wiz.searchPh': '搜索文件名…',
'wiz.includeAria': '导入 {name}',
'wiz.skipReason': '不可导入:{reason}',
'wiz.issues': '{n} 条转换提示',
'wiz.usagePick': '补充用途:',
'wiz.usageNone': '暂不补充(导入后待确认)',
'wiz.selectedN': '将导入 {n} 个文件',
'wiz.excludedM': '{m} 个不可导入',
'wiz.import': '确认导入',
'wiz.importing': '导入中…',
'wiz.back': '上一步',
'wiz.imported': '导入成功 {n}',
'wiz.skipped': '跳过 {n}',
'wiz.conflict': '已存在(重复导入自动跳过)',
'wiz.usageApplied': '已为 {n} 个新导入项补充用途',
'wiz.done': '完成',
'wiz.again': '再导一批',
'wiz.resultNote': '内容相同的重复文件会被自动跳过,重复执行不会产生重复条目。',
'wiz.importedToast': '已导入 {n} 个工作流',
'wiz.warnTag': '警告',
'wiz.confirmTag': '需人工确认',
'wiz.preflightTag': '预检未通过',
'wiz.importedTag': '已导入 #{id}',
};
+7 -2
View File
@@ -589,6 +589,11 @@
function avatarStyle(n: number): string {
return `var(--avatar-${Math.min(Math.max(n, 1), 5)})`;
}
// HEL-680 灰阶头像:1–2 亮阶配深字,3–5 暗阶配浅字,按阶数决定。
function avatarLight(n: number | undefined): boolean {
return Math.min(Math.max(n ?? 1, 1), 5) <= 2;
}
</script>
{#snippet skillRow(skill: SkillAdminEntry, detail: string)}
@@ -655,7 +660,7 @@
class:selected
onclick={() => selectAgent(agent.key)}
>
<span class="avatar" class:dark={agent.avatar_dark} style:background={avatarStyle(agent.avatar)}>
<span class="avatar" class:dark={avatarLight(agent.avatar)} style:background={avatarStyle(agent.avatar)}>
{agent.char}
</span>
<span class="agent-meta">
@@ -693,7 +698,7 @@
<section class="mid">
{#if activeAgent}
<header class="chat-head">
<span class="avatar lg" class:dark={activeAgent.avatar_dark} style:background={avatarStyle(activeAgent.avatar)}>
<span class="avatar lg" class:dark={avatarLight(activeAgent.avatar)} style:background={avatarStyle(activeAgent.avatar)}>
{activeAgent.char}
</span>
<div class="chat-head-meta">
+181 -59
View File
@@ -4,62 +4,67 @@
:root {
/* ------------------------------------------------------------------
* 融合视觉 design tokens — HEL-649 整站施工规范 §一(流水台结构 ×
* 放映厅配色)。Single source of truth for the whole app: pages and
* components reference these variables only — no hardcoded colors,
* no ad-hoc overrides. Gold is for key actions and the current
* selection only; status colors each carry exactly one meaning.
* 无彩灰阶 design tokens — HEL-680 S1(白栖知 theme-1「暗房放大机」
* × HEL-679 已确认融合样稿)。Single source of truth for the whole
* app: pages and components reference these variables only — no
* hardcoded colors, no ad-hoc overrides. 界面只有暖调黑白灰,作品是
* 屏幕上唯一成片的颜色;状态色仅按主题卡的两处有限例外(琥珀
* #A3915F、红 #B3564A)使用,不放大成全站主色。选中=取景框角线,
* 完成=「定影」,结果=相纸白边。
* ------------------------------------------------------------------ */
/* 底色:蓝黑四阶,逐层浮起 (§1.1) */
--bg: #07090e;
--bg-2: #0c0f16;
--bg-3: #121724;
--bg-4: #1a2133;
--line: #1b2233;
--line-2: #2a3450;
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(38, 52, 92, 0.25), transparent 60%);
--float-bg: linear-gradient(180deg, #10152a, var(--bg-2));
--overlay: rgba(4, 6, 10, 0.6);
--media-scrim: rgba(7, 9, 14, 0.65);
/* 底色:显影灰阶四阶,逐层浮起(theme-1:#212120 底 / #2C2C2A 面板) */
--bg: #212120;
--bg-2: #262625;
--bg-3: #2c2c2a;
--bg-4: #383835;
--line: #313130;
--line-2: #454542;
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(245, 244, 238, 0.035), transparent 60%);
--float-bg: linear-gradient(180deg, #2e2e2c, var(--bg-2));
--overlay: rgba(18, 18, 17, 0.62);
--media-scrim: rgba(18, 18, 17, 0.66);
--on-media: #fff;
--on-media-line: rgba(255, 255, 255, 0.05);
--composer-bg: linear-gradient(180deg, #0e1220, var(--bg-2));
--composer-focus: 0 0 0 1px rgba(242, 199, 132, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
--composer-bg: linear-gradient(180deg, #2a2a28, var(--bg-2));
--composer-focus: 0 0 0 1px rgba(245, 244, 238, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
/* 放映金:主按钮、当前选中、改过的参数、聚焦框、Toast 动作 (§1.2) */
--accent: #f2c784;
--accent-ink: #23170a;
--accent-soft: rgba(242, 199, 132, 0.13);
--accent-hi: #f7d9a2;
--accent-lo: #ecaf63;
--accent-glow: rgba(236, 175, 99, 0.28);
--accent-line: rgba(242, 199, 132, 0.55);
/* 相纸白(重点色):主按钮、当前选中、改过的参数、聚焦框、Toast 动作
(theme-1 #F5F4EE 重点) */
--accent: #f5f4ee;
--accent-ink: #212120;
--accent-soft: rgba(245, 244, 238, 0.08);
--accent-hi: #ffffff;
--accent-lo: #ddddd7;
--accent-glow: rgba(240, 239, 233, 0.2);
--accent-line: rgba(245, 244, 238, 0.55);
--accent-grad: linear-gradient(180deg, var(--accent-hi), var(--accent-lo));
--accent-grad-hover: linear-gradient(180deg, #fbe3b6, #f0b968);
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
--accent-wash: linear-gradient(180deg, rgba(242, 199, 132, 0.05), transparent 40%);
--sel-shadow: 0 0 0 1px rgba(242, 199, 132, 0.15), 0 6px 24px rgba(0, 0, 0, 0.35);
--accent-grad-hover: linear-gradient(180deg, #ffffff, #e8e7df);
--accent-ring: 0 0 0 1px rgba(245, 244, 238, 0.7), 0 0 22px var(--accent-glow);
--btn-glow: 0 2px 10px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.55);
--btn-glow-hover: 0 3px 14px rgba(0, 0, 0, 0.44), inset 0 1px 0 rgba(255, 255, 255, 0.65);
--accent-wash: linear-gradient(180deg, rgba(245, 244, 238, 0.05), transparent 40%);
--sel-shadow: 0 0 0 1px rgba(245, 244, 238, 0.14), 0 6px 24px rgba(0, 0, 0, 0.35);
/* 信号色:绿=完成/已确认/通过,青=进行中/在线,红=失败/危险,
琥珀=待确认/文件缺失/限额 (§1.3) */
--ok: #49cb90;
--ok-soft: rgba(73, 203, 144, 0.13);
--run: #54dfd2;
--run-soft: rgba(84, 223, 210, 0.13);
--run-grad: linear-gradient(90deg, #3bbfb4, var(--run));
--err: #f0645a;
--err-soft: rgba(240, 100, 90, 0.13);
--err-line: rgba(240, 100, 90, 0.6);
--warn: #f0b45c;
--warn-soft: rgba(240, 180, 92, 0.13);
/* 信号色:完成=显影亮灰(「定影」,配图标/文字双编码),进行中=灰阶
+动效;红/琥珀只按 theme-1 卡片的有限例外使用(红=失败/危险
#B3564A 系,琥珀=待确认/文件缺失 #A3915F) */
--ok: #ddddd7;
--ok-soft: rgba(221, 221, 215, 0.1);
--run: #b8b6ac;
--run-soft: rgba(184, 182, 172, 0.12);
--run-grad: linear-gradient(90deg, #8f8e85, #c6c5c0);
--err: #d07a6b;
--err-solid: #b3564a;
--err-soft: rgba(181, 86, 74, 0.15);
--err-line: rgba(208, 122, 107, 0.55);
--warn: #a3915f;
--warn-soft: rgba(163, 145, 95, 0.13);
/* 文字与字体 (§1.4);--text-2 是深底可读性下限,不得再暗 */
--text: #f4f6fa;
--text-2: #aab2c5;
--text-3: #6d7690;
/* 文字与字体;--text-3 是深底可读性下限(≥4.5:1 on --bg-3),不得再暗 */
--text: #f0efe9;
--text-2: #a8a69c;
--text-3: #94938c;
--font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--fs-title: 15px;
@@ -95,8 +100,14 @@
/* 专属符号 (§1.7):齿孔只在记录流/输入台分隔带,光晕只在播放舞台 */
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
--stage-bg:
radial-gradient(60% 45% at 50% 38%, rgba(242, 199, 132, 0.07), transparent 70%),
radial-gradient(80% 70% at 50% 50%, #0d1220, #05070c);
radial-gradient(60% 45% at 50% 38%, rgba(245, 244, 238, 0.045), transparent 70%),
radial-gradient(80% 70% at 50% 50%, #232322, #171716);
/* 取景框角线与相纸白边 (HEL-680):内容级选中/结果呈现的统一语言 */
--frame-w: 2px;
--frame-arm: 12px;
--frame-inset: -4px;
--paper-w: 4px;
/* 动效 (§1.6,取 HEL-648 实测值);只动 transform / opacity */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
@@ -113,13 +124,13 @@
--d-progress: 400ms;
--press-scale: 0.97;
/* Avatar five-color set — five agents only; the one colorful gradient
family the spec keeps (§3.6). */
--avatar-1: linear-gradient(135deg, #e8a33d, #b96a1b);
--avatar-2: linear-gradient(135deg, #5b8def, #3a5fa9);
--avatar-3: linear-gradient(135deg, #3fb97f, #2a7a54);
--avatar-4: linear-gradient(135deg, #b98be0, #7a4fa9);
--avatar-5: linear-gradient(135deg, #d98e6a, #a95535);
/* Avatar five-step grayscale (HEL-680 无彩化):1–2 亮阶配深字,
3–5 暗阶配浅字;仍是五个身份位,只是从彩色渐变改为显影灰阶。 */
--avatar-1: linear-gradient(135deg, #e8e7df, #cdcbc4);
--avatar-2: linear-gradient(135deg, #b5b3aa, #9c9a91);
--avatar-3: linear-gradient(135deg, #6b6a66, #55544f);
--avatar-4: linear-gradient(135deg, #55544f, #424140);
--avatar-5: linear-gradient(135deg, #424140, #2e2e2c);
/* ------------------------------------------------------------------
* Legacy token names (§六 映射表) — aliases only, never new values,
@@ -297,7 +308,7 @@ code,
color: var(--err);
}
.a-btn.danger-solid {
background: var(--err);
background: var(--err-solid);
color: var(--on-media);
}
@media (hover: hover) and (pointer: fine) {
@@ -550,11 +561,47 @@ button.status-chip:active {
.a-chip.changed {
color: var(--accent);
}
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 金色文字 + accent-soft 底 */
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 取景框角线缩影 +
相纸白文字 + accent-soft 底 (HEL-680) */
.a-chip.sel {
position: relative;
background: var(--accent-soft);
color: var(--accent);
}
.a-chip.sel::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:
6px 1.5px,
1.5px 6px,
6px 1.5px,
1.5px 6px,
6px 1.5px,
1.5px 6px,
6px 1.5px,
1.5px 6px;
background-repeat: no-repeat;
}
.a-chip.warn {
color: var(--warn);
}
@@ -753,6 +800,81 @@ button.a-tile:disabled {
box-shadow: 0 0 0 1px var(--line);
}
/* ------------------------------------------------------------------
* 取景框角线选中 (HEL-680):内容级选中态的唯一语言——四角短线取景,
* 不整框描边。挂在需要角线的元素上(元素自身可 overflow 可见;被
* overflow:hidden 裁切的容器改用 --frame-inset 正值画在内侧)。
* ------------------------------------------------------------------ */
.a-frame {
position: relative;
}
.a-frame::after {
content: '';
position: absolute;
inset: var(--frame-inset, -4px);
z-index: 3;
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;
}
/* 相纸白边 (HEL-680):结果媒体外的相纸白框,底边略厚如手工相纸。
挂在承载 img/video 的媒体容器上;容器尺寸由调用方决定。 */
.a-photo {
background: var(--accent);
border: var(--paper-w, 4px) solid var(--accent);
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
border-radius: 2px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
}
/* 「定影」完成章 (HEL-680):已定影结果右上角的完成印记,随 jobst.done
文案走(中文=定影)。absolute 定位,挂在有媒体的结果容器内。 */
.a-fix {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
z-index: 2;
padding: 1px 6px;
border: 1px solid currentColor;
border-radius: 3px;
background: var(--media-scrim);
color: var(--ok);
font-size: var(--fs-mini);
font-weight: 700;
letter-spacing: 0.1em;
text-indent: 0.1em;
transform: rotate(-4deg);
pointer-events: none;
}
/* Floating surface for pop / drawer / confirm / toast / lightbox (§四.3). */
.a-float {
background: var(--float-bg);
+16 -644
View File
@@ -7,10 +7,8 @@
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
import { WORKFLOW_USAGES } from '$lib/comfy/types';
import WorkflowLibrary from '$lib/components/workflows/WorkflowLibrary.svelte';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type WorkflowMeta } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { classifyEndpoint, endpointTypeLabel, type EndpointKind } from '$lib/endpointKind';
@@ -25,12 +23,17 @@
queryKey: ['settings'],
queryFn: settings.get,
});
const workflowsQuery = createQuery({
queryKey: ['workflows'],
queryFn: workflows.list,
// HEL-682: the library browses metadata only (view=meta) — the heavy
// workflow_json payloads are fetched per-row inside WorkflowLibrary
// exactly when an action needs them.
const wfMetaQuery = createQuery({
queryKey: ['workflows', 'meta'],
queryFn: workflows.listMeta,
});
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
const endpoints = $derived(
$settingsQuery.data?.comfyui_endpoints ?? [],
);
const activeId = $derived(
$settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '',
);
@@ -281,7 +284,7 @@
}
function workflowsBound(id: string): number {
return ($workflowsQuery.data ?? []).filter((wf) => wf.endpoint_id === id).length;
return ($wfMetaQuery.data ?? []).filter((wf) => wf.endpoint_id === id).length;
}
const deleteMessage = $derived.by(() => {
@@ -327,188 +330,6 @@
},
});
// ------------------------------------------------------------------
// Workflow library (binding + per-row recheck / test). The import
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
// ------------------------------------------------------------------
let wfImportOpen = $state(false);
function startWfImport() {
wfImportOpen = true;
}
function usageLabel(u: WorkflowUsage | null | undefined): string {
return u ? t(`usage.${u}`) : t('usage.pending');
}
function wfProblems(wf: Workflow): string[] {
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
}
// HEL-643 L2: per-row 重新检查 / 测试 in the workflow library.
let wfRecheckId = $state<number | null>(null);
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
async function recheckRow(wf: Workflow) {
wfRecheckId = wf.id;
try {
const res = await workflows.check(wf.id, { refresh: true });
wfRecheckResult[wf.id] = res;
} catch (err) {
toast.error(apiErrorMessage(err));
} finally {
wfRecheckId = null;
}
}
function recheckLine(wf: Workflow): { tone: 'ok' | 'bad' | 'warn'; text: string } | null {
const check = wfRecheckResult[wf.id];
if (!check) return null;
if (!check.reachable) return { tone: 'warn', text: t('conn.wfCheckUnreachable') };
if (check.ok) return { tone: 'ok', text: t('conn.wfCheckOk') };
const n = check.findings.length;
return { tone: 'bad', text: t('conn.wfCheckProblems', { n }) };
}
let testWf = $state<Workflow | null>(null);
let testOpen = $state(false);
function openTest(wf: Workflow) {
testWf = wf;
testOpen = true;
}
function lastTestLine(wf: Workflow): string | null {
const lt = wf.last_test;
if (!lt) return null;
if (lt.status === 'done' && lt.output_count > 0) {
return t('conn.wfTestPassed', { n: lt.output_count, time: formatDbTime(lt.completed_at ?? lt.created_at) });
}
if (lt.status === 'pending' || lt.status === 'running') {
return t('conn.wfTestRunning', { time: formatDbTime(lt.created_at) });
}
return t('conn.wfTestFailed', { time: formatDbTime(lt.completed_at ?? lt.created_at) });
}
function formatDbTime(at: string | null): string {
if (!at) return '—';
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
const d = new Date(Number.isFinite(ts) ? ts : at);
if (Number.isNaN(d.getTime())) return '—';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const wfConfirmMutation = createMutation({
mutationFn: ({ id, usage }: { id: number; usage: WorkflowUsage }) =>
workflows.update(id, { usage, usage_source: 'user' }),
onSuccess: (wf) => {
client.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('conn.wfConfirmed', { usage: usageLabel(wf.usage) }));
},
onError: (err) => {
toast.error(
t('conn.wfImportFailed', {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
// ------------------------------------------------------------------
// HEL-638: per-row output-node designation for pending workflows with
// unknown custom output nodes (imported earlier or parked by migration).
// ------------------------------------------------------------------
let wfMapEditId = $state<number | null>(null);
let wfMapPicked = $state<Record<string, boolean>>({});
let wfMapKind = $state<Record<string, 'image' | 'video'>>({});
function rowOutputCandidates(wf: Workflow) {
return (wf.output_schema ?? []).filter((o) => o.evidence === 'unknown_node');
}
function rowNeedsOutputMap(wf: Workflow): boolean {
return Boolean(wf.needs_mapping) && !(wf.output_map ?? []).length;
}
function toggleWfMapEdit(wf: Workflow) {
if (wfMapEditId === wf.id) {
wfMapEditId = null;
return;
}
const picked: Record<string, boolean> = {};
const kinds: Record<string, 'image' | 'video'> = {};
const mapped = new Map((wf.output_map ?? []).map((e) => [e.nodeId, e.kind]));
for (const c of rowOutputCandidates(wf)) {
const known = mapped.get(c.nodeId);
if (known) picked[c.nodeId] = true;
kinds[c.nodeId] = known ?? c.suggested_kind ?? 'image';
}
wfMapPicked = picked;
wfMapKind = kinds;
wfMapEditId = wf.id;
}
const wfMapMutation = createMutation({
mutationFn: ({ id, outputMap }: { id: number; outputMap: WorkflowOutputMapEntry[] }) =>
workflows.update(
id,
outputMap.length
? { output_map: outputMap, output_map_source: 'user' }
: { output_map: [] },
),
onSuccess: (wf) => {
client.invalidateQueries({ queryKey: ['workflows'] });
wfMapEditId = null;
const nodes = (wf.output_map ?? []).map((e) => e.nodeId).join('、');
toast.success(
wf.output_map?.length
? t('conn.wfOutputApplied', { nodes })
: t('conn.wfOutputCleared'),
);
},
onError: (err) => {
toast.error(
t('conn.wfImportFailed', {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitWfMap(wf: Workflow) {
if ($wfMapMutation.isPending) return;
const outputMap = rowOutputCandidates(wf)
.filter((c) => wfMapPicked[c.nodeId])
.map((c) => ({
nodeId: c.nodeId,
kind: wfMapKind[c.nodeId] ?? c.suggested_kind ?? 'image',
label: c.label,
...(c.class_type ? { class_type: c.class_type } : {}),
}));
$wfMapMutation.mutate({ id: wf.id, outputMap });
}
const wfBindMutation = createMutation({
mutationFn: ({ id, endpointId }: { id: number; endpointId: string | null }) =>
workflows.update(id, { endpoint_id: endpointId }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['workflows'] });
},
});
const wfDeleteMutation = createMutation({
mutationFn: (id: number) => workflows.delete(id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('conn.wfDeleted'));
},
onError: (err) => {
// e.g. 409 "该工作流还有 N 个排队或执行中的任务…" (HEL-619)
toast.error(apiErrorMessage(err));
},
});
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
@@ -529,7 +350,9 @@
// 任务使用目标 (a6): where each kind of job actually runs — a workflow's
// own binding first, otherwise the default connection.
function kindTarget(kind: 'image' | 'video' | null): { value: string; kind: EndpointKind | null } {
const wfs = ($workflowsQuery.data ?? []).filter((w) => kind === null || w.kind === kind);
const wfs: WorkflowMeta[] = ($wfMetaQuery.data ?? []).filter(
(w) => kind === null || w.kind === kind,
);
const ids = new Set(wfs.map((w) => w.endpoint_id || activeId).filter(Boolean));
if (wfs.length === 0) ids.add(activeId);
if (ids.size === 1) {
@@ -710,208 +533,7 @@
</p>
</div>
<!-- ---------------- workflow library ---------------- -->
<section class="wf-panel" id="wf-library">
<div class="panel-head">
<h2>{t('conn.wfTitle')}</h2>
<span class="panel-hint">{t('conn.wfHint')}</span>
<span class="panel-actions">
<Button variant="secondary" size="sm" onclick={startWfImport}>
<Icon name="upload" size={13} />
{t('conn.wfImportAction')}
</Button>
</span>
</div>
{#if ($workflowsQuery.data ?? []).length === 0}
<EmptyState title={t('conn.wfEmpty')} />
<p class="missing-note">{t('conn.missingMaterial')}</p>
{:else}
{@const wfs = $workflowsQuery.data ?? []}
{@const pending = wfs.filter((w) => !w.usage).length}
<p class="wf-count">
{t('conn.wfCount', {
n: wfs.length,
m: wfs.filter((w) => w.kind === 'image').length,
k: wfs.filter((w) => w.kind === 'video').length,
})}
{#if pending}
<span class="wf-pending-note">· {t('conn.wfPendingCount', { n: pending })}</span>
{/if}
</p>
<div class="wf-table">
{#each wfs as wf (wf.id)}
<div class="wf-row" class:pending={!wf.usage}>
<span class="wf-kind {wf.kind}">
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
</span>
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
{#if wf.usage}
<span
class="tag tag-usage"
title={wf.usage_source === 'user' ? t('conn.wfSourceUser') : t('conn.wfSourceAuto')}
>
{usageLabel(wf.usage)}
</span>
{:else}
<select
class="a-select wf-confirm-select"
aria-label={t('conn.wfConfirmAria')}
disabled={$wfConfirmMutation.isPending}
onchange={(e) => {
const usage = e.currentTarget.value as WorkflowUsage;
if (usage) $wfConfirmMutation.mutate({ id: wf.id, usage });
e.currentTarget.value = '';
}}
>
<option value="">{t('usage.pending')}…</option>
{#each WORKFLOW_USAGES as u (u)}
<option value={u}>{usageLabel(u)}</option>
{/each}
</select>
{/if}
{#if rowNeedsOutputMap(wf)}
<button
type="button"
class="tag tag-mapbtn"
class:open={wfMapEditId === wf.id}
title={t('conn.wfOutputNeedMap')}
onclick={() => toggleWfMapEdit(wf)}
>
<Icon name="alert" size={12} />
{t('conn.wfOutputPendingBadge')}
</button>
{/if}
{#if !wf.is_enabled && wf.usage && !rowNeedsOutputMap(wf)}
<span class="tag tag-disabled">{t('conn.wfDisabledBadge')}</span>
{/if}
{#if wfProblems(wf).length}
<span class="tag tag-error" title={wfProblems(wf).join('\n')}>
{t('conn.wfBrokenBadge')}
</span>
{/if}
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
<button
type="button"
class="link-accent wf-test-btn"
title={t('wft.title')}
onclick={() => openTest(wf)}
>
<Icon name="play" size={13} />
{t('conn.wfTestBtn')}
</button>
<button
type="button"
class="link-accent wf-test-btn"
disabled={wfRecheckId === wf.id}
title={t('conn.wfCheckBtn')}
onclick={() => recheckRow(wf)}
>
<Icon name="retry" size={13} />
{wfRecheckId === wf.id ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
</button>
<select
class="a-select"
value={wf.endpoint_id ?? ''}
onchange={(e) =>
$wfBindMutation.mutate({
id: wf.id,
endpointId: e.currentTarget.value || null,
})}
>
<option value="">{t('conn.wfConnectionActive')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
<button
type="button"
class="link-danger"
title={t('conn.delete')}
onclick={() => $wfDeleteMutation.mutate(wf.id)}
>
<Icon name="trash" size={14} />
</button>
</div>
{@const lastLine = lastTestLine(wf)}
{@const recheck = recheckLine(wf)}
{#if lastLine || recheck}
<div class="wf-meta-row">
{#if lastLine}<span class="wf-meta-item">{t('conn.wfLastTest')}:{lastLine}</span>{/if}
{#if recheck}
<span class="wf-meta-item recheck" class:ok={recheck.tone === 'ok'} class:bad={recheck.tone === 'bad'} class:warn={recheck.tone === 'warn'}>
{#if recheck.tone === 'bad'}
{@const check = wfRecheckResult[wf.id]}
<span class="wf-recheck-text" title={(check?.findings ?? []).map((f) => f.message).join('\n')}>
{recheck.text}
</span>
{:else}
<span class="wf-recheck-text">{recheck.text}</span>
{/if}
</span>
{/if}
</div>
{/if}
{#if wfMapEditId === wf.id}
<div class="wf-map-editor">
<p class="wf-map-hint">{t('conn.wfOutputHint')}</p>
<div class="wf-slot-rows">
{#each rowOutputCandidates(wf) as c (c.nodeId)}
<div class="wf-slot-row" class:off={!wfMapPicked[c.nodeId]}>
<label class="wf-out-check">
<input
type="checkbox"
checked={wfMapPicked[c.nodeId] ?? false}
onchange={(e) =>
(wfMapPicked = {
...wfMapPicked,
[c.nodeId]: e.currentTarget.checked,
})}
/>
<span class="wf-slot-id mono">{c.nodeId}</span>
<span class="wf-slot-name" title={c.class_type}>{c.label}</span>
</label>
<select
class="a-select"
aria-label={t('conn.wfOutputKindAria')}
disabled={!wfMapPicked[c.nodeId]}
value={wfMapKind[c.nodeId] ?? c.suggested_kind ?? 'image'}
onchange={(e) =>
(wfMapKind = {
...wfMapKind,
[c.nodeId]: e.currentTarget.value as 'image' | 'video',
})}
>
<option value="image">{t('conn.wfKindImage')}</option>
<option value="video">{t('conn.wfKindVideo')}</option>
</select>
</div>
{/each}
</div>
<div class="form-actions">
<Button
variant="secondary"
size="sm"
onclick={() => (wfMapEditId = null)}
>
{t('conn.wfMapCancel')}
</Button>
<Button
variant="primary"
size="sm"
disabled={!Object.values(wfMapPicked).some(Boolean)}
loading={$wfMapMutation.isPending}
onclick={() => submitWfMap(wf)}
>
{t('conn.wfOutputApply')}
</Button>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</section>
<WorkflowLibrary rows={$wfMetaQuery.data ?? []} {endpoints} />
</section>
{#if formMode !== 'idle'}
@@ -1104,17 +726,6 @@
</div>
{/if}
</div>
<WorkflowImportDrawer
bind:open={wfImportOpen}
{endpoints}
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
/>
<WorkflowTestDialog
bind:open={testOpen}
workflow={testWf}
{endpoints}
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
/>
</AppShell>
<ConfirmDialog
@@ -1376,231 +987,6 @@
color: var(--text);
}
/* ---------------- workflow panel ---------------- */
.wf-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.panel-head h2 {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.panel-hint {
font-size: 11.5px;
color: var(--text-3);
}
.panel-actions {
display: inline-flex;
gap: 8px;
}
.wf-count {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--text-3);
}
.missing-note {
margin: 10px 0 0;
font-size: 11.5px;
color: var(--text-3);
border-left: 2px solid var(--accent);
padding-left: 10px;
line-height: 1.6;
}
.wf-test-btn {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.wf-test-btn:disabled {
opacity: 0.6;
cursor: default;
}
.wf-meta-row {
display: flex;
gap: 14px;
flex-wrap: wrap;
padding: 0 10px 8px;
margin-top: -2px;
}
.wf-meta-item {
font-size: 10.5px;
color: var(--text-3);
}
.wf-meta-item.recheck .wf-recheck-text {
color: var(--text-3);
}
.wf-meta-item.recheck.ok .wf-recheck-text {
color: var(--success);
}
.wf-meta-item.recheck.bad .wf-recheck-text {
color: var(--error);
cursor: help;
}
.wf-meta-item.recheck.warn .wf-recheck-text {
color: var(--warn);
}
.wf-slot-rows {
display: flex;
flex-direction: column;
gap: 5px;
}
.wf-slot-row {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 8px;
border-radius: 7px;
background: var(--surface);
border: 1px solid var(--border);
}
.wf-slot-id {
font-size: 10.5px;
color: var(--text-3);
min-width: 64px;
}
.wf-slot-name {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wf-slot-row .a-select {
width: 130px;
}
.wf-slot-row.off {
opacity: 0.55;
}
.wf-out-check {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.wf-out-check input[type='checkbox'] {
accent-color: var(--accent);
}
.tag-mapbtn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: var(--error);
background: var(--error-dim);
border: 1px solid transparent;
}
.tag-mapbtn.open {
color: var(--accent);
background: var(--accent-dim);
border-color: var(--accent);
}
.wf-map-editor {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
margin: 2px 0 6px;
border-radius: 7px;
border: 1px dashed var(--border);
background: var(--surface);
}
.wf-map-hint {
margin: 0;
font-size: 11.5px;
color: var(--text-3);
}
.wf-map-editor .form-actions {
justify-content: flex-end;
}
.wf-params {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--text-3);
}
.tag-usage {
color: var(--text-2);
background: var(--bg-4);
}
.tag-disabled {
color: var(--text-3);
background: var(--surface-3);
}
.wf-pending-note {
color: var(--warn);
}
.wf-row.pending .wf-name {
color: var(--text-3);
}
.wf-confirm-select {
max-width: 150px;
font-size: 11px;
}
.wf-table {
display: flex;
flex-direction: column;
gap: 4px;
}
.wf-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background-color 150ms;
}
.wf-row:hover {
background: var(--surface-2);
}
.wf-kind {
width: 28px;
height: 28px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.wf-kind.image,
.wf-kind.video {
color: var(--text-2);
background: var(--bg-4);
}
.wf-name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wf-params {
font-size: 11px;
color: var(--text-3);
flex-shrink: 0;
}
.wf-row .a-select {
width: 220px;
}
/* ---------------- form column ---------------- */
.side-col {
width: 384px;
@@ -1879,9 +1265,6 @@
z-index: 790;
background: var(--overlay);
}
.wf-import-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1023px) {
@@ -1910,15 +1293,4 @@
.card-foot {
margin-left: 0;
}
.wf-row {
flex-wrap: wrap;
}
.wf-name {
flex-basis: 100%;
order: 5;
}
.wf-row .a-select {
flex: 1;
min-width: 160px;
}
}</style>
+21 -8
View File
@@ -209,7 +209,13 @@
{:else}
<div class="grid">
{#each visible as item (item.path)}
<div class="card" class:selected={selected.has(item.path)}>
<div
class="card"
class:selected={selected.has(item.path)}
class:a-frame={selected.has(item.path)}
style:--frame-inset="3px"
style:--frame-arm="16px"
>
<label class="check">
<input
type="checkbox"
@@ -217,12 +223,12 @@
onchange={() => toggle(item.path)}
/>
</label>
<button
type="button"
class="media"
onclick={() => openPreview(item)}
title={item.name}
>
<button
type="button"
class="media a-photo"
onclick={() => openPreview(item)}
title={item.name}
>
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
</button>
<div class="meta">
@@ -341,7 +347,6 @@
transition: border-color var(--d-state) ease;
}
.card.selected {
border-color: var(--accent-line);
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
}
.card :global(.media-el) {
@@ -380,6 +385,14 @@
cursor: zoom-in;
overflow: hidden;
}
/* 相纸白边 (HEL-680):素材墙里的作品按相纸呈现 */
.media.a-photo {
border: var(--paper-w, 4px) solid var(--accent);
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
background: var(--accent);
border-radius: 0;
box-sizing: border-box;
}
.media :global(img),
.media :global(video) {
width: 100%;
+2 -8
View File
@@ -392,14 +392,8 @@
char: string;
dark?: boolean;
}[] = [
{
role: 'story',
labelKey: 'models.agentStory',
color: 'var(--avatar-1)',
char: '剧',
dark: true,
},
{ role: 'style', labelKey: 'models.agentStyle', color: 'var(--avatar-2)', char: '视' },
{ role: 'story', labelKey: 'models.agentStory', color: 'var(--avatar-1)', char: '剧', dark: true },
{ role: 'style', labelKey: 'models.agentStyle', color: 'var(--avatar-2)', char: '视', dark: true },
{ role: 'script', labelKey: 'models.agentScript', color: 'var(--avatar-3)', char: '分' },
{ role: 'assets', labelKey: 'models.agentAssets', color: 'var(--avatar-4)', char: '素' },
{ role: 'video', labelKey: 'models.agentVideo', color: 'var(--avatar-5)', char: '镜' },
@@ -496,7 +496,11 @@
<aside class="side-col">
{#if currentAgent}
<div class="side-card agent-card">
<span class="agent-avatar" style:background={`var(--avatar-${currentAgent.avatar})`}>
<span
class="agent-avatar"
class:light={(currentAgent.avatar ?? 1) <= 2}
style:background={`var(--avatar-${currentAgent.avatar})`}
>
{currentAgent.char}
</span>
<div class="agent-copy">
@@ -740,6 +744,10 @@
color: var(--text);
flex-shrink: 0;
}
/* HEL-680 灰阶头像:1–2 亮阶用深字 */
.agent-avatar.light {
color: var(--accent-ink);
}
.agent-copy {
display: flex;
flex-direction: column;
@@ -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,315 @@
{
"id": "e3f2b845-8f2c-4b5a-9caf-eac1029d3e7e",
"revision": 0,
"last_node_id": 173,
"last_link_id": 338,
"nodes": [
{
"id": 169,
"type": "MiniMaxH3GuidedWriter",
"pos": [
0,
0
],
"size": [
400,
654
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "options",
"shape": 7,
"type": "H3_REWRITER_OPTIONS",
"link": null
}
],
"outputs": [
{
"name": "rewritten_prompt",
"type": "STRING",
"links": [
336
]
},
{
"name": "integrated_multimodal_description",
"type": "STRING",
"links": null
},
{
"name": "overall_soundscape",
"type": "STRING",
"links": null
},
{
"name": "non_diegetic_music",
"type": "STRING",
"links": null
}
],
"title": "Write the prompt",
"properties": {
"Node name for S&R": "MiniMaxH3GuidedWriter",
"max_duration": 30,
"cnr_id": "minimax-h3-prompt-rewriter",
"ver": "a59888df88a69385d429b6865ea441e7844f49c9"
},
"widgets_values": [
"图1的男人和图2的女人赤身躺在床上,女人跨坐在他身上,缓慢地起伏,双手撑在他胸口,头向后仰,呼吸越来越急。男人仰面躺着,喘息着低声说:\"看着我。\"女人俯下身吻他,镜头从侧面缓慢推近。\n",
"on disk: Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf [qwen35, 5.2 GB]",
"T2VA",
"16:9",
10,
true,
42,
"fixed",
false,
"",
"",
false,
"",
false,
""
],
"widgets_values_named": {
"prompt": "图1的男人和图2的女人赤身躺在床上,女人跨坐在他身上,缓慢地起伏,双手撑在他胸口,头向后仰,呼吸越来越急。男人仰面躺着,喘息着低声说:\"看着我。\"女人俯下身吻他,镜头从侧面缓慢推近。\n",
"model": "on disk: Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf [qwen35, 5.2 GB]",
"task": "T2VA",
"resolution": "16:9",
"duration": 10,
"greedy": true,
"seed": 42,
"control_after_generate": "fixed",
"keep_model_loaded": false,
"aspect_ratio": "",
"reference_material": "",
"bypass": false,
"system_prompt": "",
"repeat_last": false,
"library_pick": ""
}
},
{
"id": 170,
"type": "MiniMaxH3PromptCheck",
"pos": [
460,
0
],
"size": [
390,
420
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "options",
"shape": 7,
"type": "H3_REWRITER_OPTIONS",
"link": null
},
{
"label": "ref_0",
"name": "references.ref_0",
"shape": 7,
"type": "IMAGE,VIDEO,AUDIO",
"link": null
},
{
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
},
"link": 336
}
],
"outputs": [
{
"name": "prompt",
"type": "STRING",
"links": [
337
]
},
{
"name": "integrated_multimodal_description",
"type": "STRING",
"links": null
},
{
"name": "subject_definitions",
"type": "STRING",
"links": null
},
{
"name": "summary",
"type": "STRING",
"links": null
},
{
"name": "retention_analysis",
"type": "STRING",
"links": null
},
{
"name": "detailed_description",
"type": "STRING",
"links": null
},
{
"name": "overall_soundscape",
"type": "STRING",
"links": null
},
{
"name": "non_diegetic_music",
"type": "STRING",
"links": null
},
{
"name": "findings",
"type": "STRING",
"links": [
338
]
}
],
"title": "Check it against the H3 rules",
"properties": {
"Node name for S&R": "MiniMaxH3PromptCheck",
"max_duration": 30,
"cnr_id": "minimax-h3-prompt-rewriter",
"ver": "a59888df88a69385d429b6865ea441e7844f49c9"
},
"widgets_values": [
"",
"T2VA",
5
],
"widgets_values_named": {
"prompt": "",
"task": "T2VA",
"duration": 5
}
},
{
"id": 171,
"type": "PreviewAny",
"pos": [
900,
0
],
"size": [
560,
480
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 337
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": null
}
],
"title": "The finished prompt",
"properties": {
"Node name for S&R": "PreviewAny",
"cnr_id": "comfy-core",
"ver": "0.30.0"
},
"widgets_values": [],
"widgets_values_named": {}
},
{
"id": 172,
"type": "PreviewAny",
"pos": [
900,
520
],
"size": [
560,
240
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 338
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": null
}
],
"title": "What the check found",
"properties": {
"Node name for S&R": "PreviewAny",
"cnr_id": "comfy-core",
"ver": "0.30.0"
},
"widgets_values": [],
"widgets_values_named": {}
}
],
"links": [
[
336,
169,
0,
170,
2,
"STRING"
],
[
337,
170,
0,
171,
0,
"STRING"
],
[
338,
170,
8,
172,
0,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.1772972972972988,
"offset": [
429.8282570474093,
283.1220192192576
]
},
"frontendVersion": "1.53.10"
},
"version": 0.4
}
@@ -0,0 +1 @@
{"id":"fae76a0b-6efc-499c-ae87-9d887bcd785f","revision":0,"last_node_id":10,"last_link_id":5,"nodes":[{"id":10,"type":"DJZ-LoadLatent","pos":[960,680],"size":[390,60],"flags":{},"order":1,"mode":0,"inputs":[{"label":"latent_file","localized_name":"latent_file","name":"latent_file","type":"COMBO","widget":{"name":"latent_file"},"link":null}],"outputs":[{"label":"LATENT","localized_name":"Latent","name":"LATENT","type":"LATENT","links":[5]}],"properties":{"cnr_id":"djz-nodes","ver":"f6ca9376132600e6245d3d9d2f1e5d0ffa448d99","Node name for S&R":"DJZ-LoadLatent","aux_id":"MushroomFleet/DJZ-Nodes","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.4.1"}},"widgets_values":["latents\\ComfyUI_00001_nmxvu_1762517653.latent"]},{"id":3,"type":"VAEDecode","pos":[1460,760],"size":[140,50],"flags":{},"order":2,"mode":0,"inputs":[{"label":"Latent","localized_name":"Latent","name":"samples","type":"LATENT","link":5},{"label":"VAE","localized_name":"vae","name":"vae","type":"VAE","link":4}],"outputs":[{"label":"图像","localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.57","Node name for S&R":"VAEDecode","ue_properties":{"widget_ue_connectable":{},"version":"7.1","input_ue_unconnectable":{}}},"widgets_values":[]},{"id":4,"type":"VHS_VideoCombine","pos":[1680,750],"size":[400,334],"flags":{},"order":3,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"images","type":"IMAGE","link":2},{"label":"音频","localized_name":"音频","name":"audio","shape":7,"type":"AUDIO","link":null},{"label":"批次管理","localized_name":"批次管理","name":"meta_batch","shape":7,"type":"VHS_BatchManager","link":null},{"localized_name":"vae","name":"vae","shape":7,"type":"VAE","link":null},{"label":"帧率","localized_name":"帧率","name":"frame_rate","type":"FLOAT","widget":{"name":"frame_rate"},"link":null},{"label":"循环次数","localized_name":"循环次数","name":"loop_count","type":"INT","widget":{"name":"loop_count"},"link":null},{"label":"文件名前缀","localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"label":"格式","localized_name":"格式","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"label":"Ping-Pong","localized_name":"Ping-Pong","name":"pingpong","type":"BOOLEAN","widget":{"name":"pingpong"},"link":null},{"label":"保存到输出文件夹","localized_name":"保存到输出文件夹","name":"save_output","type":"BOOLEAN","widget":{"name":"save_output"},"link":null},{"name":"pix_fmt","type":["yuv420p","yuv420p10le"],"widget":{"name":"pix_fmt"},"link":null},{"name":"crf","type":"INT","widget":{"name":"crf"},"link":null},{"label":"保存元数据","localized_name":"保存元数据","name":"save_metadata","type":"BOOLEAN","widget":{"name":"save_metadata"},"link":null},{"label":"修剪音频","localized_name":"修剪音频","name":"trim_to_audio","type":"BOOLEAN","widget":{"name":"trim_to_audio"},"link":null}],"outputs":[{"label":"文件名","localized_name":"文件名","name":"Filenames","type":"VHS_FILENAMES","links":null}],"properties":{"cnr_id":"comfyui-videohelpersuite","ver":"8e4d79471bf1952154768e8435a9300077b534fa","Node name for S&R":"VHS_VideoCombine","ue_properties":{"widget_ue_connectable":{},"version":"7.1","input_ue_unconnectable":{}}},"widgets_values":{"frame_rate":16,"loop_count":0,"filename_prefix":"video/WanVideo_","format":"video/h264-mp4","pix_fmt":"yuv420p","crf":19,"save_metadata":true,"trim_to_audio":false,"pingpong":false,"save_output":true,"videopreview":{"hidden":false,"paused":false,"params":{"filename":"WanVideo__00001.mp4","subfolder":"video","type":"output","format":"video/h264-mp4","frame_rate":16,"workflow":"WanVideo__00001.png","fullpath":"E:\\ComfyUI-Data\\output\\video\\WanVideo__00001.mp4"}}}},{"id":5,"type":"VAELoader","pos":[980,820],"size":[360,60],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"vae名称","name":"vae_name","type":"COMBO","widget":{"name":"vae_name"},"link":null}],"outputs":[{"localized_name":"VAE","name":"VAE","type":"VAE","links":[4]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.68","Node name for S&R":"VAELoader","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.4.1"}},"widgets_values":["Wan2_1_VAE_bf16.safetensors"]}],"links":[[2,3,0,4,0,"IMAGE"],[4,5,0,3,1,"VAE"],[5,10,0,3,0,"LATENT"]],"groups":[],"config":{},"extra":{"ue_links":[],"ds":{"scale":1.0834705943389082,"offset":[-573.5954979317662,-431.50654580611246]},"links_added_by_ue":[]},"version":0.4}
@@ -0,0 +1 @@
{"id":"51bbecaf-42f9-4de3-95e4-b6db447eb5db","revision":0,"last_node_id":3,"last_link_id":2,"nodes":[{"id":3,"type":"PreviewImage","pos":[1480,500],"size":[400,260],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"图像","name":"images","type":"IMAGE","link":2}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.21.1","Node name for S&R":"PreviewImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":2,"type":"GuHaiAutoImageSplit","pos":[1160,500],"size":[270,210],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"图像","name":"图像","type":"IMAGE","link":1},{"localized_name":"保存目录","name":"保存目录","type":"STRING","widget":{"name":"保存目录"},"link":null},{"localized_name":"水平张数","name":"水平张数","type":"INT","widget":{"name":"水平张数"},"link":null},{"localized_name":"垂直张数","name":"垂直张数","type":"INT","widget":{"name":"垂直张数"},"link":null},{"localized_name":"移除画布边缘","name":"移除画布边缘","type":"BOOLEAN","widget":{"name":"移除画布边缘"},"link":null},{"localized_name":"移除描边","name":"移除描边","type":"INT","widget":{"name":"移除描边"},"link":null},{"localized_name":"文件名前缀","name":"文件名前缀","type":"STRING","widget":{"name":"文件名前缀"},"link":null},{"localized_name":"保存格式","name":"保存格式","type":"COMBO","widget":{"name":"保存格式"},"link":null}],"outputs":[{"localized_name":"分割图像","name":"分割图像","type":"IMAGE","links":[2]}],"properties":{"aux_id":"goohai/Goohaitools-comfyui","ver":"885cac3dd323d26a77b69ae904a9a344f0dee7d0","Node name for S&R":"GuHaiAutoImageSplit","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["output",3,3,true,8,"孤海图像分割_","PNG"]},{"id":1,"type":"LoadImage","pos":[850,490],"size":[270,320],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"图像","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"遮罩","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.21.1","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["Image (4) (1).png","image"]}],"links":[[1,1,0,2,0,"IMAGE"],[2,2,0,3,0,"IMAGE"]],"groups":[{"id":1,"title":"图像分割","bounding":[810,420,1100,400],"color":"#3f789e","flags":{}}],"config":{},"extra":{"ds":{"scale":0.9090909090909091,"offset":[-259.9355305037313,15.820561879510858]},"ue_links":[]},"version":0.4}
@@ -0,0 +1 @@
{"id":"74ef48f7-4441-45af-a6a0-a2bba28aeff5","revision":0,"last_node_id":18,"last_link_id":19,"nodes":[{"id":18,"type":"AudioCrop","pos":[-322,508],"size":[315,82],"flags":{},"order":1,"mode":0,"inputs":[{"label":"audio","localized_name":"audio","name":"audio","type":"AUDIO","link":18},{"label":"start_time","localized_name":"start_time","name":"start_time","type":"STRING","widget":{"name":"start_time"},"link":null},{"label":"end_time","localized_name":"end_time","name":"end_time","type":"STRING","widget":{"name":"end_time"},"link":null}],"outputs":[{"label":"AUDIO","localized_name":"音频","name":"AUDIO","type":"AUDIO","slot_index":0,"links":[19]}],"properties":{"cnr_id":"audio-separation-nodes-comfyui","ver":"1.4.0","Node name for S&R":"AudioCrop","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":["0:00","1:00"]},{"id":10,"type":"LoadAudio","pos":[-765,507],"size":[381.5714111328125,144],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"音频","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"音频UI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"label":"音频","localized_name":"音频","name":"AUDIO","type":"AUDIO","slot_index":0,"links":[18]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.68","Node name for S&R":"LoadAudio","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":["没出息.WAV",null,null]},{"id":15,"type":"PreviewAudio","pos":[416,514],"size":[318.9408688964845,88],"flags":{},"order":3,"mode":0,"inputs":[{"label":"音频","localized_name":"音频","name":"audio","type":"AUDIO","link":15},{"localized_name":"音频UI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.68","Node name for S&R":"PreviewAudio","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":[]},{"id":11,"type":"AudioSeparation","pos":[45,494],"size":[315,166],"flags":{},"order":2,"mode":0,"inputs":[{"label":"audio","localized_name":"audio","name":"audio","type":"AUDIO","link":19},{"label":"chunk_fade_shape","localized_name":"chunk_fade_shape","name":"chunk_fade_shape","shape":7,"type":"COMBO","widget":{"name":"chunk_fade_shape"},"link":null},{"label":"chunk_length","localized_name":"chunk_length","name":"chunk_length","shape":7,"type":"FLOAT","widget":{"name":"chunk_length"},"link":null},{"label":"chunk_overlap","localized_name":"chunk_overlap","name":"chunk_overlap","shape":7,"type":"FLOAT","widget":{"name":"chunk_overlap"},"link":null}],"outputs":[{"label":"Bass","localized_name":"Bass","name":"Bass","type":"AUDIO","links":null},{"label":"Drums","localized_name":"Drums","name":"Drums","type":"AUDIO","links":null},{"label":"Other","localized_name":"Other","name":"Other","type":"AUDIO","links":null},{"label":"Vocals","localized_name":"Vocals","name":"Vocals","type":"AUDIO","slot_index":3,"links":[15]}],"properties":{"cnr_id":"audio-separation-nodes-comfyui","ver":"1.4.0","Node name for S&R":"AudioSeparation","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":["logarithmic",16,1.1]}],"links":[[15,11,3,15,0,"AUDIO"],[18,10,0,18,0,"AUDIO"],[19,18,0,11,0,"AUDIO"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.9158251011505646,"offset":[1084.0109668805326,-51.53135329805568]}},"version":0.4}
@@ -0,0 +1 @@
{"id":"48bb093c-f7a8-421c-8fc4-2b6acc133512","revision":0,"last_node_id":4,"last_link_id":3,"nodes":[{"id":4,"type":"SaveAudioMP3","pos":[580,23110],"size":[270,136],"flags":{},"order":1,"mode":0,"inputs":[{"label":"音频","localized_name":"音频","name":"audio","type":"AUDIO","link":3},{"label":"文件名前缀","localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"label":"品质","localized_name":"品质","name":"quality","type":"COMBO","widget":{"name":"quality"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"Node name for S&R":"SaveAudioMP3"},"widgets_values":["audio/ComfyUI","V0"]},{"id":1,"type":"LoadAudio","pos":[180,23110],"size":[310,140],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"音频","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"音频UI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"音频","name":"AUDIO","type":"AUDIO","links":[3]}],"properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"Node name for S&R":"LoadAudio"},"widgets_values":["0001.G.E.M. 邓紫棋 - 光年之外.m4a",null,""]}],"links":[[3,1,0,4,0,"AUDIO"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":1.2839025177495265,"offset":[349.7067995460809,-22788.486109072262]}},"version":0.4}
@@ -0,0 +1 @@
{"id":"e6b92a63-6d7e-4d2b-b9e3-2e054825db02","revision":0,"last_node_id":7,"last_link_id":6,"nodes":[{"id":3,"type":"SaveImage","pos":[-500,-530],"size":[280,280],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"图片","name":"images","type":"IMAGE","link":2},{"localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[],"properties":{"Node name for S&R":"SaveImage","cnr_id":"comfy-core","ver":"0.16.4","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"widget_ue_connectable":{},"version":"7.7","input_ue_unconnectable":{}}},"widgets_values":["ComfyUI"]},{"id":1,"type":"RTXVideoSuperResolution","pos":[-830,-560],"size":[300,110],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":1},{"localized_name":"resize_type","name":"resize_type","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"resize_type"},"link":null},{"localized_name":"resize_type.scale","name":"resize_type.scale","type":"FLOAT","widget":{"name":"resize_type.scale"},"link":null},{"localized_name":"quality","name":"quality","type":"COMBO","widget":{"name":"quality"},"link":null}],"outputs":[{"localized_name":"upscaled_images","name":"upscaled_images","type":"IMAGE","links":[2,5]}],"properties":{"Node name for S&R":"RTXVideoSuperResolution","aux_id":"Comfy-Org/Nvidia_RTX_Nodes_ComfyUI","ver":"0.1.0","cnr_id":"comfyui_nvidia_rtx_nodes","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"widget_ue_connectable":{},"version":"7.7","input_ue_unconnectable":{}}},"widgets_values":["scale by multiplier",2,"ULTRA"]},{"id":2,"type":"LoadImage","pos":[-1150,-630],"size":[290,320],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"图像","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[1,6]},{"localized_name":"遮罩","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage","cnr_id":"comfy-core","ver":"0.16.4","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"widget_ue_connectable":{},"version":"7.7","input_ue_unconnectable":{}}},"widgets_values":["ComfyUI_01117_.png","image"]},{"id":7,"type":"Image Comparer (rgthree)","pos":[-180,-620],"size":[210,78],"flags":{},"order":3,"mode":0,"inputs":[{"dir":3,"label":"图像_A","localized_name":"图像_A","name":"image_a","type":"IMAGE","link":6},{"dir":3,"label":"图像_B","localized_name":"图像_B","name":"image_b","type":"IMAGE","link":5}],"outputs":[],"title":"图像对比","properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"comparer_mode":"Slide"},"widgets_values":[[]]}],"links":[[1,2,0,1,0,"IMAGE"],[2,1,0,3,0,"IMAGE"],[5,1,0,7,1,"IMAGE"],[6,2,0,7,0,"IMAGE"]],"groups":[],"config":{},"extra":{"workflowRendererVersion":"Vue-corrected","ds":{"scale":1.7985878990921387,"offset":[589.555558343045,830.7344763619243]},"frontendVersion":"1.39.19","VHS_latentpreview":false,"VHS_latentpreviewrate":0,"VHS_MetadataImage":true,"VHS_KeepIntermediate":true,"ue_links":[],"links_added_by_ue":[]},"version":0.4}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More