Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bfe771af |
@@ -285,16 +285,46 @@ class ComfyUIClient:
|
||||
except Exception as exc:
|
||||
logger.warning("ComfyUI interrupt failed: %s", exc)
|
||||
|
||||
def extract_outputs(self, history: dict[str, Any]) -> list[dict[str, str]]:
|
||||
# History items carry no explicit media type — infer from the file
|
||||
# extension first, then the history key the node reported under.
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
|
||||
|
||||
@classmethod
|
||||
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
|
||||
ext = Path(str(filename)).suffix.lower()
|
||||
if ext in cls._VIDEO_EXTS:
|
||||
return "video"
|
||||
if ext in cls._IMAGE_EXTS:
|
||||
return "image"
|
||||
if history_key in ("gifs", "videos"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
def extract_outputs(
|
||||
self, history: dict[str, Any], node_kinds: dict[str, str] | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Media items in a finished prompt's history.
|
||||
|
||||
``node_kinds`` (HEL-638) scopes the harvest to the workflow's
|
||||
confirmed output nodes — {nodeId: image|video} — so results from
|
||||
nodes the user never designated are ignored, and each item carries
|
||||
the node id plus its inferred media kind for mismatch checks.
|
||||
"""
|
||||
outputs: list[dict[str, str]] = []
|
||||
for _node_id, node_out in (history.get("outputs") or {}).items():
|
||||
for node_id, node_out in (history.get("outputs") or {}).items():
|
||||
if node_kinds is not None and str(node_id) not in node_kinds:
|
||||
continue
|
||||
for key in ("images", "gifs", "videos"):
|
||||
for item in node_out.get(key) or []:
|
||||
filename = item.get("filename", "")
|
||||
outputs.append(
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"filename": filename,
|
||||
"subfolder": item.get("subfolder", ""),
|
||||
"type": item.get("type", "output"),
|
||||
"node_id": str(node_id),
|
||||
"media_kind": self._infer_media_kind(filename, key) or "",
|
||||
}
|
||||
)
|
||||
return outputs
|
||||
|
||||
@@ -83,10 +83,11 @@ def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
# HEL-636: a workflow without any recognizable output node can never
|
||||
# HEL-636/638: 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.
|
||||
# ComfyUI run blind. Unknown candidates (needs_mapping) don't trigger
|
||||
# this: the user designates them on the import/connections page, and
|
||||
# until then the row stays disabled.
|
||||
from calliope.comfyui.parser import detect_output_nodes
|
||||
|
||||
outputs = detect_output_nodes(workflow)
|
||||
@@ -110,7 +111,7 @@ def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"code": "unknown_output_node",
|
||||
"node_id": names,
|
||||
"message": f"存在无法识别的输出节点({names}),"
|
||||
"导入后需人工确认用途;结果按 ComfyUI 历史的图片/视频输出提取",
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能生成",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Stored output-node mapping for imported workflows (HEL-638).
|
||||
|
||||
Unknown custom output nodes are never auto-typed. The import confirm page
|
||||
(or the connections table) stores the user's explicit designation — which
|
||||
node ids are the real outputs and each one's media type — in
|
||||
``workflows.output_map`` with ``output_map_source='user'``. Everything that
|
||||
re-derives schemas (reanalyze, migration, plain field edits) must keep that
|
||||
decision; only a fresh explicit payload replaces it.
|
||||
|
||||
Rows without unknown candidates carry an ``auto`` map derived from the
|
||||
trusted registry/tagged outputs so the generation path always knows which
|
||||
history nodes to harvest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_outputs
|
||||
from calliope.comfyui.registry import (
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
)
|
||||
|
||||
VALID_OUTPUT_KINDS = ("image", "video")
|
||||
|
||||
|
||||
def _wiring_class(class_type: str) -> bool:
|
||||
"""Parameter / wiring classes can never be designated as outputs."""
|
||||
return (
|
||||
not class_type
|
||||
or class_type in TEXT_AREA_CLASSES
|
||||
or class_type in NUMBER_CLASSES
|
||||
or class_type.startswith("Empty")
|
||||
or class_type.startswith("Primitive")
|
||||
)
|
||||
|
||||
|
||||
def _entry_public(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {
|
||||
"nodeId": str(entry.get("nodeId") or ""),
|
||||
"kind": entry.get("kind"),
|
||||
}
|
||||
if entry.get("label"):
|
||||
out["label"] = entry["label"]
|
||||
if entry.get("class_type"):
|
||||
out["class_type"] = entry["class_type"]
|
||||
return out
|
||||
|
||||
|
||||
def auto_output_map(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Trusted outputs only (tagged titles / registry classes)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
return [
|
||||
_entry_public(o)
|
||||
for o in parse_dynamic_outputs(workflow)
|
||||
if o.get("kind") in VALID_OUTPUT_KINDS
|
||||
]
|
||||
|
||||
|
||||
def needs_user_output_map(workflow: dict[str, Any]) -> bool:
|
||||
"""True when the graph has unknown output candidates (HEL-638)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return False
|
||||
return any(
|
||||
o.get("evidence") == "unknown_node" for o in parse_dynamic_outputs(workflow)
|
||||
)
|
||||
|
||||
|
||||
def validate_output_map(
|
||||
workflow: dict[str, Any], entries: Any
|
||||
) -> list[str]:
|
||||
"""Chinese problems with a user-supplied output map; empty = valid."""
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ["输出节点映射不能为空:请至少指定一个输出节点及其媒体类型"]
|
||||
seen: set[str] = set()
|
||||
problems: list[str] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
problems.append("输出节点映射格式有误(应为对象数组)")
|
||||
continue
|
||||
nid = str(entry.get("nodeId") or "")
|
||||
node = workflow.get(nid) if isinstance(workflow, dict) else None
|
||||
if not nid or not isinstance(node, dict):
|
||||
problems.append(f"输出节点 {nid or '?'} 不存在于该工作流中")
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").strip()
|
||||
if not ct:
|
||||
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
|
||||
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
|
||||
if entry.get("kind") not in VALID_OUTPUT_KINDS:
|
||||
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
|
||||
if nid in seen:
|
||||
problems.append(f"输出节点 {nid} 重复指定")
|
||||
seen.add(nid)
|
||||
return problems
|
||||
|
||||
|
||||
def load_output_map(raw: str | None) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def resolve_output_map(
|
||||
output_map_raw: str | None, source: str | None, workflow: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""The map generation harvests, plus whose decision it is.
|
||||
|
||||
A user map wins as long as its node ids still exist in the stored JSON
|
||||
(defensive pruning — PATCH cannot edit the JSON, but keep it safe);
|
||||
otherwise the trusted auto map applies. Empty list back means "not
|
||||
confirmed yet".
|
||||
"""
|
||||
if source == "user":
|
||||
entries = [
|
||||
e for e in load_output_map(output_map_raw) if str(e.get("nodeId") or "") in
|
||||
(workflow if isinstance(workflow, dict) else {})
|
||||
]
|
||||
if entries:
|
||||
return entries, "user"
|
||||
return [], None
|
||||
auto = auto_output_map(workflow)
|
||||
return (auto, "auto") if auto else ([], None)
|
||||
|
||||
|
||||
def mapping_problem(
|
||||
workflow: dict[str, Any], output_map_raw: str | None, source: str | None
|
||||
) -> str | None:
|
||||
"""Chinese reason this workflow's outputs are not confirmed, else None."""
|
||||
if not needs_user_output_map(workflow):
|
||||
return None
|
||||
entries, resolved = resolve_output_map(output_map_raw, source, workflow)
|
||||
if resolved == "user" and entries:
|
||||
return None
|
||||
return (
|
||||
"该工作流含无法识别的自定义输出节点,需在「ComfyUI 连接」页指定"
|
||||
"实际输出节点及其媒体类型(图片/视频)后才能启用或生成"
|
||||
)
|
||||
@@ -12,6 +12,8 @@ from calliope.comfyui.registry import (
|
||||
IMAGE_CLASSES,
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
IMAGE_URL_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
ComfyOutputKind,
|
||||
@@ -707,6 +709,16 @@ def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
# 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
|
||||
# Latent/source shapers (EmptyLatentImage…) are wiring even when nothing
|
||||
# consumes them — they never carry results out of the graph.
|
||||
_SOURCE_ONLY_CLASSES = _INPUT_ONLY_CLASSES | TEXT_AREA_CLASSES | NUMBER_CLASSES
|
||||
|
||||
# Classes that are known-internal tails: standard decode steps and cache
|
||||
# cleaners consume media but never emit history outputs, so they can never
|
||||
# be output candidates (HEL-638: a graph ending in these has no output).
|
||||
_NON_OUTPUT_CLASSES = frozenset(
|
||||
{"VAEDecode", "VAEDecodeTiled", "VAEDecodeAudio", "easy cleanGpuUsed"}
|
||||
)
|
||||
|
||||
# Widget names save-style custom nodes carry, and link input names that mark
|
||||
# "consumes rendered media" — used to flag unknown sinks that need mapping.
|
||||
@@ -743,6 +755,17 @@ def _unknown_sink_looks_like_save(node: dict[str, Any], workflow: dict[str, Any]
|
||||
return False
|
||||
|
||||
|
||||
def _node_has_save_widget(node: dict[str, Any]) -> bool:
|
||||
"""A concrete save widget (filename/prefix/path) — the only signal that
|
||||
counts for unknown nodes that something else consumes; media links from
|
||||
decode steps are meaningless there (every transform has them)."""
|
||||
inputs = node.get("inputs") or {}
|
||||
return any(
|
||||
name in _SAVE_WIDGET_NAMES and isinstance(val, (str, int, float, bool))
|
||||
for name, val in inputs.items()
|
||||
)
|
||||
|
||||
|
||||
def _video_semantics(workflow: dict[str, Any]) -> bool:
|
||||
"""Auxiliary evidence the graph renders video (frame counts, video nodes)."""
|
||||
for node in workflow.values():
|
||||
@@ -757,52 +780,78 @@ def _video_semantics(workflow: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _suggest_output_kind(class_type: str, videoish: bool) -> str | None:
|
||||
"""Best-guess media type for an unknown candidate — UI default only."""
|
||||
lower = class_type.lower()
|
||||
if "video" in lower or "animatediff" in lower:
|
||||
return "video"
|
||||
if "image" in lower or "save" in lower:
|
||||
return "image"
|
||||
return "video" if videoish else None
|
||||
|
||||
|
||||
def _unknown_output_entry(
|
||||
nid: str, node: dict[str, Any], workflow: dict[str, Any], videoish: bool
|
||||
) -> dict[str, Any]:
|
||||
ct = str(node.get("class_type") or "")
|
||||
return {
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
# HEL-638: unknown custom nodes are never auto-typed — the user
|
||||
# picks the media type; `suggested_kind` is only a form default.
|
||||
"kind": None,
|
||||
"class_type": ct,
|
||||
"evidence": "unknown_node",
|
||||
"suggested_kind": _suggest_output_kind(ct, videoish),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
cache-cleanup tail is still the output). Every OTHER class at an output
|
||||
position is an *unknown candidate*: kind stays null, the import page
|
||||
asks the user to designate the real output node(s) and their media
|
||||
type, and the workflow stays pending until then (HEL-638). Sinks with
|
||||
no save features only become candidates when the graph has no trusted
|
||||
output at all — next to a real saver they are utility tails.
|
||||
"""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
consumers = _consumers(workflow)
|
||||
videoish = _video_semantics(workflow)
|
||||
results: list[dict[str, Any]] = []
|
||||
registry_outputs: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _is_wiring(ct: str) -> bool:
|
||||
return (
|
||||
not ct
|
||||
or ct in _SOURCE_ONLY_CLASSES
|
||||
or ct in _NON_OUTPUT_CLASSES
|
||||
or ct.startswith("Empty")
|
||||
)
|
||||
|
||||
# 1) Trusted registry outputs at sink positions.
|
||||
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}
|
||||
registry_outputs.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",
|
||||
}
|
||||
)
|
||||
seen.add(nid)
|
||||
|
||||
# 2) Registry output classes consumed by other nodes are outputs too.
|
||||
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]),
|
||||
@@ -810,7 +859,7 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
ct = str(node.get("class_type") or "")
|
||||
if nid in seen or ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
results.append(
|
||||
registry_outputs.append(
|
||||
{
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
@@ -820,6 +869,37 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"evidence": "registry",
|
||||
}
|
||||
)
|
||||
seen.add(nid)
|
||||
|
||||
# 3) Unknown sink candidates. Save-looking sinks always need mapping;
|
||||
# feature-less sinks only when nothing trusted exists (utility tails
|
||||
# next to a real saver stay invisible).
|
||||
has_trusted = bool(registry_outputs)
|
||||
for nid, node in _sinks(workflow, consumers):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
if _unknown_sink_looks_like_save(node, workflow) or not has_trusted:
|
||||
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
|
||||
seen.add(nid)
|
||||
|
||||
# 4) Unknown save-looking nodes that something consumes (a custom saver
|
||||
# feeding a cleanup tail is still an output candidate). Only concrete
|
||||
# save widgets count here — decode-fed media links are ubiquitous among
|
||||
# mid-graph transforms (CreateVideo & co.) and never mark an output.
|
||||
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 _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
if _node_has_save_widget(node):
|
||||
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
|
||||
seen.add(nid)
|
||||
|
||||
results = registry_outputs + candidates
|
||||
results.sort(key=lambda r: _node_sort_key(r["nodeId"]))
|
||||
return results
|
||||
|
||||
|
||||
@@ -899,7 +979,7 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
names = "、".join(
|
||||
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
|
||||
)
|
||||
summary.append(f"存在无法识别的输出节点:{names},需人工确认")
|
||||
summary.append(f"存在无法识别的输出节点:{names},需人工指定输出节点及媒体类型")
|
||||
for s in image_slots:
|
||||
if s["role"] != "mask":
|
||||
summary.append(f"图片输入:节点 {s['nodeId']}({s['label']})")
|
||||
@@ -914,6 +994,18 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
"未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点"
|
||||
)
|
||||
elif unknown_outputs:
|
||||
# HEL-638: any unknown custom output node keeps the workflow
|
||||
# pending — no name-based guessing, and picking a usage alone must
|
||||
# not bypass the output-node designation.
|
||||
names = "、".join(
|
||||
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
|
||||
)
|
||||
ambiguous = True
|
||||
problem = (
|
||||
f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能保存启用"
|
||||
)
|
||||
elif len(out_kinds) > 1:
|
||||
ambiguous = True
|
||||
problem = "同一工作流同时输出图片和视频,需人工选择主用途"
|
||||
@@ -929,10 +1021,6 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
else:
|
||||
ambiguous = True
|
||||
problem = "视频工作流中的单张图片输入无法判定是首帧还是参考图,需人工确认"
|
||||
else:
|
||||
# only unknown outputs — the user must map them before it is usable
|
||||
ambiguous = True
|
||||
problem = "输出节点的媒体类型无法识别,需人工确认用途后才能生成"
|
||||
|
||||
return {
|
||||
"usage": usage,
|
||||
|
||||
@@ -30,7 +30,8 @@ VIDEO_OUTPUT_CLASSES = frozenset(
|
||||
{"VHS_VideoCombine", "SaveVideo", "VideoOutput", "AnimateDiffCombine"}
|
||||
)
|
||||
IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket"}
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
|
||||
"SaveImageAdvanced"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -121,7 +121,11 @@ CREATE TABLE IF NOT EXISTS workflows (
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
@@ -370,6 +374,42 @@ def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-638: settle the output-node mapping for existing rows.
|
||||
|
||||
Rows the user already mapped (output_map_source='user') are never
|
||||
touched. Determinate graphs get the trusted ``auto`` map. Rows whose
|
||||
graph now contains unknown custom output candidates get no auto map and
|
||||
are parked disabled — the usage decision survives, but until the user
|
||||
designates the output nodes the workflow must not generate.
|
||||
"""
|
||||
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows"
|
||||
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
if needs_user_output_map(workflow):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = NULL, output_map_source = NULL,"
|
||||
" is_enabled = 0 WHERE id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = ?, output_map_source = 'auto'"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(auto_output_map(workflow)), row[0]),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -421,9 +461,17 @@ async def migrate_db(db_path: Path) -> None:
|
||||
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")
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes ({nodeId, kind} JSON + 'user'|'auto'). Idempotent backfill
|
||||
# below parks unmapped unknown-output rows (disabled) like usage does.
|
||||
if "output_map" not in cols:
|
||||
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")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
_backfill_workflow_output_map(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")
|
||||
|
||||
@@ -222,6 +222,11 @@ class WorkflowCreate(BaseModel):
|
||||
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
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes — [{nodeId, kind: image|video}]. Required before a workflow
|
||||
# with unknown outputs can be enabled or generated.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
@@ -234,6 +239,10 @@ class WorkflowUpdate(BaseModel):
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: set/replace the user output mapping; an explicit empty list
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
|
||||
@@ -196,15 +196,25 @@ class QueueWorker:
|
||||
)
|
||||
|
||||
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
|
||||
workflow = self._load_workflow(workflow_id)
|
||||
if not workflow:
|
||||
workflow_row = self._load_workflow_row(workflow_id)
|
||||
if not workflow_row:
|
||||
raise RuntimeError(
|
||||
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
|
||||
)
|
||||
workflow = workflow_row["workflow_json"]
|
||||
broken = blocking_problem(workflow)
|
||||
if broken:
|
||||
raise RuntimeError(broken)
|
||||
|
||||
# HEL-638: harvest only the confirmed output nodes. A workflow
|
||||
# with unknown custom outputs and no user mapping must not run.
|
||||
node_kinds = self._output_node_kinds(workflow_row)
|
||||
if node_kinds is None:
|
||||
raise RuntimeError(
|
||||
"该工作流含无法识别的自定义输出节点,尚未指定输出节点及媒体类型,"
|
||||
"请到「ComfyUI 连接」页确认后再生成"
|
||||
)
|
||||
|
||||
input_values = payload.get("input_values") or {}
|
||||
if payload.get("continue_source") and kind == "video":
|
||||
input_values = await self._resolve_continue_source(
|
||||
@@ -240,13 +250,31 @@ class QueueWorker:
|
||||
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
|
||||
)
|
||||
|
||||
outputs_meta = client.extract_outputs(history)
|
||||
outputs_meta = client.extract_outputs(history, node_kinds=node_kinds)
|
||||
saved = [m for m in outputs_meta if m.get("type") == "output"]
|
||||
outputs_meta = saved or outputs_meta
|
||||
if not outputs_meta:
|
||||
checked = "、".join(sorted(node_kinds))
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
|
||||
"不算生成完成。请确认工作流带有保存/预览节点,并到 ComfyUI 历史中核对该任务。"
|
||||
"不算生成完成。已按输出映射检查节点:" f"{checked};"
|
||||
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
|
||||
)
|
||||
mismatch = next(
|
||||
(
|
||||
m
|
||||
for m in outputs_meta
|
||||
if m.get("media_kind") and m["media_kind"] != node_kinds.get(m["node_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mismatch:
|
||||
want = node_kinds.get(mismatch["node_id"])
|
||||
raise RuntimeError(
|
||||
f"输出节点 {mismatch['node_id']} 的媒体类型与映射不符"
|
||||
f"(映射为{'视频' if want == 'video' else '图片'},"
|
||||
f"实际返回 {mismatch['filename']}),任务不算生成完成。"
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
@@ -476,20 +504,45 @@ class QueueWorker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_workflow(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
def _load_workflow_row(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
if not workflow_id:
|
||||
return None
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json FROM workflows WHERE id = ?", (workflow_id,)
|
||||
"SELECT workflow_json, output_map, output_map_source FROM workflows"
|
||||
" WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row["workflow_json"])
|
||||
return {
|
||||
"workflow_json": json.loads(row["workflow_json"]),
|
||||
"output_map": row["output_map"],
|
||||
"output_map_source": row["output_map_source"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _output_node_kinds(workflow_row: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""Confirmed {nodeId: image|video} for this workflow, or None when
|
||||
the outputs are not confirmed (unknown nodes, no user mapping)."""
|
||||
from calliope.comfyui.output_map import mapping_problem, resolve_output_map
|
||||
|
||||
workflow = workflow_row["workflow_json"]
|
||||
problem = mapping_problem(
|
||||
workflow, workflow_row.get("output_map"), workflow_row.get("output_map_source")
|
||||
)
|
||||
if problem:
|
||||
return None
|
||||
entries, _source = resolve_output_map(
|
||||
workflow_row.get("output_map"),
|
||||
workflow_row.get("output_map_source"),
|
||||
workflow,
|
||||
)
|
||||
return {str(e["nodeId"]): str(e["kind"]) for e in entries} or None
|
||||
|
||||
def _job_label(self, job: dict[str, Any]) -> str:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
kind = job.get("kind") or "job"
|
||||
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.output_map import mapping_problem
|
||||
from calliope.comfyui.parser import effective_input_schema
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -335,7 +336,8 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema FROM workflows WHERE id = ?",
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema, output_map,"
|
||||
" output_map_source FROM workflows WHERE id = ?",
|
||||
(payload.workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
@@ -348,6 +350,11 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
)
|
||||
kind = wf["kind"]
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
# HEL-638: a workflow with unknown custom output nodes must have a
|
||||
# confirmed output mapping before anything is queued.
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
|
||||
@@ -6,6 +6,13 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
mapping_problem,
|
||||
needs_user_output_map,
|
||||
validate_output_map,
|
||||
)
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
effective_input_schema,
|
||||
@@ -26,9 +33,17 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data["workflow_json"] = json.loads(data["workflow_json"])
|
||||
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
# Outputs are pure graph derivation (no manual overrides) — always fresh,
|
||||
# so parser upgrades reach old rows without a re-import.
|
||||
data["output_schema"] = parse_dynamic_outputs(data["workflow_json"])
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["warnings"] = workflow_warnings(data["workflow_json"])
|
||||
raw_map = data.get("output_map")
|
||||
data["output_map"] = load_output_map(raw_map)
|
||||
# Pending flag: unknown output candidates the user has not mapped yet.
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -63,9 +78,9 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
"""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. A usage the user
|
||||
set by hand is never overwritten; only ``auto`` (and pending NULL)
|
||||
rows get the new judgment.
|
||||
schemas — this refreshes them without re-importing. A usage or output
|
||||
mapping 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:
|
||||
@@ -83,10 +98,31 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
|
||||
schema = effective_input_schema(stored, workflow)
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
if data["output_map_source"] == "user":
|
||||
output_map, output_map_source = data["output_map"], "user"
|
||||
elif needs_user_output_map(workflow):
|
||||
# Unknown output candidates: no auto map, and the row must not
|
||||
# generate until the user designates the output nodes.
|
||||
output_map, output_map_source = None, None
|
||||
else:
|
||||
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
|
||||
is_enabled = data["is_enabled"]
|
||||
if output_map_source is None or usage is None:
|
||||
is_enabled = 0
|
||||
conn.execute(
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
|
||||
" usage = ?, usage_source = ? WHERE id = ?",
|
||||
(json.dumps(schema), json.dumps(outputs), usage, usage_source, workflow_id),
|
||||
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
|
||||
" is_enabled = ? WHERE id = ?",
|
||||
(
|
||||
json.dumps(schema),
|
||||
json.dumps(outputs),
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
is_enabled,
|
||||
workflow_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
fresh = conn.execute(
|
||||
@@ -158,6 +194,20 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
}
|
||||
kind = "video" if out_kinds == {"video"} else "image"
|
||||
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
|
||||
# HEL-638: an explicit user output map wins; otherwise the trusted auto
|
||||
# map. Either way a graph with unknown output candidates is unusable
|
||||
# until the user designates the output nodes.
|
||||
if payload.output_map:
|
||||
problems = validate_output_map(payload.workflow_json, payload.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(payload.output_map)
|
||||
output_map_source = payload.output_map_source or "user"
|
||||
else:
|
||||
auto_map = auto_output_map(payload.workflow_json)
|
||||
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(payload.workflow_json) or bool(payload.output_map)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Byte-identical re-imports are the main way the library fills up with
|
||||
@@ -174,16 +224,18 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
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
|
||||
# Unconfirmed workflows (ambiguous usage / unknown outputs without a
|
||||
# user mapping) are stored but disabled: they must not reach the
|
||||
# generate pages until the user confirms usage AND, for unknown
|
||||
# output nodes, designates the outputs on the connections page.
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
endpoint_id, is_enabled, usage, usage_source,
|
||||
output_map, output_map_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
@@ -197,6 +249,8 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -228,20 +282,54 @@ 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
|
||||
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
|
||||
# resets the row to pending (disabled).
|
||||
if "output_map" in data:
|
||||
if data["output_map"]:
|
||||
problems = validate_output_map(workflow_json, data["output_map"])
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
data["output_map"] = json.dumps(data["output_map"])
|
||||
data.setdefault("output_map_source", "user")
|
||||
else:
|
||||
data["output_map"] = None
|
||||
data["output_map_source"] = None
|
||||
data["is_enabled"] = 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.
|
||||
# kind (output media) follows the usage. Whether it re-enables is
|
||||
# decided by the gate below — with unknown output nodes the user
|
||||
# must also designate the output nodes (HEL-638).
|
||||
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}
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips
|
||||
# (an output_map reset keeps its explicit NULLs).
|
||||
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
|
||||
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}
|
||||
|
||||
# Enable gate: usage must be settled and, when the graph carries
|
||||
# unknown custom output nodes, a user output mapping must exist.
|
||||
usage_ok = bool(data.get("usage") or existing["usage"])
|
||||
map_raw = data.get("output_map", existing["output_map"])
|
||||
map_source = data.get("output_map_source", existing["output_map_source"])
|
||||
map_problem = mapping_problem(workflow_json, map_raw, map_source)
|
||||
if data.get("is_enabled"):
|
||||
if not usage_ok:
|
||||
raise HTTPException(status_code=400, detail="请先选择工作流用途,再启用该工作流")
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
elif (data.get("usage") or data.get("output_map")) and "is_enabled" not in data:
|
||||
# A usage-confirm or output-map-confirm path: enabled only when
|
||||
# both decisions are settled.
|
||||
data["is_enabled"] = 1 if (usage_ok and not map_problem) else 0
|
||||
if data:
|
||||
fields = ", ".join(f"{k} = :{k}" for k in data)
|
||||
data["id"] = workflow_id
|
||||
|
||||
@@ -10,6 +10,10 @@ from fastapi.testclient import TestClient
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
# Shared fake-ComfyUI HTTP server (defined in test_studio_generation,
|
||||
# reused by the generation / status / unknown-output-mapping suites).
|
||||
from tests.test_studio_generation import fake_comfy # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
|
||||
@@ -25,9 +25,19 @@ PNG_1PX = bytes.fromhex(
|
||||
|
||||
|
||||
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image."""
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
|
||||
|
||||
History outputs are keyed by the prompt's real output node id (what
|
||||
ComfyUI does); a known output class that is absent falls back to
|
||||
``server.forced_output_node`` (default "9"). Scripted tests can pin
|
||||
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
|
||||
"""
|
||||
|
||||
server_version = "FakeComfy/1.0"
|
||||
KNOWN_OUTPUT_CLASSES = {
|
||||
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
||||
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
self.server.requests.append(self.path)
|
||||
@@ -64,11 +74,13 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
prompt_id = uuid.uuid4().hex
|
||||
outputs_key = self.server.outputs_key
|
||||
out_entry = {
|
||||
outputs_key: [{"filename": self.server.out_name, "subfolder": "", "type": "output"}]
|
||||
outputs_key: [
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {"9": out_entry},
|
||||
"outputs": {self._output_node(body): out_entry},
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
@@ -80,6 +92,19 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _output_node(self, body: bytes) -> str:
|
||||
forced = getattr(self.server, "forced_output_node", None)
|
||||
if forced:
|
||||
return forced
|
||||
try:
|
||||
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
||||
except json.JSONDecodeError:
|
||||
prompt = {}
|
||||
for nid, node in prompt.items():
|
||||
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
|
||||
return str(nid)
|
||||
return "9"
|
||||
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -109,6 +134,7 @@ def fake_comfy():
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""HEL-638: unknown custom output nodes close the loop — no name-based
|
||||
auto-typing, feature-less sinks become mappable candidates, a usage choice
|
||||
alone never bypasses the output designation, and generation harvests (and
|
||||
checks) exactly the nodes the user mapped.
|
||||
|
||||
Two brand-new class names drive the cases:
|
||||
|
||||
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
|
||||
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
|
||||
and enabled the workflow.
|
||||
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
|
||||
HEL-638 such a graph only reported ``no_output`` with no way to map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
from tests.test_studio_generation import _real_run, _wait_done
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _vault_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "NebulaGlaxVideoVault",
|
||||
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _muffin_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _analyze(client, wf: dict) -> dict:
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Unknown outputs stay pending — names never decide (gap 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_video_semantic_unknown_node_is_never_auto_typed(client):
|
||||
body = _analyze(client, _vault_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
|
||||
assert entry["evidence"] == "unknown_node"
|
||||
assert entry["kind"] is None # the user picks the media type
|
||||
assert entry["suggested_kind"] == "video" # only a form default
|
||||
assert body["needs_mapping"] is True
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "指定实际输出节点" in body["usage_problem"]
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
|
||||
"""create/PATCH with a usage but no output map keeps the row disabled."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video", "usage_source": "user"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
|
||||
assert wf["needs_mapping"] is True and not wf["output_map"]
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
# forcing the switch on is refused with the mapping reason
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
|
||||
assert r.status_code == 400
|
||||
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
|
||||
body = _analyze(client, _muffin_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
|
||||
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
|
||||
assert body["needs_mapping"] is True
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
body = _analyze(client, wf)
|
||||
assert body["usage"] is None and body["needs_mapping"] is False
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
|
||||
fake_comfy.outputs_key = "gifs"
|
||||
fake_comfy.out_name = "clip.mp4"
|
||||
fake_comfy.forced_output_node = "12" # the mapped node reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·视频库",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
"output_map_source": "user",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is True
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
|
||||
"source": "studio-video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["output_paths"][0].endswith(".mp4")
|
||||
|
||||
|
||||
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
|
||||
"""Results that only land on an unmapped node are NOT a completion."""
|
||||
fake_comfy.forced_output_node = "7" # something else reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·空手而归",
|
||||
"workflow_json": _muffin_workflow(),
|
||||
"usage": "text2image",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "15", "kind": "image"}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["is_enabled"] is True
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in job["error"]
|
||||
assert "15" in job["error"] # names the node it waited on
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
|
||||
"""A video mapping that only yields a png is a real failure."""
|
||||
fake_comfy.outputs_key = "images"
|
||||
fake_comfy.out_name = "still.png"
|
||||
fake_comfy.forced_output_node = "12"
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·类型不符",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "媒体类型与映射不符" in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Bad / cancelled mappings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_mappings_are_rejected_in_chinese(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "999", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "audio"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
|
||||
|
||||
# a prompt/parameter node can never be an output
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "1", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
|
||||
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
r = client.patch(
|
||||
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_clearing_the_mapping_resets_to_pending(client):
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
wf = client.get(f"/api/workflows/{wid}").json()
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is False and not wf["output_map"]
|
||||
assert wf["usage"] == "text2video" # the usage decision survives
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Saved mappings survive reanalyze / field edits / migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
|
||||
"description": "编辑其他字段"})
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
|
||||
"""Backend-direct generation on an unconfirmed row is refused."""
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "强开", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video"},
|
||||
).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "输出节点" in gen.json()["detail"]
|
||||
|
||||
|
||||
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
|
||||
import asyncio
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
|
||||
# graph and the old PATCH re-enabled it.
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled, usage, usage_source)"
|
||||
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
|
||||
("存量·未知输出", json.dumps(_vault_workflow())),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
parked = wfs["存量·未知输出"]
|
||||
assert parked["is_enabled"] is False
|
||||
assert parked["usage"] == "text2video" # decision preserved
|
||||
assert not parked["output_map"] and parked["needs_mapping"] is True
|
||||
|
||||
# A trusted five-type import keeps working untouched.
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "存量·可信", "kind": "image",
|
||||
"workflow_json": json.loads(
|
||||
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
|
||||
)},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
|
||||
assert trusted["is_enabled"] is True
|
||||
assert trusted["output_map_source"] == "auto"
|
||||
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
|
||||
@@ -268,12 +268,14 @@ def _scene_ids(pid: int) -> list[int]:
|
||||
|
||||
|
||||
async def _fake_history(_client, _prompt_id, *_args):
|
||||
"""A successful ComfyUI history with one video output named out.mp4.
|
||||
Extra positional args (job_id) are accepted for the cancel-check wiring."""
|
||||
"""A successful ComfyUI history with one video output named out.mp4,
|
||||
reported under the workflow's real output node (30 = the tagged
|
||||
VHS_VideoCombine). Extra positional args (job_id) are accepted for the
|
||||
cancel-check wiring."""
|
||||
return {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {
|
||||
"99": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
"30": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ import type {
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -148,6 +149,7 @@ export type {
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -470,6 +472,8 @@ export const workflows = {
|
||||
usage?: WorkflowUsage;
|
||||
usage_source?: "auto" | "user";
|
||||
input_schema?: ComfyDynamicInput[];
|
||||
output_map?: WorkflowOutputMapEntry[];
|
||||
output_map_source?: "auto" | "user";
|
||||
description?: string;
|
||||
prompt_profile?: string;
|
||||
endpoint_id?: string | null;
|
||||
@@ -490,6 +494,8 @@ export const workflows = {
|
||||
usage: WorkflowUsage;
|
||||
usage_source: "auto" | "user";
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_map: WorkflowOutputMapEntry[];
|
||||
output_map_source: "auto" | "user";
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
|
||||
@@ -45,10 +45,21 @@ export interface ComfyDynamicOutput {
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Output:image). */
|
||||
role?: string | null;
|
||||
kind: ComfyOutputKind;
|
||||
/** Null while an unknown custom node awaits the user's media-type pick. */
|
||||
kind: ComfyOutputKind | null;
|
||||
/** Untagged detection: node class + which rule typed it. */
|
||||
class_type?: string;
|
||||
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
|
||||
/** Form default for unknown candidates — never auto-applied. */
|
||||
suggested_kind?: 'image' | 'video' | null;
|
||||
}
|
||||
|
||||
/** One entry of a workflow's confirmed output mapping (HEL-638). */
|
||||
export interface WorkflowOutputMapEntry {
|
||||
nodeId: string;
|
||||
kind: 'image' | 'video';
|
||||
label?: string;
|
||||
class_type?: string;
|
||||
}
|
||||
|
||||
/** The five workflow usages the studio distinguishes (HEL-636). */
|
||||
@@ -114,6 +125,11 @@ export interface Workflow {
|
||||
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
|
||||
endpoint_id?: string | null;
|
||||
warnings?: WorkflowWarning[];
|
||||
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
|
||||
output_map?: WorkflowOutputMapEntry[] | null;
|
||||
output_map_source?: 'auto' | 'user' | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
|
||||
@@ -63,13 +63,20 @@
|
||||
|
||||
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.is_enabled && (w.usage ?? null) !== null && USAGE_BY_KIND[kind].includes(w.usage!),
|
||||
),
|
||||
);
|
||||
// HEL-636: the page serves its usages (image → t2i/i2i, video →
|
||||
// t2v/i2v/ref2v); pending workflows (usage = null) never appear.
|
||||
// HEL-638: workflows with unconfirmed unknown output nodes are also
|
||||
// hidden — enabled rows carry a settled output mapping, this is a
|
||||
// second guard for stale rows.
|
||||
const kindWorkflows = $derived(
|
||||
($workflowsQuery.data ?? []).filter(
|
||||
(w) =>
|
||||
w.is_enabled &&
|
||||
(w.usage ?? null) !== null &&
|
||||
!w.needs_mapping &&
|
||||
USAGE_BY_KIND[kind].includes(w.usage!),
|
||||
),
|
||||
);
|
||||
|
||||
const allJobs = $derived.by(() => {
|
||||
void $jobsQuery.data;
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const de: Dict = {
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Ausgabeknoten',
|
||||
'conn.wfOutputNeedPick': 'Unbekannte benutzerdefinierte Ausgabeknoten — bitte zuordnen',
|
||||
'conn.wfOutputHint': 'Markieren Sie die Knoten, die tatsächlich Ergebnisse liefern, und wählen Sie je den Medientyp; die Generierung sammelt Bilder/Videos nur aus diesen Knoten.',
|
||||
'conn.wfOutputPick': 'Mindestens einen Ausgabeknoten markieren',
|
||||
'conn.wfOutputKindAria': 'Medientyp des Ausgabeknotens',
|
||||
'conn.wfOutputPendingBadge': 'Ausgabe offen',
|
||||
'conn.wfOutputNeedMap': 'Dieser Workflow enthält nicht erkannte Ausgabeknoten; ordnen Sie sie samt Medientyp zu, um ihn zu aktivieren',
|
||||
'conn.wfOutputApply': 'Ausgabezuordnung speichern',
|
||||
'conn.wfMapCancel': 'Abbrechen',
|
||||
'conn.wfOutputApplied': 'Ausgabezuordnung gespeichert: Knoten {nodes}',
|
||||
'conn.wfOutputCleared': 'Ausgabezuordnung gelöscht — der Workflow ist wieder ausstehend',
|
||||
'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) ---
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const en = {
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Output nodes',
|
||||
'conn.wfOutputNeedPick': 'Unrecognized custom output nodes — designate them',
|
||||
'conn.wfOutputHint': 'Check the nodes that actually produce results and pick each one’s media type; generation harvests images/videos only from these nodes.',
|
||||
'conn.wfOutputPick': 'Check at least one output node',
|
||||
'conn.wfOutputKindAria': 'Output node media type',
|
||||
'conn.wfOutputPendingBadge': 'Outputs unset',
|
||||
'conn.wfOutputNeedMap': 'This workflow has unrecognized output nodes; designate them and their media type to enable it',
|
||||
'conn.wfOutputApply': 'Save output mapping',
|
||||
'conn.wfMapCancel': 'Cancel',
|
||||
'conn.wfOutputApplied': 'Output mapping saved: node {nodes}',
|
||||
'conn.wfOutputCleared': 'Output mapping cleared — the workflow is pending again',
|
||||
'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',
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const es: Dict = {
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Nodos de salida',
|
||||
'conn.wfOutputNeedPick': 'Nodos de salida personalizados no reconocidos: asígnalos',
|
||||
'conn.wfOutputHint': 'Marca los nodos que realmente producen resultados y elige el tipo de medio de cada uno; la generación solo extrae imágenes/vídeos de estos nodos.',
|
||||
'conn.wfOutputPick': 'Marca al menos un nodo de salida',
|
||||
'conn.wfOutputKindAria': 'Tipo de medio del nodo de salida',
|
||||
'conn.wfOutputPendingBadge': 'Salida pendiente',
|
||||
'conn.wfOutputNeedMap': 'Este flujo tiene nodos de salida no reconocidos; asígnalos y su tipo de medio para activarlo',
|
||||
'conn.wfOutputApply': 'Guardar mapeo de salida',
|
||||
'conn.wfMapCancel': 'Cancelar',
|
||||
'conn.wfOutputApplied': 'Mapeo de salida guardado: nodo {nodes}',
|
||||
'conn.wfOutputCleared': 'Mapeo de salida borrado: el flujo vuelve a estar pendiente',
|
||||
'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) ---
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const fr: Dict = {
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Nœuds de sortie',
|
||||
'conn.wfOutputNeedPick': 'Nœuds de sortie personnalisés non reconnus — à désigner',
|
||||
'conn.wfOutputHint': 'Cochez les nœuds qui produisent réellement les résultats et choisissez leur type de média ; la génération ne collecte les images/vidéos que sur ces nœuds.',
|
||||
'conn.wfOutputPick': 'Cochez au moins un nœud de sortie',
|
||||
'conn.wfOutputKindAria': 'Type de média du nœud de sortie',
|
||||
'conn.wfOutputPendingBadge': 'Sortie à définir',
|
||||
'conn.wfOutputNeedMap': 'Ce workflow contient des nœuds de sortie non reconnus ; désignez-les avec leur type de média pour l’activer',
|
||||
'conn.wfOutputApply': 'Enregistrer le mapping de sortie',
|
||||
'conn.wfMapCancel': 'Annuler',
|
||||
'conn.wfOutputApplied': 'Mapping de sortie enregistré : nœud {nodes}',
|
||||
'conn.wfOutputCleared': 'Mapping de sortie effacé — le workflow est de nouveau en attente',
|
||||
'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) ---
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const ja: Dict = {
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': '出力ノード',
|
||||
'conn.wfOutputNeedPick': '認識できないカスタム出力ノードがあります。手動で指定してください',
|
||||
'conn.wfOutputHint': '実際に結果を出すノードにチェックし、それぞれのメディア種別を選んでください。生成時にこれらのノードからのみ画像/動画を取り出します。',
|
||||
'conn.wfOutputPick': '出力ノードを1つ以上チェックしてください',
|
||||
'conn.wfOutputKindAria': '出力ノードのメディア種別',
|
||||
'conn.wfOutputPendingBadge': '出力未指定',
|
||||
'conn.wfOutputNeedMap': '認識できない出力ノードを含みます。ノードとメディア種別を指定すると有効化できます',
|
||||
'conn.wfOutputApply': '出力マッピングを保存',
|
||||
'conn.wfMapCancel': 'キャンセル',
|
||||
'conn.wfOutputApplied': '出力マッピングを保存しました:ノード {nodes}',
|
||||
'conn.wfOutputCleared': '出力マッピングを解除しました。ワークフローは保留に戻ります',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 専用のワークフロー JSON はまだリポジトリにありません。ComfyUI から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
|
||||
|
||||
// --- 独立画像/動画生成(a3/a4、HEL-612)---
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const ko: Dict = {
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': '출력 노드',
|
||||
'conn.wfOutputNeedPick': '인식할 수 없는 사용자 지정 출력 노드가 있습니다. 직접 지정하세요',
|
||||
'conn.wfOutputHint': '실제 결과를 내는 노드를 체크하고 각각의 미디어 유형을 선택하세요. 생성 시 이 노드에서만 이미지/동영상을 수집합니다.',
|
||||
'conn.wfOutputPick': '출력 노드를 하나 이상 체크하세요',
|
||||
'conn.wfOutputKindAria': '출력 노드 미디어 유형',
|
||||
'conn.wfOutputPendingBadge': '출력 미지정',
|
||||
'conn.wfOutputNeedMap': '인식할 수 없는 출력 노드가 포함되어 있습니다. 노드와 미디어 유형을 지정하면 활성화됩니다',
|
||||
'conn.wfOutputApply': '출력 매핑 저장',
|
||||
'conn.wfMapCancel': '취소',
|
||||
'conn.wfOutputApplied': '출력 매핑 저장됨: 노드 {nodes}',
|
||||
'conn.wfOutputCleared': '출력 매핑이 삭제되었습니다. 워크플로가 다시 대기 상태가 됩니다',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 전용 워크플로 JSON이 아직 저장소에 없습니다. ComfyUI에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
|
||||
|
||||
// --- 독립 이미지/비디오 생성 (a3/a4, HEL-612) ---
|
||||
|
||||
@@ -1435,6 +1435,17 @@ export const zh: Dict = {
|
||||
'conn.wfSourceAuto': '用途为自动判定',
|
||||
'conn.wfDisabledBadge': '已停用',
|
||||
'conn.wfConfirmAria': '确认工作流用途',
|
||||
'conn.wfOutputLabel': '输出节点',
|
||||
'conn.wfOutputNeedPick': '存在无法识别的自定义输出节点,需人工指定',
|
||||
'conn.wfOutputHint': '勾选实际产生结果的节点,并为每个节点选择媒体类型;生成时只从这些节点提取图片/视频。',
|
||||
'conn.wfOutputPick': '请至少勾选一个输出节点',
|
||||
'conn.wfOutputKindAria': '输出节点媒体类型',
|
||||
'conn.wfOutputPendingBadge': '待指定输出',
|
||||
'conn.wfOutputNeedMap': '该工作流含无法识别的输出节点,指定输出节点及媒体类型后才能启用',
|
||||
'conn.wfOutputApply': '保存输出映射',
|
||||
'conn.wfMapCancel': '取消',
|
||||
'conn.wfOutputApplied': '已保存输出映射:节点 {nodes}',
|
||||
'conn.wfOutputCleared': '已清除输出映射,工作流回到待确认状态',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
|
||||
// --- 独立生图 / 生视频(a3/a4,HEL-612)---
|
||||
'studio.image.title': '生图',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
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 { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowUsage, type WorkflowAnalyzeResult } from '$lib/api';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, 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';
|
||||
@@ -337,6 +337,9 @@
|
||||
let wfAnalysis = $state<WorkflowAnalyzeResult | null>(null);
|
||||
let wfUsage = $state<WorkflowUsage | null>(null);
|
||||
let wfRoleOverrides = $state<Record<string, string>>({});
|
||||
// HEL-638: explicit output-node designation for unknown custom nodes.
|
||||
let wfOutputPicked = $state<Record<string, boolean>>({});
|
||||
let wfOutputKind = $state<Record<string, 'image' | 'video'>>({});
|
||||
let wfAnalyzing = $state(false);
|
||||
let wfAnalyzeError = $state('');
|
||||
|
||||
@@ -374,6 +377,8 @@
|
||||
wfAnalysis = null;
|
||||
wfUsage = null;
|
||||
wfRoleOverrides = {};
|
||||
wfOutputPicked = {};
|
||||
wfOutputKind = {};
|
||||
wfAnalyzeError = '';
|
||||
}
|
||||
|
||||
@@ -390,6 +395,19 @@
|
||||
wfAnalysis = res;
|
||||
wfUsage = res.usage ?? null;
|
||||
wfRoleOverrides = {};
|
||||
// Unknown output candidates: preselect only when there is exactly
|
||||
// one — with several the user must decide (HEL-638).
|
||||
const candidates = (res.usage_evidence.outputs ?? []).filter(
|
||||
(o) => o.evidence === 'unknown_node',
|
||||
);
|
||||
const picked: Record<string, boolean> = {};
|
||||
const kinds: Record<string, 'image' | 'video'> = {};
|
||||
for (const c of candidates) {
|
||||
kinds[c.nodeId] = c.suggested_kind ?? 'image';
|
||||
}
|
||||
if (candidates.length === 1) picked[candidates[0].nodeId] = true;
|
||||
wfOutputPicked = picked;
|
||||
wfOutputKind = kinds;
|
||||
wfStep = 'confirm';
|
||||
} catch (err) {
|
||||
wfAnalyzeError = apiErrorMessage(err);
|
||||
@@ -402,6 +420,22 @@
|
||||
const wfFatal = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null && (wfAnalysis.outputs?.length ?? 0) === 0,
|
||||
);
|
||||
/** Unknown custom output nodes the user must designate (HEL-638). */
|
||||
const wfOutputCandidates = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null
|
||||
? (wfAnalysis.usage_evidence.outputs ?? []).filter((o) => o.evidence === 'unknown_node')
|
||||
: [],
|
||||
);
|
||||
const wfOutputRows = $derived(
|
||||
wfOutputCandidates.map((o) => ({
|
||||
...o,
|
||||
picked: wfOutputPicked[o.nodeId] ?? false,
|
||||
kind: wfOutputKind[o.nodeId] ?? o.suggested_kind ?? 'image',
|
||||
})),
|
||||
);
|
||||
const wfOutputPickedCount = $derived(
|
||||
Object.values(wfOutputPicked).filter(Boolean).length,
|
||||
);
|
||||
const wfMustPick = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfAnalysis !== null &&
|
||||
@@ -409,7 +443,12 @@
|
||||
(wfAnalysis.usage_ambiguous || wfAnalysis.needs_mapping) &&
|
||||
wfUsage === null,
|
||||
);
|
||||
const wfSaveable = $derived(wfStep === 'confirm' && wfUsage !== null && !wfFatal);
|
||||
const wfSaveable = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfUsage !== null &&
|
||||
!wfFatal &&
|
||||
(wfOutputCandidates.length === 0 || wfOutputPickedCount > 0),
|
||||
);
|
||||
/** Media slots the confirm page lets the user re-role. */
|
||||
const wfSlotRows = $derived.by(() => {
|
||||
if (wfStep !== 'confirm' || !wfAnalysis) return [];
|
||||
@@ -430,18 +469,29 @@
|
||||
mutationFn: async () => {
|
||||
if (!wfParsed.valid || !wfParsed.json || !wfAnalysis || !wfUsage)
|
||||
throw new Error(t('conn.wfPickUsage'));
|
||||
if (wfOutputCandidates.length > 0 && wfOutputPickedCount === 0)
|
||||
throw new Error(t('conn.wfOutputPick'));
|
||||
const corrected = Object.keys(wfRoleOverrides).length > 0;
|
||||
const schema = wfAnalysis.inputs.map((e) =>
|
||||
wfRoleOverrides[e.nodeId]
|
||||
? { ...e, role: wfRoleOverrides[e.nodeId], manual: true }
|
||||
: e,
|
||||
);
|
||||
const outputMap = wfOutputRows
|
||||
.filter((r) => r.picked)
|
||||
.map((r) => ({
|
||||
nodeId: r.nodeId,
|
||||
kind: r.kind,
|
||||
label: r.label,
|
||||
...(r.class_type ? { class_type: r.class_type } : {}),
|
||||
}));
|
||||
return workflows.create({
|
||||
name: wfImportName.trim(),
|
||||
workflow_json: wfParsed.json,
|
||||
usage: wfUsage,
|
||||
usage_source: wfUsage !== wfAnalysis.usage || corrected ? 'user' : 'auto',
|
||||
input_schema: schema,
|
||||
...(outputMap.length ? { output_map: outputMap, output_map_source: 'user' } : {}),
|
||||
...(wfImportEndpoint ? { endpoint_id: wfImportEndpoint } : {}),
|
||||
});
|
||||
},
|
||||
@@ -488,6 +538,80 @@
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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 }),
|
||||
@@ -856,10 +980,56 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
|
||||
</div>
|
||||
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if wfSlotRows.length}
|
||||
{#if wfOutputRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfOutputLabel')}<em>*</em>
|
||||
<span class="f-hint-inline">{t('conn.wfOutputNeedPick')}</span>
|
||||
</span>
|
||||
<div class="wf-slot-rows">
|
||||
{#each wfOutputRows as row (row.nodeId)}
|
||||
<div class="wf-slot-row" class:off={!row.picked}>
|
||||
<label class="wf-out-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.picked}
|
||||
onchange={(e) =>
|
||||
(wfOutputPicked = {
|
||||
...wfOutputPicked,
|
||||
[row.nodeId]: e.currentTarget.checked,
|
||||
})}
|
||||
/>
|
||||
<span class="wf-slot-id mono">{row.nodeId}</span>
|
||||
<span class="wf-slot-name" title={row.class_type}>{row.label}</span>
|
||||
</label>
|
||||
<select
|
||||
class="a-select"
|
||||
aria-label={t('conn.wfOutputKindAria')}
|
||||
disabled={!row.picked}
|
||||
value={row.kind}
|
||||
onchange={(e) =>
|
||||
(wfOutputKind = {
|
||||
...wfOutputKind,
|
||||
[row.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>
|
||||
<span class="f-hint">{t('conn.wfOutputHint')}</span>
|
||||
{#if wfOutputPickedCount === 0}
|
||||
<span class="f-error">{t('conn.wfOutputPick')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wfSlotRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t('conn.wfSlotsLabel')}</span>
|
||||
<div class="wf-slot-rows">
|
||||
@@ -916,7 +1086,11 @@
|
||||
{t('conn.wfBack')}
|
||||
</Button>
|
||||
<span class="need-test" class:passed={wfSaveable && wfImportName.trim() !== ''}>
|
||||
{wfSaveable ? usageLabel(wfUsage) : t('conn.wfUsageNeedPick')}
|
||||
{wfUsage
|
||||
? wfOutputCandidates.length && wfOutputPickedCount === 0
|
||||
? t('conn.wfOutputPick')
|
||||
: usageLabel(wfUsage)
|
||||
: t('conn.wfUsageNeedPick')}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -948,71 +1122,139 @@
|
||||
<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 !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,
|
||||
})}
|
||||
<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>
|
||||
{/if}
|
||||
{#if rowNeedsOutputMap(wf)}
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('conn.delete')}
|
||||
onclick={() => $wfDeleteMutation.mutate(wf.id)}
|
||||
class="tag tag-mapbtn"
|
||||
class:open={wfMapEditId === wf.id}
|
||||
title={t('conn.wfOutputNeedMap')}
|
||||
onclick={() => toggleWfMapEdit(wf)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
<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>
|
||||
<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>
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
@@ -1606,6 +1848,52 @@
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user