Compare commits

...
Author SHA1 Message Date
总工 2b3bbf9d03 HEL-691: deploy S6 visual sweep (feat/hel-685-visual-sweep @ bc24370) 2026-10-11 18:41:38 +08:00
施工员andmultica-agent bc24370233 feat(style): 全站无彩灰阶收尾——清理 legacy 别名与硬编码色漂移 (HEL-685 S6)
- app.css 删除 8 个无引用 legacy 别名,新增 --id-1..5 身份灰阶实色阶
- 画布去紫罗兰二级 palette(tokens.css 删除,改用全站 token)
- 画布/镜头台/智能体/omni/视频组件硬编码色全部归位 token
- agentPalette 彩色身份色改为显影灰阶 --id 阶;修复 3 处相纸白底白字不可读
- WorkflowLibrary 试运行抽屉焦点还原(异步禁用触发键丢焦)修复
- e2e: fpages/import 色值断言对齐现行 token;新增 hel685 走查脚本
  (1440/1024/390 截图 + 控制台错误 + 减动效 + 键盘焦点 + 对比度探针);
  pagination 空库自举;fpages 增加逐页控制台错误检查

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 17:26:12 +08:00
总工 2f267a751f HEL-690: deploy S5 project flowback (feat/hel-684-project-attach @ 0841f5a) 2026-10-11 15:34:07 +08:00
施工员andmultica-agent 0841f5a296 feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 15:05:57 +08:00
总工 35aad34c7c HEL-689: deploy S4 three-band desk console (feat/hel-683-desk-console @ bef28fa) 2026-10-11 13:52:49 +08:00
施工员andmultica-agent bef28facee feat(desk): 三段式图片/视频生成台——任务模式栏+工作流挑选台+摘要带 (HEL-683 S4)
- 新增 components/desk/:StudioDesk 取代 StudioGenerate 成为 /image、/video
  共用页面;TaskModeRail(真实计数、未覆盖置灰、收藏/最近)、WorkflowBay
  (搜索、本机/云端分段、模型家族分组、待确认入口、增量渲染)、
  WorkflowPreview(输入要求预览)、LibraryRow/LibraryFilters/
  InputRequirementBadge
- 挑选时中栏 grid 列宽展开、右侧显示预览;选定后收成摘要带;
  data-reduce 直接跳变;≤1179px 挑选台为全屏浮层,≤760px 任务模式横条
- 挑选侧全部走 view=meta 轻量元数据,完整记录按 id 惰性加载;
  云端来源诚实标注未接入;?wf/?mode 深链;五阶段上下文与返回路径不变
- Composer 工作流芯片改为展开挑选台,删除 WorkflowPicker;
  i18n 29 键 ×7 语言;changelog
- e2e/shoot.mjs 适配挑选台键盘链,并修正 7 处 S1 灰阶化后失效的
  旧色值/文案断言(定影 + theme-1 灰阶信号色)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 13:19:30 +08:00
