Compare commits

..
Author SHA1 Message Date
总工andmultica-agent e6f761f8c8 feat(workflows): 五类用途识别与导入确认闭环 (HEL-636)
- 判型器 classify_usage:按输出节点(sink 检测)与被消费图片槽判定
  文生图/图生图/文生视频/图生视频/参考图生视频,不依赖文件名/模型名/
  固定节点 ID;歧义(单散装图+视频、同图双输出)与无输出给中文说明
- 参考图组泛化:任意 <group>.<prefix>N 连续编号且均接图源的输入成组
  (MiniMaxH3 特判仅剩标签美化);patcher 支持通用组回填
- workflows 表新增 usage/usage_source,幂等迁移回填;判不出的存量行
  停用待确认;kind 语义收窄为输出媒体,历史无损
- 导入改两步向导:分析→预览(判定依据/输出节点/媒体槽角色可改/
  参数预览/警告)→确认保存;usage_source=user 的纠正不被重分析覆盖
- 生成页按 usage 过滤;待确认工作流不进生成页且提交被拒
- 新增 test_usage_classifier / test_workflow_import_confirm;
  两个旧测试夹具补输出节点以匹配新守卫

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 12:13:33 +08:00
22 changed files with 1897 additions and 174 deletions
+32 -1
View File
@@ -78,10 +78,41 @@ def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
"level": "warning",
"code": "orphan_latent",
"node_id": nid,
"message": f"{_title(nid, node)}没有接到任何节点,"
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
"它的宽高不会生效,已不在表单中显示",
}
)
# HEL-636: a workflow without any recognizable output node can never
# return a result — block it with a readable reason instead of letting
# ComfyUI run blind. Unknown *possible* savers (needs_mapping) don't
# trigger this: the user confirms those on the import page.
from calliope.comfyui.parser import detect_output_nodes
outputs = detect_output_nodes(workflow)
if not any(o.get("kind") in ("image", "video") for o in outputs):
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
if not unknown:
found.append(
{
"level": "error",
"code": "no_output",
"node_id": "",
"message": "未在图中找到输出节点(SaveImage / 视频合成等),"
"请确认导出的是 API 格式且包含保存节点",
}
)
else:
names = "、".join(f"节点 {o['nodeId']}" for o in unknown[:4])
found.append(
{
"level": "warning",
"code": "unknown_output_node",
"node_id": names,
"message": f"存在无法识别的输出节点({names}),"
"导入后需人工确认用途;结果按 ComfyUI 历史的图片/视频输出提取",
}
)
return found
+338 -16
View File
@@ -3,18 +3,23 @@
from __future__ import annotations
import math
import re
from typing import Any
from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
IMAGE_OUTPUT_CLASSES,
IMAGE_URL_CLASSES,
VIDEO_CLASSES,
VIDEO_OUTPUT_CLASSES,
ComfyOutputKind,
class_to_input_kind,
class_to_output_kind,
)
from calliope.comfyui.roles import (
USAGE_VALUES,
normalize_input_role,
normalize_output_role,
parse_title_tag,
@@ -76,9 +81,17 @@ def effective_input_schema(
Auto-detected (and empty) schemas are re-derived from the workflow JSON
on every read, so parser upgrades reach workflows imported earlier
without a re-import. Tagged ``(Input:…)`` schemas are kept as stored.
Entries the user corrected on the import confirm page carry
``manual: true`` — their role/label survive re-derivation.
"""
if not stored or any(isinstance(e, dict) and e.get("auto") for e in stored):
if not stored:
return parse_dynamic_inputs(workflow)
manual_by_id = {
str(e.get("nodeId")): e for e in stored if isinstance(e, dict) and e.get("manual")
}
if any(isinstance(e, dict) and e.get("auto") for e in stored):
fresh = parse_dynamic_inputs(workflow)
return [manual_by_id.get(str(e.get("nodeId")), e) for e in fresh]
return stored
@@ -266,15 +279,22 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
)
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list
# (values read from /object_info). The workflow wires ``<group>.<prefix>N``;
# extra images are cloned LoadImage nodes added at patch time (patcher.py).
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list.
# The workflow wires ``<group>.<prefix>N``; extra images are cloned LoadImage
# nodes added at patch time (patcher.py). This registry only beautifies known
# classes (label + real max from /object_info); detection itself is generic —
# any ``<group>.<prefix>N`` run of image-loader links folds into a group.
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
"MiniMaxH3ReferenceToVideo": {
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
},
}
# ``ref_images.ref_image_0`` / ``images.image_1`` style Autogrow input names.
_GROUP_INPUT_RE = re.compile(
r"^(?P<group>[A-Za-z0-9_+-]+)\.(?P<rest>[A-Za-z0-9_+-]+?)(?P<n>\d+)$"
)
# Consumer input name → (role, label) of the LoadImage wired into it.
_FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
"first_frame": ("first_frame", "首帧"),
@@ -288,6 +308,28 @@ def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
return IMAGE_GROUPS.get(class_type, {}).get(group)
def image_group_prefix(node: dict[str, Any], group: str) -> str | None:
"""The ``<prefix>`` this node numbers its ``<group>.<prefix>N`` inputs with.
Known classes read it from the registry; anything else derives it from
the node's own wired input names (generic Autogrow detection).
"""
spec = image_group_spec(str(node.get("class_type") or ""), group)
if spec:
return str(spec["prefix"])
head = f"{group}."
prefixes = set()
for name in (node.get("inputs") or {}):
if not name.startswith(head):
continue
m = _GROUP_INPUT_RE.match(name)
if m and m.group("group") == group:
prefixes.add(m.group("rest"))
if len(prefixes) == 1:
return prefixes.pop()
return None
def image_group_slots(
node: dict[str, Any], group: str, prefix: str
) -> list[tuple[int, str, Any]]:
@@ -300,6 +342,43 @@ def image_group_slots(
return sorted(out)
def _detected_group_runs(
workflow: dict[str, Any], node: dict[str, Any]
) -> list[tuple[str, str, list[str]]]:
"""``[(group, prefix, source LoadImage ids)]`` for generic Autogrow inputs.
A run qualifies when the numbers are consecutive, every slot links to an
image-loader class, and the run either starts at 0 (single-slot Autogrow
exports like H3's ``ref_image_0``) or has ≥2 slots. Single runs numbered
from 1 (e.g. TextEncodeQwenImage21's ``images.image_1``) stay ordinary
image slots.
"""
runs: dict[tuple[str, str], dict[int, Any]] = {}
for name, val in (node.get("inputs") or {}).items():
m = _GROUP_INPUT_RE.match(name)
if not m or not (isinstance(val, list) and len(val) == 2):
continue
runs.setdefault((m.group("group"), m.group("rest")), {})[int(m.group("n"))] = val
out: list[tuple[str, str, list[str]]] = []
for (group, prefix), slots in sorted(runs.items()):
ns = sorted(slots)
if any(b - a != 1 for a, b in zip(ns, ns[1:])):
continue
if not (ns[0] == 0 or len(ns) >= 2):
continue
sources: list[str] = []
for n in ns:
link = slots[n]
src = workflow.get(str(link[0]))
if not (isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES):
sources = []
break
sources.append(str(link[0]))
if sources:
out.append((group, prefix, sources))
return out
def _image_groups(
workflow: dict[str, Any],
) -> tuple[list[dict[str, Any]], set[str]]:
@@ -310,27 +389,19 @@ def _image_groups(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
for group, spec in IMAGE_GROUPS.get(str(node.get("class_type") or ""), {}).items():
sources: list[str] = []
for _n, _name, link in image_group_slots(node, group, spec["prefix"]):
if not (isinstance(link, list) and len(link) == 2):
continue
src = workflow.get(str(link[0]))
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
sources.append(str(link[0]))
if not sources:
continue
for group, _prefix, sources in _detected_group_runs(workflow, node):
spec = image_group_spec(str(node.get("class_type") or ""), group) or {}
absorbed.update(sources)
entries.append(
_entry(
f"{nid}{FIELD_KEY_SEP}{group}",
spec["label"],
str(spec.get("label") or "参考图"),
"reference_image",
"image_list",
None,
True,
min=1,
max=spec["max"],
max=spec.get("max", 9),
)
)
return entries, absorbed
@@ -624,4 +695,255 @@ def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
"kind": out_kind,
}
)
if not results:
results = detect_output_nodes(workflow)
return results
# ---------------------------------------------------------------------------
# Output (sink) detection + five-usage classification (HEL-636)
# ---------------------------------------------------------------------------
# Nodes whose output another node consumes are wiring, not results. A node
# nothing consumes is a sink; media sinks are the workflow's outputs.
_INPUT_ONLY_CLASSES = IMAGE_CLASSES | IMAGE_URL_CLASSES | AUDIO_CLASSES | VIDEO_CLASSES
# Widget names save-style custom nodes carry, and link input names that mark
# "consumes rendered media" — used to flag unknown sinks that need mapping.
_SAVE_WIDGET_NAMES = frozenset(
{"filename_prefix", "filename", "prefix", "path", "output_path", "save_output"}
)
_MEDIA_LINK_NAMES = frozenset(
{"images", "image", "video", "frames", "latent", "latents", "audio", "audios"}
)
def _sinks(
workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
) -> list[tuple[str, dict[str, Any]]]:
out: list[tuple[str, dict[str, Any]]] = []
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
if not consumers.get(nid):
out.append((nid, node))
return out
def _unknown_sink_looks_like_save(node: dict[str, Any], workflow: dict[str, Any]) -> bool:
inputs = node.get("inputs") or {}
for name, val in inputs.items():
if name in _SAVE_WIDGET_NAMES and isinstance(val, (str, int, float, bool)):
return True
if name in _MEDIA_LINK_NAMES and isinstance(val, list) and len(val) == 2:
src = workflow.get(str(val[0]))
if isinstance(src, dict) and "decode" in str(src.get("class_type") or "").lower():
return True
return False
def _video_semantics(workflow: dict[str, Any]) -> bool:
"""Auxiliary evidence the graph renders video (frame counts, video nodes)."""
for node in workflow.values():
if not isinstance(node, dict):
continue
ct = str(node.get("class_type") or "").lower()
if "video" in ct or "animatediff" in ct:
return True
for name in (node.get("inputs") or {}):
if name in _FRAME_INPUTS:
return True
return False
def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
"""Infer output nodes of an untagged workflow from its sinks.
Exact registry classes (SaveImage, VHS_VideoCombine…) count whether or
not something downstream consumes them (a SaveVideo feeding a
cache-cleanup tail is still the output). Fuzzy class-name matches only
count at a sink. Unknown sink classes that look like savers become
``other`` + ``unknown_node`` evidence — the import page then asks the
user to map them.
"""
if not isinstance(workflow, dict):
return []
consumers = _consumers(workflow)
videoish = _video_semantics(workflow)
results: list[dict[str, Any]] = []
for nid, node in _sinks(workflow, consumers):
ct = str(node.get("class_type") or "")
if not ct or ct in _INPUT_ONLY_CLASSES:
continue
title = _title_of(node)
if ct in IMAGE_OUTPUT_CLASSES or ct in VIDEO_OUTPUT_CLASSES:
kind: ComfyOutputKind = "video" if ct in VIDEO_OUTPUT_CLASSES else "image"
evidence = "registry"
else:
kind = class_to_output_kind(ct)
if kind in ("image", "video"):
evidence = "class_name_match"
elif _unknown_sink_looks_like_save(node, workflow):
# A custom saver we cannot type: with video evidence in the
# graph it most likely saves video, but the user decides.
kind = "video" if videoish else "other"
evidence = "unknown_node"
else:
continue # utility/primitive sink — not an output
results.append(
{
"nodeId": nid,
"label": title or ct,
"role": None,
"kind": kind,
"class_type": ct,
"evidence": evidence,
}
)
# Registry output classes consumed by other nodes are outputs too.
seen = {r["nodeId"] for r in results}
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
ct = str(node.get("class_type") or "")
if nid in seen or ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
continue
results.append(
{
"nodeId": nid,
"label": _title_of(node) or ct,
"role": None,
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
"class_type": ct,
"evidence": "registry",
}
)
return results
# Title heuristics the studio form applies to tagged (Input:…) slots; the
# classifier must judge a workflow the same way the form renders it.
_TAGGED_FIRST_RE = re.compile(r"first|start|首帧", re.IGNORECASE)
_TAGGED_LAST_RE = re.compile(r"last|end|尾帧", re.IGNORECASE)
_TAGGED_MASK_RE = re.compile(r"mask|遮罩", re.IGNORECASE)
def _slot_effective_role(entry: dict[str, Any]) -> str | None:
"""The role the generate form will act on for this schema entry."""
kind = str(entry.get("kind") or "")
if kind == "image_list":
return "reference_image"
role = normalize_input_role(entry.get("role"))
if entry.get("auto") or kind not in ("image", "image_url"):
return role
label = str(entry.get("label") or "")
if _TAGGED_MASK_RE.search(label):
return "mask"
if _TAGGED_FIRST_RE.search(label):
return "first_frame"
if _TAGGED_LAST_RE.search(label):
return "last_frame"
return role
def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
"""Judge a workflow's usage from graph evidence (HEL-636 §2).
Returns ``usage`` plus everything the import confirm page shows:
``ambiguous`` (needs a human decision), ``needs_mapping`` (unknown
output nodes), ``problem`` (Chinese reason when usage is None) and
``evidence`` (output nodes, image/video slots, summary lines). Never
looks at file names, model names or fixed node ids.
"""
inputs = parse_dynamic_inputs(workflow) if isinstance(workflow, dict) else []
outputs = parse_dynamic_outputs(workflow)
media_outputs = [o for o in outputs if o.get("kind") in ("image", "video")]
unknown_outputs = [o for o in outputs if o.get("evidence") == "unknown_node"]
out_kinds = sorted({o["kind"] for o in media_outputs})
image_slots: list[dict[str, Any]] = []
video_slots: list[dict[str, Any]] = []
for entry in inputs:
kind = str(entry.get("kind") or "")
if kind in ("image", "image_list", "image_url"):
image_slots.append(
{
"nodeId": entry.get("nodeId"),
"label": entry.get("label"),
"kind": kind,
"role": _slot_effective_role(entry),
}
)
elif kind == "video":
video_slots.append(
{
"nodeId": entry.get("nodeId"),
"label": entry.get("label"),
"kind": kind,
"role": normalize_input_role(entry.get("role")),
}
)
effective = [s["role"] or "image" for s in image_slots if s["role"] != "mask"]
bulk = [r for r in effective if r in (None, "image")]
has_frame = any(r in ("first_frame", "last_frame") for r in effective)
has_ref_group = any(s["kind"] == "image_list" for s in image_slots)
summary: list[str] = []
for o in media_outputs:
zh = "图片" if o["kind"] == "image" else "视频"
summary.append(f"输出:节点 {o['nodeId']}({o.get('class_type') or o['label']})→ {zh}")
if unknown_outputs:
names = "、".join(
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
)
summary.append(f"存在无法识别的输出节点:{names},需人工确认")
for s in image_slots:
if s["role"] != "mask":
summary.append(f"图片输入:节点 {s['nodeId']}({s['label']})")
if video_slots:
summary.append("含视频输入槽(本版本不支持参考视频上传)")
usage: str | None = None
ambiguous = False
problem: str | None = None
if not out_kinds and not unknown_outputs:
problem = (
"未在图中找到输出节点(SaveImage / 视频合成等),"
"请确认导出的是 API 格式且包含保存节点"
)
elif len(out_kinds) > 1:
ambiguous = True
problem = "同一工作流同时输出图片和视频,需人工选择主用途"
elif len(out_kinds) == 1:
if out_kinds[0] == "image":
usage = "image2image" if bulk else "text2image"
elif has_ref_group or len(bulk) >= 2:
usage = "ref2video"
elif has_frame and not bulk:
usage = "image2video"
elif not effective:
usage = "text2video"
else:
ambiguous = True
problem = "视频工作流中的单张图片输入无法判定是首帧还是参考图,需人工确认"
else:
# only unknown outputs — the user must map them before it is usable
ambiguous = True
problem = "输出节点的媒体类型无法识别,需人工确认用途后才能生成"
return {
"usage": usage,
"ambiguous": ambiguous,
"needs_mapping": bool(unknown_outputs),
"problem": problem,
"evidence": {
"outputs": outputs,
"image_slots": image_slots,
"video_slots": video_slots,
"summary": summary,
},
"usable_usage": list(USAGE_VALUES),
}
@@ -53,15 +53,15 @@ def apply_image_group(
first, and slots/loaders left over from the export are removed so no
stale image reaches ComfyUI.
"""
from calliope.comfyui.parser import image_group_slots, image_group_spec
from calliope.comfyui.parser import image_group_prefix, image_group_slots
node = workflow.get(node_id)
if not isinstance(node, dict):
return
spec = image_group_spec(str(node.get("class_type") or ""), group)
if spec is None:
prefix = image_group_prefix(node, group)
if prefix is None:
return
prefix = spec["prefix"]
label = "参考图"
inputs = dict(node.get("inputs") or {})
slots = image_group_slots(node, group, prefix)
loaders: list[str] = []
@@ -96,7 +96,7 @@ def apply_image_group(
loader_inputs = dict(loader.get("inputs") or {})
loader_inputs["image"] = path
loader["inputs"] = loader_inputs
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{spec['label']} {i + 1}"}
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
workflow[nid] = loader
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
node["inputs"] = inputs
@@ -30,6 +30,27 @@ OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
"video": frozenset({"video", "vid"}),
}
# The five workflow usages the studio distinguishes (HEL-636). Derived from
# graph evidence only (output media + consumed image inputs) — never from
# file names, model names or fixed node ids.
WorkflowUsage = str
USAGE_VALUES: tuple[str, ...] = (
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
)
def usage_to_kind(usage: str | None) -> str | None:
"""Output media kind of a usage (image / video); None when unknown."""
if usage in ("text2image", "image2image"):
return "image"
if usage in ("text2video", "image2video", "ref2video"):
return "video"
return None
def parse_title_tag(title: str) -> tuple[str | None, str | None, str]:
"""Return (kind, role, label) from a Comfy node title.
+41
View File
@@ -338,6 +338,38 @@ def _sqlite_connect(path: Path) -> sqlite3.Connection:
return sqlite3.connect(path)
def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
"""HEL-636: settle `workflows.usage` for rows that still have none.
Determinate graphs get ``usage='auto'``; indeterminate ones keep NULL
and are disabled (they must be confirmed on the connections page before
they can generate). Rows the user already confirmed (usage_source='user')
never pass through here again — their usage is set, so the WHERE clause
skips them. Old jobs / history / `kind` semantics are untouched.
"""
from calliope.comfyui.parser import classify_usage
rows = conn.execute(
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
).fetchall()
for row in rows: # plain connect — positional access
try:
workflow = json.loads(row[1])
except (json.JSONDecodeError, TypeError):
workflow = None
judgment = classify_usage(workflow) if isinstance(workflow, dict) else {}
if judgment.get("usage"):
conn.execute(
"UPDATE workflows SET usage = ?, usage_source = 'auto' WHERE id = ?",
(judgment["usage"], row[0]),
)
else:
conn.execute(
"UPDATE workflows SET is_enabled = 0 WHERE id = ? AND usage IS NULL",
(row[0],),
)
async def migrate_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
@@ -383,6 +415,15 @@ async def migrate_db(db_path: Path) -> None:
# comfyui_endpoints id). Dangling ids are tolerated — enqueue falls
# back to the active connection.
conn.execute("ALTER TABLE workflows ADD COLUMN endpoint_id TEXT")
# HEL-636: five-way usage (text2image…ref2video). NULL = pending human
# confirmation; `kind` keeps meaning output media for history/paths.
if "usage" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN usage TEXT")
if "usage_source" not in cols:
conn.execute("ALTER TABLE workflows ADD COLUMN usage_source 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)
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
if "location_id" not in scene_cols:
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
@@ -198,14 +198,30 @@ class ExpandClipsRequest(BaseModel):
clip_cap: int | None = None
WorkflowUsageKind = Literal[
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
]
class WorkflowCreate(BaseModel):
name: str = Field(..., min_length=1)
kind: Literal["image", "video"] = "image"
kind: Literal["image", "video"] | None = None
workflow_json: dict[str, Any]
description: str | None = None
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
# Optional binding to a ComfyUI connection id (settings.comfyui_endpoints).
endpoint_id: str | None = None
# HEL-636: five-way usage. Given by the import confirm page (possibly a
# user correction of the auto judgment); when absent it is classified
# from the graph, and indeterminate workflows stay pending (disabled).
usage: WorkflowUsageKind | None = None
usage_source: Literal["auto", "user"] | None = None
# Schema corrected on the confirm page (manual role overrides included).
input_schema: list[dict[str, Any]] | None = None
class WorkflowUpdate(BaseModel):
@@ -215,6 +231,9 @@ class WorkflowUpdate(BaseModel):
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
is_enabled: bool | None = None
endpoint_id: str | None = None
usage: WorkflowUsageKind | None = None
usage_source: Literal["auto", "user"] | None = None
input_schema: list[dict[str, Any]] | None = None
class WorkflowAnalyze(BaseModel):
@@ -7,11 +7,13 @@ from fastapi import APIRouter, HTTPException
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.parser import (
classify_usage,
effective_input_schema,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.profiles import detect_prompt_profile
from calliope.comfyui.roles import usage_to_kind
from calliope.config import settings
from calliope.db import get_db, row_to_dict
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
@@ -34,20 +36,36 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
inputs = parse_dynamic_inputs(payload.workflow_json)
outputs = parse_dynamic_outputs(payload.workflow_json)
judgment = classify_usage(payload.workflow_json)
return {
"inputs": inputs,
"outputs": outputs,
"suggested_profile": detect_prompt_profile(payload.workflow_json),
"warnings": workflow_warnings(payload.workflow_json),
"usage": judgment["usage"],
"usage_ambiguous": judgment["ambiguous"],
"needs_mapping": judgment["needs_mapping"],
"usage_problem": judgment["problem"],
"usage_evidence": judgment["evidence"],
"usable_usage": judgment["usable_usage"],
}
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
"""Schema to persist: the confirm page's corrected copy, else the auto one."""
if payload_schema and all(isinstance(e, dict) for e in payload_schema):
return json.dumps(payload_schema)
return json.dumps(parse_dynamic_inputs(workflow))
@router.post("/{workflow_id}/reanalyze")
async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
"""Recompute input/output schemas from the stored workflow JSON.
"""Recompute usage and input/output schemas from the stored workflow JSON.
Workflows imported before a parser upgrade keep stale (often empty)
schemas — this refreshes them without re-importing.
schemas — this refreshes them without re-importing. A usage the user
set by hand is never overwritten; only ``auto`` (and pending NULL)
rows get the new judgment.
"""
conn = get_db(settings.db_path)
try:
@@ -56,11 +74,19 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
raise HTTPException(status_code=404, detail="Workflow not found")
data = row_to_dict(row)
workflow = json.loads(data["workflow_json"])
inputs = parse_dynamic_inputs(workflow)
judgment = classify_usage(workflow)
usage = data["usage"]
usage_source = data["usage_source"]
if data["usage_source"] != "user" and judgment["usage"]:
usage = judgment["usage"]
usage_source = "auto"
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
schema = effective_input_schema(stored, workflow)
outputs = parse_dynamic_outputs(workflow)
conn.execute(
"UPDATE workflows SET input_schema = ?, output_schema = ? WHERE id = ?",
(json.dumps(inputs), json.dumps(outputs), workflow_id),
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
" usage = ?, usage_source = ? WHERE id = ?",
(json.dumps(schema), json.dumps(outputs), usage, usage_source, workflow_id),
)
conn.commit()
fresh = conn.execute(
@@ -118,8 +144,19 @@ async def dedupe_workflows() -> dict[str, Any]:
@router.post("")
async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
inputs = parse_dynamic_inputs(payload.workflow_json)
outputs = parse_dynamic_outputs(payload.workflow_json)
judgment = classify_usage(payload.workflow_json)
usage = payload.usage or judgment["usage"]
usage_source = "user" if payload.usage else "auto"
kind = payload.kind or usage_to_kind(usage)
if kind is None:
# Indeterminate and nothing chosen: classify_output hint keeps old
# callers working while the workflow stays pending confirmation.
out_kinds = {
o.get("kind")
for o in parse_dynamic_outputs(payload.workflow_json)
if o.get("kind") in ("image", "video")
}
kind = "video" if out_kinds == {"video"} else "image"
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
conn = get_db(settings.db_path)
try:
@@ -127,29 +164,39 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
# same-named clones (the "@" typeahead then shows 20 identical rows).
existing = conn.execute(
"SELECT id, name FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
(payload.name, payload.kind, json.dumps(payload.workflow_json)),
(payload.name, kind, json.dumps(payload.workflow_json)),
).fetchone()
if existing:
raise HTTPException(
status_code=409,
detail=f"An identical workflow already exists (id {existing['id']}, “{existing['name']}”).",
detail=(
f"An identical workflow already exists (id {existing['id']},"
f" “{existing['name']}”)."
),
)
# Unconfirmed workflows (ambiguous / unknown outputs) are stored but
# disabled: they must not reach the generate pages until the user
# confirms a usage on the connections page (PATCH sets it back on).
is_enabled = 1 if usage else 0
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema,
output_schema, description, prompt_profile,
endpoint_id, is_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
endpoint_id, is_enabled, usage, usage_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.name,
payload.kind,
kind,
json.dumps(payload.workflow_json),
json.dumps(inputs),
json.dumps(outputs),
_stored_schema(payload.input_schema, payload.workflow_json),
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
payload.description,
profile,
payload.endpoint_id,
is_enabled,
usage,
usage_source,
),
)
conn.commit()
@@ -181,6 +228,17 @@ 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
if data.get("usage"):
# Confirming / correcting the usage is an explicit user decision:
# it re-enables the workflow and keeps kind (output media) in sync.
data.setdefault("usage_source", "user")
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
if "is_enabled" not in data:
data["is_enabled"] = 1
if "input_schema" in data and data["input_schema"] is not None:
if not all(isinstance(e, dict) for e in data["input_schema"]):
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
data["input_schema"] = json.dumps(data["input_schema"])
# endpoint_id: explicit null clears the binding; other Nones are skips.
explicit_null = {"endpoint_id"} & {k for k, v in data.items() if v is None}
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
@@ -389,6 +389,13 @@ def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
"class_type": "CLIPTextEncode",
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
},
# HEL-636: a workflow needs a recognizable output node to classify a
# usage and stay enabled; without it generation is refused.
"9": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["6", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
}
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
if endpoint_id is not None:
@@ -0,0 +1,308 @@
"""HEL-636 A1–A6: the five-usage classifier judges from graph evidence only.
Every case feeds real (or minimally mutated) ComfyUI API exports — never a
file name, model name or fixed node id — and asserts the usage, the media
slots it implies, and the failure paths for ambiguous / unknown graphs.
"""
from __future__ import annotations
import json
from pathlib import Path
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.parser import (
classify_usage,
detect_untagged_inputs,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.patcher import patch_workflow
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _rename_everything(wf: dict, id_shift: int = 7) -> dict:
"""Change every node id, title and widget value the classifier must not
depend on (model file names / prompt texts)."""
def new_id(nid: str) -> str:
return f"x{int(nid) * id_shift + 3}" if nid.isdigit() else "x" + nid.replace(":", "_")
out: dict = {}
for nid, node in wf.items():
out[new_id(nid)] = json.loads(json.dumps(node))
for node in out.values():
for val in (node.get("inputs") or {}).values():
if isinstance(val, list) and len(val) == 2 and str(val[0]).isdigit():
val[0] = new_id(str(val[0]))
if node.get("_meta", {}).get("title"):
node["_meta"]["title"] = f"改·{node['_meta']['title']}"
for key, val in list((node.get("inputs") or {}).items()):
if isinstance(val, str) and val.endswith((".safetensors", ".gguf")):
node["inputs"][key] = "另一个模型_" + val
return out
def _media(inputs: list[dict]) -> list[dict]:
return [
i
for i in inputs
if i["kind"] in ("image", "image_list", "video", "audio")
]
# ---------------------------------------------------------------------------
# A1 / A2 / A3: the three real shapes, plus id/title/model renames
# ---------------------------------------------------------------------------
def test_a1_native_t2i_is_text2image_and_form_has_no_image_slot():
wf = _load(FIXTURES / "qwen_image_t2i_API.json")
c = classify_usage(wf)
assert c["usage"] == "text2image"
assert c["ambiguous"] is False and c["needs_mapping"] is False
assert _media(parse_dynamic_inputs(wf)) == []
outs = parse_dynamic_outputs(wf)
assert [(o["nodeId"], o["kind"]) for o in outs] == [("60", "image")]
def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
wf = _rename_everything(_load(FIXTURES / "qwen_image_edit_mask_API.json"))
c = classify_usage(wf)
assert c["usage"] == "image2image"
inputs = parse_dynamic_inputs(wf)
img = [i for i in _media(inputs) if i.get("role") == "image"]
assert len(img) == 1 and img[0]["required"] is True
mask = [i for i in _media(inputs) if i.get("role") == "mask"]
assert len(mask) == 1 # mask never counts as an input image
def test_a3_i2v_variant_renamed_still_image2video_first_frame_only():
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
c = classify_usage(wf)
assert c["usage"] == "image2video"
media = _media(parse_dynamic_inputs(wf))
assert [i.get("role") for i in media] == ["first_frame"]
# A3: no first/last pair → the pair validation must not fire
assert blocking_problem(wf) is None
def test_a4_ref2va_variant_with_renamed_class_and_prefix_still_groups():
wf = _rename_everything(_load(FIXTURES / "h3_ref2va_05_API.json"))
for node in wf.values():
if node.get("class_type") == "MiniMaxH3ReferenceToVideo":
node["class_type"] = "MyFancyRefToVideo"
inputs = node["inputs"]
for k in list(inputs):
if k.startswith("ref_images.ref_image_"):
inputs[k.replace("ref_images.ref_image_", "refs.pic_")] = inputs.pop(k)
c = classify_usage(wf)
assert c["usage"] == "ref2video"
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
assert len(groups) == 1
g = groups[0]
assert g["role"] == "reference_image" and (g["min"], g["max"]) == (1, 9)
# patch rewires the renamed group in order and refuses a 10th image
key = g["nodeId"]
node_id = key.split("@")[0]
patched = patch_workflow(wf, {key: [f"/u/{i}.png" for i in range(4)]})
names = sorted(
(k for k in patched[node_id]["inputs"] if k.startswith("refs.pic_")),
key=lambda k: int(k.rsplit("_", 1)[-1]) if k.rsplit("_", 1)[-1].isdigit() else int(k[-1]),
)
assert names == [f"refs.pic_{i}" for i in range(4)]
from calliope.comfyui.validate import validate_input_values
_c, errs = validate_input_values(
detect_untagged_inputs(wf), {key: [f"/u/{i}.png" for i in range(10)]}
)
assert any("最多 9 张" in e for e in errs)
def test_qwen_single_numbered_encoder_input_is_not_mistaken_for_a_group():
"""TextEncodeQwenImage21's ``images.image_1`` stays an ordinary input slot."""
inputs = parse_dynamic_inputs(_load(FIXTURES / "qwen21_edit_sage_API.json"))
assert not any(i["kind"] == "image_list" for i in inputs)
assert any(i.get("role") == "image" for i in inputs)
# ---------------------------------------------------------------------------
# Judgment table rows from the real example library
# ---------------------------------------------------------------------------
def test_all_real_exports_classify():
expect = {
"qwen_image_t2i_API.json": "text2image",
"qwen_image_edit_mask_API.json": "image2image",
"qwen21_edit_sage_API.json": "image2image",
"h3_i2v_04_API.json": "image2video",
"h3_ref2va_05_API.json": "ref2video",
"minimax_h3_t2v_untagged_API.json": "text2video",
"wen_sheng_tu_API.json": "text2image",
"wen_sheng_shipin_API.json": "text2video",
}
for name, usage in expect.items():
assert classify_usage(_load(FIXTURES / name))["usage"] == usage, name
examples = {
"Krea2_t2i_20260818_API.json": "text2image",
"krea2_T2I_CharSheetV1_API.json": "text2image",
"minimax_h3-Turbo_Text2Video_20260813_API.json": "text2video",
"minimax_h3-Turbo_Image2Video_20260813_API.json": "text2video",
"MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json": "ref2video",
"video_minimax_h3_r2v_2ref_API.json": "ref2video",
"video_minimax_h3_r2v_3ref_API.json": "ref2video",
"video_minimax_h3_r2v_4ref_API.json": "ref2video",
"video_minimax_h3_r2v_5ref_API.json": "ref2video",
}
for name, usage in examples.items():
assert classify_usage(_load(EXAMPLES / name))["usage"] == usage, name
def test_r2v_extend_reports_its_video_input_slot():
wf = _load(EXAMPLES / "MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json")
c = classify_usage(wf)
assert c["usage"] == "ref2video"
assert c["evidence"]["video_slots"], "the reference-video slot must be reported"
assert any("视频输入槽" in line for line in c["evidence"]["summary"])
# ---------------------------------------------------------------------------
# A5: ambiguity — single loose image into a video graph; multi-output graph
# ---------------------------------------------------------------------------
def _load_image(nid: str) -> dict:
return {
"class_type": "LoadImage",
"inputs": {"image": "x.png"},
"_meta": {"title": f"图 {nid}"},
}
def test_single_loose_image_with_video_output_is_ambiguous():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
c = classify_usage(wf)
assert c["usage"] is None and c["ambiguous"] is True
assert "首帧还是参考图" in c["problem"]
def test_first_frame_role_decides_image2video_without_ambiguity():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "first_frame": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
assert classify_usage(wf)["usage"] == "image2video"
def test_same_graph_both_image_and_video_outputs_is_ambiguous():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["7", 1]}},
}
c = classify_usage(wf)
assert c["usage"] is None and c["ambiguous"] is True
assert "同时输出图片和视频" in c["problem"]
def test_two_reference_images_into_a_custom_node_become_a_group_ref2video():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
"6": {"class_type": "LoadImage", "inputs": {"image": "b.png"}},
"7": {
"class_type": "GenericRefToVideo",
"inputs": {
"prompt": ["1", 0],
"refs.pic_1": ["5", 0],
"refs.pic_2": ["6", 0],
},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
c = classify_usage(wf)
assert c["usage"] == "ref2video"
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
assert [g["nodeId"] for g in groups] == ["7@refs"]
# ---------------------------------------------------------------------------
# A6: failure paths — no output, unknown custom output nodes
# ---------------------------------------------------------------------------
def test_no_output_graph_reports_chinese_error_and_blocks():
wf = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
}
c = classify_usage(wf)
assert c["usage"] is None and c["ambiguous"] is False
assert "未在图中找到输出节点" in c["problem"]
assert blocking_problem(wf) and "未在图中找到输出节点" in blocking_problem(wf)
def test_unknown_custom_save_node_needs_mapping_and_keeps_a_runnable_path():
wf = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"7": {
"class_type": "MyCloudExporter",
"inputs": {"filename_prefix": "out", "render": ["8", 0]},
},
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
}
c = classify_usage(wf)
assert c["needs_mapping"] is True
assert c["usage"] is None and "无法识别" in (c["problem"] or "")
# unknown savers are a warning (user maps them at import), not a block —
# once the user confirms the usage the job can still run and results are
# pulled from ComfyUI history's images/gifs/videos keys.
codes = {(w["level"], w["code"]) for w in workflow_warnings(wf)}
assert ("warning", "unknown_output_node") in codes
assert ("error", "no_output") not in codes
def test_utility_sink_tail_is_not_an_output_and_does_not_break_detection():
"""VideoCombine feeding a cache-cleanup tail still counts as the output."""
wf = _load(FIXTURES / "minimax_h3_t2v_untagged_API.json")
wf["9999"] = {
"class_type": "easy cleanGpuUsed",
"inputs": {"anything": ["5978", 0]},
}
c = classify_usage(wf)
assert c["usage"] == "text2video"
assert c["needs_mapping"] is False
assert workflow_warnings(wf) == []
def test_evidence_lists_output_nodes_and_slots():
wf = _load(FIXTURES / "h3_ref2va_05_API.json")
c = classify_usage(wf)
ev = c["evidence"]
assert any(o["nodeId"] == "92" and o["kind"] == "video" for o in ev["outputs"])
assert any(s["kind"] == "image_list" for s in ev["image_slots"])
assert any("输出" in line for line in ev["summary"])
@@ -26,6 +26,15 @@ VIDEO_WORKFLOW = {
"inputs": {"file": ""},
"_meta": {"title": "Previous Clip (Input:video)"},
},
"30": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["40", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
"40": {
"class_type": "VAEDecode",
"inputs": {"samples": ["10", 0]},
},
}
# Same but the video slot is missing entirely — must be rejected at enqueue.
@@ -35,6 +44,15 @@ NO_VIDEO_WORKFLOW = {
"inputs": {"text": ""},
"_meta": {"title": "Main Prompt (Input:prompt)"},
},
"30": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["40", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
"40": {
"class_type": "VAEDecode",
"inputs": {"samples": ["10", 0]},
},
}
@@ -0,0 +1,235 @@
"""HEL-636: the two-step import closes the loop — analyze → confirm → save,
user corrections stick (reanalyze never overwrites them), pending workflows
cannot generate, and legacy rows migrate losslessly."""
from __future__ import annotations
import json
from pathlib import Path
from calliope.config import settings
from calliope.db import get_db, migrate_db
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
AMBIGUOUS = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
"7": {
"class_type": "SomeVideoSampler",
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
},
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
}
def test_analyze_returns_usage_evidence_and_problem(client):
wf = _load("qwen_image_t2i_API.json")
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
assert r.status_code == 200
body = r.json()
assert body["usage"] == "text2image"
assert body["usage_ambiguous"] is False and body["needs_mapping"] is False
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
assert body["usable_usage"] == [
"text2image", "image2image", "text2video", "image2video", "ref2video",
]
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
body = r.json()
assert body["usage"] is None and body["usage_ambiguous"] is True
assert "首帧还是参考图" in body["usage_problem"]
assert body["usage_evidence"]["image_slots"][0]["nodeId"] == "5"
bare = {"1": {"class_type": "Note", "inputs": {}}}
r = client.post("/api/workflows/analyze", json={"workflow_json": bare})
body = r.json()
assert "未在图中找到输出节点" in body["usage_problem"]
def test_create_stores_auto_usage_and_kind(client):
r = client.post(
"/api/workflows",
json={"name": "自动·文生图", "workflow_json": _load("qwen_image_t2i_API.json")},
)
assert r.status_code == 200, r.text
wf = r.json()
assert wf["usage"] == "text2image" and wf["usage_source"] == "auto"
assert wf["kind"] == "image" and wf["is_enabled"] is True
r = client.post(
"/api/workflows",
json={"name": "自动·参考生视频", "workflow_json": _load("h3_ref2va_05_API.json")},
)
wf = r.json()
assert wf["usage"] == "ref2video" and wf["kind"] == "video"
def test_ambiguous_import_stays_pending_and_cannot_generate(client):
r = client.post("/api/workflows", json={"name": "歧义", "workflow_json": AMBIGUOUS})
assert r.status_code == 200
wf = r.json()
assert wf["usage"] is None and wf["is_enabled"] is False
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
)
assert gen.status_code == 400
assert "已停用" in gen.json()["detail"]
assert client.get("/api/playground/jobs").json() == []
def test_confirm_page_correction_roundtrip(client):
"""analyze → user picks usage + fixes the slot role → create carries both."""
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS}).json()
assert analyzed["usage"] is None
slot = analyzed["usage_evidence"]["image_slots"][0]
schema = [
{**i, "role": "first_frame", "manual": True} if i["nodeId"] == slot["nodeId"] else i
for i in analyzed["inputs"]
]
r = client.post(
"/api/workflows",
json={
"name": "确认·图生视频",
"workflow_json": AMBIGUOUS,
"usage": "image2video",
"usage_source": "user",
"input_schema": schema,
},
)
assert r.status_code == 200, r.text
wf = r.json()
assert wf["usage"] == "image2video" and wf["usage_source"] == "user"
assert wf["kind"] == "video" and wf["is_enabled"] is True
# the manual role sticks on read (and through re-derivation)
entry = next(i for i in wf["input_schema"] if i["nodeId"] == slot["nodeId"])
assert entry["role"] == "first_frame"
# reanalyze refreshes the auto schema but never the user's usage
r = client.post(f"/api/workflows/{wf['id']}/reanalyze")
assert r.status_code == 200
fresh = r.json()
assert fresh["usage"] == "image2video" and fresh["usage_source"] == "user"
entry = next(i for i in fresh["input_schema"] if i["nodeId"] == slot["nodeId"])
assert entry["role"] == "first_frame" and entry.get("manual") is True
def test_patch_usage_confirms_pending_workflow(client):
r = client.post("/api/workflows", json={"name": "待确认", "workflow_json": AMBIGUOUS})
wid = r.json()["id"]
assert r.json()["is_enabled"] is False
r = client.patch(f"/api/workflows/{wid}", json={"usage": "ref2video"})
assert r.status_code == 200
wf = r.json()
assert wf["usage"] == "ref2video" and wf["usage_source"] == "user"
assert wf["kind"] == "video" and wf["is_enabled"] is True
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wid)
assert listed["usage"] == "ref2video"
def test_reanalyze_upgrades_auto_usage_only(client):
r = client.post(
"/api/workflows",
json={"name": "重分析", "workflow_json": _load("qwen_image_t2i_API.json")},
)
wid = r.json()["id"]
r = client.patch(f"/api/workflows/{wid}", json={"usage": "image2image", "usage_source": "user"})
assert r.json()["usage"] == "image2image"
r = client.post(f"/api/workflows/{wid}/reanalyze")
assert r.json()["usage"] == "image2image" # user decision survives
assert r.json()["usage_source"] == "user"
def test_legacy_kind_only_callers_still_work(client):
"""Old clients post kind without usage — determinate graphs classify, the
row is enabled and generation proceeds as before."""
r = client.post(
"/api/workflows",
json={"name": "旧调用", "kind": "image", "workflow_json": _load("qwen_image_t2i_API.json")},
)
assert r.status_code == 200
wf = r.json()
assert wf["usage"] == "text2image" and wf["is_enabled"] is True
def test_migration_backfills_usage_and_keeps_history(client):
"""A8: pre-HEL-636 rows (direct SQL, no usage) settle on re-migration;
determinate ones gain usage, ambiguous ones are parked disabled, and
existing jobs/history stay readable with their original kind."""
pid = client.post("/api/projects", json={"title": "存量项目", "idea": "x"}).json()["id"]
conn = get_db(settings.db_path)
try:
conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
" output_schema, is_enabled) VALUES (?, 'image', ?, '[]', '[]', 1)",
("存量·文生图", json.dumps(_load("qwen_image_t2i_API.json"))),
)
conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
" output_schema, is_enabled) VALUES (?, 'video', ?, '[]', '[]', 1)",
("存量·歧义", json.dumps(AMBIGUOUS)),
)
conn.execute(
"INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,"
" output_paths_json) VALUES (?, 'image', NULL, 'done', '{}', ?)",
(pid, json.dumps(["/old/result.png"])),
)
conn.commit()
finally:
conn.close()
import asyncio
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
assert wfs["存量·文生图"]["usage"] == "text2image"
assert wfs["存量·文生图"]["is_enabled"] is True
assert wfs["存量·歧义"]["usage"] is None
assert wfs["存量·歧义"]["is_enabled"] is False
assert wfs["存量·歧义"]["kind"] == "video" # old kind semantics untouched
jobs = client.get(f"/api/jobs?project_id={pid}").json()
assert any(
j["status"] == "done" and j["kind"] == "image" and j["output_paths"] == ["/old/result.png"]
for j in jobs
)
# a third run changes nothing (idempotent)
asyncio.run(migrate_db(settings.db_path))
wfs2 = {w["name"]: w for w in client.get("/api/workflows").json()}
assert wfs2["存量·文生图"]["usage"] == "text2image"
assert wfs2["存量·歧义"]["usage"] is None
def test_broken_qwen_import_stays_blocked_but_recorded(client):
"""The damaged Qwen export classifies fine (image2image); its structural
lint errors still refuse generation — a broken file case, not a usage case."""
wf_json = _load("qwen21_edit_sage_API.json")
r = client.post(
"/api/workflows/analyze", json={"workflow_json": wf_json}
)
assert r.json()["usage"] == "image2image"
r = client.post(
"/api/workflows", json={"name": "损坏·Qwen", "kind": "image", "workflow_json": wf_json}
)
assert r.status_code == 200
wf = r.json()
assert wf["usage"] == "image2image" and wf["is_enabled"] is True
assert any(w["level"] == "error" for w in wf["warnings"])
gen = client.post(
"/api/playground/generate",
json={"workflow_id": wf["id"], "input_values": {"4@prompt": "x"}},
)
assert gen.status_code == 400 and "重新导出" in gen.json()["detail"]
+12 -6
View File
@@ -133,6 +133,8 @@ import type {
SceneVideoSettings,
Clip,
Workflow,
WorkflowUsage,
WorkflowAnalyzeResult,
ComfyDynamicInput,
ComfyDynamicOutput,
} from "./comfy/types";
@@ -142,6 +144,8 @@ export type {
SceneVideoSettings,
Clip,
Workflow,
WorkflowUsage,
WorkflowAnalyzeResult,
ComfyDynamicInput,
ComfyDynamicOutput,
};
@@ -453,11 +457,7 @@ export const comfy = {
export const workflows = {
list: () => api<Workflow[]>("/api/workflows"),
analyze: (workflow_json: Record<string, unknown>) =>
api<{
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
}>("/api/workflows/analyze", {
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
method: "POST",
body: JSON.stringify({ workflow_json }),
}),
@@ -465,8 +465,11 @@ export const workflows = {
api<Workflow>(`/api/workflows/${id}/reanalyze`, { method: "POST" }),
create: (payload: {
name: string;
kind: "image" | "video";
workflow_json: Record<string, unknown>;
kind?: "image" | "video";
usage?: WorkflowUsage;
usage_source?: "auto" | "user";
input_schema?: ComfyDynamicInput[];
description?: string;
prompt_profile?: string;
endpoint_id?: string | null;
@@ -484,6 +487,9 @@ export const workflows = {
description: string;
prompt_profile: string;
endpoint_id: string | null;
usage: WorkflowUsage;
usage_source: "auto" | "user";
input_schema: ComfyDynamicInput[];
}>,
) =>
api<Workflow>(`/api/workflows/${id}`, {
+48 -1
View File
@@ -43,9 +43,53 @@ export interface ComfyDynamicInput {
export interface ComfyDynamicOutput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image) → "image". */
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
kind: ComfyOutputKind;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
}
/** The five workflow usages the studio distinguishes (HEL-636). */
export type WorkflowUsage =
| 'text2image'
| 'image2image'
| 'text2video'
| 'image2video'
| 'ref2video';
export const WORKFLOW_USAGES: WorkflowUsage[] = [
'text2image',
'image2image',
'text2video',
'image2video',
'ref2video',
];
/** Which usages each generate page serves. */
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
image: ['text2image', 'image2image'],
video: ['text2video', 'image2video', 'ref2video'],
};
/** Result of POST /api/workflows/analyze (import confirm step 1). */
export interface WorkflowAnalyzeResult {
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
summary: string[];
};
usable_usage: WorkflowUsage[];
}
export interface WorkflowNode {
@@ -58,6 +102,9 @@ export interface Workflow {
id: number;
name: string;
kind: 'image' | 'video';
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: 'auto' | 'user' | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
@@ -22,6 +22,7 @@
type Job,
} from '$lib/api';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { USAGE_BY_KIND, type WorkflowUsage } from '$lib/comfy/types';
import { compactInputValues, isPromptLikeInput } from '$lib/comfy/promptInput';
import { parseDbTs } from '$lib/dbtime';
import { normalizeInputRole } from '$lib/comfy/parser';
@@ -62,8 +63,12 @@
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
// HEL-636: the page serves its usages (image → t2i/i2i, video →
// t2v/i2v/ref2v); pending workflows (usage = null) never appear.
const kindWorkflows = $derived(
($workflowsQuery.data ?? []).filter((w) => w.kind === kind && w.is_enabled),
($workflowsQuery.data ?? []).filter(
(w) => w.is_enabled && (w.usage ?? null) !== null && USAGE_BY_KIND[kind].includes(w.usage!),
),
);
const allJobs = $derived.by(() => {
@@ -317,8 +322,12 @@
return out;
});
/** Mode badge from what the workflow really wires (spec §1 table). */
/** Mode badge: the stored usage when we have one, else the wiring shape. */
function usageLabel(u: WorkflowUsage): string {
return t(`usage.${u}`);
}
const modeLabel = $derived.by(() => {
if (currentWorkflow?.usage) return usageLabel(currentWorkflow.usage);
if (refGroup) return t('studio.modeRefToVideo');
if (mediaSlots.some((s) => isFirstFrame(s.inp))) return t('studio.modeImageToVideo');
if (kind === 'image' && mediaSlots.some((s) => s.mediaKind === 'image' && role(s.inp) !== 'mask'))
@@ -550,7 +559,10 @@
let target = mediaSlots.find((s) => s.mediaKind === 'image' && role(s.inp) !== 'mask')?.inp ?? null;
if (!target) {
const candidate = ($workflowsQuery.data ?? []).find(
(w) => w.kind === 'image' && w.is_enabled && (w.input_schema ?? []).some(isMedia),
(w) =>
w.is_enabled &&
w.usage === 'image2image' &&
(w.input_schema ?? []).some(isMedia),
);
if (!candidate) {
toast.error(t('studio.noImg2ImgWf'));
+35
View File
@@ -1400,6 +1400,41 @@ export const de: Dict = {
'conn.wfDeleted': 'Workflow gelöscht',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
// --- Five usages & import confirmation (HEL-636) ---
'usage.text2image': 'Text → Image',
'usage.image2image': 'Image → Image',
'usage.text2video': 'Text → Video',
'usage.image2video': 'Image → Video',
'usage.ref2video': 'Reference → Video',
'usage.pending': 'Usage pending',
'wfRole.image': 'Input image',
'wfRole.first_frame': 'First frame',
'wfRole.last_frame': 'Last frame',
'wfRole.reference_image': 'Reference',
'wfRole.mask': 'Mask',
'conn.wfImportAction': 'Import workflow',
'conn.wfConfirmTitle': 'Confirm detection',
'conn.wfStepAnalyze': 'Paste & analyze',
'conn.wfStepConfirm': 'Confirm & save',
'conn.wfChooseFile': 'Choose file',
'conn.wfAnalyze': 'Analyze',
'conn.wfAnalyzing': 'Analyzing…',
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
'conn.wfUsageLabel': 'Usage',
'conn.wfUsageAuto': 'Auto-detected: {usage}',
'conn.wfUsageNeedPick': 'Pick manually',
'conn.wfPickUsage': 'Choose a usage first',
'conn.wfSlotsLabel': 'Image slots',
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
'conn.wfBack': 'Back',
'conn.wfConfirmSave': 'Confirm & save',
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
'conn.wfPendingCount': '{n} pending',
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
'conn.wfSourceAuto': 'Usage auto-detected',
'conn.wfDisabledBadge': 'Disabled',
'conn.wfConfirmAria': 'Confirm workflow usage',
'conn.missingMaterial': 'Ein dediziertes Qwen Image 2.1-Workflow-JSON fehlt noch im Repo: aus ComfyUI exportieren und hier importieren — der generische Importweg steht bereit.',
// --- Eigenständige Bild-/Videogenerierung (a3/a4, HEL-612) ---
+35
View File
@@ -1400,6 +1400,41 @@ export const en = {
'conn.wfDeleted': 'Workflow deleted',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
// --- Five usages & import confirmation (HEL-636) ---
'usage.text2image': 'Text → Image',
'usage.image2image': 'Image → Image',
'usage.text2video': 'Text → Video',
'usage.image2video': 'Image → Video',
'usage.ref2video': 'Reference → Video',
'usage.pending': 'Usage pending',
'wfRole.image': 'Input image',
'wfRole.first_frame': 'First frame',
'wfRole.last_frame': 'Last frame',
'wfRole.reference_image': 'Reference',
'wfRole.mask': 'Mask',
'conn.wfImportAction': 'Import workflow',
'conn.wfConfirmTitle': 'Confirm detection',
'conn.wfStepAnalyze': 'Paste & analyze',
'conn.wfStepConfirm': 'Confirm & save',
'conn.wfChooseFile': 'Choose file',
'conn.wfAnalyze': 'Analyze',
'conn.wfAnalyzing': 'Analyzing…',
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
'conn.wfUsageLabel': 'Usage',
'conn.wfUsageAuto': 'Auto-detected: {usage}',
'conn.wfUsageNeedPick': 'Pick manually',
'conn.wfPickUsage': 'Choose a usage first',
'conn.wfSlotsLabel': 'Image slots',
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
'conn.wfBack': 'Back',
'conn.wfConfirmSave': 'Confirm & save',
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
'conn.wfPendingCount': '{n} pending',
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
'conn.wfSourceAuto': 'Usage auto-detected',
'conn.wfDisabledBadge': 'Disabled',
'conn.wfConfirmAria': 'Confirm workflow usage',
'conn.missingMaterial': 'A dedicated Qwen Image 2.1 workflow JSON is not in the repo yet: export it from ComfyUI and import it here — the generic import path is ready.',
// --- Standalone image / video generation (a3/a4, HEL-612) ---
'studio.image.title': 'Image',
+35
View File
@@ -1400,6 +1400,41 @@ export const es: Dict = {
'conn.wfDeleted': 'Flujo eliminado',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
// --- Five usages & import confirmation (HEL-636) ---
'usage.text2image': 'Text → Image',
'usage.image2image': 'Image → Image',
'usage.text2video': 'Text → Video',
'usage.image2video': 'Image → Video',
'usage.ref2video': 'Reference → Video',
'usage.pending': 'Usage pending',
'wfRole.image': 'Input image',
'wfRole.first_frame': 'First frame',
'wfRole.last_frame': 'Last frame',
'wfRole.reference_image': 'Reference',
'wfRole.mask': 'Mask',
'conn.wfImportAction': 'Import workflow',
'conn.wfConfirmTitle': 'Confirm detection',
'conn.wfStepAnalyze': 'Paste & analyze',
'conn.wfStepConfirm': 'Confirm & save',
'conn.wfChooseFile': 'Choose file',
'conn.wfAnalyze': 'Analyze',
'conn.wfAnalyzing': 'Analyzing…',
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
'conn.wfUsageLabel': 'Usage',
'conn.wfUsageAuto': 'Auto-detected: {usage}',
'conn.wfUsageNeedPick': 'Pick manually',
'conn.wfPickUsage': 'Choose a usage first',
'conn.wfSlotsLabel': 'Image slots',
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
'conn.wfBack': 'Back',
'conn.wfConfirmSave': 'Confirm & save',
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
'conn.wfPendingCount': '{n} pending',
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
'conn.wfSourceAuto': 'Usage auto-detected',
'conn.wfDisabledBadge': 'Disabled',
'conn.wfConfirmAria': 'Confirm workflow usage',
'conn.missingMaterial': 'Aún falta en el repo un JSON dedicado de Qwen Image 2.1: expórtalo desde ComfyUI e impórtalo aquí; la vía genérica ya está lista.',
// --- Generación independiente de imagen / vídeo (a3/a4, HEL-612) ---
+35
View File
@@ -1400,6 +1400,41 @@ export const fr: Dict = {
'conn.wfDeleted': 'Workflow supprimé',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
// --- Five usages & import confirmation (HEL-636) ---
'usage.text2image': 'Text → Image',
'usage.image2image': 'Image → Image',
'usage.text2video': 'Text → Video',
'usage.image2video': 'Image → Video',
'usage.ref2video': 'Reference → Video',
'usage.pending': 'Usage pending',
'wfRole.image': 'Input image',
'wfRole.first_frame': 'First frame',
'wfRole.last_frame': 'Last frame',
'wfRole.reference_image': 'Reference',
'wfRole.mask': 'Mask',
'conn.wfImportAction': 'Import workflow',
'conn.wfConfirmTitle': 'Confirm detection',
'conn.wfStepAnalyze': 'Paste & analyze',
'conn.wfStepConfirm': 'Confirm & save',
'conn.wfChooseFile': 'Choose file',
'conn.wfAnalyze': 'Analyze',
'conn.wfAnalyzing': 'Analyzing…',
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
'conn.wfUsageLabel': 'Usage',
'conn.wfUsageAuto': 'Auto-detected: {usage}',
'conn.wfUsageNeedPick': 'Pick manually',
'conn.wfPickUsage': 'Choose a usage first',
'conn.wfSlotsLabel': 'Image slots',
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
'conn.wfBack': 'Back',
'conn.wfConfirmSave': 'Confirm & save',
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
'conn.wfPendingCount': '{n} pending',
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
'conn.wfSourceAuto': 'Usage auto-detected',
'conn.wfDisabledBadge': 'Disabled',
'conn.wfConfirmAria': 'Confirm workflow usage',
'conn.missingMaterial': 'Un JSON de workflow Qwen Image 2.1 dédié manque encore au dépôt : exportez-le depuis ComfyUI et importez-le ici — le chemin d\'import générique est prêt.',
// --- Génération indépendante image / vidéo (a3/a4, HEL-612) ---
+35
View File
@@ -1400,6 +1400,41 @@ export const ja: Dict = {
'conn.wfDeleted': 'ワークフローを削除しました',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
// --- Five usages & import confirmation (HEL-636) ---
'usage.text2image': 'Text → Image',
'usage.image2image': 'Image → Image',
'usage.text2video': 'Text → Video',
'usage.image2video': 'Image → Video',
'usage.ref2video': 'Reference → Video',
'usage.pending': 'Usage pending',
'wfRole.image': 'Input image',
'wfRole.first_frame': 'First frame',
'wfRole.last_frame': 'Last frame',
'wfRole.reference_image': 'Reference',
'wfRole.mask': 'Mask',
'conn.wfImportAction': 'Import workflow',
'conn.wfConfirmTitle': 'Confirm detection',
'conn.wfStepAnalyze': 'Paste & analyze',
'conn.wfStepConfirm': 'Confirm & save',
'conn.wfChooseFile': 'Choose file',
'conn.wfAnalyze': 'Analyze',
'conn.wfAnalyzing': 'Analyzing…',
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
'conn.wfUsageLabel': 'Usage',
'conn.wfUsageAuto': 'Auto-detected: {usage}',
'conn.wfUsageNeedPick': 'Pick manually',
'conn.wfPickUsage': 'Choose a usage first',
'conn.wfSlotsLabel': 'Image slots',
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
'conn.wfBack': 'Back',
'conn.wfConfirmSave': 'Confirm & save',
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
'conn.wfPendingCount': '{n} pending',
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
'conn.wfSourceAuto': 'Usage auto-detected',
'conn.wfDisabledBadge': 'Disabled',
'conn.wfConfirmAria': 'Confirm workflow usage',
'conn.missingMaterial': 'Qwen Image 2.1 専用のワークフロー JSON はまだリポジトリにありません。ComfyUI から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
// --- 独立画像/動画生成(a3/a4、HEL-612)---
+35
View File
@@ -1400,6 +1400,41 @@ export const ko: Dict = {
'conn.wfDeleted': '워크플로가 삭제되었습니다',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
// --- Five usages & import confirmation (HEL-636) ---
'usage.text2image': 'Text → Image',
'usage.image2image': 'Image → Image',
'usage.text2video': 'Text → Video',
'usage.image2video': 'Image → Video',
'usage.ref2video': 'Reference → Video',
'usage.pending': 'Usage pending',
'wfRole.image': 'Input image',
'wfRole.first_frame': 'First frame',
'wfRole.last_frame': 'Last frame',
'wfRole.reference_image': 'Reference',
'wfRole.mask': 'Mask',
'conn.wfImportAction': 'Import workflow',
'conn.wfConfirmTitle': 'Confirm detection',
'conn.wfStepAnalyze': 'Paste & analyze',
'conn.wfStepConfirm': 'Confirm & save',
'conn.wfChooseFile': 'Choose file',
'conn.wfAnalyze': 'Analyze',
'conn.wfAnalyzing': 'Analyzing…',
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
'conn.wfUsageLabel': 'Usage',
'conn.wfUsageAuto': 'Auto-detected: {usage}',
'conn.wfUsageNeedPick': 'Pick manually',
'conn.wfPickUsage': 'Choose a usage first',
'conn.wfSlotsLabel': 'Image slots',
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
'conn.wfBack': 'Back',
'conn.wfConfirmSave': 'Confirm & save',
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
'conn.wfPendingCount': '{n} pending',
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
'conn.wfSourceAuto': 'Usage auto-detected',
'conn.wfDisabledBadge': 'Disabled',
'conn.wfConfirmAria': 'Confirm workflow usage',
'conn.missingMaterial': 'Qwen Image 2.1 전용 워크플로 JSON이 아직 저장소에 없습니다. ComfyUI에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
// --- 독립 이미지/비디오 생성 (a3/a4, HEL-612) ---
+46 -11
View File
@@ -1379,7 +1379,7 @@ export const zh: Dict = {
'conn.noticeStrong': '连通性以服务器为准:',
'conn.noticeBody': '地址在浏览器能打开不代表生成可用;测试与生成请求都由运行影工坊的服务器发起。令牌仅存服务端。',
'conn.wfTitle': '工作流库',
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,分类为图片 / 视频,并绑定到某个连接',
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,自动判定五类用途,并绑定到某个连接',
'conn.wfEmpty': '还没有导入工作流。在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON 后粘贴导入。',
'conn.wfCount': '{n} 个工作流 · {m} 图片 · {k} 视频',
'conn.wfKindImage': '图片',
@@ -1391,16 +1391,51 @@ export const zh: Dict = {
'conn.wfImporting': '导入中…',
'conn.wfNodes': '{n} 个节点',
'conn.wfImportedN': '工作流已导入,识别到 {n} 个可填参数',
'conn.wfImportedNoParams': '工作流已导入,但没有识别到可填参数:生成时将按工作流内置设置运行',
'conn.wfImportFailed': '导入失败:{detail}',
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
'conn.wfImportHint': '粘贴或选择文件;名称必填,分类决定它出现在生图还是生视频窗口。',
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
'conn.wfDeleted': '工作流已删除',
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
'conn.wfBrokenBadge': '文件不完整,无法生成',
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
'conn.wfImportedNoParams': '工作流已导入,但没有识别到可填参数:生成时将按工作流内置设置运行',
'conn.wfImportFailed': '导入失败:{detail}',
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
'conn.wfImportHint': '粘贴或选择文件并点「分析」;系统按输出节点和图片槽位判定五类用途,确认后保存。',
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
'conn.wfDeleted': '工作流已删除',
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
'conn.wfBrokenBadge': '文件不完整,无法生成',
// --- 五类用途与导入确认(HEL-636)---
'usage.text2image': '文生图',
'usage.image2image': '图生图',
'usage.text2video': '文生视频',
'usage.image2video': '图生视频',
'usage.ref2video': '参考图生视频',
'usage.pending': '用途待确认',
'wfRole.image': '输入图',
'wfRole.first_frame': '首帧',
'wfRole.last_frame': '尾帧',
'wfRole.reference_image': '参考图',
'wfRole.mask': '遮罩',
'conn.wfImportAction': '导入工作流',
'conn.wfConfirmTitle': '确认识别结果',
'conn.wfStepAnalyze': '粘贴分析',
'conn.wfStepConfirm': '确认保存',
'conn.wfChooseFile': '选择文件',
'conn.wfAnalyze': '分析',
'conn.wfAnalyzing': '分析中…',
'conn.wfConfirmHint': '核对判定依据、输出节点与图片槽位;用途或槽位不对可在此纠正后再保存。',
'conn.wfUsageLabel': '用途',
'conn.wfUsageAuto': '自动判定:{usage}',
'conn.wfUsageNeedPick': '需人工选择',
'conn.wfPickUsage': '请先选择工作流用途',
'conn.wfSlotsLabel': '图片槽位',
'conn.wfSlotsHint': '槽位角色按连线推断;不符时可改为输入图 / 首帧 / 尾帧 / 参考图 / 遮罩,保存后以人工选择为准。',
'conn.wfBack': '上一步',
'conn.wfConfirmSave': '确认保存',
'conn.wfImportedWithUsage': '已保存为「{usage}」,识别到 {n} 个可填参数',
'conn.wfPendingCount': '{n} 个待确认',
'conn.wfConfirmed': '已确认用途:{usage},工作流已可生成',
'conn.wfSourceUser': '用途为人工确认,重新分析不会覆盖',
'conn.wfSourceAuto': '用途为自动判定',
'conn.wfDisabledBadge': '已停用',
'conn.wfConfirmAria': '确认工作流用途',
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
// --- 独立生图 / 生视频(a3/a4,HEL-612)---
'studio.image.title': '生图',
'studio.image.subtitle': '用已导入的图片工作流生成图片 · 连接与队列和「ComfyUI 连接」共用',
+469 -116
View File
@@ -7,7 +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 { comfy, settings, workflows, type ComfyEndpoint, type Workflow } from '$lib/api';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowUsage, type WorkflowAnalyzeResult } from '$lib/api';
import { WORKFLOW_USAGES } from '$lib/comfy/types';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { classifyEndpoint, endpointTypeLabel, type EndpointKind } from '$lib/endpointKind';
@@ -325,13 +326,19 @@
});
// ------------------------------------------------------------------
// Workflow library (import + binding)
// Workflow library (import + binding) — two-step wizard: paste/analyze,
// then confirm the judged usage and slot roles before saving (HEL-636).
// ------------------------------------------------------------------
let wfJsonText = $state('');
let wfImportOpen = $state(false);
let wfImportName = $state('');
let wfImportKind = $state<'image' | 'video'>('image');
let wfImportEndpoint = $state('');
let wfStep = $state<'input' | 'confirm'>('input');
let wfAnalysis = $state<WorkflowAnalyzeResult | null>(null);
let wfUsage = $state<WorkflowUsage | null>(null);
let wfRoleOverrides = $state<Record<string, string>>({});
let wfAnalyzing = $state(false);
let wfAnalyzeError = $state('');
const wfParsed = $derived.by(() => {
const text = wfJsonText.trim();
@@ -354,25 +361,87 @@
return { valid: false as const, json: null };
});
function startWfImport(kind: 'image' | 'video') {
function usageLabel(u: WorkflowUsage | null | undefined): string {
return u ? t(`usage.${u}`) : t('usage.pending');
}
function startWfImport() {
wfImportOpen = true;
wfImportKind = kind;
wfImportName = '';
wfJsonText = '';
wfImportEndpoint = '';
wfStep = 'input';
wfAnalysis = null;
wfUsage = null;
wfRoleOverrides = {};
wfAnalyzeError = '';
}
function wfProblems(wf: Workflow): string[] {
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
}
async function runWfAnalyze() {
if (!wfParsed.valid || !wfParsed.json) return;
wfAnalyzing = true;
wfAnalyzeError = '';
try {
const res = await workflows.analyze(wfParsed.json);
wfAnalysis = res;
wfUsage = res.usage ?? null;
wfRoleOverrides = {};
wfStep = 'confirm';
} catch (err) {
wfAnalyzeError = apiErrorMessage(err);
} finally {
wfAnalyzing = false;
}
}
/** True when the graph has no output node at all — nothing to confirm. */
const wfFatal = $derived(
wfStep === 'confirm' && wfAnalysis !== null && (wfAnalysis.outputs?.length ?? 0) === 0,
);
const wfMustPick = $derived(
wfStep === 'confirm' &&
wfAnalysis !== null &&
!wfFatal &&
(wfAnalysis.usage_ambiguous || wfAnalysis.needs_mapping) &&
wfUsage === null,
);
const wfSaveable = $derived(wfStep === 'confirm' && wfUsage !== null && !wfFatal);
/** Media slots the confirm page lets the user re-role. */
const wfSlotRows = $derived.by(() => {
if (wfStep !== 'confirm' || !wfAnalysis) return [];
return wfAnalysis.usage_evidence.image_slots.map((s) => ({
...s,
role: wfRoleOverrides[s.nodeId] ?? s.role ?? 'image',
fixed: s.kind === 'image_list',
}));
});
const wfParamPreview = $derived.by(() => {
if (!wfAnalysis) return [];
return wfAnalysis.inputs
.filter((i) => !['image', 'image_list', 'image_url', 'video', 'audio'].includes(i.kind))
.map((i) => i.label);
});
const wfImportMutation = createMutation({
mutationFn: async () => {
if (!wfParsed.valid || !wfParsed.json) throw new Error(t('conn.wfJsonInvalid'));
if (!wfParsed.valid || !wfParsed.json || !wfAnalysis || !wfUsage)
throw new Error(t('conn.wfPickUsage'));
const corrected = Object.keys(wfRoleOverrides).length > 0;
const schema = wfAnalysis.inputs.map((e) =>
wfRoleOverrides[e.nodeId]
? { ...e, role: wfRoleOverrides[e.nodeId], manual: true }
: e,
);
return workflows.create({
name: wfImportName.trim(),
kind: wfImportKind,
workflow_json: wfParsed.json,
usage: wfUsage,
usage_source: wfUsage !== wfAnalysis.usage || corrected ? 'user' : 'auto',
input_schema: schema,
...(wfImportEndpoint ? { endpoint_id: wfImportEndpoint } : {}),
});
},
@@ -381,11 +450,13 @@
const n = wf.input_schema?.length ?? 0;
const problems = wfProblems(wf);
if (problems.length) toast.error(t('conn.wfImportedBroken', { detail: problems.join(';') }));
else if (n) toast.success(t('conn.wfImportedN', { n }));
else if (n) toast.success(t('conn.wfImportedWithUsage', { usage: usageLabel(wf.usage), n }));
else toast.info(t('conn.wfImportedNoParams'));
wfImportOpen = false;
wfJsonText = '';
wfImportName = '';
wfStep = 'input';
wfAnalysis = null;
},
onError: (err) => {
toast.error(
@@ -397,10 +468,26 @@
});
function submitWfImport() {
if (!wfImportName.trim() || !wfParsed.valid || $wfImportMutation.isPending) return;
if (!wfSaveable || !wfImportName.trim() || $wfImportMutation.isPending) return;
$wfImportMutation.mutate();
}
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),
}),
);
},
});
const wfBindMutation = createMutation({
mutationFn: ({ id, endpointId }: { id: number; endpointId: string | null }) =>
workflows.update(id, { endpoint_id: endpointId }),
@@ -627,36 +714,39 @@
</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('image')}>
<Icon name="image" size={13} />
{t('conn.wfKindImage')}
</Button>
<Button variant="secondary" size="sm" onclick={() => startWfImport('video')}>
<Icon name="film" size={13} />
{t('conn.wfKindVideo')}
</Button>
</span>
</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 wfImportOpen}
<div class="wf-import" transition:fade={{ duration: 180 }}>
<div class="wf-import-head">
<h3>{t('conn.wfImportTitle')}</h3>
<button
type="button"
class="icon-btn"
aria-label={t('conn.formClose')}
onclick={() => (wfImportOpen = false)}
>
<Icon name="close" size={16} />
</button>
</div>
{#if wfImportOpen}
<div class="wf-import" transition:fade={{ duration: 180 }}>
<div class="wf-import-head">
<h3>
{wfStep === 'input' ? t('conn.wfImportTitle') : t('conn.wfConfirmTitle')}
<span class="wf-step-tag">
{wfStep === 'input' ? `1 / 2 · ${t('conn.wfStepAnalyze')}` : `2 / 2 · ${t('conn.wfStepConfirm')}`}
</span>
</h3>
<button
type="button"
class="icon-btn"
aria-label={t('conn.formClose')}
onclick={() => (wfImportOpen = false)}
>
<Icon name="close" size={16} />
</button>
</div>
{#if wfStep === 'input'}
<p class="wf-import-hint">{t('conn.wfImportHint')}</p>
<div class="wf-import-grid">
<label class="f-field">
@@ -681,31 +771,6 @@
{/each}
</select>
</label>
<div class="f-field">
<span class="f-label">
{t('conn.wfKindImage')} / {t('conn.wfKindVideo')}
</span>
<div class="seg" role="radiogroup">
<button
type="button"
class="seg-item"
class:selected={wfImportKind === 'image'}
aria-pressed={wfImportKind === 'image'}
onclick={() => (wfImportKind = 'image')}
>
{t('conn.wfKindImage')}
</button>
<button
type="button"
class="seg-item"
class:selected={wfImportKind === 'video'}
aria-pressed={wfImportKind === 'video'}
onclick={() => (wfImportKind = 'video')}
>
{t('conn.wfKindVideo')}
</button>
</div>
</div>
<label class="f-field">
<span class="f-label">API JSON<em>*</em></span>
<div class="wf-json-row">
@@ -720,7 +785,7 @@
></textarea>
<label class="file-btn">
<Icon name="upload" size={14} />
{t('conn.wfImport')}
{t('conn.wfChooseFile')}
<input type="file" accept=".json,application/json" onchange={onWfFile} />
</label>
</div>
@@ -739,66 +804,216 @@
variant="primary"
size="sm"
disabled={!wfParsed.valid || !wfImportName.trim()}
loading={wfAnalyzing}
onclick={runWfAnalyze}
>
{wfAnalyzing ? t('conn.wfAnalyzing') : t('conn.wfAnalyze')}
</Button>
</div>
{:else if wfAnalysis}
<p class="wf-import-hint">{t('conn.wfConfirmHint')}</p>
{#if wfAnalysis.usage_problem}
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
<p>{wfAnalysis.usage_problem}</p>
</div>
{/if}
{#if wfAnalyzeError}
<div class="wf-note fatal" role="alert">
<Icon name="alert" size={14} />
<p>{wfAnalyzeError}</p>
</div>
{/if}
<ul class="wf-evidence">
{#each wfAnalysis.usage_evidence.summary as line (line)}
<li>{line}</li>
{/each}
</ul>
<div class="f-field">
<span class="f-label">
{t('conn.wfUsageLabel')}<em>*</em>
<span class="f-hint-inline">
{#if wfAnalysis.usage}
{t('conn.wfUsageAuto', { usage: usageLabel(wfAnalysis.usage) })}
{:else}
{t('conn.wfUsageNeedPick')}
{/if}
</span>
</span>
<div class="seg wrap" role="radiogroup">
{#each WORKFLOW_USAGES as u (u)}
<button
type="button"
class="seg-item"
class:selected={wfUsage === u}
aria-pressed={wfUsage === u}
onclick={() => (wfUsage = u)}
>
{usageLabel(u)}
</button>
{/each}
</div>
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
</div>
{#if wfSlotRows.length}
<div class="f-field">
<span class="f-label">{t('conn.wfSlotsLabel')}</span>
<div class="wf-slot-rows">
{#each wfSlotRows as row (row.nodeId)}
<div class="wf-slot-row">
<span class="wf-slot-id mono">{row.nodeId}</span>
<span class="wf-slot-name">{row.label}</span>
{#if row.fixed}
<span class="tag tag-usage">{t('wfRole.reference_image')}</span>
{:else}
<select
class="a-select"
value={row.role}
onchange={(e) =>
(wfRoleOverrides = {
...wfRoleOverrides,
[row.nodeId]: e.currentTarget.value,
})}
>
{#each ['image', 'first_frame', 'last_frame', 'reference_image', 'mask'] as r (r)}
<option value={r}>{t(`wfRole.${r}`)}</option>
{/each}
</select>
{/if}
</div>
{/each}
</div>
<span class="f-hint">{t('conn.wfSlotsHint')}</span>
</div>
{/if}
<p class="wf-params">
{t('conn.wfParams', { n: wfParamPreview.length })}
{#if wfParamPreview.length}
<span class="wf-params-list">{wfParamPreview.slice(0, 8).join(' · ')}{wfParamPreview.length > 8 ? ' …' : ''}</span>
{/if}
</p>
{#if (wfAnalysis.warnings ?? []).length}
<ul class="wf-warnings">
{#each wfAnalysis.warnings as w (w.node_id + w.code)}
<li class:err={w.level === 'error'}>{w.message}</li>
{/each}
</ul>
{/if}
<div class="form-actions">
<Button
variant="secondary"
size="sm"
disabled={$wfImportMutation.isPending}
onclick={() => (wfStep = 'input')}
>
{t('conn.wfBack')}
</Button>
<span class="need-test" class:passed={wfSaveable && wfImportName.trim() !== ''}>
{wfSaveable ? usageLabel(wfUsage) : t('conn.wfUsageNeedPick')}
</span>
<Button
variant="primary"
size="sm"
disabled={!wfSaveable || !wfImportName.trim()}
loading={$wfImportMutation.isPending}
onclick={submitWfImport}
>
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfImport')}
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfConfirmSave')}
</Button>
</div>
</div>
{/if}
{/if}
</div>
{/if}
{#if ($workflowsQuery.data ?? []).length === 0}
<EmptyState title={t('conn.wfEmpty')} />
<p class="missing-note">{t('conn.missingMaterial')}</p>
{:else}
{@const wfs = $workflowsQuery.data ?? []}
<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,
})}
</p>
<div class="wf-table">
{#each wfs as wf (wf.id)}
<div class="wf-row">
<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 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>
<select
class="a-select"
value={wf.endpoint_id ?? ''}
onchange={(e) =>
$wfBindMutation.mutate({
id: wf.id,
endpointId: e.currentTarget.value || null,
})}
{#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')}
>
<option value="">{t('conn.wfConnectionActive')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{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>
<button
type="button"
class="link-danger"
title={t('conn.delete')}
onclick={() => $wfDeleteMutation.mutate(wf.id)}
>
<Icon name="trash" size={14} />
</button>
</div>
{/each}
</div>
{/if}
{/if}
{#if !wf.is_enabled && wf.usage}
<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>
<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>
{/each}
</div>
{/if}
</section>
</section>
@@ -1304,6 +1519,138 @@
margin-bottom: 14px;
background: var(--surface-2);
}
.wf-step-tag {
margin-left: 8px;
font-size: 10.5px;
font-weight: 500;
color: var(--text-3);
}
.wf-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 10px;
padding: 9px 12px;
border-radius: 7px;
background: var(--accent-dim);
color: var(--text-2);
}
.wf-note :global(.icon) {
color: var(--accent);
flex-shrink: 0;
margin-top: 2px;
}
.wf-note.fatal {
background: var(--error-dim);
}
.wf-note.fatal :global(.icon) {
color: var(--error);
}
.wf-note p {
margin: 0;
font-size: 11.5px;
line-height: 1.55;
}
.wf-evidence {
margin: 0 0 12px;
padding: 0 0 0 4px;
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
}
.wf-evidence li {
font-size: 11px;
color: var(--text-3);
line-height: 1.5;
}
.wf-evidence li::before {
content: '·';
margin-right: 6px;
color: var(--accent);
}
.seg.wrap {
flex-wrap: wrap;
}
.seg.wrap .seg-item {
flex: 1 0 30%;
}
.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-params {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--text-3);
}
.wf-params-list {
margin-left: 6px;
color: var(--text-2);
}
.wf-warnings {
margin: 0 0 10px;
padding: 8px 12px;
list-style: none;
border-radius: 7px;
background: var(--error-dim);
display: flex;
flex-direction: column;
gap: 3px;
}
.wf-warnings li {
font-size: 11px;
line-height: 1.5;
color: var(--text-2);
}
.wf-warnings li.err {
color: var(--error);
}
.tag-usage {
color: var(--accent);
background: var(--accent-dim);
}
.tag-disabled {
color: var(--text-3);
background: var(--surface-3);
}
.wf-pending-note {
color: var(--accent);
}
.wf-row.pending .wf-name {
color: var(--text-3);
}
.wf-confirm-select {
max-width: 150px;
font-size: 11px;
}
.wf-import-head {
display: flex;
align-items: center;
@@ -1326,7 +1673,7 @@
grid-template-columns: 1fr 1fr;
gap: 0 14px;
}
.wf-import-grid .f-field:nth-child(4) {
.wf-import-grid .f-field:nth-child(3) {
grid-column: 1 / -1;
}
.wf-json-row {
@@ -1562,6 +1909,12 @@
font-size: 11px;
color: var(--text-3);
}
.f-hint-inline {
margin-left: auto;
font-size: 10.5px;
font-weight: 400;
color: var(--text-3);
}
.f-error {
display: block;
margin-top: 5px;