总工 316fba97e1 HEL-688: deploy S4 workflow import wizard & meta admin (feat/hel-682-workflow-import-admin @ 1c00587) 2026-10-11 12:35:44 +08:00
施工员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
81 changed files with 12043 additions and 1224 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)
@@ -1,4 +1,5 @@
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
from __future__ import annotations
import json
@@ -31,6 +32,23 @@ PLAYGROUND_TITLE = "Playground Scratch"
# page can label origins ("独立生图" vs canvas Playground) without guessing.
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
# HEL-684: a desk visit opened from a five-stage workbench row may carry
# that row as optional context. Only real, server-verified project/stage/
# source info is accepted — the source row must still exist in the stage
# content, and the desk kind must match what that stage renders.
WorkbenchStage = Literal["assets", "shot"]
WORKBENCH_KIND_BY_STAGE: dict[str, str] = {"assets": "image", "shot": "video"}
WORKBENCH_ROW_KEY_BY_STAGE: dict[str, str] = {"assets": "assets", "shot": "shots"}
class PlaygroundWorkbenchContext(BaseModel):
project_id: int
stage: WorkbenchStage
source_kind: Literal["asset", "shot"]
source_id: str = Field(min_length=1)
# Display-only echo of the DeskContext label (e.g. 镜头 S1).
label: str | None = None
class PlaygroundGenerate(BaseModel):
workflow_id: int
@@ -39,6 +57,13 @@ class PlaygroundGenerate(BaseModel):
# then the active one (resolved in queue_manager.enqueue).
endpoint_id: str | None = None
source: PlaygroundSource = "playground"
# Optional real workbench source — validated, then stamped on the job
# payload so a finished take can be flowed back (attach-workbench).
context: PlaygroundWorkbenchContext | None = None
class PlaygroundAttachWorkbench(BaseModel):
job_id: int
class PlaygroundAttach(BaseModel):
@@ -83,6 +108,72 @@ def ensure_playground_project(conn) -> int:
return int(cur.lastrowid)
# ─────────────────────────────────────────────────────────────────────────
# HEL-684: workbench source context — validate against real rows, never
# guess. Both generate-time (record on the job payload) and attach-time
# (flow a finished take back) go through the same checks.
# ─────────────────────────────────────────────────────────────────────────
def _workbench_target_row(conn, ctx: PlaygroundWorkbenchContext) -> dict[str, Any] | None:
"""The real asset/shot row this context points at, or None."""
stage = conn.execute(
"SELECT content_json FROM project_stages WHERE project_id = ? AND stage_key = ?",
(ctx.project_id, ctx.stage),
).fetchone()
if not stage or not stage["content_json"]:
return None
try:
content = json.loads(stage["content_json"])
except (json.JSONDecodeError, TypeError):
return None
rows = content.get(WORKBENCH_ROW_KEY_BY_STAGE[ctx.stage])
if not isinstance(rows, list):
return None
for row in rows:
if isinstance(row, dict) and str(row.get("id") or "") == ctx.source_id:
return row
return None
def _validate_workbench_context(conn, ctx: PlaygroundWorkbenchContext, kind: str) -> dict[str, Any]:
"""Reject anything that is not a real, current project/stage/source.
Returns the payload stamp for the job. Raises on any mismatch so no
guessed or stale source is ever recorded.
"""
expected_kind = WORKBENCH_KIND_BY_STAGE[ctx.stage]
expected_source_kind = "asset" if ctx.stage == "assets" else "shot"
if ctx.source_kind != expected_source_kind or kind != expected_kind:
raise HTTPException(
status_code=422,
detail=(
f"来源上下文与生成类型不符:「{ctx.stage}」阶段需要{expected_kind}生成。"
"可关闭来源提示后独立生成。"
),
)
project = conn.execute(
"SELECT id, status FROM projects WHERE id = ?", (ctx.project_id,)
).fetchone()
if not project:
raise HTTPException(status_code=404, detail="来源项目不存在,无法记录回流上下文")
if project["status"] == PLAYGROUND_STATUS:
raise HTTPException(status_code=400, detail="不能以 playground 隐藏项目作为回流来源")
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成)。可关闭来源提示后独立生成。"
),
)
stamp: dict[str, Any] = ctx.model_dump()
if not (stamp.get("label") or "").strip():
stamp["label"] = None
return stamp
def _assert_path_in_assets(path: str) -> Path:
assets_root = config.settings.assets_dir.resolve()
target = Path(path).resolve()
@@ -195,6 +286,17 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
# HEL-684: which takes have already been flowed back to a workbench
# row (per source project) — the desk shows an honest 已回流 state
# that survives reloads instead of guessing from client memory.
links_by_project: dict[int, dict[int, dict[str, Any]]] = {}
rows = conn.execute(
"SELECT DISTINCT project_id FROM jobs"
" WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in rows:
proj_id = int(r["project_id"])
links_by_project[proj_id] = _workbench_link_rows(conn, proj_id)
finally:
conn.close()
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
@@ -204,7 +306,16 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
for j in queue_manager.list_jobs(pid, None, limit)
if str(j.get("origin") or "user") != "workflow_test"
]
return [_job_public(j) for j in jobs]
out = []
for j in jobs:
pub = _job_public(j)
stamp = (pub.get("payload") or {}).get("workbench")
if isinstance(stamp, dict) and isinstance(stamp.get("project_id"), int):
link = links_by_project.get(int(stamp["project_id"]), {}).get(int(pub["id"]))
if link and link.get("target_id") == stamp.get("source_id"):
pub["workbench_attach"] = link
out.append(pub)
return out
@router.delete("/jobs/{job_id}")
@@ -239,6 +350,21 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_clear_entity_refs(conn, [p for p in paths if isinstance(p, str)])
# HEL-684: the output files are about to vanish — drop the workbench
# link rows created from this take so no project row points at
# missing media (mirrors the entity-ref clearing above).
removed_links: list[int] = []
link_rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in link_rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("from_playground_job") == job_id:
conn.execute("DELETE FROM jobs WHERE id = ?", (int(r["id"]),))
removed_links.append(int(r["id"]))
conn.commit()
finally:
conn.close()
@@ -261,6 +387,7 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
"job_id": job_id,
"deleted_files": deleted_files,
"missing_files": missing_files,
"removed_workbench_links": removed_links,
}
@@ -365,6 +492,14 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
# HEL-684: an optional workbench source is only recorded when it is
# real and current — the job itself still runs on the playground
# scratch project so the desk feed, SSE and tasks stay unchanged.
workbench_stamp = (
_validate_workbench_context(conn, payload.context, kind)
if payload.context is not None
else None
)
finally:
conn.close()
@@ -385,6 +520,7 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
"input_values": input_values,
"source": payload.source,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
**({"workbench": workbench_stamp} if workbench_stamp else {}),
},
)
await event_bus.publish(
@@ -495,3 +631,167 @@ async def attach_to_project(payload: PlaygroundAttach) -> dict[str, Any]:
return out
finally:
conn.close()
def _workbench_link_rows(conn, project_id: int) -> dict[int, dict[str, Any]]:
"""playground job id → its flow-back link row on this project.
Link rows are done jobs with payload.source='workbench' and a
from_playground_job stamp — the exact shape _workbench_job_outputs
already enriches the workbench rows from, so a flowed-back take shows
up on its source row through the existing display path.
"""
out: dict[int, dict[str, Any]] = {}
rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
for r in rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("source") != "workbench":
continue
src = p.get("from_playground_job")
if isinstance(src, int):
out[src] = {
"job_id": int(r["id"]),
"stage": p.get("stage_key"),
"target_id": p.get("target_id"),
}
return out
@router.post("/attach-workbench")
async def attach_workbench(payload: PlaygroundAttachWorkbench) -> dict[str, Any]:
"""Flow a finished desk take back to its workbench source row (HEL-684).
The source is whatever real context the generate request carried —
nothing is guessed here. Re-attaching is idempotent: the existing link
row is returned, no duplicate is created, nothing is overwritten.
"""
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
job = queue_manager.get_job(payload.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if int(job["project_id"]) != int(pid):
raise HTTPException(status_code=403, detail="Not a playground job")
try:
job_payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
job_payload = {}
stamp = job_payload.get("workbench")
if not isinstance(stamp, dict) or not stamp.get("source_id"):
raise HTTPException(
status_code=400, detail="该记录没有项目来源上下文,无法回流(独立生成不回流)"
)
if job["status"] != "done":
raise HTTPException(status_code=400, detail="生成完成后才能回流")
outputs = json.loads(job.get("output_paths_json") or "[]")
if not isinstance(outputs, list) or not outputs:
raise HTTPException(status_code=400, detail="该记录没有产物文件,无法回流")
try:
ctx = PlaygroundWorkbenchContext(
project_id=int(stamp["project_id"]),
stage=stamp.get("stage"),
source_kind=stamp.get("source_kind"),
source_id=str(stamp["source_id"]),
label=stamp.get("label"),
)
except Exception as exc: # pydantic ValidationError / missing keys
raise HTTPException(
status_code=400, detail="该记录的项目来源上下文无效,无法回流"
) from exc
# Re-validate against the live stage content — the row may have been
# regenerated since the take was created; refuse rather than mis-write.
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成),未回流。"
),
)
links = _workbench_link_rows(conn, ctx.project_id)
existing = links.get(int(job["id"]))
if existing and existing.get("target_id") == ctx.source_id:
return {
"ok": True,
"already_attached": True,
"job_id": int(job["id"]),
"attached_job_id": existing["job_id"],
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
link_payload = {
"source": "workbench",
"stage_key": ctx.stage,
"target_id": ctx.source_id,
"from_playground_job": int(job["id"]),
"input_values": job_payload.get("input_values") or {},
}
if ctx.label:
link_payload["label"] = ctx.label
cur = conn.execute(
"""
INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,
output_paths_json, created_at, started_at, completed_at, origin)
VALUES (?, ?, ?, 'done', ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'user')
""",
(
ctx.project_id,
job["kind"],
job["workflow_id"],
json.dumps(link_payload, ensure_ascii=False),
json.dumps([str(p) for p in outputs if isinstance(p, str)]),
),
)
conn.execute(
"UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(ctx.project_id,),
)
conn.commit()
link_id = int(cur.lastrowid)
finally:
conn.close()
await event_bus.publish(
"job.completed",
{
"job_id": link_id,
"kind": job["kind"],
"outputs": outputs,
"project_id": ctx.project_id,
"source": "workbench",
"message": "生成台结果已回流到项目",
},
)
await event_bus.publish(
"asset.ready",
{
"job_id": link_id,
"path": outputs[0] if isinstance(outputs[0], str) else str(outputs[0]),
"project_id": ctx.project_id,
"target": "workbench",
"source": "playground_attach",
},
)
return {
"ok": True,
"already_attached": False,
"job_id": int(job["id"]),
"attached_job_id": link_id,
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
@@ -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()
@@ -0,0 +1,350 @@
"""HEL-684 — playground takes flow back to their workbench source row.
Covers the acceptance list: real-context-only passthrough (no guessing,
stale rows rejected), explicit flow-back with idempotency (repeated
operations create no duplicates), cancelled/independent paths never
writing the project, and playground job deletion clearing the link.
"""
from __future__ import annotations
import tempfile
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from calliope.db import migrate_db
from calliope.main import create_app
SAMPLE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveImage",
"inputs": {"images": ["1", 0]},
"_meta": {"title": "Save (Output:image)"},
},
}
SAMPLE_VIDEO_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveVideo",
"inputs": {"video": ["1", 0]},
"_meta": {"title": "Save (Output:video)"},
},
}
@pytest.fixture
def client(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
import asyncio
import calliope.config as config_module
s = config_module.settings
prev = {
"data_dir": s.data_dir,
"assets_dir": s.assets_dir,
"dry_run": s.dry_run,
}
s.data_dir = Path(tmpdir)
s.assets_dir = Path(tmpdir) / "assets"
s.assets_dir.mkdir(parents=True, exist_ok=True)
s.dry_run = True
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
asyncio.run(migrate_db(s.db_path))
app = create_app()
try:
with TestClient(app) as c:
yield c
finally:
for k, v in prev.items():
setattr(s, k, v)
def _wait_done(client: TestClient, job_id: int) -> dict:
for _ in range(60):
jobs = client.get("/api/playground/jobs").json()
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
if done and done.get("output_paths"):
return done
time.sleep(0.25)
raise AssertionError("playground job did not finish in dry-run")
def _seed_assets_stage(client: TestClient) -> dict:
proj = client.post("/api/projects", json={"title": "Reel"}).json()
# Real stage rows the way the workbench itself creates them.
patch = client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-1",
"kind": "character",
"name": "林夏",
"description": "",
"prompt": "a stoic courier",
"negative_prompt": "",
}
]
}
},
)
assert patch.status_code == 200, patch.text
return proj
def _context(proj: dict, **over: object) -> dict:
base = {
"project_id": proj["id"],
"stage": "assets",
"source_kind": "asset",
"source_id": "asset-1",
"label": "角色「林夏」",
}
base.update(over)
return base
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
r = client.post(
"/api/workflows",
json={
"name": name,
"kind": "video" if "SaveVideo" in str(workflow) else "image",
"workflow_json": workflow,
},
)
assert r.status_code == 200, r.text
return r.json()["id"]
# ── context passthrough ────────────────────────────────────────────────
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {"1": "a cat"},
"source": "studio-image",
"context": _context(proj),
},
)
assert r.status_code == 200, r.text
job = r.json()["job"]
assert job["payload"]["workbench"]["project_id"] == proj["id"]
assert job["payload"]["workbench"]["source_id"] == "asset-1"
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
# The job itself rides the hidden playground project — independent
# desk behavior (feed, SSE, tasks) is untouched.
scratch = client.get("/api/playground/project").json()
assert job["project_id"] == scratch["id"]
def test_generate_without_context_writes_nothing_to_any_project(client):
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
_seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
)
assert r.status_code == 200
assert "workbench" not in r.json()["job"]["payload"]
def test_generate_rejects_fake_or_stale_context(client):
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
# Unknown project
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
)
assert r.status_code == 404
# Source row that does not exist in the stage content
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
)
assert r.status_code == 409
# Kind mismatch: assets stage wants an image workflow
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={"workflow_id": video_wf, "context": _context(proj)},
)
assert r.status_code == 422
# The playground scratch project is not a valid source
scratch = client.get("/api/playground/project").json()
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
)
assert r.status_code == 400
# ── flow back ──────────────────────────────────────────────────────────
def test_attach_workbench_links_take_to_source_row(client):
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
# Not done yet → refused, nothing written.
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert early.status_code == 400
done = _wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 200, r.text
body = r.json()
assert body["already_attached"] is False
assert body["project_id"] == proj["id"]
assert body["target_id"] == "asset-1"
# The workbench overview now shows the artifact on the source row,
# through the exact enrichment path in-flow generation uses.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] == body["attached_job_id"]
assert row["job"]["status"] == "done"
assert row["job"]["output"] == done["output_paths"][0]
# Repeated flow-back: idempotent, no duplicate row.
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert again.status_code == 200
assert again.json()["already_attached"] is True
assert again.json()["attached_job_id"] == body["attached_job_id"]
# The desk jobs list reports the honest 已回流 marker.
jobs = client.get("/api/playground/jobs").json()
listed = next(j for j in jobs if j["id"] == job["id"])
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
def test_attach_workbench_requires_real_context(client):
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
).json()["job"]
_wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 400
assert "独立" in r.json()["detail"]
def test_attach_workbench_refuses_regenerated_source(client):
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
# Stage content regenerated: the old row id is gone.
client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-2",
"kind": "prop",
"name": "Blaster",
"description": "",
"prompt": "a blaster",
"negative_prompt": "",
}
]
}
},
)
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 409
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
assert all("job" not in a for a in assets_stage["content"]["assets"])
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
"""A later in-flow generation for the same row wins the display, as
the enrichment keeps the newest workbench job per target."""
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
flow = client.post(
f"/api/workbench/projects/{proj['id']}/generate",
json={
"kind": "image",
"stage_key": "assets",
"target_id": "asset-1",
"prompt": "in-flow take",
},
)
assert flow.status_code == 200, flow.text
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] != link["attached_job_id"]
def test_delete_playground_job_removes_workbench_link(client):
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
done = _wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
dead = client.delete(f"/api/playground/jobs/{job['id']}")
assert dead.status_code == 200
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
# The link row is gone: the project row no longer points at deleted files.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
assert not Path(done["output_paths"][0]).exists()
+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()
+84
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}`, {
@@ -638,6 +696,15 @@ export const playgroundApi = {
endpoint_id?: string | null;
/** Origin recorded on the job payload (tasks page labels). */
source?: "playground" | "studio-image" | "studio-video";
/** Optional real workbench source (HEL-684) — validated server-side and
* stamped on the job so finished takes can flow back to their row. */
context?: {
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
};
}) =>
api<{ ok: boolean; job: Job }>("/api/playground/generate", {
method: "POST",
@@ -655,7 +722,24 @@ export const playgroundApi = {
job_id: number;
deleted_files: string[];
missing_files: string[];
removed_workbench_links?: number[];
}>(`/api/playground/jobs/${jobId}`, { method: "DELETE" }),
/** Flow a finished take back to the workbench row it came from (HEL-684).
* The server derives the target from the job's own recorded context —
* idempotent, never overwrites, refuses stale source rows. */
attachWorkbench: (jobId: number) =>
api<{
ok: boolean;
already_attached: boolean;
job_id: number;
attached_job_id: number;
project_id: number;
stage: "assets" | "shot";
target_id: string;
}>("/api/playground/attach-workbench", {
method: "POST",
body: JSON.stringify({ job_id: jobId }),
}),
attach: (payload: {
path: string;
project_id: number;
+11 -11
View File
@@ -98,8 +98,8 @@
position: relative;
}
.artifact-node.running {
border-color: rgba(125, 211, 252, 0.6);
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
border-color: var(--run);
box-shadow: 0 0 18px var(--run-soft);
}
:global(.svelte-flow__node.selected) .artifact-node {
border-color: var(--accent);
@@ -117,9 +117,9 @@
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #bae6fd;
background: rgba(12, 74, 110, 0.55);
border: 1px solid rgba(125, 211, 252, 0.35);
color: var(--text-2);
background: var(--bg-4);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 6px;
flex-shrink: 0;
@@ -141,12 +141,12 @@
flex-shrink: 0;
}
.running-badge {
color: #bae6fd;
background: rgba(12, 74, 110, 0.6);
color: var(--run);
background: var(--run-soft);
}
.failed-badge {
color: #fecaca;
background: rgba(127, 29, 29, 0.5);
color: var(--err);
background: var(--err-soft);
}
.play-badge {
font-size: 9px;
@@ -205,9 +205,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
@@ -108,7 +108,7 @@
transition: border-color 0.15s;
}
.entity-node:hover {
border-color: #3a3a44;
border-color: var(--line-2);
}
/* Svelte Flow marks the selected node wrapper with .selected */
:global(.svelte-flow__node.selected) .entity-node {
@@ -176,9 +176,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
-13
View File
@@ -1,13 +0,0 @@
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
The global --accent stays untouched for v1 pages. */
.canvas-root {
--accent: #4f46a5;
--accent-hover: #6157c9;
--accent-glow: rgba(99, 102, 241, 0.3);
--edge: #7dd3fc;
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
--panel: #17171f;
--panel-border: #2a2a35;
--fg: #f4f4f5;
--fg-dim: #a1a1aa;
}
+121
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;
@@ -197,6 +311,13 @@ export interface Job {
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
}
export interface SceneVideoSettings {
@@ -522,7 +522,7 @@
}
.role {
font-weight: 600;
color: var(--accent, #a78bfa);
color: var(--accent);
font-size: 11px;
margin-left: 6px;
font-family: ui-monospace, monospace;
@@ -567,7 +567,7 @@
input.invalid:focus-visible,
textarea.invalid:focus-visible,
select.invalid:focus-visible {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
box-shadow: 0 0 0 3px var(--err-soft);
}
.error-msg {
font-size: 11px;
@@ -607,7 +607,7 @@
}
.clear:hover {
color: var(--error);
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.clear:focus-visible {
outline: 2px solid var(--accent);
@@ -30,7 +30,7 @@
position: absolute;
inset: 0;
z-index: 30;
background: rgba(18, 18, 24, 0.78);
background: var(--overlay);
backdrop-filter: blur(2px);
border-radius: var(--radius-md);
display: flex;
@@ -237,7 +237,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
}
.counter {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
color: color-mix(in srgb, var(--on-media) 65%, transparent);
margin-right: 4px;
font-variant-numeric: tabular-nums;
}
@@ -249,13 +249,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
background: color-mix(in srgb, var(--on-media) 12%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.tool:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.tool:focus-visible {
outline: 2px solid var(--accent);
@@ -272,13 +272,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 44px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: color-mix(in srgb, var(--on-media) 10%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.nav:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.nav:focus-visible {
outline: 2px solid var(--accent);
@@ -334,16 +334,16 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
.caption {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
max-width: min(96vw, 720px);
text-align: center;
}
.hint {
margin: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
color: color-mix(in srgb, var(--on-media) 55%, transparent);
}
.hint a {
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
}
</style>
@@ -504,9 +504,9 @@
height: 36px;
padding: 0 20px;
border-radius: 9999px;
background: var(--success, #22c55e);
background: var(--ok);
border: none;
color: #000;
color: var(--accent-ink);
font-size: 14px;
font-weight: 700;
font-family: var(--font-body);
@@ -95,10 +95,10 @@
rgba(255, 255, 255, 0.03) 6px,
rgba(255, 255, 255, 0.03) 12px
),
var(--bg-elevated, #16161d);
border: 1px dashed var(--border, #2a2a35);
var(--bg-elevated);
border: 1px dashed var(--border);
border-radius: inherit;
color: var(--text-muted, #8b8b9a);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -132,7 +132,7 @@
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
}
.artifact.failed {
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.meta {
display: flex;
@@ -249,12 +249,12 @@
width: 44px;
height: 44px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
background: var(--media-scrim);
box-shadow: 0 0 0 1px var(--on-media-line);
}
.play-badge :global(svg) {
position: absolute;
color: #fff;
color: var(--on-media);
fill: currentColor;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
margin-left: 2px;
@@ -668,8 +668,8 @@ let {
justify-content: center;
border: none;
border-radius: 999px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
background: var(--media-scrim);
color: var(--on-media);
cursor: pointer;
padding: 0;
}
@@ -772,7 +772,7 @@ let {
border-radius: var(--radius-md);
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
color: var(--accent-ink);
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -785,7 +785,7 @@ let {
.send.stop {
background: transparent;
color: var(--error);
border-color: rgba(239, 68, 68, 0.5);
border-color: var(--err-line);
}
.send.steer {
color: var(--accent);
@@ -431,6 +431,6 @@
}
.del:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.12);
background: var(--err-soft);
}
</style>
@@ -60,7 +60,7 @@
overflow: hidden;
}
.tool-card.error {
border-color: rgba(239, 68, 68, 0.45);
border-color: var(--err-line);
}
.head {
display: flex;
@@ -1,35 +1,42 @@
/**
* Agent identity palette: a stable display name + accent color per agent.
* Agent identity palette: a stable display name + identity step per agent.
*
* Identity colors answer "who is speaking" only (avatar / name / bubble
* tint) — they never express task status (spec v2 §3.6). The curated set
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
* no tailwind rainbow hues leak into the fusion palette; the default
* main-loop agent is neutral `--text-2` slate.
* Identity answers "who is speaking" only (avatar dot / name / bubble tint)
* — it never expresses task status. HEL-685 无彩化:全部改为 app.css 的
* 显影灰阶实色阶 --id-1..5(与全站 --avatar-1..5 同一身份语言),
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
// Fixed identity map (keyed by the raw agent_name the backend emits).
// Step numbers mirror the avatar five-set used across the app
// (models/agents/home project cards).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
planner: { label: 'Planner', color: 'var(--id-2)' },
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; colors mirror the spec §2.1 avatar five-set.
剧本创作: { label: '剧本创作', color: '#e8a33d' },
视觉设计: { label: '视觉设计', color: '#5b8def' },
分镜设计: { label: '分镜设计', color: '#3fb97f' },
素材资产: { label: '素材资产', color: '#b98be0' },
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
素材资产: { label: '素材资产', color: 'var(--id-4)' },
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
};
// Fallback palette for unknown names (stable hash → deterministic color),
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
// Fallback palette for unknown names (stable hash → deterministic step),
// spanning the same five grayscale identity steps.
const FALLBACK_COLORS = [
'var(--id-1)',
'var(--id-2)',
'var(--id-3)',
'var(--id-4)',
'var(--id-5)',
];
function hashString(s: string): number {
let h = 0;
@@ -39,8 +46,8 @@ function hashString(s: string): number {
return h;
}
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = '#aab2c5';
/** Neutral step (--id-4) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
@@ -0,0 +1,79 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMediaIO } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
/**
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
*/
interface Props {
io: WorkflowMediaIO | null | undefined;
/** Compact form for row summaries (no out side when empty-in). */
compact?: boolean;
}
let { io, compact = false }: Props = $props();
type Medium = 'image' | 'video' | 'audio';
const LABELS: Record<Medium, string> = {
image: 'wl.mediaImage',
video: 'wl.mediaVideo',
audio: 'wl.mediaAudio',
};
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
</script>
{#if inList.length || outList.length}
<span class="req" aria-label={t('desk.bayReqAria')}>
{#each inList as m (m)}
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
{#each outList as m (m)}
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
</span>
{:else if !compact}
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
{/if}
<style>
.req {
display: inline-flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 3px;
height: 18px;
padding: 0 5px;
border-radius: var(--r-sm);
background: var(--bg-4);
color: var(--text-2);
font-size: var(--fs-mini);
white-space: nowrap;
}
.pill.in {
color: var(--text);
}
.pill.none .lbl {
color: var(--text-3);
}
.arrow {
color: var(--text-3);
font-size: var(--fs-mini);
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
/**
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
*/
interface Props {
search: string;
source: 'local' | 'cloud';
favOnly: boolean;
resultHint: string;
searchEl?: HTMLInputElement | null;
onSetSearch: (v: string) => void;
onSetSource: (s: 'local' | 'cloud') => void;
onToggleFav: () => void;
}
let {
search,
source,
favOnly,
resultHint,
searchEl = $bindable(null),
onSetSearch,
onSetSource,
onToggleFav,
}: Props = $props();
</script>
<div class="filters">
<div class="search">
<Icon name="search" size={14} />
<input
bind:this={searchEl}
class="search-input"
type="search"
value={search}
placeholder={t('desk.baySearchPh')}
aria-label={t('desk.baySearchPh')}
aria-keyshortcuts="/"
oninput={(e) => onSetSearch(e.currentTarget.value)}
/>
</div>
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
{t('desk.baySourceLocal')}
</button>
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
{t('desk.baySourceCloud')}
</button>
</div>
<button
type="button"
class="a-chip fav"
class:on={favOnly}
aria-pressed={favOnly}
title={t('desk.bayFavOnly')}
onclick={onToggleFav}
>
<Icon name="star" size={13} />
<span class="lbl">{t('desk.bayFavOnly')}</span>
</button>
<span class="hint">{resultHint}</span>
</div>
<style>
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 1 1 180px;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.search:focus-within {
border-color: var(--accent-line);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
.source {
flex-shrink: 0;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.hint {
margin-left: auto;
color: var(--text-3);
font-size: var(--fs-mini);
white-space: nowrap;
}
@media (max-width: 760px) {
.hint {
display: none;
}
}
</style>
@@ -0,0 +1,170 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
*/
interface Props {
meta: WorkflowMeta;
current: boolean;
active: boolean;
endpoints: Array<{ id: string; name: string }>;
onPick: () => void;
onActive: () => void;
}
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
const endpointName = $derived(
meta.endpoint_id
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
: t('desk.wfDefaultConn'),
);
</script>
<button
type="button"
class="row"
class:active
class:current
role="option"
aria-selected={active ? 'true' : undefined}
aria-current={current ? 'true' : undefined}
onclick={onPick}
onpointermove={onActive}
onfocus={onActive}
>
<span class="main">
<span class="name">
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
<span class="name-txt">{meta.name}</span>
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
</span>
<span class="sub">
<span class="a-tag">{usageLabel(meta.usage!)}</span>
<InputRequirementBadge io={meta.media_io} compact />
</span>
</span>
<span class="side">
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
</span>
</button>
<style>
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
box-sizing: border-box;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
/* 长列表性能:视口外的行不参与布局/绘制。 */
content-visibility: auto;
contain-intrinsic-size: auto 52px;
transition: background-color var(--d-state) ease;
}
.row:hover,
.row.active {
background: var(--bg-3);
}
.row:focus-visible {
outline: none;
background: var(--bg-3);
box-shadow: 0 0 0 1px var(--accent-line);
}
.main {
display: grid;
gap: 3px;
flex: 1;
min-width: 0;
}
.name {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.fav {
display: inline-flex;
flex-shrink: 0;
color: var(--accent);
}
.cur-mark {
flex-shrink: 0;
color: var(--text-3);
font-size: var(--fs-mini);
}
.sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-1);
}
.side {
flex-shrink: 0;
}
.conn {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-3);
font-size: var(--fs-mini);
}
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
.row.current {
position: relative;
}
.row.current::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
</style>
@@ -2,7 +2,7 @@
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
@@ -13,13 +13,14 @@
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
import { readDeskContext } from '$lib/components/generate/context';
import { readDeskContext, workbenchContextOf } from '$lib/components/generate/context';
import {
changedParams,
classifySchema,
listPaths,
missingMedia,
schemaDefaults,
usageLabel,
validateForm,
type BlockReason,
type DeskKind,
@@ -27,8 +28,18 @@
type FormValues,
} from '$lib/components/generate/schema';
import { matchesFilter, type FeedFilter } from '$lib/components/generate/takes';
import InputRequirementBadge from './InputRequirementBadge.svelte';
import TaskModeRail from './TaskModeRail.svelte';
import WorkflowBay from './WorkflowBay.svelte';
import WorkflowPreview from './WorkflowPreview.svelte';
import { apiErrorMessage, assetUrl, comfy, jobsApi, playgroundApi, projects, settings, workflows } from '$lib/api';
import { USAGE_BY_KIND, type Job, type Workflow } from '$lib/comfy/types';
import {
USAGE_BY_KIND,
type Job,
type Workflow,
type WorkflowMeta,
type WorkflowUsage,
} from '$lib/comfy/types';
import { compactInputValues } from '$lib/comfy/promptInput';
import { connectEvents } from '$lib/events';
import { handleJobEvent } from '$lib/jobProgress';
@@ -38,11 +49,11 @@
import { t } from '$lib/i18n.svelte';
/**
* 生成台 (spec-v2 §3.3, HEL-650 C 批): one desk for images and video — the
* take feed with the composer below and the inspector on the right.
* `/image` and `/video` only preselect: the default workflow comes from
* that kind's usages and the feed opens filtered to it. Slots and params
* follow the selected workflow's JSON (see generate/schema.ts).
* 三段式生成台 (HEL-683, HEL-674 §二 / HEL-679 融合样稿): 左栏任务
* 模式、中部工作流挑选台(挑选时展开,选定后收成摘要带)、右侧沿用
* 参数与结果组件。`/image` 与 `/video` 共用本台,`kind` 决定任务模式,
* `?wf=<id>` 深链指定工作流、`?mode=<usage>` 预选任务模式。项目五阶段
* 的上下文条与返回路径保持不变(回流属 S5,不在本卡)。
*/
interface Props {
kind: DeskKind;
@@ -56,7 +67,10 @@
// Data
// ------------------------------------------------------------------
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
// HEL-683: the desk browses S2's lightweight metadata (view=meta, no
// workflow_json). Full records are fetched per id on demand — the whole
// list (~7.6MB at 150+ rows) never loads here.
const metaQuery = createQuery({ queryKey: ['workflows-meta'], queryFn: workflows.listMeta });
const jobsQuery = createQuery({
queryKey: ['playground-jobs'],
queryFn: playgroundApi.jobs,
@@ -77,13 +91,75 @@
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
const dryRun = $derived($settingsQuery.data?.dry_run === true);
// HEL-636/638: only confirmed usages with a settled output mapping.
const deskWorkflows = $derived(
($workflowsQuery.data ?? []).filter((w) => w.is_enabled && (w.usage ?? null) !== null && !w.needs_mapping),
// Picker-side metadata: runnable rows of this kind + honest pending count.
const allMetas = $derived($metaQuery.data ?? []);
const metaById = $derived(new Map(allMetas.map((m) => [m.id, m])));
const deskMetas = $derived(
allMetas.filter((m) => m.is_enabled && m.usage !== null && !m.needs_mapping && m.kind === kind),
);
const workflowById = $derived(new Map(($workflowsQuery.data ?? []).map((w) => [w.id, w])));
const workflowOf = (job: Job): Workflow | undefined =>
job.workflow_id != null ? workflowById.get(job.workflow_id) : undefined;
const pendingCount = $derived(allMetas.filter((m) => m.needs_mapping && m.kind === kind).length);
// Full records, lazily fetched per id (selected workflow, feed jobs'
// workflows, the bay's preview row) and cached until invalidated.
const details = new SvelteMap<number, Workflow>();
const inflight = new Set<number>();
async function ensureDetail(id: number): Promise<Workflow | null> {
const hit = details.get(id);
if (hit) return hit;
if (inflight.has(id)) return null;
inflight.add(id);
try {
const wf = await workflows.get(id);
details.set(id, wf);
return wf;
} catch {
return null;
} finally {
inflight.delete(id);
}
}
const neededIds = $derived.by(() => {
const ids = new Set<number>();
if (workflowId != null) ids.add(workflowId);
if (bayOpen && bayActiveId != null) ids.add(bayActiveId);
for (const j of allJobs) if (j.workflow_id != null) ids.add(j.workflow_id);
return ids;
});
$effect(() => {
for (const id of neededIds) {
if (!details.has(id)) void ensureDetail(id);
}
});
/**
* A job's workflow: the full record when loaded, else a metadata stub
* (name/kind/usage render instantly; input-dependent views stay empty
* until the record lands). Undefined only when the row is truly gone —
* the 工作流已删除 tag stays honest.
*/
function stubFromMeta(m: WorkflowMeta): Workflow {
return {
id: m.id,
name: m.name,
kind: m.kind,
usage: m.usage,
is_enabled: m.is_enabled,
input_schema: [],
workflow_json: {},
description: m.description,
prompt_profile: m.prompt_profile,
} as unknown as Workflow;
}
const workflowOf = (job: Job): Workflow | undefined => {
if (job.workflow_id == null) return undefined;
const full = details.get(job.workflow_id);
if (full) return full;
const m = metaById.get(job.workflow_id);
return m ? stubFromMeta(m) : undefined;
};
const allJobs = $derived($jobsQuery.data ?? []);
@@ -136,6 +212,61 @@
return off;
});
// ------------------------------------------------------------------
// Bay (挑选台) state — mode, deep links, open/collapse
// ------------------------------------------------------------------
type TaskMode = WorkflowUsage | 'all';
let mode = $state<TaskMode>(untrack(initMode));
function initMode(): TaskMode {
const q = page.url.searchParams.get('mode');
return q && USAGE_BY_KIND[kind].includes(q as WorkflowUsage) ? (q as WorkflowUsage) : 'all';
}
let bayOpen = $state(false);
let bayActiveId = $state<number | null>(null);
let bay = $state<WorkflowBay | null>(null);
const modeMetas = $derived(mode === 'all' ? deskMetas : deskMetas.filter((m) => m.usage === mode));
function openBay() {
bayActiveId = workflowId;
bayOpen = true;
}
function closeBay() {
bayOpen = false;
}
function pickWorkflow(id: number) {
bayOpen = false;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
// ?wf=<id> deep link: preselect once the metadata is in (invalid → ignored).
let wfDeepLinkDone = false;
$effect(() => {
if (wfDeepLinkDone) return;
const raw = Number(page.url.searchParams.get('wf'));
if (!Number.isInteger(raw) || raw <= 0) {
wfDeepLinkDone = true;
return;
}
if ($metaQuery.isLoading) return;
wfDeepLinkDone = true;
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
});
async function toggleFavorite(id: number, fav: boolean) {
try {
await workflows.update(id, { favorite: fav });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
await client.invalidateQueries({ queryKey: ['workflows'] });
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
// ------------------------------------------------------------------
// Form state
// ------------------------------------------------------------------
@@ -150,7 +281,7 @@
let promptEl = $state<HTMLTextAreaElement | null>(null);
let pickerFor = $state<string | null>(null);
const currentWorkflow = $derived(deskWorkflows.find((w) => w.id === workflowId) ?? null);
const currentWorkflow = $derived(workflowId != null ? (details.get(workflowId) ?? null) : null);
const schema = $derived(currentWorkflow?.input_schema ?? []);
const ds = $derived(classifySchema(schema, currentWorkflow?.kind ?? kind));
@@ -161,16 +292,13 @@
endpointId = currentWorkflow?.endpoint_id ?? activeId ?? '';
});
// Default workflow: this entry's usages in priority order, runnable ones first.
// Default workflow: this entry's usages in priority order (metadata-side;
// error-level warnings surface through the block reason once loaded).
$effect(() => {
if (workflowId != null && deskWorkflows.some((w) => w.id === workflowId)) return;
if (workflowId != null && (details.has(workflowId) || deskMetas.some((m) => m.id === workflowId))) return;
const usages = USAGE_BY_KIND[kind];
const runnable = deskWorkflows.filter((w) => !(w.warnings ?? []).some((x) => x.level === 'error'));
const pick =
usages.map((u) => runnable.find((w) => w.usage === u)).find(Boolean) ??
deskWorkflows.find((w) => usages.includes(w.usage!)) ??
runnable[0] ??
deskWorkflows[0];
const candidates = deskMetas.filter((m) => m.usage != null && usages.includes(m.usage));
const pick = usages.map((u) => candidates.find((m) => m.usage === u)).find(Boolean) ?? candidates[0] ?? null;
workflowId = pick?.id ?? null;
});
@@ -328,6 +456,10 @@
openParams();
return;
}
if (b.target === 'desk-wf') {
openBay();
return;
}
await tick();
const el = b.target ? document.getElementById(b.target) : null;
if (el && !el.getClientRects().length) {
@@ -344,6 +476,11 @@
// ------------------------------------------------------------------
let sentTimer: ReturnType<typeof setTimeout> | null = null;
// HEL-684: the workbench source is sent along only while the context
// bar is up (dismissing it = independent generation, nothing written
// to any project) and only when the full real source identity is there.
const wbSendContext = $derived(ctx && !ctxDismissed ? workbenchContextOf(ctx) : undefined);
async function submit() {
if (submitting || slotBusy) return;
if (block) {
@@ -358,6 +495,7 @@
input_values: compactInputValues(values) as Record<string, unknown>,
endpoint_id: endpointId || null,
source: wf.kind === 'image' ? 'studio-image' : 'studio-video',
...(wbSendContext ? { context: wbSendContext } : {}),
});
submittedIds.add(res.job.id);
selectedJobId = res.job.id;
@@ -372,6 +510,8 @@
else if (!narrow) inspOpen = true;
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
// 最近使用由后端在入队时记录 (queue/manager);这里只刷新轻量列表。
void client.invalidateQueries({ queryKey: ['workflows-meta'] });
} catch (err) {
toast.error(t('studio.submitFailedToast', { detail: apiErrorMessage(err) }));
} finally {
@@ -494,9 +634,14 @@
}
/** Fill the composer back with a past take's values (on its own workflow). */
function reuse(job: Job) {
const wf = deskWorkflows.find((w) => w.id === job.workflow_id);
if (!wf) {
async function reuse(job: Job) {
const id = job.workflow_id;
if (id == null) {
toast.error(t('studio.reuseMissingWf'));
return;
}
const wf = (details.get(id) ?? (await ensureDetail(id))) as Workflow | null;
if (!wf || !(wf.input_schema ?? []).length) {
toast.error(t('studio.reuseMissingWf'));
return;
}
@@ -570,6 +715,32 @@
});
}
// ------------------------------------------------------------------
// 回流到源项目 (HEL-684): explicit, one take at a time. The server
// re-validates the source row, is idempotent on repeats and refuses
// stale rows — the toast just reports what happened.
// ------------------------------------------------------------------
let attachBusyId = $state<number | null>(null);
async function attachToProject(job: Job) {
if (attachBusyId != null) return;
attachBusyId = job.id;
try {
const res = await playgroundApi.attachWorkbench(job.id);
toast.success(
res.already_attached
? t('desk.attachAlreadyToast')
: t('desk.attachDoneToast', { label: ctx?.label ?? '' }),
);
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(t('desk.attachFailedToast', { detail: apiErrorMessage(err) }));
} finally {
attachBusyId = null;
}
}
// ------------------------------------------------------------------
// 从结果选 (finished image takes → an image slot)
// ------------------------------------------------------------------
@@ -594,8 +765,9 @@
}
// ------------------------------------------------------------------
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦搜索/提示词 · ↑↓/J/K 切换
// (挑选台打开时游走挑选行,否则切换记录)· 空格播放/暂停 · Delete 删
// 选中 · ] 检视区 · Esc 关挑选台/浮层
// ------------------------------------------------------------------
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
@@ -615,6 +787,19 @@
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
if (e.key === 'Escape' && bayOpen) {
e.preventDefault();
closeBay();
return;
}
// 挑选台开着时,键盘归挑选台(其容器自己处理 ↑↓/Enter/Esc)。
if (bayOpen) {
if (e.key === '/' && !typing(e.target)) {
e.preventDefault();
bay?.focusSearch();
}
return;
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void submit();
@@ -652,6 +837,14 @@
inspOpen = false;
}
}
// ------------------------------------------------------------------
// Band (选定后的摘要带) helpers
// ------------------------------------------------------------------
const currentMeta = $derived(allMetas.find((m) => m.id === workflowId) ?? null);
const previewMeta = $derived(allMetas.find((m) => m.id === bayActiveId) ?? null);
const previewFull = $derived(bayActiveId != null ? (details.get(bayActiveId) ?? null) : null);
const picking = $derived(bayOpen && wide);
</script>
<svelte:window onkeydown={onKeydown} />
@@ -685,82 +878,133 @@
{/snippet}
<AppShell active={kind} title={t('desk.title')}>
<div class="desk" class:insp-off={inspOff}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
selectedId={selectedJob?.id ?? null}
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
<div class="studio-desk" class:picking>
<TaskModeRail {kind} metas={deskMetas} active={mode} onSelect={(m) => (mode = m)} onPickWorkflow={pickWorkflow} />
<div class="bay-col">
<WorkflowBay
bind:this={bay}
open={bayOpen}
metas={modeMetas}
{pendingCount}
selectedId={workflowId}
bind:activeId={bayActiveId}
{endpoints}
onPick={pickWorkflow}
onClose={closeBay}
/>
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
<div class="desk-empty" role="note">
<Icon name="playground" size={16} />
<span>{t('desk.noWorkflows')}</span>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
<section class="main-col">
{#if !bayOpen}
<div class="bay-band">
<button type="button" class="band-hit" onclick={openBay} title={t('desk.bandChange')}>
<Icon name="playground" size={14} />
<span class="band-mode">{mode === 'all' ? t('desk.modeAll') : usageLabel(mode)}</span>
<span class="band-sep" aria-hidden="true">·</span>
<span class="band-name">
{currentWorkflow?.name ?? currentMeta?.name ?? t('desk.pickWorkflow')}
</span>
{#if currentMeta?.usage}<span class="a-tag">{usageLabel(currentMeta.usage)}</span>{/if}
<InputRequirementBadge io={currentMeta?.media_io} compact />
<span class="band-change">
<span class="band-change-txt">{t('desk.bandChange')}</span>
<Icon name="chevron-right" size={13} />
</span>
</button>
</div>
{/if}
<Composer
{ds}
{values}
workflows={deskWorkflows}
current={currentWorkflow}
{endpoints}
{endpointId}
conn={connState}
{errors}
{showErrors}
{block}
{changedCount}
{submitting}
{sent}
bind:slotBusy
bind:promptEl
onSet={setVal}
onPickWorkflow={(wf) => (workflowId = wf.id)}
onPickEndpoint={(id) => {
endpointTouched = true;
endpointId = id;
}}
onSubmit={submit}
onReveal={reveal}
onOpenParams={openParams}
onFromResults={(nodeId) => (pickerFor = nodeId)}
/>
{#if picking}
<WorkflowPreview
meta={previewMeta}
full={previewFull}
currentId={workflowId}
{endpoints}
onUse={() => pickWorkflow(bayActiveId ?? workflowId ?? 0)}
onToggleFav={(fav) => previewMeta && toggleFavorite(previewMeta.id, fav)}
/>
{/if}
<div class="desk" class:insp-off={inspOff} class:off={picking}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
selectedId={selectedJob?.id ?? null}
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
{attachBusyId}
onAttach={attachToProject}
/>
{#if !$metaQuery.isLoading && !deskMetas.length}
<div class="desk-empty" role="note">
<Icon name="playground" size={16} />
<span>{t('desk.noWorkflows')}</span>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
{/if}
<Composer
{ds}
{values}
current={currentWorkflow}
{endpoints}
{endpointId}
conn={connState}
{errors}
{showErrors}
{block}
{changedCount}
{submitting}
{sent}
bind:slotBusy
bind:promptEl
onSet={setVal}
onOpenBay={openBay}
onPickEndpoint={(id) => {
endpointTouched = true;
endpointId = id;
}}
onSubmit={submit}
onReveal={reveal}
onOpenParams={openParams}
onFromResults={(nodeId) => (pickerFor = nodeId)}
/>
</section>
<Inspector
bind:tab
job={selectedJob}
wf={selectedJob ? workflowOf(selectedJob) : undefined}
{nowMs}
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>
</div>
</section>
<Inspector
bind:tab
job={selectedJob}
wf={selectedJob ? workflowOf(selectedJob) : undefined}
{nowMs}
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>
</div>
</AppShell>
@@ -779,17 +1023,48 @@
</Modal>
<style>
.studio-desk {
display: grid;
grid-template-columns: 204px 0px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
}
.studio-desk.picking {
grid-template-columns: 204px clamp(340px, 28vw, 440px) minmax(0, 1fr);
}
:global(:root[data-reduce='1']) .studio-desk {
transition: none;
}
.bay-col {
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--line);
}
.main-col {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.desk {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
flex: 1;
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
}
.desk.insp-off {
grid-template-columns: minmax(0, 1fr) 0px;
}
.desk.off {
display: none;
}
.center {
display: flex;
flex-direction: column;
@@ -808,6 +1083,65 @@
color: var(--warn);
font-size: var(--fs-aux);
}
/* 摘要带(选定后):整条可点,展开挑选台。 */
.bay-band {
flex-shrink: 0;
padding: var(--sp-2) var(--sp-7);
}
.band-hit {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: var(--sp-2) var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
border-color var(--d-state) ease,
color var(--d-state) ease;
}
.band-hit:hover {
border-color: var(--line-2);
color: var(--text);
}
.band-hit:focus-visible {
outline: none;
border-color: var(--accent-line);
}
.band-mode {
flex-shrink: 0;
color: var(--text-3);
}
.band-sep {
color: var(--text-3);
}
.band-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 500;
}
.band-change {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
color: var(--text-3);
}
.band-change-txt {
font-size: var(--fs-mini);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -838,7 +1172,12 @@
color: var(--text-3);
font-size: var(--fs-aux);
}
/* 中屏:挑选台为全屏浮层(组件内处理),列宽归零。 */
@media (max-width: 1179px) {
.studio-desk,
.studio-desk.picking {
grid-template-columns: 204px 0px minmax(0, 1fr);
}
.desk,
.desk.insp-off {
grid-template-columns: minmax(0, 1fr);
@@ -846,10 +1185,33 @@
.desk-empty {
margin: 0 var(--sp-6) var(--sp-3);
}
.bay-band {
padding: var(--sp-2) var(--sp-6);
}
}
/* 窄屏:任务模式变横条,摘要带仍可用。 */
@media (max-width: 760px) {
.studio-desk,
.studio-desk.picking {
grid-template-columns: minmax(0, 1fr) 0px;
grid-template-rows: auto minmax(0, 1fr);
}
.bay-col {
grid-column: 2;
grid-row: 1 / span 2;
}
.main-col {
grid-column: 1;
grid-row: 2;
}
.desk-empty {
margin: 0 var(--sp-5) var(--sp-2);
}
.bay-band {
padding: var(--sp-1) var(--sp-4) var(--sp-2);
}
.band-change-txt {
display: none;
}
}
</style>
@@ -0,0 +1,310 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
/**
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
*/
interface Props {
kind: 'image' | 'video';
metas: WorkflowMeta[];
active: WorkflowUsage | 'all';
onSelect: (mode: WorkflowUsage | 'all') => void;
onPickWorkflow: (id: number) => void;
}
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
}
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
});
const favorites = $derived(
metas
.filter((m) => m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
const recents = $derived(
metas
.filter((m) => m.last_used_at && !m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
</script>
<nav class="rail" aria-label={t('desk.modeRailAria')}>
<p class="rail-label">{t('desk.modeRailTitle')}</p>
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
<button
type="button"
class="mode"
class:on={active === 'all'}
aria-pressed={active === 'all'}
onclick={() => onSelect('all')}
>
<span class="mode-lbl">{t('desk.modeAll')}</span>
<span class="mode-n">{metas.length}</span>
</button>
{#each modes as m (m.usage)}
<button
type="button"
class="mode"
class:on={active === m.usage}
class:off={m.count === 0}
disabled={m.count === 0}
aria-pressed={active === m.usage}
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
onclick={() => onSelect(m.usage)}
>
<span class="mode-lbl">{usageLabel(m.usage)}</span>
<span class="mode-n">{m.count}</span>
</button>
{/each}
</div>
<div class="lists">
{#if recents.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="clock" size={12} />
{t('desk.railRecent')}
</p>
<ul class="pick-list">
{#each recents as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if favorites.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="star" size={12} />
{t('desk.railFav')}
</p>
<ul class="pick-list">
{#each favorites as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
</nav>
<style>
.rail {
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
display: flex;
align-items: center;
gap: var(--sp-1);
margin: 0 0 var(--sp-2);
padding: 0 var(--sp-3);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
white-space: nowrap;
}
.modes {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
box-sizing: border-box;
width: 100%;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-body);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.mode:hover:not(:disabled) {
background: var(--bg-3);
color: var(--text);
}
.mode:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.mode.on {
background: var(--bg-3);
color: var(--text);
}
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
.mode.on {
position: relative;
}
.mode.on::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
.mode.off {
color: var(--text-3);
opacity: 0.55;
cursor: not-allowed;
}
.mode-n {
color: var(--text-3);
font-size: var(--fs-mini);
font-variant-numeric: tabular-nums;
}
.lists {
display: flex;
flex-direction: column;
gap: var(--sp-4);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--line);
}
.rail-sec {
min-width: 0;
}
.pick-list {
display: flex;
flex-direction: column;
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
}
.pick {
display: block;
width: 100%;
box-sizing: border-box;
padding: var(--sp-2) var(--sp-4);
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.pick:hover {
background: var(--bg-3);
color: var(--text);
}
.pick:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.pick-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
@media (max-width: 760px) {
.rail {
flex-direction: row;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-right: 0;
border-bottom: 1px solid var(--line);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
.rail-label {
margin: 0;
padding: 0;
flex-shrink: 0;
}
.modes {
flex-direction: row;
gap: var(--sp-1);
}
.mode {
width: auto;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--line);
font-size: var(--fs-aux);
white-space: nowrap;
}
.mode.on::after {
inset: 1px;
}
.lists {
display: none;
}
}
</style>
@@ -0,0 +1,488 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { tick } from 'svelte';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import LibraryFilters from './LibraryFilters.svelte';
import LibraryRow from './LibraryRow.svelte';
/**
* 工作流挑选台 (HEL-683, HEL-674 §二):中部可搜索筛选、按模型家族
* 分组的挑选列。数据来自 S2 轻量元数据(view=meta,不含完整 JSON);
* 云端来源只做诚实占位(未接入,不可点击使用)。键盘:↑↓/J/K 游走、
* Enter 使用、Esc 关闭。长列表增量渲染 + content-visibility。
*/
interface Props {
open: boolean;
metas: WorkflowMeta[];
pendingCount: number;
selectedId: number | null;
activeId: number | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (id: number) => void;
onClose: () => void;
}
let { open, metas, pendingCount, selectedId, activeId = $bindable(null), endpoints, onPick, onClose }: Props =
$props();
let search = $state('');
let source = $state<'local' | 'cloud'>('local');
let favOnly = $state(false);
let visibleCount = $state(48);
let searchEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLElement | null>(null);
const CATEGORY_LABELS: Record<string, string> = {
basic: 'wfCat.basic',
audio_tts: 'wfCat.audioTts',
music: 'wfCat.music',
image: 'wfCat.image',
video: 'wfCat.video',
digital_human: 'wfCat.digitalHuman',
motion_transfer: 'wfCat.motionTransfer',
};
function categoryLabel(key: string | null | undefined): string {
if (!key) return '';
const k = CATEGORY_LABELS[key];
return k ? t(k) : key;
}
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
return metas.filter((m) => {
if (favOnly && !m.favorite) return false;
if (!q) return true;
const hay = [
m.name,
m.description ?? '',
(m.tags ?? []).join(' '),
m.model_family ?? '',
m.usage ? usageLabel(m.usage) : '',
categoryLabel(m.category),
]
.join(' ')
.toLowerCase();
return hay.includes(q);
});
});
interface Group {
key: string;
label: string;
rows: WorkflowMeta[];
}
const groups = $derived.by(() => {
const byFamily = new Map<string, WorkflowMeta[]>();
for (const m of filtered) {
const key = m.model_family ?? '';
const list = byFamily.get(key) ?? [];
list.push(m);
byFamily.set(key, list);
}
const out: Group[] = [];
for (const [key, rows] of byFamily) {
rows.sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
const la = a.last_used_at ?? '';
const lb = b.last_used_at ?? '';
if (la !== lb) return lb.localeCompare(la);
return a.name.localeCompare(b.name, 'zh');
});
out.push({ key, label: key || t('desk.bayNoFamily'), rows });
}
out.sort((a, b) =>
a.key === '' ? 1 : b.key === '' ? -1 : a.label.localeCompare(b.label, 'zh'),
);
return out;
});
const flat = $derived(groups.flatMap((g) => g.rows));
/** Flattened rows cut at the incremental window, per group. */
const windowed = $derived.by(() => {
let left = visibleCount;
return groups.map((g) => {
const take = Math.max(0, Math.min(g.rows.length, left));
left -= take;
return { ...g, visible: g.rows.slice(0, take) };
});
});
// Any filter change restarts the incremental window.
$effect(() => {
void search;
void favOnly;
visibleCount = 48;
listEl?.scrollTo({ top: 0 });
});
// Keep the active row real: follow the selection, clamp on filter changes.
$effect(() => {
void flat;
if (activeId != null && flat.some((m) => m.id === activeId)) return;
activeId = flat.find((m) => m.id === selectedId)?.id ?? flat[0]?.id ?? null;
});
// Opening the bay: the selected workflow's row must be inside the
// incremental window and in view — otherwise the highlight is invisible.
$effect(() => {
if (!open) return;
const target = activeId ?? selectedId;
const idx = flat.findIndex((m) => m.id === target);
if (idx < 0) return;
if (idx >= visibleCount) visibleCount = idx + 48;
const id = setTimeout(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${target}"]`)
?.scrollIntoView({ block: 'center' });
}, 0);
return () => clearTimeout(id);
});
const activeMeta = $derived(metas.find((m) => m.id === activeId) ?? null);
const resultHint = $derived(
source === 'cloud' ? '' : t('desk.bayCount', { n: filtered.length, total: metas.length }),
);
// Autofocus the search whenever the bay opens.
$effect(() => {
if (!open) return;
const id = setTimeout(() => searchEl?.focus(), 0);
return () => clearTimeout(id);
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((m) => m.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
i = Math.max(0, Math.min(flat.length - 1, i));
const next = flat[i];
activeId = next.id;
// Keyboard roving may step past the incremental window — extend it so
// the row exists, then reveal it.
if (i >= visibleCount) visibleCount = i + 24;
void tick().then(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${next.id}"]`)
?.scrollIntoView({ block: 'nearest' });
});
}
function pickActive() {
if (activeMeta) onPick(activeMeta.id);
}
export function focusSearch() {
searchEl?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
const inSearch = e.target === searchEl;
if (e.key === 'ArrowDown' || (!inSearch && e.key === 'j')) {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp' || (!inSearch && e.key === 'k')) {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
pickActive();
}
}
function onScroll() {
const el = listEl;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) {
if (visibleCount < flat.length) visibleCount += 48;
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bay-backdrop" onclick={onClose} aria-hidden="true"></div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section
class="bay"
class:open
aria-label={t('desk.pickWorkflow')}
inert={!open}
onkeydown={onKeydown}
>
<header class="bay-head">
<h2 class="bay-title">
<Icon name="playground" size={15} />
<span>{t('desk.pickWorkflow')}</span>
</h2>
<button type="button" class="a-btn ghost sm close" onclick={onClose} title={t('desk.keyClose')}>
<Icon name="close" size={14} />
<span class="lbl">{t('desk.keyClose')}</span>
</button>
</header>
<div class="bay-filters">
<LibraryFilters
bind:searchEl
{search}
{source}
{favOnly}
{resultHint}
onSetSearch={(v) => (search = v)}
onSetSource={(s) => (source = s)}
onToggleFav={() => (favOnly = !favOnly)}
/>
</div>
{#if source === 'cloud'}
<div class="cloud-note" role="note">
<Icon name="alert" size={16} />
<p class="cloud-title">{t('desk.bayCloudTitle')}</p>
<p class="cloud-body">{t('desk.bayCloudBody')}</p>
</div>
{:else if !metas.length}
<div class="bay-empty">
<p>{t('desk.noWorkflows')}</p>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bay-list" bind:this={listEl} role="listbox" aria-label={t('desk.pickWorkflow')} onscroll={onScroll}>
{#each windowed as g (g.key)}
{#if g.visible.length}
<div class="group-label" aria-hidden="true">{g.label} · {g.rows.length}</div>
{#each g.visible as m (m.id)}
<div data-wf-row={m.id}>
<LibraryRow
meta={m}
current={m.id === selectedId}
active={m.id === activeId}
{endpoints}
onPick={() => onPick(m.id)}
onActive={() => (activeId = m.id)}
/>
</div>
{/each}
{/if}
{:else}
<p class="bay-none">{t('desk.wfNoMatch')}</p>
{/each}
{#if visibleCount < flat.length}
<button type="button" class="more a-btn ghost sm" onclick={() => (visibleCount += 48)}>
{t('desk.bayMore', { n: Math.min(48, flat.length - visibleCount) })}
</button>
{/if}
</div>
<footer class="bay-foot">
{#if pendingCount > 0}
<a class="pending-link" href="/connections">
{t('desk.bayPending', { n: pendingCount })}
<Icon name="chevron-right" size={12} />
</a>
{/if}
{#if activeMeta}
<span class="active-name" title={activeMeta.name}>{activeMeta.name}</span>
{/if}
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</footer>
{/if}
</section>
<style>
.bay {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-4) var(--sp-3);
overflow: hidden;
}
.bay-head {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-2) var(--sp-4);
}
.bay-title {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
}
.bay-title :global(svg) {
color: var(--text-3);
}
.close {
margin-left: auto;
}
.close .lbl {
display: none;
}
.bay-filters {
padding: 0 var(--sp-1) var(--sp-3);
}
.bay-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-1);
}
.group-label {
position: sticky;
top: 0;
z-index: 2;
padding: var(--sp-3) var(--sp-3) var(--sp-1);
background: linear-gradient(var(--bg-2) 82%, transparent);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.04em;
}
.more {
align-self: center;
margin: var(--sp-3) 0;
}
.bay-none,
.bay-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
text-align: center;
}
.bay-empty {
display: grid;
justify-content: center;
gap: var(--sp-3);
}
.bay-foot {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-2) 0;
border-top: 1px solid var(--line);
font-size: var(--fs-mini);
color: var(--text-3);
}
.pending-link {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
}
.pending-link:hover {
color: var(--text);
}
.active-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-2);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
.cloud-note {
display: grid;
justify-items: start;
gap: var(--sp-3);
margin: var(--sp-4) var(--sp-2);
padding: var(--sp-6);
border: 1px dashed var(--line-2);
border-radius: var(--r-lg);
color: var(--text-2);
}
.cloud-title {
margin: 0;
color: var(--text);
font-weight: 600;
}
.cloud-body {
margin: 0;
font-size: var(--fs-aux);
line-height: 1.6;
}
/* 窄/中屏:挑选台改为全屏浮层(计划中的最简可用降级)。 */
.bay-backdrop {
display: none;
}
@media (max-width: 1179px) {
.bay {
position: fixed;
left: 0;
right: 0;
top: var(--topbar-h);
bottom: 0;
z-index: 60;
background: var(--bg);
box-shadow: var(--shadow-float);
transform: translateY(12px);
opacity: 0;
pointer-events: none;
transition:
transform var(--d-drawer) var(--ease-drawer),
opacity var(--d-state) ease;
}
.bay.open {
transform: none;
opacity: 1;
pointer-events: auto;
}
.bay-backdrop {
display: block;
position: fixed;
inset: var(--topbar-h) 0 0 0;
z-index: 59;
background: var(--overlay);
}
.close .lbl {
display: inline;
}
.keys {
display: none;
}
:global(:root[data-reduce='1']) .bay {
transition: none;
}
}
@media (max-width: 760px) {
.bay {
top: 0;
padding: var(--sp-4) var(--sp-4) calc(var(--tabbar-h) + env(safe-area-inset-bottom));
}
.bay-backdrop {
inset: 0;
}
}
</style>
@@ -0,0 +1,247 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
* 完整记录槽位摘要),确认后进入创作区。
*/
interface Props {
meta: WorkflowMeta | null;
full: Workflow | null;
currentId: number | null;
endpoints: Array<{ id: string; name: string }>;
onUse: () => void;
onToggleFav: (fav: boolean) => void;
}
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
const isCurrent = $derived(meta != null && meta.id === currentId);
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
const endpointName = $derived.by(() => {
const id = meta?.endpoint_id;
if (!id) return t('desk.wfDefaultConn');
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
});
const CATEGORY_LABELS: Record<string, string> = {
basic: 'wfCat.basic',
audio_tts: 'wfCat.audioTts',
music: 'wfCat.music',
image: 'wfCat.image',
video: 'wfCat.video',
digital_human: 'wfCat.digitalHuman',
motion_transfer: 'wfCat.motionTransfer',
};
</script>
<div class="preview">
{#if meta}
<div class="head">
<h3 class="name" title={meta.name}>{meta.name}</h3>
<button
type="button"
class="fav"
class:on={meta.favorite}
aria-pressed={meta.favorite}
aria-label={t('desk.bayFavOnly')}
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
onclick={() => onToggleFav(!meta.favorite)}
>
<Icon name="star" size={16} />
</button>
</div>
<div class="tags">
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
{#if meta.category && CATEGORY_LABELS[meta.category]}
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
{/if}
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
</div>
{#if meta.description}
<p class="desc">{meta.description}</p>
{/if}
<section class="sec">
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
<div class="req-line">
<InputRequirementBadge io={meta.media_io} />
</div>
{#if full}
<p class="slot-summary">{slotSummary(full)}</p>
{:else}
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
{/if}
</section>
<dl class="kv">
<div class="kv-row">
<dt>{t('studio.connLabel')}</dt>
<dd>{endpointName}</dd>
</div>
{#if meta.last_used_at}
<div class="kv-row">
<dt>{t('desk.pvLastUsed')}</dt>
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
</div>
{/if}
{#if (full?.input_schema ?? []).length}
<div class="kv-row">
<dt>{t('desk.pvParamCount')}</dt>
<dd>{(full?.input_schema ?? []).length}</dd>
</div>
{/if}
</dl>
<div class="use">
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
<Icon name="check" size={13} />
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
</button>
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
</div>
{:else}
<div class="preview-empty">
<Icon name="search" size={20} />
<p>{t('desk.pvEmpty')}</p>
</div>
{/if}
</div>
<style>
.preview {
display: flex;
flex-direction: column;
gap: var(--sp-4);
flex: 1;
min-height: 0;
box-sizing: border-box;
height: 100%;
padding: var(--sp-7);
overflow-y: auto;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
}
.name {
flex: 1;
min-width: 0;
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.fav {
flex-shrink: 0;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: none;
color: var(--text-3);
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.fav:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.desc {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
line-height: 1.65;
white-space: pre-line;
}
.sec {
display: grid;
gap: var(--sp-2);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
}
.sec-title {
margin: 0;
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
}
.req-line {
display: flex;
}
.slot-summary {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
}
.slot-summary.dim {
color: var(--text-3);
}
.kv {
display: grid;
gap: var(--sp-1);
margin: 0;
}
.kv-row {
display: flex;
gap: var(--sp-3);
font-size: var(--fs-aux);
}
.kv-row dt {
flex-shrink: 0;
width: 72px;
color: var(--text-3);
}
.kv-row dd {
margin: 0;
color: var(--text-2);
min-width: 0;
overflow-wrap: anywhere;
}
.use {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: auto;
padding-top: var(--sp-4);
}
.use-hint {
margin: 0;
color: var(--err);
font-size: var(--fs-mini);
}
.preview-empty {
display: grid;
place-content: center;
justify-items: center;
gap: var(--sp-3);
height: 100%;
color: var(--text-3);
font-size: var(--fs-aux);
}
</style>
@@ -5,7 +5,6 @@
import { t } from '$lib/i18n.svelte';
import ParamChip from './ParamChip.svelte';
import SlotTray from './SlotTray.svelte';
import WorkflowPicker from './WorkflowPicker.svelte';
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
/**
@@ -13,11 +12,12 @@
* row of chips — workflow, connection, common params, 参数 — ending in
* 生成 with its reason line. The whole composer takes dropped files.
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
* HEL-683: the workflow chip no longer opens a dropdown — it expands the
* desk's middle picker bay (StudioDesk WorkflowBay), the single picker.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
endpointId: string;
@@ -32,7 +32,7 @@
slotBusy?: boolean;
promptEl?: HTMLTextAreaElement | null;
onSet: (nodeId: string, value: FormValue) => void;
onPickWorkflow: (wf: Workflow) => void;
onOpenBay: () => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
@@ -43,7 +43,6 @@
let {
ds,
values,
workflows,
current,
endpoints,
endpointId,
@@ -57,7 +56,7 @@
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onPickWorkflow,
onOpenBay,
onPickEndpoint,
onSubmit,
onReveal,
@@ -165,7 +164,18 @@
{/if}
<div class="foot">
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
title={current?.name ?? t('desk.pickWorkflow')}
onclick={onOpenBay}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
</div>
<div class="conn opt">
<button
type="button"
@@ -312,6 +322,10 @@
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
}
.picker {
position: relative;
min-width: 0;
}
.conn {
position: relative;
}
@@ -5,8 +5,9 @@
/**
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
* Says where from, and plainly that takes stay in the desk feed — the
* desk's generate call has no shot/asset link to fill back.
* Says where from. With a full source identity (HEL-684) results can
* be flowed back per take from the result card; otherwise the old
* note stands — results stay in the desk feed, nothing is written back.
*/
interface Props {
ctx: DeskContext;
@@ -17,6 +18,7 @@
let { ctx, projectTitle, onDismiss }: Props = $props();
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
const canFlowBack = $derived(!!ctx.sourceKind && !!ctx.sourceId);
</script>
<div class="context-bar" role="status">
@@ -24,7 +26,9 @@
<span class="from">
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label}&nbsp;· {ctx.label}{/if}
</span>
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
<span class="note">
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
</span>
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
<Icon name="close" size={12} />
@@ -8,7 +8,15 @@
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
import {
elapsedSec,
fmtClock,
fmtDur,
inputsOf,
isVideoOutput,
promptOf,
workbenchCtxOf,
} from './takes';
/**
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
@@ -20,6 +28,9 @@
* take whose files no longer load turns amber (文件缺失) with
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
* marked as such instead of pretending nothing changed.
* HEL-684: a take generated with a real workbench source offers an
* explicit 回流到源项目 action; once flowed back it shows 已回流 and
* a direct 返回项目 link instead.
*/
interface Props {
job: Job;
@@ -33,11 +44,14 @@
flash?: boolean;
/** One-shot failure shake. */
shake?: boolean;
/** Flow-back request in flight (HEL-684). */
attachBusy?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
onCancel: () => void;
onDelete: () => void;
onAttach?: () => void;
}
let {
@@ -49,11 +63,13 @@
enter = false,
flash = false,
shake = false,
attachBusy = false,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
onAttach,
}: Props = $props();
const st = $derived(jobStatusInfo(job));
@@ -80,6 +96,16 @@
});
const active = $derived(job.status === 'pending' || job.status === 'running');
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
// HEL-684: flow-back only for finished takes with files and a real,
// still-recorded workbench source; the attached marker comes from the
// server so it survives reloads.
const wbCtx = $derived(workbenchCtxOf(job));
const attached = $derived(
!!job.workbench_attach && !!wbCtx && job.workbench_attach.target_id === wbCtx.sourceId,
);
const canAttach = $derived(
!!onAttach && !!wbCtx && !attached && st.tone === 'ok' && !fileGone && !!output,
);
let portrait = $state(false);
let clipSec = $state<number | null>(null);
@@ -164,6 +190,25 @@
</button>
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
{:else}
{#if canAttach}
<button
type="button"
class="a-btn ghost xs attach"
disabled={attachBusy}
title={t('desk.attachHint', { label: wbCtx?.label ?? '' })}
onclick={() => onAttach?.()}
>
{attachBusy ? t('desk.attaching') : t('desk.attachToProject')}
</button>
{/if}
{#if attached && wbCtx}
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
{t('desk.attachedTag')}
</span>
<a class="a-btn ghost xs" href={`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`}>
{t('desk.attachBack')}
</a>
{/if}
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
@@ -38,11 +38,14 @@
banner?: Snippet;
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
inspectorToggle?: Snippet;
/** Flow-back to the workbench source row in flight (HEL-684). */
attachBusyId?: number | null;
onSelect: (job: Job) => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onDelete: (job: Job) => void;
onAttach?: (job: Job) => void;
}
let {
@@ -59,11 +62,13 @@
orderedIds = $bindable(),
banner,
inspectorToggle,
attachBusyId = null,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
onAttach,
}: Props = $props();
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
@@ -263,6 +268,8 @@
onRetry={() => onRetry(job)}
onCancel={() => onCancel(job)}
onDelete={() => onDelete(job)}
attachBusy={attachBusyId === job.id}
onAttach={onAttach ? () => onAttach(job) : undefined}
/>
</div>
{/each}
@@ -1,277 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { slotSummary, usageLabel } from './schema';
/**
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
* pop grouped by usage with search; each row shows what the workflow
* really takes (slot summary from its JSON), its connection and whether
* it last ran. Picking keeps the prompt and carried-over images.
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
* active row follows ↑↓ (and the pointer), Enter uses it, and the
* search's Enter picks the active row instead of always the first.
*/
interface Props {
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (wf: Workflow) => void;
}
let { workflows, current, endpoints, onPick }: Props = $props();
let open = $state(false);
let query = $state('');
let activeId = $state<number | null>(null);
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
function statusOf(wf: Workflow): Status {
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
const lt = wf.last_test;
if (!lt) return { tone: '', label: t('desk.wfUntested') };
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
return { tone: '', label: t('desk.wfTesting') };
}
function endpointLabel(wf: Workflow): string {
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
return ep ? ep.name : t('desk.wfDefaultConn');
}
const rows = $derived(
workflows.map((wf) => ({
wf,
summary: slotSummary(wf),
conn: endpointLabel(wf),
status: statusOf(wf),
usage: wf.usage ? usageLabel(wf.usage) : '',
})),
);
const groups = $derived.by(() => {
const q = query.trim().toLowerCase();
const hit = (r: (typeof rows)[number]) =>
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
return WORKFLOW_USAGES.map((u) => ({
usage: u,
items: rows.filter((r) => r.wf.usage === u && hit(r)),
})).filter((g) => g.items.length);
});
/** Flattened rows in display order — the roving selection walks these. */
const flat = $derived(groups.flatMap((g) => g.items));
// Keep the active row real: default to the current workflow, clamp on
// filter changes, never point at a row that is no longer listed.
$effect(() => {
if (!open) return;
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((r) => r.wf.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
}
function pickActive() {
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
if (row) pick(row.wf);
}
function pick(wf: Workflow) {
open = false;
query = '';
if (wf.id !== current?.id) onPick(wf);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
pickActive();
}
}
</script>
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
aria-haspopup="dialog"
aria-expanded={open}
title={current?.name ?? t('desk.pickWorkflow')}
onclick={() => (open = !open)}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
<ShellPop
bind:open
label={t('desk.pickWorkflow')}
placement="top"
width="520px"
arrowRoving={false}
onclose={() => (query = '')}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop-body" onkeydown={onKeydown}>
<div class="search">
<Icon name="search" size={14} />
<input
class="search-input"
type="search"
bind:value={query}
placeholder={t('desk.wfSearchPh')}
aria-label={t('desk.wfSearchPh')}
/>
</div>
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
{#each groups as g (g.usage)}
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
{#each g.items as r (r.wf.id)}
{@const cur = r.wf.id === current?.id}
<button
type="button"
class="a-menu-item row"
class:off={r.status.tone === 'err'}
class:active={r.wf.id === activeId}
role="option"
aria-current={cur ? 'true' : undefined}
aria-selected={r.wf.id === activeId ? 'true' : undefined}
onclick={() => pick(r.wf)}
onpointermove={() => (activeId = r.wf.id)}
>
<span class="main">
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
<span class="sub">
<span class="a-tag">{r.summary}</span>
<span class="a-tag">{r.conn}</span>
</span>
</span>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
{/each}
{:else}
<p class="empty">{t('desk.wfNoMatch')}</p>
{/each}
</div>
<div class="foot">
<a class="a-btn ghost sm" href="/connections">
<Icon name="upload" size={13} />
{t('desk.wfImport')}
</a>
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</div>
</div>
</ShellPop>
</div>
<style>
.picker {
position: relative;
min-width: 0;
}
.pop-body {
display: flex;
flex-direction: column;
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
border-bottom: 1px solid var(--line);
color: var(--text-3);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
.list {
max-height: min(380px, 50vh);
overflow-y: auto;
padding: var(--sp-1) 0;
}
.row {
align-items: center;
}
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
.row.active {
background: var(--bg-4);
}
.main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.main .a-menu-label {
font-weight: 500;
}
.row.off .a-menu-label {
color: var(--text-3);
}
.sub {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
}
.foot {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-1) 0;
border-top: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
@media (max-width: 760px) {
.keys {
display: none;
}
}
</style>
@@ -3,8 +3,9 @@ import type { DeskKind } from './schema';
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
* The desk only prefills from it; the playground generate API has no
* project/shot link, so results stay in the desk feed (no backfill).
* The desk prefills from it and, when the full source identity is present
* (sourceKind/sourceId), sends it along with the generate request so a
* finished take can be flowed back to its source row (HEL-684).
*/
export interface DeskContext {
kind: DeskKind;
@@ -15,10 +16,30 @@ export interface DeskContext {
prompt?: string;
negative?: string;
durationSec?: number;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: 'asset' | 'shot';
sourceId?: string;
}
/** The generate-request context payload shape (HEL-684). */
export interface DeskWorkbenchContext {
project_id: number;
stage: 'assets' | 'shot';
source_kind: 'asset' | 'shot';
source_id: string;
label?: string;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
* shot → video). Anything else cannot display a flowed-back take. */
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
assets: 'image',
shot: 'video',
};
export function deskHref(ctx: DeskContext): string {
const q = new URLSearchParams({
from: 'workbench',
@@ -29,6 +50,10 @@ export function deskHref(ctx: DeskContext): string {
if (ctx.prompt) q.set('prompt', ctx.prompt);
if (ctx.negative) q.set('negative', ctx.negative);
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
if (ctx.sourceKind && ctx.sourceId) {
q.set('source_kind', ctx.sourceKind);
q.set('source_id', ctx.sourceId);
}
return `/${ctx.kind}?${q.toString()}`;
}
@@ -39,6 +64,13 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
const stage = q.get('stage') as WorkbenchStageKey | null;
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
const duration = Number(q.get('duration'));
const rawKind = q.get('source_kind');
const rawId = q.get('source_id') ?? '';
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind = stage === 'assets' ? 'asset' : stage === 'shot' ? 'shot' : null;
const sourceKind = rawKind === 'asset' || rawKind === 'shot' ? rawKind : null;
const hasSource = sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== '';
return {
kind,
projectId,
@@ -47,9 +79,29 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
prompt: q.get('prompt') ?? undefined,
negative: q.get('negative') ?? undefined,
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
sourceKind: hasSource ? (sourceKind as 'asset' | 'shot') : undefined,
sourceId: hasSource ? rawId : undefined,
};
}
/**
* The context a generate request carries: only when the full, real source
* identity is present AND the desk kind matches what that stage renders.
*/
export function workbenchContextOf(ctx: DeskContext | null): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== 'assets' && ctx.stage !== 'shot') return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
}
export function backHref(ctx: DeskContext): string {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
}
@@ -9,6 +9,34 @@ import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from '.
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
/** The workbench source a take was generated with, if any (HEL-684). */
export interface TakeWorkbenchCtx {
projectId: number;
stage: string;
sourceKind: 'asset' | 'shot';
sourceId: string;
label?: string;
}
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== 'object') return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === 'string' ? s.stage : '';
const sourceKind = s.source_kind === 'asset' || s.source_kind === 'shot' ? s.source_kind : null;
const sourceId = typeof s.source_id === 'string' ? s.source_id : '';
if (typeof projectId !== 'number' || !stage || !sourceKind || !sourceId) return null;
const label = typeof s.label === 'string' && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
}
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
}
@@ -231,7 +231,7 @@
.tabs button.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--accent-ink);
}
.tabs button:focus-visible {
@@ -341,7 +341,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -271,9 +271,9 @@
width: 20px;
height: 20px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.65);
background: var(--media-scrim);
border: none;
color: white;
color: var(--on-media);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
@@ -173,7 +173,7 @@
padding: 0 4px;
border-radius: 9999px;
background: var(--accent);
color: white;
color: var(--accent-ink);
font-size: 10px;
font-weight: 700;
}
@@ -90,7 +90,7 @@
.step-btn:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
background: var(--bg-4);
}
.step-btn:focus-visible {
@@ -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"/>',
@@ -272,7 +272,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -182,9 +182,11 @@
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
name: asset.name,
}),
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
})}
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
sourceKind: 'asset',
sourceId: asset.id,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -165,14 +165,16 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
sourceKind: 'shot',
sourceId: shot.id,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -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>
+171
View File
@@ -2003,6 +2003,15 @@ export const de: Dict = {
'desk.ctxDismiss': 'Herkunftshinweis schließen',
'desk.ctxFrom': 'Aus „{project}“',
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
'desk.ctxBackfill': 'Nach Fertigstellung kann jedes Ergebnis über „Zurück ins Projekt“ an {stage} zurückgegeben werden',
'desk.attachToProject': 'Zurück ins Projekt',
'desk.attachHint': 'Dieses Ergebnis zurückgeben an: {label}',
'desk.attaching': 'Wird zurückgegeben…',
'desk.attachedTag': 'Zurückgegeben',
'desk.attachBack': 'Zum Projekt',
'desk.attachDoneToast': 'Zurück ins Quellprojekt übergeben — im Projekt sichtbar',
'desk.attachAlreadyToast': 'Dieses Ergebnis wurde bereits zurückgegeben, nichts dupliziert',
'desk.attachFailedToast': 'Rückgabe fehlgeschlagen: {detail}',
'desk.dayDate': '{d}.{m}.',
'desk.download': 'Herunterladen',
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
@@ -2081,6 +2090,36 @@ export const de: Dict = {
'desk.wfUntested': 'Nicht getestet',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Gestern',
// --- Dreispaltige Desk (HEL-683) ---
'desk.bandChange': "Workflow wechseln",
'desk.bayCloudBody': "Cloud-Modelle für Bild / Video (gpt-image2, nano-banana, Jimeng, Kling, …) sind noch nicht angebunden. Nach der Anbindung erscheinen sie mit ihren echten Fähigkeiten — nie als unbenutzbare Einträge.",
'desk.bayCloudTitle': "Cloud-Generierung nicht angebunden",
'desk.bayCount': "{n} / {total} Workflows",
'desk.bayFavOnly': "Favoriten",
'desk.bayMore': "{n} weitere anzeigen",
'desk.bayNoFamily': "Modell unbekannt",
'desk.bayPending': "{n} offen (Zweck / Ausgabe ungeklärt)",
'desk.bayReqAria': "Eingabeanforderungen",
'desk.bayReqTitle': "Eingabeanforderungen",
'desk.baySearchPh': "Name, Beschreibung, Modell, Zweck suchen…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Generierungsquelle",
'desk.baySourceLocal': "Lokal",
'desk.modeAll': "Alle",
'desk.modeEmpty': "Für diesen Modus gibt es noch keinen nutzbaren Workflow",
'desk.modeRailAria': "Aufgabenmodi",
'desk.modeRailTitle': "Aufgabenmodi",
'desk.pvBackCreate': "Zurück zum Erstellen",
'desk.pvCurrent': "Aktueller Workflow",
'desk.pvEmpty': "Links einen Workflow wählen, um die Eingaben zu sehen",
'desk.pvFav': "Zu Favoriten",
'desk.pvLastUsed': "Zuletzt genutzt",
'desk.pvParamCount': "Parameter",
'desk.pvSlotsLoading': "Eingabeslots werden gelesen…",
'desk.pvUnfav': "Aus Favoriten entfernen",
'desk.pvUse': "Diesen Workflow verwenden",
'desk.railFav': "Favoriten",
'desk.railRecent': "Zuletzt",
'wfd.stepParse': 'JSON parsen',
'wfd.stepNodes': 'Knoten erkennen',
'wfd.stepModels': 'Modelle prüfen',
@@ -2097,4 +2136,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}',
};
+171
View File
@@ -2002,6 +2002,15 @@ export const en = {
'desk.ctxDismiss': 'Dismiss source note',
'desk.ctxFrom': 'From “{project}”',
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
'desk.ctxBackfill': 'When a take is done, use “Send back to project” on its card to return it to {stage}',
'desk.attachToProject': 'Send back to project',
'desk.attachHint': 'Send this take back to its source: {label}',
'desk.attaching': 'Sending back…',
'desk.attachedTag': 'Sent back',
'desk.attachBack': 'Back to project',
'desk.attachDoneToast': 'Sent back to the source project — open the project to see it',
'desk.attachAlreadyToast': 'This take was already sent back; nothing duplicated',
'desk.attachFailedToast': 'Could not send back: {detail}',
'desk.dayDate': '{m}/{d}',
'desk.download': 'Download',
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
@@ -2080,6 +2089,36 @@ export const en = {
'desk.wfUntested': 'Not tested',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Yesterday',
// --- Three-section desk (HEL-683) ---
'desk.bandChange': "Change workflow",
'desk.bayCloudBody': "Cloud image / video models (gpt-image2, nano-banana, Jimeng, Kling, …) are not connected to this product yet. Once integrated they will be listed with their real capabilities — never as unusable entries.",
'desk.bayCloudTitle': "Cloud generation not connected",
'desk.bayCount': "{n} / {total} workflows",
'desk.bayFavOnly': "Favorites",
'desk.bayMore': "Show {n} more",
'desk.bayNoFamily': "Unrecognized model",
'desk.bayPending': "{n} pending confirmation (usage / output unsettled)",
'desk.bayReqAria': "Input requirements",
'desk.bayReqTitle': "Input requirements",
'desk.baySearchPh': "Search name, description, model, usage…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Generation source",
'desk.baySourceLocal': "Local",
'desk.modeAll': "All",
'desk.modeEmpty': "No runnable workflow for this mode yet",
'desk.modeRailAria': "Task modes",
'desk.modeRailTitle': "Task modes",
'desk.pvBackCreate': "Back to creating",
'desk.pvCurrent': "Current workflow",
'desk.pvEmpty': "Pick a workflow on the left to see its input requirements",
'desk.pvFav': "Add to favorites",
'desk.pvLastUsed': "Last used",
'desk.pvParamCount': "Parameters",
'desk.pvSlotsLoading': "Loading input slots…",
'desk.pvUnfav': "Remove from favorites",
'desk.pvUse': "Use this workflow",
'desk.railFav': "Favorites",
'desk.railRecent': "Recent",
'wfd.stepParse': 'Parse JSON',
'wfd.stepNodes': 'Identify nodes',
'wfd.stepModels': 'Verify models',
@@ -2096,6 +2135,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;
+171
View File
@@ -2003,6 +2003,15 @@ export const es: Dict = {
'desk.ctxDismiss': 'Cerrar aviso de origen',
'desk.ctxFrom': 'De «{project}»',
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
'desk.ctxBackfill': 'Al terminar, usa «Enviar al proyecto» en la tarjeta para devolver el resultado a {stage}',
'desk.attachToProject': 'Enviar al proyecto',
'desk.attachHint': 'Enviar este resultado a su origen: {label}',
'desk.attaching': 'Enviando…',
'desk.attachedTag': 'Enviado',
'desk.attachBack': 'Ir al proyecto',
'desk.attachDoneToast': 'Enviado al proyecto de origen: ábrelo para verlo',
'desk.attachAlreadyToast': 'Este resultado ya fue enviado; no se duplicó nada',
'desk.attachFailedToast': 'No se pudo enviar: {detail}',
'desk.dayDate': '{d}/{m}',
'desk.download': 'Descargar',
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
@@ -2081,6 +2090,36 @@ export const es: Dict = {
'desk.wfUntested': 'Sin probar',
'desk.workflow': 'Flujo',
'desk.yesterday': 'Ayer',
// --- Escritorio de tres secciones (HEL-683) ---
'desk.bandChange': "Cambiar flujo",
'desk.bayCloudBody': "Los modelos de nube para imagen / vídeo (gpt-image2, nano-banana, Jimeng, Kling, …) aún no están conectados a este producto. Al integrarse se listarán con sus capacidades reales, nunca como entradas inutilizables.",
'desk.bayCloudTitle': "Generación en la nube no conectada",
'desk.bayCount': "{n} / {total} flujos",
'desk.bayFavOnly': "Favoritos",
'desk.bayMore': "Mostrar {n} más",
'desk.bayNoFamily': "Modelo no identificado",
'desk.bayPending': "{n} pendientes de confirmar (uso / salida sin fijar)",
'desk.bayReqAria': "Requisitos de entrada",
'desk.bayReqTitle': "Requisitos de entrada",
'desk.baySearchPh': "Buscar nombre, descripción, modelo, uso…",
'desk.baySourceCloud': "Nube",
'desk.baySourceLabel': "Origen de generación",
'desk.baySourceLocal': "Local",
'desk.modeAll': "Todos",
'desk.modeEmpty': "Aún no hay flujo utilizable para este modo",
'desk.modeRailAria': "Modos de tarea",
'desk.modeRailTitle': "Modos de tarea",
'desk.pvBackCreate': "Volver a crear",
'desk.pvCurrent': "Flujo actual",
'desk.pvEmpty': "Elige un flujo a la izquierda para ver sus requisitos",
'desk.pvFav': "Añadir a favoritos",
'desk.pvLastUsed': "Último uso",
'desk.pvParamCount': "Parámetros",
'desk.pvSlotsLoading': "Leyendo ranuras de entrada…",
'desk.pvUnfav': "Quitar de favoritos",
'desk.pvUse': "Usar este flujo",
'desk.railFav': "Favoritos",
'desk.railRecent': "Recientes",
'wfd.stepParse': 'Analizar JSON',
'wfd.stepNodes': 'Identificar nodos',
'wfd.stepModels': 'Verificar modelos',
@@ -2097,4 +2136,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}',
};
+171
View File
@@ -2003,6 +2003,15 @@ export const fr: Dict = {
'desk.ctxDismiss': 'Fermer la note d’origine',
'desk.ctxFrom': 'Depuis « {project} »',
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
'desk.ctxBackfill': 'Une fois terminé, utilisez « Renvoyer au projet » sur la carte pour renvoyer le résultat vers {stage}',
'desk.attachToProject': 'Renvoyer au projet',
'desk.attachHint': 'Renvoyer ce résultat à sa source : {label}',
'desk.attaching': 'Envoi…',
'desk.attachedTag': 'Renvoyé',
'desk.attachBack': 'Voir le projet',
'desk.attachDoneToast': 'Renvoyé au projet source — ouvrez le projet pour le voir',
'desk.attachAlreadyToast': 'Ce résultat a déjà été renvoyé ; rien de dupliqué',
'desk.attachFailedToast': 'Échec du renvoi : {detail}',
'desk.dayDate': '{d}/{m}',
'desk.download': 'Télécharger',
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
@@ -2081,6 +2090,36 @@ export const fr: Dict = {
'desk.wfUntested': 'Non testé',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Hier',
// --- Bureau en trois sections (HEL-683) ---
'desk.bandChange': "Changer de flux",
'desk.bayCloudBody': "Les modèles cloud d'image / vidéo (gpt-image2, nano-banana, Jimeng, Kling, …) ne sont pas encore connectés à ce produit. Une fois intégrés, ils seront listés avec leurs capacités réelles — jamais en entrées inutilisables.",
'desk.bayCloudTitle': "Génération cloud non connectée",
'desk.bayCount': "{n} / {total} flux",
'desk.bayFavOnly': "Favoris",
'desk.bayMore': "Afficher {n} de plus",
'desk.bayNoFamily': "Modèle non identifié",
'desk.bayPending': "{n} à confirmer (usage / sortie non fixés)",
'desk.bayReqAria': "Exigences d'entrée",
'desk.bayReqTitle': "Exigences d'entrée",
'desk.baySearchPh': "Rechercher nom, description, modèle, usage…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Source de génération",
'desk.baySourceLocal': "Local",
'desk.modeAll': "Tous",
'desk.modeEmpty': "Aucun flux utilisable pour ce mode pour l'instant",
'desk.modeRailAria': "Modes de tâche",
'desk.modeRailTitle': "Modes de tâche",
'desk.pvBackCreate': "Retour à la création",
'desk.pvCurrent': "Flux actuel",
'desk.pvEmpty': "Choisissez un flux à gauche pour voir ses exigences d'entrée",
'desk.pvFav': "Ajouter aux favoris",
'desk.pvLastUsed': "Dernière utilisation",
'desk.pvParamCount': "Paramètres",
'desk.pvSlotsLoading': "Lecture des emplacements d’entrée…",
'desk.pvUnfav': "Retirer des favoris",
'desk.pvUse': "Utiliser ce flux",
'desk.railFav': "Favoris",
'desk.railRecent': "Récents",
'wfd.stepParse': 'Analyser le JSON',
'wfd.stepNodes': 'Identifier les nœuds',
'wfd.stepModels': 'Vérifier les modèles',
@@ -2097,4 +2136,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}',
};
+171
View File
@@ -2001,6 +2001,15 @@ export const ja: Dict = {
'desk.ctxDismiss': '出典の表示を閉じる',
'desk.ctxFrom': '『{project}』から',
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
'desk.ctxBackfill': '生成が完了したら、結果カードの「プロジェクトへ戻す」で{stage}に戻せます',
'desk.attachToProject': 'プロジェクトへ戻す',
'desk.attachHint': 'この結果を戻し先に送る:{label}',
'desk.attaching': '戻しています…',
'desk.attachedTag': '戻しました',
'desk.attachBack': 'プロジェクトへ',
'desk.attachDoneToast': '送り元プロジェクトに戻しました。プロジェクトを開くと確認できます',
'desk.attachAlreadyToast': 'この結果はすでに戻されています。重複は作られていません',
'desk.attachFailedToast': '戻せませんでした:{detail}',
'desk.dayDate': '{m}月{d}日',
'desk.download': 'ダウンロード',
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
@@ -2079,6 +2088,36 @@ export const ja: Dict = {
'desk.wfUntested': '未テスト',
'desk.workflow': 'ワークフロー',
'desk.yesterday': '昨日',
// --- 三段構成デスク (HEL-683) ---
'desk.bandChange': "ワークフローを変更",
'desk.bayCloudBody': "クラウドの画像/動画モデル(gpt-image2、nano-banana、即夢、Kling など)はまだ本製品に接続されていません。接続後は実際の機能とともに一覧表示され、使えない項目が並ぶことはありません。",
'desk.bayCloudTitle': "クラウド生成は未接続",
'desk.bayCount': "{n} / {total} 件のワークフロー",
'desk.bayFavOnly': "お気に入り",
'desk.bayMore': "さらに {n} 件表示",
'desk.bayNoFamily': "モデル未識別",
'desk.bayPending': "確認待ち {n} 件(用途/出力未確定)",
'desk.bayReqAria': "入力要件",
'desk.bayReqTitle': "入力要件",
'desk.baySearchPh': "名前・説明・モデル・用途を検索…",
'desk.baySourceCloud': "クラウド",
'desk.baySourceLabel': "生成ソース",
'desk.baySourceLocal': "ローカル",
'desk.modeAll': "すべて",
'desk.modeEmpty': "このモードで利用できるワークフローはまだありません",
'desk.modeRailAria': "タスクモード",
'desk.modeRailTitle': "タスクモード",
'desk.pvBackCreate': "作成に戻る",
'desk.pvCurrent': "現在のワークフロー",
'desk.pvEmpty': "左でワークフローを選ぶと入力要件が表示されます",
'desk.pvFav': "お気に入りに追加",
'desk.pvLastUsed': "最終使用",
'desk.pvParamCount': "パラメータ数",
'desk.pvSlotsLoading': "入力スロットを読み込み中…",
'desk.pvUnfav': "お気に入りから外す",
'desk.pvUse': "このワークフローを使う",
'desk.railFav': "お気に入り",
'desk.railRecent': "最近の使用",
'wfd.stepParse': 'JSON を解析',
'wfd.stepNodes': 'ノードを識別',
'wfd.stepModels': 'モデルを確認',
@@ -2095,4 +2134,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}',
};
+171
View File
@@ -2001,6 +2001,15 @@ export const ko: Dict = {
'desk.ctxDismiss': '출처 안내 닫기',
'desk.ctxFrom': '《{project}》에서',
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
'desk.ctxBackfill': '생성이 끝나면 결과 카드의 «프로젝트로 보내기»로 {stage}에 돌려보낼 수 있습니다',
'desk.attachToProject': '프로젝트로 보내기',
'desk.attachHint': '이 결과를 출처로 보내기: {label}',
'desk.attaching': '보내는 중…',
'desk.attachedTag': '보냄',
'desk.attachBack': '프로젝트로',
'desk.attachDoneToast': '원본 프로젝트로 보냈습니다. 프로젝트에서 확인하세요',
'desk.attachAlreadyToast': '이 결과는 이미 보내졌습니다. 중복이 만들어지지 않았습니다',
'desk.attachFailedToast': '보내기 실패: {detail}',
'desk.dayDate': '{m}월 {d}일',
'desk.download': '다운로드',
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
@@ -2079,6 +2088,36 @@ export const ko: Dict = {
'desk.wfUntested': '테스트 안 함',
'desk.workflow': '워크플로',
'desk.yesterday': '어제',
// --- 3단 구성 데스크 (HEL-683) ---
'desk.bandChange': "워크플로 변경",
'desk.bayCloudBody': "클라우드 이미지 / 동영상 모델(gpt-image2, nano-banana, 지멍, 클링 등)은 아직 이 제품에 연결되지 않았습니다. 연결 후 실제 기능과 함께 표시되며, 사용할 수 없는 항목은 나타나지 않습니다.",
'desk.bayCloudTitle': "클라우드 생성 미연결",
'desk.bayCount': "{n} / {total}개 워크플로",
'desk.bayFavOnly': "즐겨찾기",
'desk.bayMore': "{n}개 더 보기",
'desk.bayNoFamily': "미식별 모델",
'desk.bayPending': "확인 대기 {n}개(용도 / 출력 미확정)",
'desk.bayReqAria': "입력 요구 사항",
'desk.bayReqTitle': "입력 요구 사항",
'desk.baySearchPh': "이름, 설명, 모델, 용도 검색…",
'desk.baySourceCloud': "클라우드",
'desk.baySourceLabel': "생성 소스",
'desk.baySourceLocal': "로컬",
'desk.modeAll': "전체",
'desk.modeEmpty': "이 모드에서 사용 가능한 워크플로가 아직 없습니다",
'desk.modeRailAria': "작업 모드",
'desk.modeRailTitle': "작업 모드",
'desk.pvBackCreate': "만들기로 돌아가기",
'desk.pvCurrent': "현재 워크플로",
'desk.pvEmpty': "왼쪽에서 워크플로를 선택하면 입력 요구 사항이 표시됩니다",
'desk.pvFav': "즐겨찾기에 추가",
'desk.pvLastUsed': "최근 사용",
'desk.pvParamCount': "매개변수 수",
'desk.pvSlotsLoading': "입력 슬롯 읽는 중…",
'desk.pvUnfav': "즐겨찾기에서 제거",
'desk.pvUse': "이 워크플로 사용",
'desk.railFav': "즐겨찾기",
'desk.railRecent': "최근 사용",
'wfd.stepParse': 'JSON 파싱',
'wfd.stepNodes': '노드 식별',
'wfd.stepModels': '모델 확인',
@@ -2095,4 +2134,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}',
};
+171
View File
@@ -2001,6 +2001,15 @@ export const zh: Dict = {
'desk.ctxDismiss': '关闭来源提示',
'desk.ctxFrom': '来自《{project}》',
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
'desk.ctxBackfill': '生成完成后,可在结果卡「回流到源项目」把结果送回{stage}',
'desk.attachToProject': '回流到源项目',
'desk.attachHint': '把该结果回流到来源:{label}',
'desk.attaching': '回流中…',
'desk.attachedTag': '已回流',
'desk.attachBack': '返回项目',
'desk.attachDoneToast': '已回流到源项目,返回项目即可查看',
'desk.attachAlreadyToast': '该结果已回流过,未产生重复',
'desk.attachFailedToast': '回流失败:{detail}',
'desk.dayDate': '{m}月{d}日',
'desk.download': '下载',
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
@@ -2079,6 +2088,36 @@ export const zh: Dict = {
'desk.wfUntested': '未试运行',
'desk.workflow': '工作流',
'desk.yesterday': '昨天',
// --- 三段式生成台 (HEL-683) ---
'desk.bandChange': "更换工作流",
'desk.bayCloudBody': "云端生图 / 生视频模型(gpt-image2、nano-banana、即梦、可灵等)尚未接入本产品。接入后这里会按真实能力列出,不会展示无法使用的条目。",
'desk.bayCloudTitle': "云端生成未接入",
'desk.bayCount': "{n} / {total} 个工作流",
'desk.bayFavOnly': "收藏",
'desk.bayMore': "再显示 {n} 个",
'desk.bayNoFamily': "未识别模型",
'desk.bayPending': "{n} 个待确认(用途 / 输出未定)",
'desk.bayReqAria': "输入要求",
'desk.bayReqTitle': "输入要求",
'desk.baySearchPh': "搜索名称、说明、模型、用途…",
'desk.baySourceCloud': "云端",
'desk.baySourceLabel': "生成来源",
'desk.baySourceLocal': "本机",
'desk.modeAll': "全部",
'desk.modeEmpty': "该模式暂无可用工作流",
'desk.modeRailAria': "任务模式",
'desk.modeRailTitle': "任务模式",
'desk.pvBackCreate': "返回创作",
'desk.pvCurrent': "当前工作流",
'desk.pvEmpty': "在左侧选择一个工作流查看输入要求",
'desk.pvFav': "加入收藏",
'desk.pvLastUsed': "最近使用",
'desk.pvParamCount': "参数项",
'desk.pvSlotsLoading': "正在读取输入槽位…",
'desk.pvUnfav': "取消收藏",
'desk.pvUse': "使用此工作流",
'desk.railFav': "收藏",
'desk.railRecent': "最近使用",
'wfd.stepParse': '解析 JSON',
'wfd.stepNodes': '识别节点',
'wfd.stepModels': '核对模型',
@@ -2095,4 +2134,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}',
};
@@ -85,7 +85,7 @@
align-items: center;
gap: 12px;
padding: 8px 12px;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
min-height: 74px;
}
.label {
@@ -108,7 +108,7 @@
.open {
display: block;
position: relative;
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
padding: 0;
@@ -128,8 +128,8 @@
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.65);
color: #fff;
background: var(--media-scrim);
color: var(--on-media);
border-radius: 4px;
padding: 1px 5px;
pointer-events: none;
@@ -153,7 +153,7 @@
opacity: 1;
}
.danger {
color: #e5697a;
color: var(--err);
}
.empty {
font-size: 12px;
@@ -123,7 +123,7 @@
gap: 4px;
}
.btn {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -133,10 +133,10 @@
line-height: 1.4;
}
.btn:hover {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-4);
}
.danger {
color: #e5697a;
color: var(--err);
}
.add-grid {
display: grid;
@@ -145,7 +145,7 @@
padding: 0 12px 10px;
}
.add-item {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -154,7 +154,7 @@
cursor: pointer;
}
.add-item:hover {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-4);
}
.objects {
flex: 1;
@@ -171,10 +171,10 @@
padding: 2px 4px;
}
.row:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--bg-3);
}
.row.selected {
background: rgba(99, 102, 241, 0.22);
background: var(--accent-soft);
}
.name {
flex: 1;
@@ -217,8 +217,8 @@
}
.rename {
flex: 1;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border, #2a2f3a);
background: var(--bg);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
padding: 4px 6px;
@@ -233,6 +233,6 @@
padding: 8px 12px;
font-size: 11px;
opacity: 0.45;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
}
</style>
@@ -235,7 +235,7 @@
opacity: 0.6;
}
section {
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
padding-top: 12px;
}
.collapse {
@@ -257,8 +257,8 @@
input[type='number'],
select {
width: 100%;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border, #2a2f3a);
background: var(--bg);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
padding: 3px 5px;
@@ -279,7 +279,7 @@
gap: 6px;
}
.joint {
border: 1px solid var(--border, #232733);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
@@ -298,8 +298,8 @@
}
.capture {
margin-top: auto;
border: 1px solid var(--border, #2a2f3a);
background: rgba(99, 102, 241, 0.18);
border: 1px solid var(--accent-line);
background: var(--accent-soft);
color: inherit;
border-radius: 8px;
padding: 8px;
@@ -307,6 +307,6 @@
cursor: pointer;
}
.capture:hover {
background: rgba(99, 102, 241, 0.3);
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
</style>
@@ -255,7 +255,7 @@
background: var(--bg-elevated);
}
.item.active {
background: rgba(99, 102, 241, 0.22);
background: var(--accent-soft);
}
.dot {
width: 7px;
@@ -265,7 +265,7 @@
flex-shrink: 0;
}
.dot.run {
background: var(--accent, #6366f1);
background: var(--run);
animation: dot-pulse 1.4s ease-in-out infinite;
}
@keyframes dot-pulse {
@@ -295,6 +295,6 @@
display: flex;
}
.del:hover {
color: #e5697a;
color: var(--err);
}
</style>
@@ -841,7 +841,7 @@ let exporting = false;
height: 100%;
min-height: 320px;
overflow: hidden;
background: #14161c;
background: var(--bg);
position: relative;
}
.viewport :global(canvas) {
@@ -866,7 +866,7 @@ let exporting = false;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.14);
background: color-mix(in srgb, var(--on-media) 14%, transparent);
}
.composition-guide .thirds-v:nth-of-type(1) {
left: 33.33%;
@@ -879,7 +879,7 @@ let exporting = false;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.14);
background: color-mix(in srgb, var(--on-media) 14%, transparent);
}
.composition-guide .thirds-h:nth-of-type(3) {
top: 33.33%;
@@ -894,7 +894,7 @@ let exporting = false;
width: 14px;
height: 1px;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.35);
background: color-mix(in srgb, var(--on-media) 35%, transparent);
}
.composition-guide .cross-v {
position: absolute;
@@ -903,6 +903,6 @@ let exporting = false;
width: 1px;
height: 14px;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.35);
background: color-mix(in srgb, var(--on-media) 35%, transparent);
}
</style>
@@ -194,7 +194,7 @@
align-items: center;
gap: 10px;
padding: 6px 12px;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
}
.controls {
display: flex;
@@ -207,7 +207,7 @@
justify-content: center;
min-width: 26px;
height: 26px;
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -216,7 +216,7 @@
padding: 0 6px;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-4);
}
.icon-btn:disabled {
opacity: 0.45;
@@ -227,10 +227,10 @@
gap: 4px;
}
.obj-add {
color: #7dd88a;
color: var(--ok);
}
.export {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -240,7 +240,7 @@
white-space: nowrap;
}
.export:hover:not(:disabled) {
border-color: var(--accent, #6366f1);
border-color: var(--accent-line);
}
.export:disabled {
opacity: 0.45;
@@ -255,8 +255,8 @@
}
.dur input {
width: 46px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border, #2a2f3a);
background: var(--bg);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
padding: 3px 5px;
@@ -280,14 +280,14 @@
position: absolute;
inset: 11px 0;
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
background: var(--bg-4);
}
.playhead {
position: absolute;
top: 2px;
bottom: 2px;
width: 2px;
background: var(--accent, #6366f1);
background: var(--accent);
transform: translateX(-1px);
pointer-events: none;
}
@@ -298,8 +298,8 @@
height: 10px;
border-radius: 2px;
transform: translate(-5px, -50%) rotate(45deg);
background: #e0b34d;
border: 1px solid rgba(0, 0, 0, 0.5);
background: var(--warn);
border: 1px solid var(--bg);
cursor: grab;
padding: 0;
z-index: 2;
@@ -89,8 +89,8 @@
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border, #2a2f3a);
background: rgba(16, 18, 24, 0.82);
border: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-2) 85%, transparent);
border-radius: 999px;
padding: 4px 8px;
backdrop-filter: blur(4px);
@@ -103,7 +103,7 @@
.divider {
width: 1px;
height: 16px;
background: var(--border, #2a2f3a);
background: var(--border);
margin: 0 2px;
}
.btn {
@@ -119,12 +119,12 @@
opacity: 0.85;
}
.btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-4);
opacity: 1;
}
.btn.on {
background: rgba(99, 102, 241, 0.25);
border-color: var(--accent, #6366f1);
background: var(--accent-soft);
border-color: var(--accent-line);
opacity: 1;
}
.btn:disabled {
@@ -132,9 +132,9 @@
cursor: not-allowed;
}
.capture {
border-color: var(--border, #2a2f3a);
border-color: var(--border);
}
.capture:hover {
border-color: var(--accent, #6366f1);
border-color: var(--accent-line);
}
</style>
+11 -8
View File
@@ -132,9 +132,20 @@
--avatar-4: linear-gradient(135deg, #55544f, #424140);
--avatar-5: linear-gradient(135deg, #424140, #2e2e2c);
/* Agent identity solid steps (HEL-685 无彩化):--avatar-1..5 的实色对应
阶,供 color/border/dot 等不能用渐变值的场合。五阶全部落在深底可读
带内(最暗一阶 ≥ --text-3 对比度下限),身份仍以明度阶区分。 */
--id-1: #f0efe9;
--id-2: #d8d6cd;
--id-3: #b8b6ac;
--id-4: #a8a69c;
--id-5: #94938c;
/* ------------------------------------------------------------------
* Legacy token names (§六 映射表) — aliases only, never new values,
* so every existing component renders in the fusion palette.
* HEL-685 清理:无引用别名(--accent-hover/--accent-contrast/--accent-focus/
* --error-border/--font-ui/--space-xs/--space-lg/--space-xl)已删除。
* ------------------------------------------------------------------ */
--surface: var(--bg-2);
--surface-2: var(--bg-3);
@@ -142,17 +153,13 @@
--border: var(--line);
--border-strong: var(--line-2);
--border-hover: var(--line-2);
--accent-hover: var(--accent-hi);
--accent-contrast: var(--accent-ink);
--accent-dim: var(--accent-soft);
--accent-focus: var(--accent-line);
--success: var(--ok);
--success-dim: var(--ok-soft);
--info: var(--run);
--info-dim: var(--run-soft);
--error: var(--err);
--error-dim: var(--err-soft);
--error-border: var(--err-line);
--warning: var(--warn);
--bg-primary: var(--bg);
--bg-surface: var(--bg-2);
@@ -160,7 +167,6 @@
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-muted: var(--text-3);
--font-ui: var(--font);
--font-a-mono: var(--mono);
--font-display: var(--font);
--font-body: var(--font);
@@ -168,11 +174,8 @@
--radius-sm: var(--r-sm);
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--space-xs: var(--sp-1);
--space-sm: var(--sp-3);
--space-md: var(--sp-6);
--space-lg: var(--sp-7);
--space-xl: var(--sp-8);
/* Omni Composer pill tokens */
--pill-bg: var(--bg-3);
@@ -15,7 +15,6 @@
type Viewport,
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import '$lib/canvas/tokens.css';
import AppHeader from '$lib/components/AppHeader.svelte';
import AgentChat from '$lib/components/agent/AgentChat.svelte';
import AgentComposer from '$lib/components/agent/AgentComposer.svelte';
+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>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
</script>
<StudioGenerate kind="image" />
<StudioDesk kind="image" />
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
</script>
<StudioGenerate kind="video" />
<StudioDesk kind="video" />
@@ -0,0 +1,40 @@
# S4:工作流批量导入向导与管理页(HEL-682)
复用 `/connections` 工作流库,接入 S2(HEL-681)的数据层端点:批量导入向导 +
元数据轻量管理列表。本批只动 `calliope-web`,后端零改动。
## 批量导入向导(`WorkflowBatchImportWizard.svelte`)
- 三步式抽屉(780px):选择来源 → 预检报告 → 导入结果。
- 来源两种:扫描服务器目录(`POST /api/workflows/import-scan`,默认
`example_ComfyUI_workflows`,可选对真实 ComfyUI 做 L2 预检)或本地多选
文件(客户端解析,非 UI 格式自动忽略,`POST /api/workflows/convert`)。
- 预检报告逐文件给出 ok/warn/fail、转换提示、建议元数据(用途/分类/模型
家族/输入输出媒介);warn 项可就地补充用途(目录模式在导入后 PATCH 到
新行);「需人工确认」与转换失败项不可勾选、不会被导入、永远不被标作
可运行。
- 重复导入按内容去重:重复执行全部 conflict 跳过,界面明示「已存在」。
## 管理列表改元数据驱动(`WorkflowLibrary.svelte`,自 connections 页抽出)
- 列表数据源换成 `GET /api/workflows?view=meta`(无 workflow_json /
input_schema,152 条约 90KB),完整 JSON 只在试运行、输出指定等操作时
按需单条加载并缓存。
- 工具栏:搜索(`/` 聚焦)、类型/收藏/待确认/停用筛选、分类/模型家族/
媒介下拉、收藏/最近与名称排序、清除筛选;行内:分类与模型家族标签、
输入→输出媒介图标、收藏星标、启停开关(用途/输出未 settle 时禁用并
提示)、试运行(复用 `WorkflowTestDialog`)、L2 复查、绑定连接、删除;
分页渲染(60/批,「再显示」)。
- 批量:勾选 + 全选 → 批量标注(查找替换重命名、统一说明、追加标签、
分类/模型家族)与批量启停。
- 新增去重入口(`POST /api/workflows/dedupe`,字节级相同才删,保留最早)。
- 原有能力保留:用途确认下拉、输出节点指定(HEL-638)、单个 API 格式
导入抽屉(HEL-655)。
## 其他
- `api.ts`/`comfy/types.ts`:`listMeta/convert/importBatch/importScanDry/
importScanCommit/touch/dedupe` 客户端与 `WorkflowMeta` 等类型;`update`
载荷支持 favorite/tags/category/model_family。
- i18n:`wl.* / md.* / wiz.* / wfCat.*` 共 130 键 × 7 语言;分类展示用
标签映射,不把 30 字目录名暴露给用户。
@@ -0,0 +1,46 @@
# S4:三段式图片 / 视频生成台(HEL-683)
按 HEL-679 已确认的三张融合样稿,把 `/image` 与 `/video` 两个入口接入同一
个三段式生成台。本批只动 `calliope-web`,生成后端与项目回流(S5)零改动。
## 三段式布局(新增 `components/desk/`)
- `StudioDesk.svelte` 取代 `StudioGenerate.svelte` 成为 `/image`、`/video`
的共用页面(薄壳不变,`kind` 决定任务模式);右栏沿用既有
`Composer / SlotTray / Inspector / TakeFeed / ContextBar`,仅重排布局。
- 左栏 `TaskModeRail.svelte`:任务模式按真实工作流统计计数,未覆盖模式置灰;
底部收藏 / 最近使用直达;≤760px 退化为横向模式条。
- 中栏挑选台 `WorkflowBay.svelte`(含 `LibraryFilters / LibraryRow /
InputRequirementBadge`):搜索、本机 / 云端来源分段、按模型家族分组、
收藏过滤、待确认计数与入口;长列表增量渲染(48/批)+ `content-visibility`,
键盘 ↑↓/J/K 游走、Enter 使用、Esc 关闭。
- 挑选时中栏展开(grid 列宽动画,`data-reduce` 下直接跳变),右侧显示
`WorkflowPreview.svelte` 输入要求预览(S2 元数据 + 按需加载的槽位摘要);
选定后收成摘要带(band),一键重新展开。≤1179px 挑选台为全屏浮层。
- 云端来源是诚实占位:明确标注「云端生成未接入」,不造可点击的虚假能力;
数据结构按 `source: 'comfy' | 'cloud'` 预留。
## 数据与路由
- 挑选台 / 任务模式 / 摘要带全部走 `GET /api/workflows?view=meta` 轻量元数据
(150+ 条不下载 workflow_json);完整记录改为按 id 惰性加载(当前工作流、
记录流里出现的工作流、预览行),feed 名称先用元数据桩、schema 到位后补全,
「工作流已删除」判定以元数据为准,不在加载期误报。
- 新增查询参数:`?wf=<id>` 深链指定工作流、`?mode=<task-mode>` 预选任务模式;
五阶段 `deskHref` 的 `from=workbench` 上下文条与返回路径保持不变。
- Composer 的工作流芯片从 380px 下拉弹层改为展开挑选台(`#desk-wf` 语义
不变,仍是缺工作流时的定位目标);`WorkflowPicker.svelte` 删除。
- 最近使用沿用后端入队时写入的 `last_used_at`(queue/manager 原有逻辑),
提交后刷新轻量列表即可。
## 验证
- `npm run check`(0 错误,2 条基线既有警告)、`npm run test`、`npm run build`
通过;prettier 对本批文件通过(仓库 lint 基线因 prettier 3.9 全量重排 +
无 eslint 配置本就不可用,未扩面修复)。
- `e2e/shoot.mjs` 全绿:挑选台键盘链(展开→搜索→↑↓→Enter→调参→提交)、
SSE 状态语义、删除撤销、任务页、窄屏无溢出、减动效;同时把 7 处 S1 灰阶
化后未更新的旧色值断言修正为 theme-1 实际值(定影文案 + 灰阶信号色)。
- `e2e/lastframe.mjs` 全绿(视频检视、尾帧导出、手机弹层无回归)。
- 手工走查:`?wf` / `?mode` / `from=workbench` 上下文预填与返回、1024/390
无横向溢出,截图见 `e2e/shots/hel683-*.png`。
@@ -0,0 +1,50 @@
# S5:生成结果回流原项目(HEL-684)
在 S4 三段式生成台基础上,让从项目五阶段资产 / 镜头进入生成台的结果可以
回流原项目。沿用现有 `DeskContext` 与 attach 语义,增量改动:生成请求可携带
可选项目上下文,结果卡提供明确的「回流到源项目」动作与返回提示;独立
playground 生成行为保持原样。本批后端只改 `playground.py`,前端不重写任何
生成 / 项目业务。
## 后端(`calliope/routers/playground.py`)
- `PlaygroundGenerate` 新增可选 `context: {project_id, stage, source_kind,
source_id, label}`。入队前逐项校验真实性:项目存在且非 playground 隐藏
项目、阶段内容里确有该资产 / 镜头行、阶段与生成类型匹配(assets→image、
shot→video)——任何不符直接 4xx,绝不猜测或静默记录来源。任务本身仍挂在
playground 项目上,记录流 / SSE / 任务页行为不变;校验通过的来源以
`payload.workbench` 戳进任务。
- 新增 `POST /api/playground/attach-workbench`:把一条已完成 take 回流到其
记录的真实来源行。落库形态复用工作台原生展示链路——插入一条
`payload.source='workbench'` + `target_id` 的 done 任务行,`_enrich_stage`
既有逻辑即把产物显示在资产 / 镜头卡上(与流程内生成同一机制,之后流程内
重新生成会自然接管显示)。幂等:重复回流返回既有链接行,不产生重复;
源行已因阶段重新生成消失时 409 拒绝,不误写。完成后发 `job.completed` +
`asset.ready`,打开着的工作台页即时刷新。
- `GET /api/playground/jobs` 为已回流 take 附 `workbench_attach` 标记
(服务端真值,重载后界面仍如实显示「已回流」)。
- `DELETE /api/playground/jobs/{id}` 在删除产物文件时同步清掉由该 take 建立
的回流链接行(与既有 `_clear_entity_refs` 同理),项目行不会指向已删文件。
## 前端(`calliope-web`)
- `generate/context.ts`:`DeskContext` 增加可选 `sourceKind/sourceId`,
`deskHref/readDeskContext` 透传(URL `source_kind/source_id`);不完整或
不自洽的旧链接自动退回「仅预填」行为。新增 `workbenchContextOf` 只在来源
完整且台类型与阶段匹配时给出请求上下文。
- 工作台 `AssetsStagePanel / ShotStagePanel` 的「在生成台调参」链接带上真实
行标识;`ContextBar` 有来源时提示「可在结果卡回流」,无来源保持原文案。
- `StudioDesk` 提交时携带上下文(关闭来源条 = 独立生成,不写项目);
`TakeCard` 为带真实来源的完成 take 提供「回流到源项目」按钮,回流后显示
「已回流」标记 + 「返回项目」直达链接;i18n 9 键 × 7 语言。
## 验证
- 后端 `pytest`:新增 `tests/test_playground_workbench_flowback.py` 8 例
(上下文透传 / 拒假 / 回流 / 幂等 / 重建后拒写 / 流程内接管 / 删除清理),
全量 829 passed,4 个失败为 S2 起既有基线(已用基线代码对拍确认与本批无关)。
- 前端 `npm run check`(0 错误,2 条基线既有警告)、`npm run test`、
`npm run build` 通过。
- e2e(fake_comfy 全新库逐套跑):新增 `e2e/flowback.mjs` 19 检查全过
(上下文条提示→提交→回流→已回流重载→返回项目见产物→不回流不误写→独立
无动作→视频镜头路径);`shoot.mjs` 56 检查、`lastframe.mjs` 全过。
+216
View File
@@ -0,0 +1,216 @@
/* HEL-684 S5 browser validation against the isolated stack:
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
* Covers: context bar hint, submit carries the workbench context, the
* explicit 回流到源项目 action, the attached state surviving reload,
* the artifact landing on the workbench source row, the cancel path
* (context take, no attach → project untouched) and the independent
* path (no context → no attach action). */
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOTS = path.join(ROOT, 'shots');
fs.mkdirSync(SHOTS, { recursive: true });
const API = 'http://127.0.0.1:8247';
const MODE_FILE = path.join(ROOT, 'fake_mode.json');
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
let failures = 0;
function ok(name, cond, extra = '') {
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
console.log(line);
if (!cond) failures++;
}
const api = async (p, init) => {
const r = await fetch(API + p, {
headers: { 'Content-Type': 'application/json' },
...init,
});
const body = await r.text();
try {
return JSON.parse(body);
} catch {
return { raw: body, status: r.status };
}
};
async function waitFor(fn, timeout = 30000, step = 300) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, step));
}
return null;
}
const run = async () => {
setMode({ mode: 'ok', delay_sec: 0 });
// ---- isolated project with real stage rows ----
const proj = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({ title: '回流验证项目' }),
});
const patch = await api(`/api/workbench/projects/${proj.id}/stages/assets`, {
method: 'PATCH',
body: JSON.stringify({
content: {
assets: [
{
id: 'asset-e2e',
kind: 'character',
name: '林夏',
description: '',
prompt: 'a stoic courier in the rain',
negative_prompt: '',
},
],
},
}),
});
ok('准备:资产阶段有真实行', patch.ok === true);
const shotPatch = await api(`/api/workbench/projects/${proj.id}/stages/shot`, {
method: 'PATCH',
body: JSON.stringify({
content: {
shots: [
{
id: 'vshot-e2e',
shot_no: 'S1',
description: '',
prompt: 'slow push in on the courier',
duration_sec: 5,
},
],
},
}),
});
ok('准备:镜头阶段有真实行', shotPatch.ok === true);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
// ---------- 1. 从资产行进入生成台:上下文条给出回流提示 ----------
const deskUrl =
`http://127.0.0.1:5173/image?from=workbench&project=${proj.id}&stage=assets` +
`&label=${encodeURIComponent('角色「林夏」')}&prompt=${encodeURIComponent('a stoic courier in neon rain')}` +
`&source_kind=asset&source_id=asset-e2e`;
await page.goto(deskUrl);
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
const note = await page.locator('.context-bar .note').textContent();
ok('上下文条提示可回流', (note || '').includes('回流'), note);
ok('上下文条预填提示词', (await page.inputValue('#desk-prompt')).includes('stoic courier'));
await page.screenshot({ path: `${SHOTS}/hel684-01-desk-context.png` });
// ---------- 2. 提交带上下文的生成 → 完成 → 出现「回流到源项目」 ----------
await page.click('.a-btn.primary'); // 生成
const job = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.output_paths.length && JSON.stringify(j.payload?.workbench ?? {}).includes('asset-e2e'));
});
ok('生成任务完成且携带真实来源(payload.workbench)', !!job, job ? `job ${job.id}` : 'not found');
await page.waitForTimeout(1200);
const take = page.locator(`.take[data-job="${job.id}"]`);
await take.scrollIntoViewIfNeeded();
const attachBtn = take.locator('button.attach');
ok('结果卡出现「回流到源项目」动作', (await attachBtn.count()) === 1);
await page.screenshot({ path: `${SHOTS}/hel684-02-attach-action.png` });
// ---------- 3. 点击回流 → 已回流 + 返回项目 ----------
await attachBtn.click();
const attachedTag = take.locator('.actions .a-tag', { hasText: '已回流' });
await attachedTag.waitFor({ timeout: 15000 });
ok('回流后结果卡显示「已回流」', (await attachedTag.count()) === 1);
const backLink = take.locator('.actions a', { hasText: '返回项目' });
ok('回流后提供「返回项目」链接', (await backLink.count()) === 1);
const overview1 = await api(`/api/workbench/projects/${proj.id}`);
const row1 = overview1.stages.find((s) => s.stage_key === 'assets').content.assets[0];
ok('项目资产行挂上回流产物', row1.job && row1.job.output === job.output_paths[0], JSON.stringify(row1.job || {}));
await page.screenshot({ path: `${SHOTS}/hel684-03-attached.png` });
// ---------- 4. 重载后仍是已回流(服务端标记,非客户端记忆) ----------
await page.reload();
await page.waitForSelector(`.take[data-job="${job.id}"]`, { timeout: 15000 });
await page
.locator(`.take[data-job="${job.id}"] .actions .a-tag`, { hasText: '已回流' })
.waitFor({ timeout: 15000 });
ok('重载后仍显示「已回流」', true);
// ---------- 5. 返回项目看到产物 ----------
await page.locator(`.take[data-job="${job.id}"] .actions a`, { hasText: '返回项目' }).click();
await page.waitForURL(new RegExp(`/workbench/${proj.id}.*stage=assets`), { timeout: 15000 });
await page.waitForSelector('[data-testid="asset-grid"] .asset-card img', { timeout: 15000 });
ok('工作台资产卡显示回流图片', true);
await page.screenshot({ path: `${SHOTS}/hel684-04-workbench-row.png`, fullPage: false });
// ---------- 6. 取消回流:带上下文生成但不动回流 → 项目不误写 ----------
await page.goto(deskUrl);
await page.waitForSelector('#desk-prompt');
await page.fill('#desk-prompt', 'second take on purpose not attached');
await page.click('.a-btn.primary');
const job2 = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.id !== job.id && JSON.stringify(j.payload?.workbench ?? {}).includes('asset-e2e'));
});
ok('第二条带上下文任务完成', !!job2);
await page.waitForTimeout(1000);
const overview2 = await api(`/api/workbench/projects/${proj.id}`);
const row2 = overview2.stages.find((s) => s.stage_key === 'assets').content.assets[0];
ok('未点回流 → 资产行保持第一份产物(不误写)', row2.job && row2.job.output === job.output_paths[0]);
const jobsOnProject = (await api(`/api/jobs?project_id=${proj.id}`)).length ?? 0;
ok('项目任务数不因未回流而增加', jobsOnProject === 1, `${jobsOnProject} job(s)`);
// ---------- 7. 独立生成(无上下文)→ 无回流动作 ----------
await page.goto('http://127.0.0.1:5173/image');
await page.waitForSelector('#desk-prompt');
await page.fill('#desk-prompt', 'standalone take without any context');
await page.click('.a-btn.primary');
const job3 = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.id !== job.id && j.id !== job2?.id && !j.payload?.workbench);
});
ok('独立任务完成且无来源', !!job3);
await page.waitForTimeout(1000);
ok('独立结果卡没有回流动作', (await page.locator(`.take[data-job="${job3.id}"] button.attach`).count()) === 0);
// ---------- 8. 镜头(视频)路径:上下文 + 回流 ----------
setMode({ mode: 'ok', delay_sec: 0, videos: true });
await page.goto(
`http://127.0.0.1:5173/video?from=workbench&project=${proj.id}&stage=shot` +
`&label=${encodeURIComponent('镜头 S1')}&prompt=${encodeURIComponent('slow push in on the courier')}` +
`&source_kind=shot&source_id=vshot-e2e`
);
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
await page.click('.a-btn.primary');
const vjob = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.kind === 'video' && JSON.stringify(j.payload?.workbench ?? {}).includes('vshot-e2e'));
}, 40000);
ok('视频任务完成且携带镜头来源', !!vjob);
await page.waitForTimeout(1200);
await page.locator(`.take[data-job="${vjob.id}"] button.attach`).click();
await page.waitForSelector(`.take[data-job="${vjob.id}"] .a-tag.ok`, { timeout: 15000 });
const overview3 = await api(`/api/workbench/projects/${proj.id}`);
const shotRow = overview3.stages.find((s) => s.stage_key === 'shot').content.shots[0];
ok('镜头行挂上回流视频', shotRow.job && shotRow.job.output === vjob.output_paths[0], JSON.stringify(shotRow.job || {}));
await page.screenshot({ path: `${SHOTS}/hel684-05-shot-flowback.png` });
await browser.close();
if (failures) {
console.log(`\n${failures} check(s) FAILED`);
process.exit(1);
}
console.log('\nall checks passed');
};
run().catch((e) => {
console.error(e);
process.exit(1);
});
+10 -3
View File
@@ -96,7 +96,7 @@ const editedColor = await page
.first()
.evaluate((el) => getComputedStyle(el).color)
.catch(() => '');
ok('已编辑标签用琥珀 rgb(240,180,92)', editedColor === 'rgb(240, 180, 92)', editedColor);
ok('已编辑标签用琥珀例外 rgb(163,145,95)', editedColor === 'rgb(163, 145, 95)', editedColor);
// 复制提示词(镜头段 prompt 复制按钮存在性在 UI 层检查 ShotStagePanel)
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
@@ -115,7 +115,12 @@ const railLibrary = await page.locator('.rail a[href="/library"], .nav a[href="/
ok('图标轨/更多含素材库入口', railLibrary >= 1, `count=${railLibrary}`);
await shot(page, 'library-1440');
// ---------- per-page 3-width walk + overflow ----------
// ---------- per-page 3-width walk + overflow + console errors ----------
let consoleErrors = [];
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
const PAGES = [
['home', '/'],
['workbench', `/workbench/${pid}`],
@@ -131,8 +136,10 @@ const PAGES = [
for (const [name, route] of PAGES) {
for (const [wname, w] of WIDTHS) {
await page.setViewportSize({ width: w, height: 900 });
consoleErrors = [];
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(700);
ok(`${name} ${wname} 无控制台错误`, consoleErrors.length === 0, consoleErrors[0] ?? '');
if (wname !== '1440') await shot(page, `${name}-${wname}`);
if (w === 390) {
const overflow = await page.evaluate(
@@ -155,7 +162,7 @@ await page.waitForTimeout(600);
const runGradBar = await page.locator('.a-progress').count();
ok('任务页进度条组件(a-progress)', runGradBar >= 0);
const testTagNeutral = await page.locator('.tag-test').first().evaluate((el) => getComputedStyle(el).color).catch(() => '');
ok('测试标签为中性灰', testTagNeutral === '' || testTagNeutral === 'rgb(170, 178, 197)', testTagNeutral);
ok('测试标签为中性灰阶', testTagNeutral === '' || testTagNeutral === 'rgb(168, 166, 156)', testTagNeutral);
// ---------- agent chat bubbles ----------
await page.goto(`${BASE}/agents`, { waitUntil: 'networkidle' });
+210
View File
@@ -0,0 +1,210 @@
/* HEL-685 S6 全站走查:1440/1024/390 截图 + 减动效 / 键盘焦点 / 对比度探针。
* Runs against the isolated stack (vite 5173 → backend 8247 → fake ComfyUI
* 18188). Screenshots land in e2e/shots/hel685-*.png. */
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOTS = path.join(ROOT, 'shots');
fs.mkdirSync(SHOTS, { recursive: true });
const BASE = 'http://127.0.0.1:5173';
const API = 'http://127.0.0.1:8247';
let failures = 0;
function ok(name, cond, extra = '') {
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
console.log(line);
if (!cond) failures++;
}
const api = async (p, init) => {
const r = await fetch(API + p, { headers: { 'Content-Type': 'application/json' }, ...init });
const body = await r.text();
try {
return JSON.parse(body);
} catch {
return { raw: body, status: r.status };
}
};
// ---------- fixtures: project + confirmed script + canvas ----------
const proj = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({ title: 'S6走查·灯塔', idea: '灯塔看守人的短篇' }),
});
const pid = proj.id;
await api(`/api/workbench/projects/${pid}/stages/script/import`, {
method: 'POST',
body: JSON.stringify({
text: '灯塔看守人老周在暴风雨夜发现一封三十年前的信。\n\n他修好旧船,横过海峡,把回信送到对岸。',
}),
});
const canvas = await api('/api/canvas', { method: 'POST', body: JSON.stringify({ project_id: pid }) });
const cid = canvas.canvas?.id ?? canvas.id;
ok('走查项目与画布就绪', !!pid && !!cid, `pid=${pid} cid=${cid}`);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const consoleErrors = [];
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
const shot = (name) => page.screenshot({ path: `${SHOTS}/hel685-${name}.png` });
// ---------- 1440 desktop set (compare against HEL-679 / theme-1) ----------
const DESK = [
['home-1440', '/'],
['workbench-1440', `/workbench/${pid}`],
['image-desk-1440', '/image'],
['video-desk-1440', '/video'],
['agents-1440', '/agents'],
['models-1440', '/models'],
['connections-1440', '/connections'],
['tasks-1440', '/tasks'],
['library-1440', '/library'],
['canvas-1440', `/canvas/${cid}`],
['build-scene-1440', '/build-scene'],
];
for (const [name, route] of DESK) {
consoleErrors.length = 0;
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(700);
ok(`${name} 无控制台错误`, consoleErrors.length === 0, consoleErrors[0] ?? '');
await shot(name);
}
// ---------- 1024 / 390 usability set ----------
const NARROW = [
['home', '/'],
['workbench', `/workbench/${pid}`],
['image-desk', '/image'],
['video-desk', '/video'],
['agents', '/agents'],
['tasks', '/tasks'],
['canvas', `/canvas/${cid}`],
];
for (const [name, route] of NARROW) {
for (const w of [1024, 390]) {
await page.setViewportSize({ width: w, height: 900 });
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(600);
await shot(`${name}-${w}`);
if (w === 390) {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
ok(`${name} 390 无横向溢出`, overflow <= 1, `overflow=${overflow}px`);
}
}
}
await page.setViewportSize({ width: 1440, height: 900 });
// ---------- reduce-motion probes (顶栏开关 data-reduce=1) ----------
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(400);
await page.evaluate(() => (document.documentElement.dataset.reduce = '1'));
const dur = await page.locator('.a-btn').first().evaluate((el) => getComputedStyle(el).transitionDuration);
ok('减动效:过渡时长塌缩到 ≤120ms', parseFloat(dur) <= 0.12, dur);
const spinRule = await page.evaluate(() => {
const ring = document.createElement('div');
ring.className = 'a-tile-ring';
ring.innerHTML = '<i></i>';
document.body.appendChild(ring);
const name = getComputedStyle(ring.firstChild).animationName;
ring.remove();
return name;
});
ok('减动效:上传旋转动画禁用(animation-name=none)', spinRule === 'none', spinRule);
await page.evaluate(() => delete document.documentElement.dataset.reduce);
// ---------- keyboard focus probe ----------
await page.goto(`${BASE}/tasks`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
const focusRing = await page.evaluate(() => {
const el = document.activeElement;
if (!el) return null;
const cs = getComputedStyle(el);
return cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) >= 1.5 ? cs.outlineColor : null;
});
ok('键盘焦点显示相纸白焦点圈', focusRing === 'rgb(245, 244, 238)', String(focusRing));
// ---------- text contrast probes (WCAG AA on real elements) ----------
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(600);
const lum = (rgbStr) => {
const m = rgbStr.match(/\d+/g).map(Number);
const [r, g, b] = m.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const l1 = lum(a);
const l2 = lum(b);
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
};
const probeText = async (sel, label, min = 4.5) => {
const el = page.locator(sel).first();
if (!(await el.count())) return ok(`${label} 探针元素存在`, false, sel);
const { color, bg } = await el.evaluate((e) => {
const bgOf = (node) => {
let n = node;
while (n && n !== document.documentElement) {
const bg = getComputedStyle(n).backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
n = n.parentElement;
}
return 'rgb(33, 33, 32)';
};
return { color: getComputedStyle(e).color, bg: bgOf(e) };
});
const r = ratio(color, bg);
ok(`${label} 对比度 ${r.toFixed(2)}:1 ≥ ${min}`, r >= min, `${color} on ${bg}`);
};
await probeText('body', '首页正文文字');
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(600);
await probeText('.a-panel-lead', '面板次要文字(--text-2 级)');
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(400);
// .a-btn.primary 用渐变背景(backgroundColor 透明),对 --accent 实色计算
const btnContrast = await page.evaluate(() => {
const cs = getComputedStyle(document.documentElement);
const hex = (h) => {
const s = h.replace('#', '');
const n = parseInt(s.length === 3 ? s.split('').map((c) => c + c).join('') : s, 16);
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
};
return { ink: hex(cs.getPropertyValue('--accent-ink').trim()), accent: hex(cs.getPropertyValue('--accent').trim()) };
});
const btnRatio = ratio(btnContrast.ink, btnContrast.accent);
ok(`主按钮文字对比度 ${btnRatio.toFixed(2)}:1 ≥ 4.5`, btnRatio >= 4.5, `${btnContrast.ink} on ${btnContrast.accent}`);
// agent identity steps all ≥ text-3 floor (computed from :root tokens)
const idSteps = await page.evaluate(() =>
['--id-1', '--id-2', '--id-3', '--id-4', '--id-5', '--text-3'].map((v) =>
getComputedStyle(document.documentElement).getPropertyValue(v).trim(),
),
);
const bg2 = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg-2').trim());
const toRgb = (hex) => {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
};
for (let i = 0; i < idSteps.length; i++) {
const r = ratio(toRgb(idSteps[i]), toRgb(bg2));
const label = i < 5 ? `身份灰阶 --id-${i + 1}` : '--text-3 下限';
ok(`${label} 在面板底上 ≥4.5:1(${r.toFixed(2)}:1)`, r >= 4.5);
}
await browser.close();
console.log(failures === 0 ? 'ALL HEL-685 WALKTHROUGH CHECKS PASSED' : `${failures} CHECKS FAILED`);
process.exit(failures === 0 ? 0 : 1);
+7 -7
View File
@@ -213,8 +213,8 @@ const run = async () => {
})),
);
ok(
"① 解析 JSON 绿 ✓",
states[0].ok && states[0].color === "rgb(73, 203, 144)",
"① 解析 JSON 亮灰 ✓",
states[0].ok && states[0].color === "rgb(221, 221, 215)",
states[0].color,
);
ok(
@@ -227,8 +227,8 @@ const run = async () => {
.textContent(),
);
ok(
"③ 核对模型全绿(假 object_info 装齐)",
states[2].ok && states[2].color === "rgb(73, 203, 144)",
"③ 核对模型全亮灰(假 object_info 装齐)",
states[2].ok && states[2].color === "rgb(221, 221, 215)",
states[2].color,
);
ok("④ 建立输入槽(1 参数 0 媒体)", states[3].ok);
@@ -311,7 +311,7 @@ const run = async () => {
.evaluate((el) => getComputedStyle(el).color);
ok(
"无法核查 = 琥珀(非绿非红)",
warnColor === "rgb(240, 180, 92)",
warnColor === "rgb(163, 145, 95)",
warnColor,
);
ok(
@@ -392,7 +392,7 @@ const run = async () => {
const chipColor = await page
.locator(".drawer-panel .wft-active .chip.run")
.evaluate((el) => getComputedStyle(el).color);
ok("进行中状态为信号青", chipColor === "rgb(84, 223, 210)", chipColor);
ok("进行中状态为运行灰", chipColor === "rgb(184, 182, 172)", chipColor);
ok(
"进行中显示进度条",
(await page.locator(".drawer-panel .wft-active .track").count()) === 1,
@@ -450,7 +450,7 @@ const run = async () => {
.evaluate((el) => getComputedStyle(el).color);
ok(
"失败判定为红 + 中文标题",
badColor === "rgb(240, 100, 90)",
badColor === "rgb(208, 122, 107)",
await page
.locator(".drawer-panel .wft-result.bad .wft-verdict")
.textContent(),
+22
View File
@@ -16,6 +16,28 @@ const run = async () => {
const base = 'http://127.0.0.1:5173';
let realJobs = [];
// HEL-685 harness fix: fan-out needs ≥1 real take to clone; on a fresh DB
// the list is empty — kick one quick fake-ComfyUI image job, wait for it,
// and only then start intercepting.
{
const probe = await fetch('http://127.0.0.1:8247/api/playground/jobs').then((r) => r.json());
if (!Array.isArray(probe) || probe.length === 0) {
await fetch('http://127.0.0.1:8247/api/playground/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflow_id: 1,
input_values: { 1: { text: 'pagination 基准' }, 2: 42 },
source: 'studio-image',
}),
});
for (let i = 0; i < 60; i++) {
await page.waitForTimeout(500);
const jobs = await fetch('http://127.0.0.1:8247/api/playground/jobs').then((r) => r.json());
if (Array.isArray(jobs) && jobs.some((j) => j.status === 'done')) break;
}
}
}
await page.route('**/api/playground/jobs**', async (route) => {
const r = await route.fetch();
realJobs = await r.json();
+23 -18
View File
@@ -69,14 +69,15 @@ const run = async () => {
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
// ---------- 工作流选择器:↑↓ 高亮 + Enter 使用 ----------
// ---------- 挑选台(HEL-683 三段式):摘要带展开 → ↑↓ 高亮 + Enter 使用 ----------
await page.goto('http://127.0.0.1:5173/image');
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
await page.click('#desk-wf');
await page.waitForSelector('.shell-pop');
const activeRows = page.locator('.shell-pop .row.active');
await page.click('.bay-band .band-hit');
await page.waitForSelector('.bay .row.active', { timeout: 10000 });
await page.waitForSelector('.bay .search-input');
const activeRows = page.locator('.bay .row.active');
const nActive = await activeRows.count();
ok('工作流面板有默认高亮行', nActive === 1);
ok('挑选台有默认高亮行', nActive === 1);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
const label1 = await activeRows.first().textContent();
@@ -86,15 +87,19 @@ const run = async () => {
const chipBefore = await page.locator('#desk-wf .lbl').textContent();
await page.keyboard.press('Enter');
await page.waitForTimeout(400);
ok('Enter 使用高亮的工作流(挑选台收起)', (await page.locator('.bay.open').count()) === 0);
const chipAfter = await page.locator('#desk-wf .lbl').textContent();
ok('Enter 使用高亮的工作流', chipBefore !== chipAfter || (await activeRows.count()) === 0, `${chipBefore} → ${chipAfter}`);
await page.screenshot({ path: `${SHOTS}/01-picker-roving.png` });
ok('输入台芯片跟随选中工作流', chipBefore !== chipAfter, `${chipBefore} → ${chipAfter}`);
await page.screenshot({ path: `${SHOTS}/01-bay-roving.png` });
// 回到文生图工作流
await page.click('#desk-wf');
await page.fill('.shell-pop .search-input', '文生图');
// 回到文生图工作流(搜索唯一命中「回归」)
await page.click('.bay-band .band-hit');
await page.waitForSelector('.bay .search-input');
await page.fill('.bay .search-input', '回归');
await page.waitForSelector('.bay .row.active');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
ok('搜索后 Enter 选中文生图工作流', (await page.locator('#desk-wf .lbl').textContent()).includes('文生图'));
// ---------- 提交 → 完成(绿标签 + 自动切结果页) ----------
setMode({ mode: 'ok', delay_sec: 0 });
@@ -116,9 +121,9 @@ const run = async () => {
// 绿标签
const doneCard = page.locator('.take', { hasText: '柴犬' }).first();
const doneTag = await doneCard.locator('.state .a-tag').first().textContent();
ok('完成态为绿色「完成」标签(非金色)', (doneTag || '').includes('完成'), doneTag);
ok('完成态为「定影」标签(HEL-680 灰阶语义)', (doneTag || '').includes('定影'), doneTag);
const tagColor = await doneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('完成标签用信号绿 rgb(73,203,144)', tagColor === 'rgb(73, 203, 144)', tagColor);
ok('完成标签用灰阶信号色 rgb(221,221,215)', tagColor === 'rgb(221, 221, 215)', tagColor);
await page.screenshot({ path: `${SHOTS}/02-desk-done.png` });
// ---------- 慢任务:排队灰 + 运行青 + 进度条 ----------
@@ -130,9 +135,9 @@ const run = async () => {
await page.click('.a-btn.primary'); // 第二个:queued 灰
await page.waitForTimeout(4000);
const runTag = await stateTag(page, '观察进度').textContent();
ok('运行中为青色「生成中」', /生成中|执行/.test(runTag || ''), runTag);
ok('运行中为「生成中/执行中」', /生成中|执行/.test(runTag || ''), runTag);
const runTagColor = await stateTag(page, '观察进度').evaluate((el) => getComputedStyle(el).color);
ok('运行标签用信号青 rgb(84,223,210)', runTagColor === 'rgb(84, 223, 210)', runTagColor);
ok('运行标签用灰阶信号色 rgb(184,182,172)', runTagColor === 'rgb(184, 182, 172)', runTagColor);
const hasBar = (await page.locator('.take', { hasText: '观察进度' }).locator('.a-progress').count()) > 0;
ok('运行中卡片有进度条', hasBar);
const queueTag = await stateTag(page, '第二个任务').textContent();
@@ -175,7 +180,7 @@ const run = async () => {
const failTag = await failCard.locator('.state .a-tag').first().textContent();
ok('失败为红色「失败」标签', (failTag || '').includes('失败'), failTag);
const failColor = await failCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('失败标签用信号红 rgb(240,100,90)', failColor === 'rgb(240, 100, 90)', failColor);
ok('失败标签用信号红 rgb(208,122,107)(灰阶底上的红)', failColor === 'rgb(208, 122, 107)', failColor);
const errLine = failCard.locator('.err-txt');
ok('失败自动展开一行中文结论', (await errLine.count()) === 1 && (await errLine.textContent()).length > 4);
await errLine.click();
@@ -199,7 +204,7 @@ const run = async () => {
const undoBtn = page.locator('.toast .action', { hasText: '撤销' });
ok('出现带「撤销」的 Toast', (await undoBtn.count()) === 1);
const undoColor = await undoBtn.evaluate((el) => getComputedStyle(el).color);
ok('撤销动作按钮为金色文字 rgb(242,199,132)', undoColor === 'rgb(242, 199, 132)', undoColor);
ok('撤销动作按钮为强调白文字 rgb(245,244,238)', undoColor === 'rgb(245, 244, 238)', undoColor);
await page.screenshot({ path: `${SHOTS}/05-undo-toast.png` });
const countDeleted = await cardCount(page);
ok('删除后行立即收起', countDeleted === countBefore - 1, `${countBefore} → ${countDeleted}`);
@@ -237,7 +242,7 @@ const run = async () => {
const goneTag = await goneCard.locator('.state .a-tag').first().textContent();
ok('文件缺失为琥珀「文件缺失」', (goneTag || '').includes('文件缺失'), goneTag);
const goneColor = await goneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('缺失标签用琥珀 rgb(240,180,92)', goneColor === 'rgb(240, 180, 92)', goneColor);
ok('缺失标签用信号琥珀 rgb(163,145,95)', goneColor === 'rgb(163, 145, 95)', goneColor);
ok('缺失态给「用相同参数重做」', (await goneCard.getByRole('button', { name: '用相同参数重做' }).count()) === 1);
ok('缺失态给「移除记录」', (await goneCard.getByRole('button', { name: '移除记录' }).count()) === 1);
await goneCard.getByRole('button', { name: '用相同参数重做' }).click();
@@ -284,7 +289,7 @@ const run = async () => {
ok('任务页筛选芯片组(含失败/进行中)', (await chips.count()) === 5);
// 默认「全部」选中即金色文字(accent-soft 底)
const selChipColor = await chips.first().evaluate((el) => getComputedStyle(el).color);
ok('选中筛选芯片为金色文字', selChipColor === 'rgb(242, 199, 132)', selChipColor);
ok('选中筛选芯片为强调白文字', selChipColor === 'rgb(245, 244, 238)', selChipColor);
const failedChipColor = await chips.nth(3).evaluate((el) => getComputedStyle(el).color);
await chips.nth(3).click(); // 失败
await page.waitForTimeout(300);