Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634965f5f5 | ||
|
|
e62499c517 | ||
|
|
b6b3a9fed3 | ||
|
|
7bf63d0587 | ||
|
|
5cb75d5718 | ||
|
|
2304ab1538 | ||
|
|
8e84e4c21f | ||
|
|
35fd07af24 | ||
|
|
012a946f85 | ||
|
|
875dfcb41a | ||
|
|
780ac52ffc | ||
|
|
d4e199f3c8 | ||
|
|
ae9e34f6ab | ||
|
|
16933b1dfc | ||
|
|
07f6475b1a | ||
|
|
ac1c0f98a6 | ||
|
|
aaddd3dcaf | ||
|
|
2b3bbf9d03 | ||
|
|
bc24370233 | ||
|
|
2f267a751f | ||
|
|
0841f5a296 | ||
|
|
35aad34c7c | ||
|
|
bef28facee | ||
|
|
316fba97e1 | ||
|
|
1c00587a56 | ||
|
|
7c49250267 | ||
|
|
c5aa575ac3 | ||
|
|
6623ffa04c | ||
|
|
0ee8280ddb |
@@ -0,0 +1,128 @@
|
||||
"""Regenerate ``src/calliope/comfyui/ui_widget_orders.py`` (HEL-681).
|
||||
|
||||
Offline widget-order registry for the UI→API converter. Sources, in priority:
|
||||
|
||||
1. ``widgets_values_named`` instances across ``example_ComfyUI_workflows``
|
||||
(including subgraph inner nodes) — the frontend serializes named widgets
|
||||
in widget order, so the key order IS the serialization order;
|
||||
2. the repo's 10 ground-truth API exports (root ``*_API.json``) — scalar
|
||||
input names in export order (control_after_generate entries are absent
|
||||
there; the converter's walk skips them positionally).
|
||||
|
||||
Run from anywhere inside the repo:
|
||||
python scripts/build_widget_orders.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
REPO = BACKEND.parent
|
||||
EXAMPLES = REPO / "example_ComfyUI_workflows"
|
||||
OUT = BACKEND / "src" / "calliope" / "comfyui" / "ui_widget_orders.py"
|
||||
|
||||
BUTTONS = {"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
|
||||
MAX_SIGNATURES = 3
|
||||
MAX_ORDER_LEN = 48
|
||||
|
||||
# Hand-curated fallbacks for node classes the corpus never serializes with
|
||||
# names (classic core/custom widgets whose order is stable and public).
|
||||
CURATED: dict[str, list[list[str]]] = {
|
||||
"PrimitiveNode": [["value"]],
|
||||
"Seed (rgthree)": [["seed"]],
|
||||
}
|
||||
|
||||
|
||||
def _iter_ui_nodes() -> list[dict]:
|
||||
nodes: list[dict] = []
|
||||
for path in sorted(EXAMPLES.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue
|
||||
nodes.extend(n for n in data["nodes"] if isinstance(n, dict))
|
||||
for sub in data.get("definitions", {}).get("subgraphs", []) or []:
|
||||
if isinstance(sub, dict):
|
||||
nodes.extend(n for n in sub.get("nodes", []) or [] if isinstance(n, dict))
|
||||
return nodes
|
||||
|
||||
|
||||
def _iter_api_workflows() -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for path in sorted(EXAMPLES.glob("*_API.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if isinstance(data, dict) and "nodes" not in data:
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
learned: dict[str, collections.Counter[tuple[str, ...]]] = collections.defaultdict(
|
||||
collections.Counter
|
||||
)
|
||||
for node in _iter_ui_nodes():
|
||||
named = node.get("widgets_values_named")
|
||||
if not isinstance(named, dict) or not named:
|
||||
continue
|
||||
keys = tuple(
|
||||
str(k) for k in named.keys() if k and k not in BUTTONS
|
||||
)
|
||||
if keys and len(keys) <= MAX_ORDER_LEN:
|
||||
learned[str(node.get("type") or "")][keys] += 1
|
||||
|
||||
for workflow in _iter_api_workflows():
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
names = [
|
||||
str(k)
|
||||
for k, v in (node.get("inputs") or {}).items()
|
||||
if not isinstance(v, list) and k and k not in BUTTONS
|
||||
]
|
||||
if len(names) > MAX_ORDER_LEN:
|
||||
continue
|
||||
# Display nodes (PreviewAny…) carry zero scalar inputs — that
|
||||
# empty order is real signal: their positional arrays are pure
|
||||
# button leftovers and map to {}.
|
||||
learned[str(node.get("class_type") or "")][tuple(names)] += 1
|
||||
|
||||
registry: dict[str, list[list[str]]] = {}
|
||||
for node_type, counter in sorted(learned.items()):
|
||||
if not node_type:
|
||||
continue
|
||||
orders: list[list[str]] = []
|
||||
for signature, _count in counter.most_common(MAX_SIGNATURES):
|
||||
orders.append(list(signature))
|
||||
if orders:
|
||||
registry[node_type] = orders
|
||||
for node_type, orders in CURATED.items():
|
||||
registry.setdefault(node_type, orders)
|
||||
|
||||
body = json.dumps(registry, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
OUT.write_text(
|
||||
'"""GENERATED by scripts/build_widget_orders.py — do not edit by hand.\n'
|
||||
"\n"
|
||||
"Widget-order registry for the UI→API converter: class_type → candidate\n"
|
||||
"widget name orders, learned from the repo's named-widget UI files and\n"
|
||||
"ground-truth API exports (HEL-681).\n"
|
||||
'"""\n'
|
||||
"\n"
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
f"WIDGET_ORDERS: dict[str, list[list[str]]] = {body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
total = sum(len(o) for orders in registry.values() for o in orders)
|
||||
print(f"wrote {OUT.name}: {len(registry)} node types, {total} orders total")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Generate the HEL-681 dry-run report over the repo's UI workflow library.
|
||||
|
||||
Walks ``example_ComfyUI_workflows`` (or ``--root``), runs every file through
|
||||
the production converter + lint + parser pipeline (exactly what
|
||||
``POST /api/workflows/convert`` does, minus HTTP), and writes a JSON detail
|
||||
file plus a human-readable Markdown summary. Read-only: nothing is imported,
|
||||
no DB is touched.
|
||||
|
||||
Usage:
|
||||
python scripts/ui_import_dryrun.py [--root DIR] [--out PREFIX]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(BACKEND / "src"))
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings # noqa: E402
|
||||
from calliope.comfyui.metadata import suggested_metadata # noqa: E402
|
||||
from calliope.comfyui.parser import classify_usage # noqa: E402
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow # noqa: E402
|
||||
|
||||
REPO = BACKEND.parent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default=str(REPO / "example_ComfyUI_workflows"))
|
||||
parser.add_argument("--out", default=str(REPO / "dryrun_report"))
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
out_prefix = Path(args.out)
|
||||
|
||||
entries = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
entries.append(
|
||||
{
|
||||
"source_path": str(path.relative_to(root)),
|
||||
"status": "fail",
|
||||
"issues": [{"code": "unreadable", "message": str(exc)}],
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format / non-workflow files are out of scope
|
||||
rel = str(path.relative_to(root))
|
||||
result = convert_ui_workflow(data)
|
||||
entry: dict = {
|
||||
"source_path": rel,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is not None:
|
||||
entry["node_count"] = len(result.workflow)
|
||||
entry["suggested"] = suggested_metadata(result.workflow, rel, data)
|
||||
errors = [w for w in workflow_warnings(result.workflow) if w["level"] == "error"]
|
||||
entry["lint_errors"] = [e["code"] for e in errors]
|
||||
entry["importable"] = not result.needs_confirmation and not errors
|
||||
else:
|
||||
entry["importable"] = False
|
||||
entries.append(entry)
|
||||
|
||||
counts = collections.Counter(e["status"] for e in entries)
|
||||
code_counts = collections.Counter(
|
||||
i["code"] for e in entries for i in e["issues"]
|
||||
)
|
||||
category_counts = collections.Counter(
|
||||
(e.get("suggested") or {}).get("category") or "?" for e in entries
|
||||
)
|
||||
family_counts = collections.Counter(
|
||||
(e.get("suggested") or {}).get("model_family") or "?" for e in entries
|
||||
)
|
||||
importable = sum(1 for e in entries if e.get("importable"))
|
||||
needs_confirmation = sum(1 for e in entries if e["needs_confirmation"])
|
||||
lint_blocked = [e for e in entries if e.get("lint_errors")]
|
||||
|
||||
detail = {
|
||||
"root": str(root),
|
||||
"total": len(entries),
|
||||
"status_counts": dict(counts),
|
||||
"issue_code_counts": dict(code_counts),
|
||||
"importable": importable,
|
||||
"needs_confirmation": needs_confirmation,
|
||||
"lint_blocked": [e["source_path"] for e in lint_blocked],
|
||||
"files": entries,
|
||||
}
|
||||
out_prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||
(out_prefix.with_suffix(".json")).write_text(
|
||||
json.dumps(detail, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# HEL-681 工作流 UI→API 转换 dry-run 报告",
|
||||
"",
|
||||
f"- 扫描目录:`{root}`(根目录 10 个 API 格式旧文件不在本管线范围)",
|
||||
f"- UI 格式文件总数:**{len(entries)}**",
|
||||
f"- 转换结果:ok **{counts.get('ok', 0)}** / warn **{counts.get('warn', 0)}** / fail **{counts.get('fail', 0)}**",
|
||||
f"- 其中「需人工确认」(控件无法可靠映射,禁止直接导入):**{needs_confirmation}**",
|
||||
f"- 通过转换+lint 后可直接导入:**{importable}**",
|
||||
f"- 转换成功但被现有 lint 拦截(导入时按阻止处理):**{len(lint_blocked)}**",
|
||||
"",
|
||||
"## 分类分布(自动提取)",
|
||||
"",
|
||||
"| 分类 | 数量 |",
|
||||
"|---|---|",
|
||||
]
|
||||
for cat, n in sorted(category_counts.items()):
|
||||
lines.append(f"| {cat} | {n} |")
|
||||
lines += ["", "## 模型家族分布(自动提取)", "", "| 家族 | 数量 |", "|---|---|"]
|
||||
for fam, n in sorted(family_counts.items(), key=lambda kv: -kv[1]):
|
||||
lines.append(f"| {fam} | {n} |")
|
||||
lines += ["", "## 问题代码统计", "", "| 代码 | 次数 | 含义 |", "|---|---|---|"]
|
||||
meaning = {
|
||||
"muted_input_dropped": "上游节点被静音,输入按未连接处理",
|
||||
"variable_upstream_dropped": "Set/Get 变量上游被旁路,取值按未连接处理",
|
||||
"get_without_set": "GetNode 没有同名 SetNode",
|
||||
"widget_unmapped": "控件值无法可靠映射到输入名(需人工确认)",
|
||||
"widget_order_mismatch": "控件数量与已知顺序不符(需人工确认)",
|
||||
"no_output_node": "图中无已知输出节点,全部活跃节点保留待确认",
|
||||
"cyclic_variable": "Set/Get 循环(FOR 循环流),该连线按未连接处理",
|
||||
"dangling_link": "连线指向不存在的节点",
|
||||
}
|
||||
for code, n in code_counts.most_common():
|
||||
lines.append(f"| `{code}` | {n} | {meaning.get(code, '')} |")
|
||||
lines += ["", "## 需人工确认文件清单", ""]
|
||||
for e in entries:
|
||||
if e["needs_confirmation"]:
|
||||
reasons = sorted({i["code"] for i in e["issues"]})[:3]
|
||||
lines.append(f"- `{e['source_path']}`({', '.join(reasons)})")
|
||||
lines += ["", "## lint 拦截文件清单", ""]
|
||||
for e in lint_blocked:
|
||||
lines.append(f"- `{e['source_path']}`({', '.join(e['lint_errors'])})")
|
||||
lines += [
|
||||
"",
|
||||
"## 逐文件结果",
|
||||
"",
|
||||
"| 文件 | 状态 | 节点数 | 分类 | 模型家族 | 用途 | 问题 |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for e in entries:
|
||||
sug = e.get("suggested") or {}
|
||||
issues = collections.Counter(i["code"] for i in e["issues"])
|
||||
issue_text = ", ".join(f"{c}×{n}" for c, n in issues.most_common(2)) or "-"
|
||||
usage = sug.get("usage") or ("待定" if sug.get("usage_ambiguous") else "-")
|
||||
lines.append(
|
||||
f"| `{e['source_path']}` | {e['status']} | {e.get('node_count', '-')} "
|
||||
f"| {sug.get('category') or '-'} | {sug.get('model_family') or '-'} "
|
||||
f"| {usage} | {issue_text} |"
|
||||
)
|
||||
(out_prefix.with_suffix(".md")).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"{len(entries)} files: {dict(counts)}, importable={importable}, nc={needs_confirmation}")
|
||||
print(f"report → {out_prefix}.md / {out_prefix}.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Suggested workflow metadata for imports (HEL-681 / S2 data layer).
|
||||
|
||||
Everything here derives from two honest sources: the repo directory the file
|
||||
came from (category) and the converted API graph itself (usage, media in/out,
|
||||
model family, note text). Per HEL-675's inventory, clean display names and
|
||||
tags stay human work — the suggestion is a starting point, never a guess that
|
||||
hides uncertainty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
|
||||
# Repo top-level directory → (category key, display label). Directory names
|
||||
# are mapping sources only — they never surface to users verbatim.
|
||||
CATEGORY_DIRS: dict[str, tuple[str, str]] = {
|
||||
"0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大": ("basic", "基础处理"),
|
||||
"1-语音生成": ("audio_tts", "语音生成"),
|
||||
"2-音乐生成": ("music", "音乐生成"),
|
||||
"3-图像生成": ("image", "图像生成"),
|
||||
"4-视频生成": ("video", "视频生成"),
|
||||
"5-数字人": ("digital_human", "数字人"),
|
||||
"6-动作迁移": ("motion_transfer", "动作迁移"),
|
||||
}
|
||||
|
||||
# Evidence patterns → model family. Shared VAE/text-encoder files must NOT
|
||||
# count (HEL-675: Anima/Z-Image graphs reference qwen_image_vae and get
|
||||
# mislabeled Qwen — `qwen.?image(?!_vae)` is deliberate).
|
||||
_FAMILY_PATTERNS: tuple[tuple[str, str], ...] = (
|
||||
(r"minimax[_\-]?(music|_audio)|MiniMaxMusic", "MiniMax-Music"),
|
||||
(r"minimax.?h3|minimax_h3|h3[-_]realism|fl2v[aa]?", "MiniMax-H3"),
|
||||
(r"ltx[-_.]?2", "LTX-2.5"),
|
||||
(r"ltx", "LTX"),
|
||||
(r"krea", "Krea2"),
|
||||
(r"qwen.?image(?!_vae)|qwen_image_2|qwen2vl|qwen3vl|Qwen3?[-.]?ASR|qwen3?[-_.]?tts", "Qwen"),
|
||||
(r"z[._-]?image", "Z-Image"),
|
||||
(r"flux", "Flux"),
|
||||
(r"\bwan", "Wan"),
|
||||
(r"ace[_-]?step", "ACE-Step"),
|
||||
(r"index[_-]?tts", "IndexTTS2"),
|
||||
(r"anima[-_.]?(turbo|klein)", "Anima"),
|
||||
(r"sdpose", "SDPose"),
|
||||
(r"gemma", "Gemma"),
|
||||
(r"instantmesh|instant_mesh", "InstantMesh"),
|
||||
(r"hidream", "HiDream"),
|
||||
)
|
||||
_FAMILY_RE = [(re.compile(pat, re.IGNORECASE), fam) for pat, fam in _FAMILY_PATTERNS]
|
||||
|
||||
# Main-model loaders dominate the vote: a UNET/checkpoint reference says far
|
||||
# more about the workflow's family than a shared VAE or text encoder does.
|
||||
_PRIMARY_LOADER_RE = re.compile(r"UNETLoader|CheckpointLoader|unet|diffusion_model", re.IGNORECASE)
|
||||
|
||||
# Directory names that carry family evidence the files' own widget values
|
||||
# lack (HEL-675: 14 krea2 files reference no "krea" string at all).
|
||||
_FAMILY_DIRS = (
|
||||
(re.compile(r"krea2", re.IGNORECASE), "Krea2"),
|
||||
(re.compile(r"Minimax-h3|uncensored", re.IGNORECASE), "MiniMax-H3"),
|
||||
(re.compile(r"Z-Image", re.IGNORECASE), "Z-Image"),
|
||||
(re.compile(r"Qwen-Image", re.IGNORECASE), "Qwen"),
|
||||
(re.compile(r"^Anima$", re.IGNORECASE), "Anima"),
|
||||
(re.compile(r"Flux", re.IGNORECASE), "Flux"),
|
||||
)
|
||||
|
||||
_NOTE_TYPES = frozenset({"Note", "MarkdownNote"})
|
||||
|
||||
|
||||
def category_from_source_path(source_path: str | None) -> tuple[str | None, str | None]:
|
||||
"""(category key, label) derived from the file's repo-relative path."""
|
||||
if not source_path:
|
||||
return None, None
|
||||
head = source_path.replace("\\", "/").split("/", 1)[0]
|
||||
return CATEGORY_DIRS.get(head, (None, None))
|
||||
|
||||
|
||||
def model_family_from(workflow: dict[str, Any], source_path: str | None = None) -> str | None:
|
||||
"""The graph's dominant model family: main-model loader references weigh
|
||||
triple, other node evidence single, and the repo sub-directory adds a
|
||||
strong vote for families the widget values don't spell out."""
|
||||
votes: dict[str, int] = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
blob = " ".join(
|
||||
[class_type]
|
||||
+ [str(v) for v in (node.get("inputs") or {}).values() if isinstance(v, str)]
|
||||
)
|
||||
weight = 3 if _PRIMARY_LOADER_RE.search(blob) else 1
|
||||
for pattern, family in _FAMILY_RE:
|
||||
if pattern.search(blob):
|
||||
votes[family] = votes.get(family, 0) + weight
|
||||
break # one vote per node: strongest family that node shows
|
||||
if source_path:
|
||||
parts = source_path.replace("\\", "/").split("/")
|
||||
for part in parts[1:2]:
|
||||
for pattern, family in _FAMILY_DIRS:
|
||||
if pattern.search(part):
|
||||
votes[family] = votes.get(family, 0) + 2
|
||||
if not votes:
|
||||
return None
|
||||
return max(votes.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
|
||||
def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | None:
|
||||
"""First non-trivial author note as description material (never final copy)."""
|
||||
for node in ui_workflow.get("nodes") or []:
|
||||
if not isinstance(node, dict) or str(node.get("type")) not in _NOTE_TYPES:
|
||||
continue
|
||||
values = node.get("widgets_values")
|
||||
text = values[0] if isinstance(values, list) and values else None
|
||||
if isinstance(values, dict):
|
||||
text = values.get("text") or next(iter(values.values()), None)
|
||||
if isinstance(text, str):
|
||||
text = text.strip()
|
||||
if len(text) >= 8:
|
||||
return text[:limit].strip()
|
||||
return None
|
||||
|
||||
|
||||
def suggested_metadata(
|
||||
converted: dict[str, Any],
|
||||
source_path: str | None = None,
|
||||
ui_workflow: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The import suggestion bundle for one converted workflow."""
|
||||
judgment = classify_usage(converted)
|
||||
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
|
||||
media_in = sorted(
|
||||
{
|
||||
entry["kind"].replace("_list", "")
|
||||
for entry in parse_dynamic_inputs(converted)
|
||||
if entry.get("kind") in media_kinds
|
||||
}
|
||||
)
|
||||
media_out = sorted(
|
||||
{
|
||||
str(entry.get("kind"))
|
||||
for entry in parse_dynamic_outputs(converted)
|
||||
if entry.get("kind") in ("image", "video", "audio")
|
||||
}
|
||||
)
|
||||
category, category_label = category_from_source_path(source_path)
|
||||
return {
|
||||
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"category": category,
|
||||
"category_label": category_label,
|
||||
"model_family": model_family_from(converted, source_path),
|
||||
"media_in": media_in,
|
||||
"media_out": media_out,
|
||||
"description": note_description(ui_workflow or {}),
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
"""UI→API converter for ComfyUI workflow JSON (HEL-681 / S2 data layer).
|
||||
|
||||
ComfyUI saves two formats: the editor ("UI") format with ``nodes``/``links``
|
||||
arrays, and the API ("prompt") format ``{node_id: {class_type, inputs}}`` the
|
||||
product executes. This module converts UI→API offline and deterministically:
|
||||
|
||||
* links are rewritten through ``Reroute`` chains and bypassed (mode 4) nodes;
|
||||
* packed subgraph instances (frontend ≥1.x ``definitions.subgraphs``) expand
|
||||
into their inner nodes under qualified ids ``<packed>:<inner>`` — the same
|
||||
scheme ComfyUI itself exports;
|
||||
* widget values map through, in priority order: the node's serialized input
|
||||
list (entries carrying a ``widget`` marker — authoritative, this corpus's
|
||||
frontend version always writes them), the ``widgets_values_named`` dict,
|
||||
then a generated registry of widget orders learned from the corpus and the
|
||||
repo's ground-truth API exports (``ui_widget_orders``).
|
||||
|
||||
Anything that cannot be mapped with certainty is reported as an issue and the
|
||||
file is flagged ``needs_confirmation`` — never silently guessed: a wrong
|
||||
widget order produces a runnable-looking workflow with swapped seed/steps,
|
||||
which is worse than an honest「需人工确认」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.ui_widget_orders import WIDGET_ORDERS
|
||||
|
||||
# Values of the frontend-only "control_after_generate" control that serializes
|
||||
# right after every seed-like INT widget (and is not a real server input).
|
||||
_CONTROL_MODES = frozenset({"fixed", "increment", "decrement", "randomize"})
|
||||
|
||||
# Widget names that are pure UI buttons/previews — never serialized in
|
||||
# widgets_values (named dicts skip them too) and never API inputs.
|
||||
_BUTTON_WIDGETS = frozenset(
|
||||
{"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
|
||||
)
|
||||
|
||||
# Leftover positional values that are provably action buttons, not data:
|
||||
# known labels, self-describing ids, or emoji-prefixed actions. Anything
|
||||
# else left over keeps the file in the needs-confirmation queue.
|
||||
_BUTTON_LEFTOVER_LABELS = frozenset({"Edit", "lanpaint_star_button"})
|
||||
_BUTTON_LEFTOVER_EMOJI = ("🖼", "🎬", "🎵", "➕", "🔁", "🗑", "📂", "🔎", "✨")
|
||||
|
||||
|
||||
def _is_button_leftover(value: Any) -> bool:
|
||||
if value is None or value == "":
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return (
|
||||
value in _BUTTON_LEFTOVER_LABELS
|
||||
or "button" in value.lower()
|
||||
or value.startswith(_BUTTON_LEFTOVER_EMOJI)
|
||||
)
|
||||
return False
|
||||
|
||||
# Virtual editor-only nodes. Links pass *through* reroute-style ones (see
|
||||
# _resolve_source); the rest are dropped from the graph entirely.
|
||||
_REROUTE_TYPES = frozenset({"Reroute", "Reroute (rgthree)"})
|
||||
# Frontend-virtual variable nodes: SetNode captures a value under a name,
|
||||
# GetNode reads it. Neither reaches the server — links through a Get resolve
|
||||
# to whatever its same-named Set captured (scope-local).
|
||||
_SET_TYPES = frozenset({"SetNode"})
|
||||
_GET_TYPES = frozenset({"GetNode"})
|
||||
_UI_ONLY_TYPES = frozenset(
|
||||
{
|
||||
"Note",
|
||||
"MarkdownNote",
|
||||
"Image Comparer (rgthree)",
|
||||
"GH_ImageVideoComparer",
|
||||
"Label (rgthree)",
|
||||
"Fast Groups Bypasser (rgthree)",
|
||||
"Fast Groups Muter (rgthree)",
|
||||
"Fast Muter (rgthree)",
|
||||
"FastGroupsBypassSwitch",
|
||||
"Mute / Bypass Repeater (rgthree)",
|
||||
}
|
||||
)
|
||||
|
||||
# Server-side OUTPUT nodes: the closure roots. Learned from the repo's
|
||||
# ground-truth API exports (their unreferenced classes) + the corpus's
|
||||
# real save/preview sinks (SaveVideo/SaveImageAdvanced/… custom nodes).
|
||||
_OUTPUT_CLASSES = frozenset(
|
||||
{
|
||||
"SaveImage",
|
||||
"PreviewImage",
|
||||
"SaveImageAdvanced",
|
||||
"SaveImageKJ",
|
||||
"SaveAnimatedWEBP",
|
||||
"SaveAnimatedPNG",
|
||||
"SaveAnimatedGIF",
|
||||
"SaveVideo",
|
||||
"SaveAudio",
|
||||
"SaveAudioMP3",
|
||||
"SaveAudioAdvanced",
|
||||
"PreviewAudio",
|
||||
"MaskPreview",
|
||||
"MaskPreview+",
|
||||
"ImageAndMaskPreview",
|
||||
"VHS_VideoCombine",
|
||||
"easy saveVideo",
|
||||
"easy multitrackProjectVideoCombine",
|
||||
"easy multiTrackInfoOutput",
|
||||
"easy multiTrackTaskOutput",
|
||||
"easy compareVideos",
|
||||
"easy compareImages",
|
||||
"easy showText",
|
||||
"easy imageWall",
|
||||
"CR Prompt Text",
|
||||
"PreviewAny",
|
||||
"easy clearGpuUsed",
|
||||
"easy clearCacheAll",
|
||||
"easy showSpentTime",
|
||||
}
|
||||
)
|
||||
|
||||
MODE_ACTIVE = 0
|
||||
MODE_MUTED = 2
|
||||
MODE_BYPASSED = 4
|
||||
|
||||
_SUBGRAPH_IN = -10 # pseudo-node id of a subgraph's boundary input
|
||||
_SUBGRAPH_OUT = -20 # pseudo-node id of a subgraph's boundary output
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertIssue:
|
||||
node_id: str
|
||||
code: str
|
||||
message: str
|
||||
level: str = "warning" # 'error' | 'warning'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertResult:
|
||||
workflow: dict[str, Any] | None
|
||||
status: str # 'ok' | 'warn' | 'fail'
|
||||
issues: list[ConvertIssue] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def needs_confirmation(self) -> bool:
|
||||
"""Widget mapping uncertain — must not be imported as runnable."""
|
||||
return any(
|
||||
i.code in ("widget_unmapped", "widget_order_mismatch") for i in self.issues
|
||||
)
|
||||
|
||||
|
||||
def _norm_link(link: Any) -> tuple[int, int, int, int, int, str] | None:
|
||||
"""(id, origin_id, origin_slot, target_id, target_slot, type) from either form."""
|
||||
if isinstance(link, dict):
|
||||
try:
|
||||
return (
|
||||
int(link["id"]),
|
||||
int(link["origin_id"]),
|
||||
int(link["origin_slot"]),
|
||||
int(link["target_id"]),
|
||||
int(link["target_slot"]),
|
||||
str(link.get("type") or "*"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if isinstance(link, (list, tuple)) and len(link) >= 5:
|
||||
try:
|
||||
return (
|
||||
int(link[0]),
|
||||
int(link[1]),
|
||||
int(link[2]),
|
||||
int(link[3]),
|
||||
int(link[4]),
|
||||
str(link[5]) if len(link) > 5 and link[5] else "*",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _is_seed_like(name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered in ("seed", "noise_seed", "value", "rand_seed", "random_seed") or (
|
||||
lowered.endswith("seed")
|
||||
)
|
||||
|
||||
|
||||
def _is_upload_entry(name: str, type_: str) -> bool:
|
||||
return type_.upper().endswith("UPLOAD") or name == "upload" or "UPLOAD" in type_.upper()
|
||||
|
||||
|
||||
class _Converter:
|
||||
"""One conversion run over a single UI-format document."""
|
||||
|
||||
def __init__(self, ui: dict[str, Any]) -> None:
|
||||
self.ui = ui
|
||||
self.issues: list[ConvertIssue] = []
|
||||
self.subgraphs: dict[str, dict[str, Any]] = {
|
||||
str(s.get("id")): s
|
||||
for s in self.ui.get("definitions", {}).get("subgraphs", []) or []
|
||||
if isinstance(s, dict)
|
||||
}
|
||||
# qualified id → UI node + owning scope
|
||||
self.nodes: dict[str, dict[str, Any]] = {}
|
||||
# scope key ('' for top level) → link table / node-id index / owner
|
||||
self.scope_links: dict[str, dict[int, tuple[int, int, int, int, str]]] = {}
|
||||
self.scope_ids: dict[str, dict[str, str]] = {}
|
||||
self.scope_owner: dict[str, str] = {}
|
||||
self._set_capture_cache: dict[str, dict[str, tuple[str, int]]] = {}
|
||||
self._set_names: dict[str, set[str]] = {}
|
||||
self._depth = 0
|
||||
self._cycle_warned = False
|
||||
self._set_building: set[str] = set()
|
||||
|
||||
# -------------------------------------------------------------- indexing
|
||||
def _index_scope(self, scope: str, links: list[Any], owner: str | None) -> None:
|
||||
table = self.scope_links.setdefault(scope, {})
|
||||
for link in links or []:
|
||||
norm = _norm_link(link)
|
||||
if norm is not None:
|
||||
table[norm[0]] = norm[1:]
|
||||
self.scope_ids.setdefault(scope, {})
|
||||
if owner is not None:
|
||||
self.scope_owner[scope] = owner
|
||||
|
||||
def _flatten(
|
||||
self, node: dict[str, Any], scope: str, prefix: str
|
||||
) -> None:
|
||||
qualified = f"{prefix}{node.get('id')}"
|
||||
self.nodes[qualified] = {"node": node, "scope": scope}
|
||||
self.scope_ids.setdefault(scope, {})[str(node.get("id"))] = qualified
|
||||
sub = self.subgraphs.get(str(node.get("type") or ""))
|
||||
if sub is None:
|
||||
return
|
||||
if not isinstance(sub.get("nodes"), list):
|
||||
self.issues.append(
|
||||
ConvertIssue(qualified, "subgraph_bad_definition", "子图缺少内部节点定义")
|
||||
)
|
||||
return
|
||||
sub_scope = f"{scope}|{node.get('type')}" if scope else f"{node.get('type')}"
|
||||
if sub_scope not in self.scope_links:
|
||||
self._index_scope(sub_scope, sub.get("links") or [], qualified)
|
||||
for inner in sub["nodes"]:
|
||||
if isinstance(inner, dict):
|
||||
self._flatten(inner, sub_scope, f"{qualified}:")
|
||||
|
||||
# --------------------------------------------------------------- lookups
|
||||
def _node(self, qualified: str) -> dict[str, Any] | None:
|
||||
entry = self.nodes.get(qualified)
|
||||
return entry["node"] if entry else None
|
||||
|
||||
def _scope(self, qualified: str) -> str:
|
||||
entry = self.nodes.get(qualified)
|
||||
return entry["scope"] if entry else ""
|
||||
|
||||
def _link(self, scope: str, link_id: Any) -> tuple[int, int, int, int, str] | None:
|
||||
if link_id is None:
|
||||
return None
|
||||
try:
|
||||
key = int(link_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return self.scope_links.get(scope, {}).get(key)
|
||||
|
||||
def _boundary_source(self, scope: str, link_id: Any) -> tuple[str, int] | None:
|
||||
"""(packed qualified id, boundary slot) when this link leaves a
|
||||
subgraph through its -10 input boundary."""
|
||||
link = self._link(scope, link_id)
|
||||
if link is None or link[0] != _SUBGRAPH_IN:
|
||||
return None
|
||||
packed_q = self.scope_owner.get(scope)
|
||||
if packed_q is None:
|
||||
return None
|
||||
return packed_q, link[1]
|
||||
|
||||
def _subgraph_of(self, qualified: str) -> dict[str, Any] | None:
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
return self.subgraphs.get(str(node.get("type") or ""))
|
||||
|
||||
@staticmethod
|
||||
def _virtual_name(node: dict[str, Any]) -> str:
|
||||
"""A Set/Get node's captured variable name (its first widget value)."""
|
||||
named = node.get("widgets_values_named")
|
||||
if isinstance(named, dict):
|
||||
for key in ("const_name", "name", "value"):
|
||||
if key in named:
|
||||
return str(named[key])
|
||||
values = node.get("widgets_values")
|
||||
if isinstance(values, list) and values and isinstance(values[0], str):
|
||||
return values[0]
|
||||
return ""
|
||||
|
||||
def _set_captures(self, scope: str) -> dict[str, tuple[str, int]]:
|
||||
"""{variable name → resolved source} for the Set nodes in one scope."""
|
||||
table = self._set_capture_cache.get(scope)
|
||||
if table is not None:
|
||||
return table
|
||||
table = {}
|
||||
# Seed before resolving: a Set feeding a same-named Get (FOR-loop
|
||||
# graphs) would otherwise recurse into this builder forever. A miss
|
||||
# while building is not a missing Set — never warn mid-construction.
|
||||
self._set_capture_cache[scope] = table
|
||||
self._set_building.add(scope)
|
||||
try:
|
||||
return self._build_set_captures(scope, table)
|
||||
finally:
|
||||
self._set_building.discard(scope)
|
||||
|
||||
def _build_set_captures(
|
||||
self, scope: str, table: dict[str, tuple[str, int]]
|
||||
) -> dict[str, tuple[str, int]]:
|
||||
names: set[str] = set()
|
||||
for qualified, entry in self.nodes.items():
|
||||
if entry["scope"] != scope:
|
||||
continue
|
||||
node = entry["node"]
|
||||
if str(node.get("type") or "") not in _SET_TYPES:
|
||||
continue
|
||||
name = self._virtual_name(node)
|
||||
if not name:
|
||||
continue
|
||||
names.add(name)
|
||||
ins = self._inputs_of(node)
|
||||
link = self._link(scope, ins[0].get("link")) if ins else None
|
||||
resolved = self._resolve_link(scope, link)
|
||||
if resolved is not None:
|
||||
table.setdefault(name, resolved)
|
||||
self._set_names[scope] = names
|
||||
self._set_capture_cache[scope] = table
|
||||
return table
|
||||
|
||||
@staticmethod
|
||||
def _inputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [i for i in node.get("inputs") or [] if isinstance(i, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _outputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [o for o in node.get("outputs") or [] if isinstance(o, dict)]
|
||||
|
||||
# ------------------------------------------------------------- resolution
|
||||
def _resolve_link(
|
||||
self, scope: str, link: tuple[int, int, int, int, str] | None
|
||||
) -> tuple[str, int] | None:
|
||||
"""Resolve one link (in scope) to an API source (qualified id, slot)."""
|
||||
if link is None:
|
||||
return None
|
||||
if self._depth > 200: # Set/Get cycles (FOR-loop graphs) never unwind
|
||||
if not self._cycle_warned:
|
||||
self._cycle_warned = True
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
"",
|
||||
"cyclic_variable",
|
||||
"变量/Set-Get 连线存在循环(常见于 FOR 循环流),该连线按未连接处理",
|
||||
)
|
||||
)
|
||||
return None
|
||||
self._depth += 1
|
||||
try:
|
||||
return self._resolve_link_inner(scope, link)
|
||||
finally:
|
||||
self._depth -= 1
|
||||
|
||||
def _resolve_link_inner(
|
||||
self, scope: str, link: tuple[int, int, int, int, str]
|
||||
) -> tuple[str, int] | None:
|
||||
origin_id, origin_slot = link[0], link[1]
|
||||
if origin_id == _SUBGRAPH_IN:
|
||||
# Boundary: hand off to the packed instance's own input slot.
|
||||
packed_q = self.scope_owner.get(scope)
|
||||
if packed_q is None:
|
||||
return None
|
||||
node = self._node(packed_q)
|
||||
if node is None:
|
||||
return None
|
||||
entries = self._inputs_of(node)
|
||||
if origin_slot >= len(entries):
|
||||
return None
|
||||
entry = entries[origin_slot]
|
||||
if entry.get("link") is None:
|
||||
return None # unlinked boundary → inner widget value applies
|
||||
outer = self._scope(packed_q)
|
||||
return self._resolve_link(outer, self._link(outer, entry.get("link")))
|
||||
qualified = self.scope_ids.get(scope, {}).get(str(origin_id))
|
||||
if qualified is None:
|
||||
return None
|
||||
return self._resolve_source(qualified, origin_slot)
|
||||
|
||||
def _resolve_source(self, qualified: str, slot: int) -> tuple[str, int] | None:
|
||||
"""What an outgoing edge from (node, output slot) really connects to."""
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
node_type = str(node.get("type") or "")
|
||||
mode = node.get("mode") or 0
|
||||
scope = self._scope(qualified)
|
||||
|
||||
if node_type in _UI_ONLY_TYPES:
|
||||
return None
|
||||
|
||||
if node_type in _REROUTE_TYPES:
|
||||
ins = self._inputs_of(node)
|
||||
if not ins:
|
||||
return None
|
||||
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
|
||||
|
||||
if node_type in _SET_TYPES:
|
||||
ins = self._inputs_of(node)
|
||||
if not ins:
|
||||
return None
|
||||
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
|
||||
|
||||
if node_type in _GET_TYPES:
|
||||
name = self._virtual_name(node)
|
||||
captured = self._set_captures(scope).get(name)
|
||||
if captured is None and scope not in self._set_building:
|
||||
if name in self._set_names.get(scope, set()):
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
qualified,
|
||||
"variable_upstream_dropped",
|
||||
f"变量「{name}」的上游被旁路或未连接,取值按未连接处理",
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
qualified,
|
||||
"get_without_set",
|
||||
f"变量节点「{name}」没有对应的 Set 节点",
|
||||
)
|
||||
)
|
||||
return captured
|
||||
|
||||
sub = self._subgraph_of(qualified)
|
||||
if sub is not None:
|
||||
outputs = [o for o in sub.get("outputs") or [] if isinstance(o, dict)]
|
||||
if slot >= len(outputs):
|
||||
return None
|
||||
sub_scope = f"{scope}|{node_type}" if scope else node_type
|
||||
for link in self.scope_links.get(sub_scope, {}).values():
|
||||
if link[2] == _SUBGRAPH_OUT and link[3] == slot:
|
||||
inner_q = self.scope_ids.get(sub_scope, {}).get(str(link[0]))
|
||||
return self._resolve_source(inner_q, link[1]) if inner_q else None
|
||||
return None # boundary output with no inner source
|
||||
|
||||
if mode == MODE_MUTED:
|
||||
return None
|
||||
if mode == MODE_BYPASSED:
|
||||
return self._bypass_through(qualified, slot)
|
||||
return (qualified, slot)
|
||||
|
||||
def _bypass_through(self, qualified: str, slot: int) -> tuple[str, int] | None:
|
||||
"""Pass-through of a bypassed node: same-name input first, then same-type."""
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
outputs = self._outputs_of(node)
|
||||
out = outputs[slot] if slot < len(outputs) else {}
|
||||
out_name = str(out.get("name") or "")
|
||||
out_type = str(out.get("type") or "*")
|
||||
linkable = [
|
||||
e for e in self._inputs_of(node) if not e.get("widget") and e.get("link") is not None
|
||||
]
|
||||
pick = None
|
||||
if out_name:
|
||||
pick = next((e for e in linkable if str(e.get("name") or "") == out_name), None)
|
||||
if pick is None:
|
||||
pick = next(
|
||||
(
|
||||
e
|
||||
for e in linkable
|
||||
if out_type == "*" or str(e.get("type") or "*") in (out_type, "*")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pick is None:
|
||||
return None
|
||||
scope = self._scope(qualified)
|
||||
return self._resolve_link(scope, self._link(scope, pick.get("link")))
|
||||
|
||||
# --------------------------------------------------------- widget mapping
|
||||
def _widget_entries(self, node: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
"""[(name, type)] of entries carrying the ``widget`` marker, in order."""
|
||||
out: list[tuple[str, str]] = []
|
||||
for entry in self._inputs_of(node):
|
||||
marker = entry.get("widget")
|
||||
if isinstance(marker, dict):
|
||||
name = str(marker.get("name") or entry.get("name") or "")
|
||||
if name:
|
||||
out.append((name, str(entry.get("type") or "")))
|
||||
return out
|
||||
|
||||
def _widgets_from_entries(
|
||||
self,
|
||||
node_id: str,
|
||||
entries: list[tuple[str, str]],
|
||||
values: Any,
|
||||
) -> tuple[dict[str, Any], list[ConvertIssue]]:
|
||||
"""Map widget values using the node's serialized input list."""
|
||||
inputs: dict[str, Any] = {}
|
||||
issues: list[ConvertIssue] = []
|
||||
if isinstance(values, dict):
|
||||
for name, type_ in entries:
|
||||
if name in _BUTTON_WIDGETS or self._is_upload(name, type_):
|
||||
continue
|
||||
if name in values:
|
||||
inputs[name] = values[name]
|
||||
else:
|
||||
issues.append(
|
||||
ConvertIssue(node_id, "widget_unmapped", f"控件「{name}」没有序列化值")
|
||||
)
|
||||
return inputs, issues
|
||||
if not isinstance(values, list):
|
||||
return inputs, issues
|
||||
if not values:
|
||||
return inputs, issues # nothing serialized — server defaults apply
|
||||
i = 0
|
||||
for name, type_ in entries:
|
||||
if name in _BUTTON_WIDGETS:
|
||||
continue # button widgets never serialize
|
||||
if i >= len(values):
|
||||
issues.append(
|
||||
ConvertIssue(node_id, "widget_order_mismatch", f"控件值数量不足({name})")
|
||||
)
|
||||
break
|
||||
value = values[i]
|
||||
i += 1
|
||||
if self._is_upload(name, type_):
|
||||
continue # consumes a slot, never an API input
|
||||
inputs[name] = value
|
||||
if (
|
||||
type_.upper() == "INT"
|
||||
and _is_seed_like(name)
|
||||
and i < len(values)
|
||||
and values[i] in _CONTROL_MODES
|
||||
):
|
||||
i += 1 # control_after_generate rides right after its seed
|
||||
leftover_ok = all(_is_button_leftover(v) for v in values[i:])
|
||||
if not issues and i != len(values) and not leftover_ok:
|
||||
issues.append(
|
||||
ConvertIssue(
|
||||
node_id,
|
||||
"widget_order_mismatch",
|
||||
f"有 {len(values)} 个控件值,输入清单只覆盖 {i} 个",
|
||||
)
|
||||
)
|
||||
return inputs, issues
|
||||
|
||||
@staticmethod
|
||||
def _is_upload(name: str, type_: str) -> bool:
|
||||
return _is_upload_entry(name, type_)
|
||||
|
||||
def _widgets_from_registry(
|
||||
self, node: dict[str, Any], values: list[Any]
|
||||
) -> tuple[dict[str, Any], list[ConvertIssue]]:
|
||||
node_type = str(node.get("type") or "")
|
||||
if not values:
|
||||
return {}, [] # nothing serialized — server defaults apply
|
||||
for order in WIDGET_ORDERS.get(node_type) or []:
|
||||
inputs: dict[str, Any] = {}
|
||||
i = 0
|
||||
for name in order:
|
||||
if name in _BUTTON_WIDGETS:
|
||||
continue
|
||||
if i >= len(values):
|
||||
break
|
||||
value = values[i]
|
||||
i += 1
|
||||
if _is_upload_entry(name, ""):
|
||||
continue
|
||||
inputs[name] = value
|
||||
if _is_seed_like(name) and i < len(values) and values[i] in _CONTROL_MODES:
|
||||
i += 1
|
||||
if i == len(values) or all(_is_button_leftover(v) for v in values[i:]):
|
||||
return inputs, [] # exact fit, or trailing button leftovers
|
||||
return {}, [
|
||||
ConvertIssue(
|
||||
node.get("id", ""),
|
||||
"widget_unmapped" if not WIDGET_ORDERS.get(node_type) else "widget_order_mismatch",
|
||||
f"「{node_type}」的 {len(values)} 个控件值无法可靠对应到输入名",
|
||||
)
|
||||
]
|
||||
|
||||
def _packed_widget_values(self, qualified: str) -> Any:
|
||||
"""Exposed widget values of a packed instance as {boundary name: value}."""
|
||||
node = self._node(qualified)
|
||||
sub = self._subgraph_of(qualified)
|
||||
if node is None or sub is None:
|
||||
return None
|
||||
boundary = [i for i in sub.get("inputs") or [] if isinstance(i, dict)]
|
||||
named = node.get("widgets_values_named")
|
||||
if isinstance(named, dict):
|
||||
return named
|
||||
values = node.get("widgets_values")
|
||||
if not isinstance(values, list):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
i = 0
|
||||
for entry in boundary:
|
||||
name = str(entry.get("name") or "")
|
||||
type_ = str(entry.get("type") or "")
|
||||
if name and i < len(values):
|
||||
out[name] = values[i]
|
||||
i += 1
|
||||
if (
|
||||
type_.upper() == "INT"
|
||||
and _is_seed_like(name)
|
||||
and i < len(values)
|
||||
and values[i] in _CONTROL_MODES
|
||||
):
|
||||
i += 1
|
||||
return out
|
||||
|
||||
# -------------------------------------------------------------- serialize
|
||||
def _serialize_node(self, qualified: str) -> dict[str, Any] | None:
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
node_type = str(node.get("type") or "")
|
||||
if (
|
||||
not node_type
|
||||
or node_type in _UI_ONLY_TYPES
|
||||
or node_type in _REROUTE_TYPES
|
||||
or node_type in _SET_TYPES
|
||||
or node_type in _GET_TYPES
|
||||
):
|
||||
return None
|
||||
scope = self._scope(qualified)
|
||||
inputs: dict[str, Any] = {}
|
||||
|
||||
widget_values = node.get("widgets_values_named")
|
||||
if not isinstance(widget_values, dict):
|
||||
widget_values = node.get("widgets_values")
|
||||
if self._subgraph_of(qualified) is not None:
|
||||
widget_values = self._packed_widget_values(qualified)
|
||||
|
||||
widget_inputs: dict[str, Any] = {}
|
||||
entries = self._widget_entries(node)
|
||||
if widget_values is not None:
|
||||
if entries:
|
||||
widget_inputs, w_issues = self._widgets_from_entries(
|
||||
qualified, entries, widget_values
|
||||
)
|
||||
self.issues.extend(w_issues)
|
||||
elif isinstance(widget_values, dict):
|
||||
widget_inputs = {
|
||||
k: v for k, v in widget_values.items() if k not in _BUTTON_WIDGETS
|
||||
}
|
||||
elif isinstance(widget_values, list):
|
||||
widget_inputs, w_issues = self._widgets_from_registry(node, widget_values)
|
||||
self.issues.extend(w_issues)
|
||||
|
||||
for entry in self._inputs_of(node):
|
||||
name = str(entry.get("name") or "")
|
||||
if not name or name in inputs:
|
||||
continue
|
||||
if entry.get("link") is None:
|
||||
continue # unconnected optional input; widget values applied below
|
||||
# Widget entries fed from the subgraph boundary (-10): when the
|
||||
# packed instance left that boundary slot unlinked, the value is
|
||||
# the packed node's own exposed widget value, not the inner one.
|
||||
boundary = self._boundary_source(scope, entry.get("link"))
|
||||
if boundary is not None:
|
||||
packed_q, boundary_slot = boundary
|
||||
packed_node = self._node(packed_q)
|
||||
packed_entries = self._inputs_of(packed_node) if packed_node else []
|
||||
if boundary_slot < len(packed_entries) and packed_entries[
|
||||
boundary_slot
|
||||
].get("link") is None:
|
||||
values = self._packed_widget_values(packed_q)
|
||||
boundary_name = str(packed_entries[boundary_slot].get("name") or "")
|
||||
if isinstance(values, dict) and boundary_name in values:
|
||||
inputs[name] = values[boundary_name]
|
||||
continue
|
||||
# linked boundary falls through to normal resolution
|
||||
link = self._link(scope, entry.get("link"))
|
||||
if link is None:
|
||||
self.issues.append(
|
||||
ConvertIssue(qualified, "dangling_link", f"输入「{name}」的连线不存在")
|
||||
)
|
||||
continue
|
||||
resolved = self._resolve_link(scope, link)
|
||||
if resolved is not None:
|
||||
inputs[name] = [resolved[0], resolved[1]]
|
||||
elif entry.get("widget"):
|
||||
pass # muted upstream: the widget value below takes over
|
||||
else:
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
qualified,
|
||||
"muted_input_dropped",
|
||||
f"输入「{name}」上游被静音,已按未连接处理",
|
||||
)
|
||||
)
|
||||
|
||||
for name, value in widget_inputs.items():
|
||||
inputs.setdefault(name, value)
|
||||
|
||||
api_node: dict[str, Any] = {"class_type": node_type, "inputs": inputs}
|
||||
title = str(node.get("title") or "").strip()
|
||||
if title:
|
||||
api_node["_meta"] = {"title": title}
|
||||
return api_node
|
||||
|
||||
# ------------------------------------------------------------------- main
|
||||
def run(self) -> ConvertResult:
|
||||
nodes = self.ui.get("nodes")
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
return ConvertResult(
|
||||
None,
|
||||
"fail",
|
||||
[ConvertIssue("", "no_nodes", "文件不含 nodes 数组,不是 UI 格式工作流", "error")],
|
||||
)
|
||||
try:
|
||||
self._index_scope("", self.ui.get("links") or [], None)
|
||||
for node in nodes:
|
||||
if isinstance(node, dict):
|
||||
self._flatten(node, "", "")
|
||||
except RecursionError:
|
||||
return ConvertResult(
|
||||
None, "fail", [ConvertIssue("", "subgraph_recursion", "子图嵌套过深", "error")]
|
||||
)
|
||||
|
||||
# Closure from known output nodes (ComfyUI's own graphToPrompt roots).
|
||||
roots = [
|
||||
q
|
||||
for q, entry in self.nodes.items()
|
||||
if str(entry["node"].get("type") or "") in _OUTPUT_CLASSES
|
||||
and (entry["node"].get("mode") or 0) == MODE_ACTIVE
|
||||
]
|
||||
keep: dict[str, None] = {}
|
||||
stack = list(roots)
|
||||
while stack:
|
||||
qualified = stack.pop()
|
||||
if qualified in keep:
|
||||
continue
|
||||
entry = self.nodes.get(qualified)
|
||||
if entry is None:
|
||||
continue
|
||||
keep[qualified] = None
|
||||
node = entry["node"]
|
||||
scope = entry["scope"]
|
||||
for e in self._inputs_of(node):
|
||||
if e.get("link") is None:
|
||||
continue
|
||||
link = self._link(scope, e.get("link"))
|
||||
resolved = self._resolve_link(scope, link)
|
||||
if resolved is not None:
|
||||
stack.append(resolved[0])
|
||||
|
||||
if not keep:
|
||||
# No known output node: keep every active computed node and warn —
|
||||
# the import lint will report the missing output separately.
|
||||
for qualified, entry in self.nodes.items():
|
||||
node = entry["node"]
|
||||
node_type = str(node.get("type") or "")
|
||||
if (
|
||||
node_type in _UI_ONLY_TYPES
|
||||
or node_type in _REROUTE_TYPES
|
||||
or (node.get("mode") or 0) != MODE_ACTIVE
|
||||
or self._subgraph_of(qualified) is not None
|
||||
):
|
||||
continue
|
||||
keep[qualified] = None
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
"",
|
||||
"no_output_node",
|
||||
"图中没有已知输出节点(SaveImage / 视频合成等),"
|
||||
"全部活跃节点已保留,需人工确认",
|
||||
)
|
||||
)
|
||||
|
||||
workflow: dict[str, Any] = {}
|
||||
for qualified in keep:
|
||||
api_node = self._serialize_node(qualified)
|
||||
if api_node is not None and api_node["class_type"]:
|
||||
workflow[qualified] = api_node
|
||||
|
||||
if not workflow:
|
||||
return ConvertResult(
|
||||
None,
|
||||
"fail",
|
||||
self.issues or [ConvertIssue("", "empty", "转换后没有任何节点", "error")],
|
||||
)
|
||||
status = (
|
||||
"fail"
|
||||
if any(i.level == "error" for i in self.issues)
|
||||
else ("ok" if not self.issues else "warn")
|
||||
)
|
||||
return ConvertResult(workflow, status, self.issues)
|
||||
|
||||
|
||||
def convert_ui_workflow(ui: dict[str, Any]) -> ConvertResult:
|
||||
"""Convert one ComfyUI UI-format workflow dict into API format."""
|
||||
if not isinstance(ui, dict) or not isinstance(ui.get("nodes"), list):
|
||||
return ConvertResult(
|
||||
None,
|
||||
"fail",
|
||||
[ConvertIssue("", "not_ui_format", "不是 UI 格式(缺少 nodes 数组)", "error")],
|
||||
)
|
||||
return _Converter(ui).run()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -506,6 +506,27 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
|
||||
if "output_map_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
|
||||
# HEL-681 (S2): library metadata for 150+ scale workflow browsing. Purely
|
||||
# additive columns — old rows stay NULL/0 and every legacy path ignores
|
||||
# them; the lightweight list view (?view=meta) is their only reader.
|
||||
if "category" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN category TEXT")
|
||||
if "model_family" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN model_family TEXT")
|
||||
if "media_io" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN media_io TEXT")
|
||||
if "tags" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN tags TEXT")
|
||||
if "favorite" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0")
|
||||
if "last_used_at" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN last_used_at TIMESTAMP")
|
||||
if "source" not in cols:
|
||||
# where the row came from: 'ui_convert' (HEL-681 batch import) vs
|
||||
# NULL = manual/API import as before.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN source TEXT")
|
||||
if "source_path" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN source_path TEXT")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
|
||||
@@ -243,6 +243,55 @@ class WorkflowUpdate(BaseModel):
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
# HEL-681 (S2): library metadata fields.
|
||||
favorite: bool | None = None
|
||||
tags: list[str] | None = None
|
||||
category: str | None = None
|
||||
model_family: str | None = None
|
||||
|
||||
|
||||
class WorkflowUIFileItem(BaseModel):
|
||||
"""One UI-format file for the HEL-681 convert/import pipeline."""
|
||||
|
||||
name: str | None = None
|
||||
# repo-relative path (category/model-family evidence); display name
|
||||
# defaults to the file stem.
|
||||
source_path: str | None = None
|
||||
workflow_json: dict[str, Any]
|
||||
# optional human confirmations from the import wizard (S3): a settled
|
||||
# usage and a corrected schema let warn files import enabled.
|
||||
usage: WorkflowUsageKind | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
description: str | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class WorkflowConvertRequest(BaseModel):
|
||||
"""Dry-run: per-file ok/warn/fail + suggested metadata, nothing stored."""
|
||||
|
||||
files: list[WorkflowUIFileItem]
|
||||
# Include the converted API JSON per file (default on for small batches;
|
||||
# the 146-file report leaves it off to keep the payload sane).
|
||||
include_workflows: bool = False
|
||||
# L2 preflight against a real ComfyUI (opt-in; needs a connection).
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowImportBatchRequest(BaseModel):
|
||||
"""Commit: convert UI files and insert, skipping conflicts. Idempotent."""
|
||||
|
||||
files: list[WorkflowUIFileItem]
|
||||
|
||||
|
||||
class WorkflowImportScanRequest(BaseModel):
|
||||
"""Scan a server-side directory of UI JSONs (repo example library)."""
|
||||
|
||||
root: str = "example_ComfyUI_workflows"
|
||||
# Set to preview what would import (same shape as /convert) — the safe
|
||||
# default for a first run against the 146-file library.
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
|
||||
@@ -49,6 +49,12 @@ class QueueManager:
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
# HEL-681: every enqueued job counts as a use (最近使用).
|
||||
if workflow_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
@@ -97,7 +97,7 @@ class MemoryCreate(BaseModel):
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str = ""
|
||||
mentions: list[MessageMention] = Field(default_factory=list, max_length=1)
|
||||
mentions: list[MessageMention] = Field(default_factory=list, max_length=5)
|
||||
attachments: list[MessageAttachment] = Field(default_factory=list, max_length=8)
|
||||
answer_to: int | None = None # question/asked seq this message answers (card click)
|
||||
|
||||
@@ -113,8 +113,10 @@ class MessageCreate(BaseModel):
|
||||
@field_validator("mentions")
|
||||
@classmethod
|
||||
def _one_workflow(cls, v: list[MessageMention]) -> list[MessageMention]:
|
||||
if len(v) > 1:
|
||||
if sum(m.type == "workflow" for m in v) > 1:
|
||||
raise ValueError("Only one @workflow mention per message")
|
||||
if sum(m.type == "skill" for m in v) > 4:
|
||||
raise ValueError("At most four skill mentions per message")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -31,6 +32,23 @@ PLAYGROUND_TITLE = "Playground Scratch"
|
||||
# page can label origins ("独立生图" vs canvas Playground) without guessing.
|
||||
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
|
||||
|
||||
# HEL-684: a desk visit opened from a five-stage workbench row may carry
|
||||
# that row as optional context. Only real, server-verified project/stage/
|
||||
# source info is accepted — the source row must still exist in the stage
|
||||
# content, and the desk kind must match what that stage renders.
|
||||
WorkbenchStage = Literal["assets", "shot"]
|
||||
WORKBENCH_KIND_BY_STAGE: dict[str, str] = {"assets": "image", "shot": "video"}
|
||||
WORKBENCH_ROW_KEY_BY_STAGE: dict[str, str] = {"assets": "assets", "shot": "shots"}
|
||||
|
||||
|
||||
class PlaygroundWorkbenchContext(BaseModel):
|
||||
project_id: int
|
||||
stage: WorkbenchStage
|
||||
source_kind: Literal["asset", "shot"]
|
||||
source_id: str = Field(min_length=1)
|
||||
# Display-only echo of the DeskContext label (e.g. 镜头 S1).
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class PlaygroundGenerate(BaseModel):
|
||||
workflow_id: int
|
||||
@@ -39,6 +57,13 @@ class PlaygroundGenerate(BaseModel):
|
||||
# then the active one (resolved in queue_manager.enqueue).
|
||||
endpoint_id: str | None = None
|
||||
source: PlaygroundSource = "playground"
|
||||
# Optional real workbench source — validated, then stamped on the job
|
||||
# payload so a finished take can be flowed back (attach-workbench).
|
||||
context: PlaygroundWorkbenchContext | None = None
|
||||
|
||||
|
||||
class PlaygroundAttachWorkbench(BaseModel):
|
||||
job_id: int
|
||||
|
||||
|
||||
class PlaygroundAttach(BaseModel):
|
||||
@@ -83,6 +108,72 @@ def ensure_playground_project(conn) -> int:
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# HEL-684: workbench source context — validate against real rows, never
|
||||
# guess. Both generate-time (record on the job payload) and attach-time
|
||||
# (flow a finished take back) go through the same checks.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _workbench_target_row(conn, ctx: PlaygroundWorkbenchContext) -> dict[str, Any] | None:
|
||||
"""The real asset/shot row this context points at, or None."""
|
||||
stage = conn.execute(
|
||||
"SELECT content_json FROM project_stages WHERE project_id = ? AND stage_key = ?",
|
||||
(ctx.project_id, ctx.stage),
|
||||
).fetchone()
|
||||
if not stage or not stage["content_json"]:
|
||||
return None
|
||||
try:
|
||||
content = json.loads(stage["content_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
rows = content.get(WORKBENCH_ROW_KEY_BY_STAGE[ctx.stage])
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and str(row.get("id") or "") == ctx.source_id:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _validate_workbench_context(conn, ctx: PlaygroundWorkbenchContext, kind: str) -> dict[str, Any]:
|
||||
"""Reject anything that is not a real, current project/stage/source.
|
||||
|
||||
Returns the payload stamp for the job. Raises on any mismatch so no
|
||||
guessed or stale source is ever recorded.
|
||||
"""
|
||||
expected_kind = WORKBENCH_KIND_BY_STAGE[ctx.stage]
|
||||
expected_source_kind = "asset" if ctx.stage == "assets" else "shot"
|
||||
if ctx.source_kind != expected_source_kind or kind != expected_kind:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"来源上下文与生成类型不符:「{ctx.stage}」阶段需要{expected_kind}生成。"
|
||||
"可关闭来源提示后独立生成。"
|
||||
),
|
||||
)
|
||||
project = conn.execute(
|
||||
"SELECT id, status FROM projects WHERE id = ?", (ctx.project_id,)
|
||||
).fetchone()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="来源项目不存在,无法记录回流上下文")
|
||||
if project["status"] == PLAYGROUND_STATUS:
|
||||
raise HTTPException(status_code=400, detail="不能以 playground 隐藏项目作为回流来源")
|
||||
if _workbench_target_row(conn, ctx) is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"来源"
|
||||
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
|
||||
"(阶段内容可能已重新生成)。可关闭来源提示后独立生成。"
|
||||
),
|
||||
)
|
||||
stamp: dict[str, Any] = ctx.model_dump()
|
||||
if not (stamp.get("label") or "").strip():
|
||||
stamp["label"] = None
|
||||
return stamp
|
||||
|
||||
|
||||
def _assert_path_in_assets(path: str) -> Path:
|
||||
assets_root = config.settings.assets_dir.resolve()
|
||||
target = Path(path).resolve()
|
||||
@@ -195,6 +286,17 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
# HEL-684: which takes have already been flowed back to a workbench
|
||||
# row (per source project) — the desk shows an honest 已回流 state
|
||||
# that survives reloads instead of guessing from client memory.
|
||||
links_by_project: dict[int, dict[int, dict[str, Any]]] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT project_id FROM jobs"
|
||||
" WHERE payload_json LIKE '%\"from_playground_job\"%'"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
proj_id = int(r["project_id"])
|
||||
links_by_project[proj_id] = _workbench_link_rows(conn, proj_id)
|
||||
finally:
|
||||
conn.close()
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
@@ -204,7 +306,16 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
for j in queue_manager.list_jobs(pid, None, limit)
|
||||
if str(j.get("origin") or "user") != "workflow_test"
|
||||
]
|
||||
return [_job_public(j) for j in jobs]
|
||||
out = []
|
||||
for j in jobs:
|
||||
pub = _job_public(j)
|
||||
stamp = (pub.get("payload") or {}).get("workbench")
|
||||
if isinstance(stamp, dict) and isinstance(stamp.get("project_id"), int):
|
||||
link = links_by_project.get(int(stamp["project_id"]), {}).get(int(pub["id"]))
|
||||
if link and link.get("target_id") == stamp.get("source_id"):
|
||||
pub["workbench_attach"] = link
|
||||
out.append(pub)
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
@@ -239,6 +350,21 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_clear_entity_refs(conn, [p for p in paths if isinstance(p, str)])
|
||||
# HEL-684: the output files are about to vanish — drop the workbench
|
||||
# link rows created from this take so no project row points at
|
||||
# missing media (mirrors the entity-ref clearing above).
|
||||
removed_links: list[int] = []
|
||||
link_rows = conn.execute(
|
||||
"SELECT id, payload_json FROM jobs WHERE payload_json LIKE '%\"from_playground_job\"%'"
|
||||
).fetchall()
|
||||
for r in link_rows:
|
||||
try:
|
||||
p = json.loads(r["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if p.get("from_playground_job") == job_id:
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (int(r["id"]),))
|
||||
removed_links.append(int(r["id"]))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -261,6 +387,7 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
|
||||
"job_id": job_id,
|
||||
"deleted_files": deleted_files,
|
||||
"missing_files": missing_files,
|
||||
"removed_workbench_links": removed_links,
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +492,14 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
# HEL-684: an optional workbench source is only recorded when it is
|
||||
# real and current — the job itself still runs on the playground
|
||||
# scratch project so the desk feed, SSE and tasks stay unchanged.
|
||||
workbench_stamp = (
|
||||
_validate_workbench_context(conn, payload.context, kind)
|
||||
if payload.context is not None
|
||||
else None
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -385,6 +520,7 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
"input_values": input_values,
|
||||
"source": payload.source,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
**({"workbench": workbench_stamp} if workbench_stamp else {}),
|
||||
},
|
||||
)
|
||||
await event_bus.publish(
|
||||
@@ -495,3 +631,167 @@ async def attach_to_project(payload: PlaygroundAttach) -> dict[str, Any]:
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _workbench_link_rows(conn, project_id: int) -> dict[int, dict[str, Any]]:
|
||||
"""playground job id → its flow-back link row on this project.
|
||||
|
||||
Link rows are done jobs with payload.source='workbench' and a
|
||||
from_playground_job stamp — the exact shape _workbench_job_outputs
|
||||
already enriches the workbench rows from, so a flowed-back take shows
|
||||
up on its source row through the existing display path.
|
||||
"""
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM jobs WHERE project_id = ? ORDER BY id ASC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
try:
|
||||
p = json.loads(r["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if p.get("source") != "workbench":
|
||||
continue
|
||||
src = p.get("from_playground_job")
|
||||
if isinstance(src, int):
|
||||
out[src] = {
|
||||
"job_id": int(r["id"]),
|
||||
"stage": p.get("stage_key"),
|
||||
"target_id": p.get("target_id"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/attach-workbench")
|
||||
async def attach_workbench(payload: PlaygroundAttachWorkbench) -> dict[str, Any]:
|
||||
"""Flow a finished desk take back to its workbench source row (HEL-684).
|
||||
|
||||
The source is whatever real context the generate request carried —
|
||||
nothing is guessed here. Re-attaching is idempotent: the existing link
|
||||
row is returned, no duplicate is created, nothing is overwritten.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
job = queue_manager.get_job(payload.job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if int(job["project_id"]) != int(pid):
|
||||
raise HTTPException(status_code=403, detail="Not a playground job")
|
||||
|
||||
try:
|
||||
job_payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
job_payload = {}
|
||||
stamp = job_payload.get("workbench")
|
||||
if not isinstance(stamp, dict) or not stamp.get("source_id"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="该记录没有项目来源上下文,无法回流(独立生成不回流)"
|
||||
)
|
||||
if job["status"] != "done":
|
||||
raise HTTPException(status_code=400, detail="生成完成后才能回流")
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
if not isinstance(outputs, list) or not outputs:
|
||||
raise HTTPException(status_code=400, detail="该记录没有产物文件,无法回流")
|
||||
|
||||
try:
|
||||
ctx = PlaygroundWorkbenchContext(
|
||||
project_id=int(stamp["project_id"]),
|
||||
stage=stamp.get("stage"),
|
||||
source_kind=stamp.get("source_kind"),
|
||||
source_id=str(stamp["source_id"]),
|
||||
label=stamp.get("label"),
|
||||
)
|
||||
except Exception as exc: # pydantic ValidationError / missing keys
|
||||
raise HTTPException(
|
||||
status_code=400, detail="该记录的项目来源上下文无效,无法回流"
|
||||
) from exc
|
||||
# Re-validate against the live stage content — the row may have been
|
||||
# regenerated since the take was created; refuse rather than mis-write.
|
||||
if _workbench_target_row(conn, ctx) is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"来源"
|
||||
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
|
||||
"(阶段内容可能已重新生成),未回流。"
|
||||
),
|
||||
)
|
||||
|
||||
links = _workbench_link_rows(conn, ctx.project_id)
|
||||
existing = links.get(int(job["id"]))
|
||||
if existing and existing.get("target_id") == ctx.source_id:
|
||||
return {
|
||||
"ok": True,
|
||||
"already_attached": True,
|
||||
"job_id": int(job["id"]),
|
||||
"attached_job_id": existing["job_id"],
|
||||
"project_id": ctx.project_id,
|
||||
"stage": ctx.stage,
|
||||
"target_id": ctx.source_id,
|
||||
}
|
||||
|
||||
link_payload = {
|
||||
"source": "workbench",
|
||||
"stage_key": ctx.stage,
|
||||
"target_id": ctx.source_id,
|
||||
"from_playground_job": int(job["id"]),
|
||||
"input_values": job_payload.get("input_values") or {},
|
||||
}
|
||||
if ctx.label:
|
||||
link_payload["label"] = ctx.label
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,
|
||||
output_paths_json, created_at, started_at, completed_at, origin)
|
||||
VALUES (?, ?, ?, 'done', ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'user')
|
||||
""",
|
||||
(
|
||||
ctx.project_id,
|
||||
job["kind"],
|
||||
job["workflow_id"],
|
||||
json.dumps(link_payload, ensure_ascii=False),
|
||||
json.dumps([str(p) for p in outputs if isinstance(p, str)]),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(ctx.project_id,),
|
||||
)
|
||||
conn.commit()
|
||||
link_id = int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
await event_bus.publish(
|
||||
"job.completed",
|
||||
{
|
||||
"job_id": link_id,
|
||||
"kind": job["kind"],
|
||||
"outputs": outputs,
|
||||
"project_id": ctx.project_id,
|
||||
"source": "workbench",
|
||||
"message": "生成台结果已回流到项目",
|
||||
},
|
||||
)
|
||||
await event_bus.publish(
|
||||
"asset.ready",
|
||||
{
|
||||
"job_id": link_id,
|
||||
"path": outputs[0] if isinstance(outputs[0], str) else str(outputs[0]),
|
||||
"project_id": ctx.project_id,
|
||||
"target": "workbench",
|
||||
"source": "playground_attach",
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"already_attached": False,
|
||||
"job_id": int(job["id"]),
|
||||
"attached_job_id": link_id,
|
||||
"project_id": ctx.project_id,
|
||||
"stage": ctx.stage,
|
||||
"target_id": ctx.source_id,
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ class StagePatch(BaseModel):
|
||||
chosen_style_id: str | None = None
|
||||
|
||||
|
||||
class StageNotePatch(BaseModel):
|
||||
text: str = Field(max_length=200_000)
|
||||
|
||||
|
||||
class StageRunRequest(BaseModel):
|
||||
instruction: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
@@ -292,10 +296,12 @@ def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
content = json.loads(row["content_json"]) if row["content_json"] else None
|
||||
return {
|
||||
"stage_key": row["stage_key"],
|
||||
"status": row["status"],
|
||||
"content": json.loads(row["content_json"]) if row["content_json"] else None,
|
||||
"content": content,
|
||||
"assistant_note": (content or {}).get("assistant_note", ""),
|
||||
"confirmed_content": (
|
||||
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
|
||||
),
|
||||
@@ -571,6 +577,10 @@ async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction
|
||||
|
||||
conn = get_db(db)
|
||||
try:
|
||||
latest_stage = _load_stage(conn, project_id, stage_key)
|
||||
previous = json.loads(latest_stage["content_json"]) if latest_stage["content_json"] else {}
|
||||
if previous.get("assistant_note"):
|
||||
content["assistant_note"] = previous["assistant_note"]
|
||||
conn.execute(
|
||||
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
@@ -681,6 +691,9 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
content = NORMALIZERS[stage_key](payload.content)
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if old_content.get("assistant_note"):
|
||||
content["assistant_note"] = old_content["assistant_note"]
|
||||
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
|
||||
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
|
||||
# Editing a confirmed stage produces a new draft awaiting re-confirm;
|
||||
@@ -715,6 +728,32 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/stages/{stage_key}/note")
|
||||
async def patch_stage_note(project_id: int, stage_key: str, payload: StageNotePatch) -> dict[str, Any]:
|
||||
"""Keep free-text advice separate from validated stage artifacts and snapshots."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_get_project(conn, project_id)
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if stage["status"] == "running":
|
||||
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再保存建议。")
|
||||
content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if payload.text.strip():
|
||||
content["assistant_note"] = payload.text.strip()
|
||||
else:
|
||||
content.pop("assistant_note", None)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id=? AND stage_key=?",
|
||||
(json.dumps(content, ensure_ascii=False) if content else None, project_id, stage_key),
|
||||
)
|
||||
conn.execute("UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (project_id,))
|
||||
conn.commit()
|
||||
return {"ok": True, "stage": _stage_public(_load_stage(conn, project_id, stage_key))}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/stages/{stage_key}/run")
|
||||
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
|
||||
instruction = (payload.instruction if payload else None) or None
|
||||
@@ -777,7 +816,10 @@ async def confirm_stage(
|
||||
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
|
||||
if not stage["content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
|
||||
content = json.loads(stage["content_json"])
|
||||
try:
|
||||
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
chosen = payload.chosen_style_id if payload else None
|
||||
if stage_key == "style":
|
||||
directions = content.get("directions") or []
|
||||
@@ -815,11 +857,15 @@ async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if not stage["confirmed_content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
|
||||
content = json.loads(stage["confirmed_content_json"])
|
||||
current = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if current.get("assistant_note"):
|
||||
content["assistant_note"] = current["assistant_note"]
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=confirmed_content_json, "
|
||||
"UPDATE project_stages SET content_json=?, "
|
||||
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(project_id, stage_key),
|
||||
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, stage_key)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.metadata import suggested_metadata
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
@@ -23,6 +24,7 @@ from calliope.comfyui.parser import (
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -30,8 +32,12 @@ from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowConvertRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowImportBatchRequest,
|
||||
WorkflowImportScanRequest,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUIFileItem,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
@@ -44,6 +50,14 @@ from calliope.queue.workflow_test import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# HEL-681: metadata columns the lightweight list view exposes (everything
|
||||
# except the heavy workflow_json / input_schema / output_map payloads).
|
||||
_META_COLUMNS = (
|
||||
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
|
||||
" favorite, last_used_at, description, source, source_path, prompt_profile,"
|
||||
" created_at, endpoint_id, usage_source, output_map_source"
|
||||
)
|
||||
|
||||
|
||||
def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data = row_to_dict(row)
|
||||
@@ -61,9 +75,21 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
# HEL-681: structured library metadata (raw JSON columns → values).
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
return data
|
||||
|
||||
|
||||
def _load_json_col(raw: Any, default: Any) -> Any:
|
||||
try:
|
||||
value = json.loads(raw) if raw else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
value = None
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
@@ -216,9 +242,31 @@ def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workflows() -> list[dict[str, Any]]:
|
||||
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if view == "meta":
|
||||
# HEL-681: metadata-only list for 150+ scale browsing — no
|
||||
# workflow_json, no per-row re-parse. `needs_mapping` here is the
|
||||
# cheap pending check (usage/output_map not yet settled); rows
|
||||
# that never generated keep showing as pending exactly like the
|
||||
# full view reports them.
|
||||
rows = conn.execute(
|
||||
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
|
||||
" last_used_at DESC, id DESC"
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
data = row_to_dict(row)
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["needs_mapping"] = data.get("usage") is None or data.get(
|
||||
"output_map_source"
|
||||
) is None
|
||||
out.append(data)
|
||||
return out
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
@@ -395,6 +443,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "is_enabled" in data and data["is_enabled"] is not None:
|
||||
data["is_enabled"] = 1 if data["is_enabled"] else 0
|
||||
# HEL-681: library metadata patch fields (favorite/tags/category/
|
||||
# model_family) — booleans to ints, lists to JSON text.
|
||||
if "favorite" in data and data["favorite"] is not None:
|
||||
data["favorite"] = 1 if data["favorite"] else 0
|
||||
if data.get("tags") is not None:
|
||||
data["tags"] = json.dumps(data["tags"])
|
||||
workflow_json = json.loads(existing["workflow_json"])
|
||||
# HEL-638: output_map payload. A non-empty list validates against the
|
||||
# stored JSON and becomes the user decision; an explicit empty list
|
||||
@@ -600,3 +654,280 @@ async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
|
||||
# The convert endpoint never writes; import-batch converts again server-side
|
||||
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
|
||||
# — repeated runs import nothing new, so the batch is idempotent by design.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""(report, converted workflow) for one UI file — shared by every
|
||||
convert/import path so the analysis can never drift between them."""
|
||||
result = convert_ui_workflow(item.workflow_json)
|
||||
stem = (
|
||||
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
|
||||
if item.source_path
|
||||
else None
|
||||
)
|
||||
name = item.name or stem or "未命名工作流"
|
||||
report: dict[str, Any] = {
|
||||
"name": name,
|
||||
"source_path": item.source_path,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is None:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "转换失败:" + (
|
||||
result.issues[0].message if result.issues else "未知错误"
|
||||
)
|
||||
return report, None
|
||||
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
|
||||
report["suggested"] = suggested
|
||||
# The import gate re-runs the product's own checks — a converted graph
|
||||
# that the lint would block (missing model link etc.) is reported, not
|
||||
# silently imported.
|
||||
broken = blocking_problem(result.workflow)
|
||||
if broken:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = broken
|
||||
report["status"] = "warn"
|
||||
return report, result.workflow
|
||||
if result.needs_confirmation:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
|
||||
return report, result.workflow
|
||||
report["importable"] = True
|
||||
return report, result.workflow
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
|
||||
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
|
||||
issues, suggested metadata and importability. Nothing is stored."""
|
||||
files = []
|
||||
object_info: dict[str, Any] | None = None
|
||||
client = None
|
||||
if payload.endpoint_id:
|
||||
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
|
||||
if endpoint is not None:
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=payload.refresh)
|
||||
except Exception:
|
||||
object_info = None
|
||||
try:
|
||||
for item in payload.files:
|
||||
report, converted = _convert_one(item)
|
||||
if object_info is not None and report.get("importable") and converted:
|
||||
findings = check_workflow_against(object_info, converted)
|
||||
errors = [f for f in findings if f.get("level") == "error"]
|
||||
report["preflight"] = {
|
||||
"endpoint_id": payload.endpoint_id,
|
||||
"ok": not errors,
|
||||
"findings": findings,
|
||||
}
|
||||
if errors:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
|
||||
if payload.include_workflows:
|
||||
report["workflow_json"] = converted
|
||||
files.append(report)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.close()
|
||||
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _insert_converted(
|
||||
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Insert one converted workflow row (import-batch's write path).
|
||||
|
||||
Mirrors create_workflow's enable gate: usage must be settled (auto or the
|
||||
wizard's confirmation) and unknown outputs need a mapping; anything less
|
||||
lands disabled exactly like a manual import would.
|
||||
"""
|
||||
suggested = report["suggested"]
|
||||
judgment = classify_usage(converted)
|
||||
usage = item.usage or judgment["usage"]
|
||||
usage_source = "user" if item.usage else "auto"
|
||||
kind = usage_to_kind(usage) or suggested["kind"]
|
||||
profile = detect_prompt_profile(converted)
|
||||
if item.output_map:
|
||||
problems = validate_output_map(converted, item.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(item.output_map)
|
||||
output_map_source = "user"
|
||||
else:
|
||||
auto_map = auto_output_map(converted)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
|
||||
workflow_text = json.dumps(converted)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(report["name"], kind, workflow_text),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {
|
||||
"result": "skipped",
|
||||
"skip_reason": "conflict",
|
||||
"existing_id": existing["id"],
|
||||
}
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
|
||||
description, prompt_profile, endpoint_id, is_enabled,
|
||||
usage, usage_source, output_map, output_map_source,
|
||||
category, model_family, media_io, tags, favorite,
|
||||
source, source_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
|
||||
""",
|
||||
(
|
||||
report["name"],
|
||||
kind,
|
||||
workflow_text,
|
||||
json.dumps(
|
||||
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
|
||||
),
|
||||
json.dumps(parse_dynamic_outputs(converted)),
|
||||
item.description if item.description is not None else suggested["description"],
|
||||
profile,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
suggested["category"],
|
||||
suggested["model_family"],
|
||||
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
|
||||
json.dumps(item.tags or []),
|
||||
item.source_path,
|
||||
),
|
||||
)
|
||||
return {"result": "imported", "id": cur.lastrowid}
|
||||
|
||||
|
||||
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
|
||||
reports: list[dict[str, Any]] = []
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
for item in items:
|
||||
report, converted = _convert_one(item)
|
||||
if not report.get("importable"):
|
||||
report["result"] = "skipped"
|
||||
reports.append(report)
|
||||
continue
|
||||
try:
|
||||
outcome = _insert_converted(conn, item, report, converted)
|
||||
except HTTPException as exc:
|
||||
report["result"] = "failed"
|
||||
report["skip_reason"] = str(exc.detail)
|
||||
reports.append(report)
|
||||
continue
|
||||
conn.commit()
|
||||
report.update(outcome)
|
||||
reports.append(report)
|
||||
finally:
|
||||
conn.close()
|
||||
imported = [r for r in reports if r.get("result") == "imported"]
|
||||
skipped = [
|
||||
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
|
||||
for r in reports
|
||||
if r.get("result") != "imported"
|
||||
]
|
||||
return {
|
||||
"total": len(reports),
|
||||
"imported": len(imported),
|
||||
"skipped": len(skipped),
|
||||
"imported_ids": [r["id"] for r in imported],
|
||||
"skipped_files": skipped,
|
||||
"files": reports,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import-batch")
|
||||
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
|
||||
"""Batch-import UI-format workflows: convert server-side, insert what is
|
||||
cleanly convertible, skip conflicts and anything uncertain. Re-running
|
||||
with the same files creates zero new rows."""
|
||||
return await _run_import(payload.files)
|
||||
|
||||
|
||||
@router.post("/import-scan")
|
||||
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
|
||||
"""Scan a server-side directory for UI-format workflow JSONs and run the
|
||||
convert (dry_run, default) or import pipeline over every file found."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(payload.root)
|
||||
if not root.is_absolute():
|
||||
candidates = [
|
||||
Path.cwd() / root,
|
||||
Path(__file__).resolve().parents[4] / root,
|
||||
]
|
||||
root = next((c for c in candidates if c.is_dir()), root)
|
||||
if not root.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
|
||||
items: list[WorkflowUIFileItem] = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format or non-workflow files: not this pipeline's job
|
||||
items.append(
|
||||
WorkflowUIFileItem(
|
||||
source_path=str(path.relative_to(root)), workflow_json=data
|
||||
)
|
||||
)
|
||||
if payload.dry_run:
|
||||
files = [_convert_one(item)[0] for item in items]
|
||||
counts = {
|
||||
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
|
||||
}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
return await _run_import(items)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/touch")
|
||||
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Workflow uniqueness is independent of the selected task skills."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from calliope.routers.agent import MessageCreate
|
||||
|
||||
|
||||
def workflow(n=1):
|
||||
return {"type": "workflow", "id": n, "name": f"workflow-{n}", "kind": "image"}
|
||||
|
||||
|
||||
def skill(n=1):
|
||||
return {"type": "skill", "name": f"skill-{n}"}
|
||||
|
||||
|
||||
def test_workflow_and_skills_can_be_sent_together():
|
||||
message = MessageCreate(content="Create an image", mentions=[workflow(), *[skill(i) for i in range(4)]])
|
||||
assert len(message.mentions) == 5
|
||||
assert message.mentions[0].id == 1
|
||||
|
||||
|
||||
def test_legacy_single_workflow_is_accepted():
|
||||
assert MessageCreate(mentions=[workflow()]).mentions[0].type == "workflow"
|
||||
|
||||
|
||||
def test_skills_without_workflow_are_accepted():
|
||||
assert len(MessageCreate(mentions=[skill(i) for i in range(4)]).mentions) == 4
|
||||
|
||||
|
||||
def test_second_workflow_is_still_rejected():
|
||||
with pytest.raises(ValidationError, match="Only one @workflow"):
|
||||
MessageCreate(mentions=[workflow(), skill(), workflow(2)])
|
||||
|
||||
|
||||
def test_fifth_skill_is_rejected():
|
||||
with pytest.raises(ValidationError, match="At most four skill"):
|
||||
MessageCreate(mentions=[skill(i) for i in range(5)])
|
||||
@@ -0,0 +1,350 @@
|
||||
"""HEL-684 — playground takes flow back to their workbench source row.
|
||||
|
||||
Covers the acceptance list: real-context-only passthrough (no guessing,
|
||||
stale rows rejected), explicit flow-back with idempotency (repeated
|
||||
operations create no duplicates), cancelled/independent paths never
|
||||
writing the project, and playground job deletion clearing the link.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
SAMPLE_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "hello"},
|
||||
"_meta": {"title": "Prompt (Input:prompt)"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": ["1", 0]},
|
||||
"_meta": {"title": "Save (Output:image)"},
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_VIDEO_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "hello"},
|
||||
"_meta": {"title": "Prompt (Input:prompt)"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["1", 0]},
|
||||
"_meta": {"title": "Save (Output:video)"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
import asyncio
|
||||
|
||||
import calliope.config as config_module
|
||||
|
||||
s = config_module.settings
|
||||
prev = {
|
||||
"data_dir": s.data_dir,
|
||||
"assets_dir": s.assets_dir,
|
||||
"dry_run": s.dry_run,
|
||||
}
|
||||
s.data_dir = Path(tmpdir)
|
||||
s.assets_dir = Path(tmpdir) / "assets"
|
||||
s.assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
s.dry_run = True
|
||||
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
|
||||
asyncio.run(migrate_db(s.db_path))
|
||||
app = create_app()
|
||||
try:
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
finally:
|
||||
for k, v in prev.items():
|
||||
setattr(s, k, v)
|
||||
|
||||
|
||||
def _wait_done(client: TestClient, job_id: int) -> dict:
|
||||
for _ in range(60):
|
||||
jobs = client.get("/api/playground/jobs").json()
|
||||
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
|
||||
if done and done.get("output_paths"):
|
||||
return done
|
||||
time.sleep(0.25)
|
||||
raise AssertionError("playground job did not finish in dry-run")
|
||||
|
||||
|
||||
def _seed_assets_stage(client: TestClient) -> dict:
|
||||
proj = client.post("/api/projects", json={"title": "Reel"}).json()
|
||||
# Real stage rows the way the workbench itself creates them.
|
||||
patch = client.patch(
|
||||
f"/api/workbench/projects/{proj['id']}/stages/assets",
|
||||
json={
|
||||
"content": {
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-1",
|
||||
"kind": "character",
|
||||
"name": "林夏",
|
||||
"description": "",
|
||||
"prompt": "a stoic courier",
|
||||
"negative_prompt": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert patch.status_code == 200, patch.text
|
||||
return proj
|
||||
|
||||
|
||||
def _context(proj: dict, **over: object) -> dict:
|
||||
base = {
|
||||
"project_id": proj["id"],
|
||||
"stage": "assets",
|
||||
"source_kind": "asset",
|
||||
"source_id": "asset-1",
|
||||
"label": "角色「林夏」",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": name,
|
||||
"kind": "video" if "SaveVideo" in str(workflow) else "image",
|
||||
"workflow_json": workflow,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
# ── context passthrough ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
|
||||
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {"1": "a cat"},
|
||||
"source": "studio-image",
|
||||
"context": _context(proj),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()["job"]
|
||||
assert job["payload"]["workbench"]["project_id"] == proj["id"]
|
||||
assert job["payload"]["workbench"]["source_id"] == "asset-1"
|
||||
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
|
||||
|
||||
# The job itself rides the hidden playground project — independent
|
||||
# desk behavior (feed, SSE, tasks) is untouched.
|
||||
scratch = client.get("/api/playground/project").json()
|
||||
assert job["project_id"] == scratch["id"]
|
||||
|
||||
|
||||
def test_generate_without_context_writes_nothing_to_any_project(client):
|
||||
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
|
||||
_seed_assets_stage(client)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "workbench" not in r.json()["job"]["payload"]
|
||||
|
||||
|
||||
def test_generate_rejects_fake_or_stale_context(client):
|
||||
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
|
||||
# Unknown project
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# Source row that does not exist in the stage content
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
# Kind mismatch: assets stage wants an image workflow
|
||||
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": video_wf, "context": _context(proj)},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
# The playground scratch project is not a valid source
|
||||
scratch = client.get("/api/playground/project").json()
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── flow back ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_attach_workbench_links_take_to_source_row(client):
|
||||
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
|
||||
# Not done yet → refused, nothing written.
|
||||
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert early.status_code == 400
|
||||
|
||||
done = _wait_done(client, job["id"])
|
||||
|
||||
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["already_attached"] is False
|
||||
assert body["project_id"] == proj["id"]
|
||||
assert body["target_id"] == "asset-1"
|
||||
|
||||
# The workbench overview now shows the artifact on the source row,
|
||||
# through the exact enrichment path in-flow generation uses.
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
||||
assert row["job"]["id"] == body["attached_job_id"]
|
||||
assert row["job"]["status"] == "done"
|
||||
assert row["job"]["output"] == done["output_paths"][0]
|
||||
|
||||
# Repeated flow-back: idempotent, no duplicate row.
|
||||
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert again.status_code == 200
|
||||
assert again.json()["already_attached"] is True
|
||||
assert again.json()["attached_job_id"] == body["attached_job_id"]
|
||||
|
||||
# The desk jobs list reports the honest 已回流 marker.
|
||||
jobs = client.get("/api/playground/jobs").json()
|
||||
listed = next(j for j in jobs if j["id"] == job["id"])
|
||||
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
|
||||
|
||||
|
||||
def test_attach_workbench_requires_real_context(client):
|
||||
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
|
||||
).json()["job"]
|
||||
_wait_done(client, job["id"])
|
||||
|
||||
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert r.status_code == 400
|
||||
assert "独立" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_attach_workbench_refuses_regenerated_source(client):
|
||||
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
_wait_done(client, job["id"])
|
||||
|
||||
# Stage content regenerated: the old row id is gone.
|
||||
client.patch(
|
||||
f"/api/workbench/projects/{proj['id']}/stages/assets",
|
||||
json={
|
||||
"content": {
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-2",
|
||||
"kind": "prop",
|
||||
"name": "Blaster",
|
||||
"description": "",
|
||||
"prompt": "a blaster",
|
||||
"negative_prompt": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert r.status_code == 409
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
assert all("job" not in a for a in assets_stage["content"]["assets"])
|
||||
|
||||
|
||||
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
|
||||
"""A later in-flow generation for the same row wins the display, as
|
||||
the enrichment keeps the newest workbench job per target."""
|
||||
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
_wait_done(client, job["id"])
|
||||
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
|
||||
|
||||
flow = client.post(
|
||||
f"/api/workbench/projects/{proj['id']}/generate",
|
||||
json={
|
||||
"kind": "image",
|
||||
"stage_key": "assets",
|
||||
"target_id": "asset-1",
|
||||
"prompt": "in-flow take",
|
||||
},
|
||||
)
|
||||
assert flow.status_code == 200, flow.text
|
||||
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
||||
assert row["job"]["id"] != link["attached_job_id"]
|
||||
|
||||
|
||||
def test_delete_playground_job_removes_workbench_link(client):
|
||||
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
done = _wait_done(client, job["id"])
|
||||
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
|
||||
|
||||
dead = client.delete(f"/api/playground/jobs/{job['id']}")
|
||||
assert dead.status_code == 200
|
||||
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
|
||||
|
||||
# The link row is gone: the project row no longer points at deleted files.
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
||||
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
|
||||
assert not Path(done["output_paths"][0]).exists()
|
||||
@@ -0,0 +1,449 @@
|
||||
"""HEL-681: UI→API converter unit tests + ground-truth cross-checks.
|
||||
|
||||
The synthetic graphs pin the structural semantics (reroute / bypass / mute /
|
||||
subgraph expansion / Set-Get variables / control_after_generate pairing);
|
||||
the corpus section runs the real 146-file library and the repo's API exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _node(nid, ntype, *, mode=0, inputs=None, outputs=None, wv=None, wvn=None, title=None):
|
||||
node = {"id": nid, "type": ntype, "mode": mode, "inputs": inputs or [], "order": 0}
|
||||
if outputs is not None:
|
||||
node["outputs"] = outputs
|
||||
if wv is not None:
|
||||
node["widgets_values"] = wv
|
||||
if wvn is not None:
|
||||
node["widgets_values_named"] = wvn
|
||||
if title:
|
||||
node["title"] = title
|
||||
return node
|
||||
|
||||
|
||||
def _in_slot(name, type_, link=None, widget=None):
|
||||
entry = {"name": name, "type": type_, "link": link}
|
||||
if widget:
|
||||
entry["widget"] = {"name": widget}
|
||||
return entry
|
||||
|
||||
|
||||
def _out(name, type_, links=None):
|
||||
return {"name": name, "type": type_, "links": links or [], "slot_index": 0}
|
||||
|
||||
|
||||
def _base_graph():
|
||||
"""text2image: loader → prompts/latent → KSampler → VAE → SaveImage."""
|
||||
nodes = [
|
||||
_node(
|
||||
1,
|
||||
"CheckpointLoaderSimple",
|
||||
outputs=[_out("MODEL", "MODEL"), _out("CLIP", "CLIP"), _out("VAE", "VAE")],
|
||||
wv=["model.safetensors"],
|
||||
inputs=[_in_slot("ckpt_name", "COMBO", widget="ckpt_name")],
|
||||
),
|
||||
_node(
|
||||
2,
|
||||
"CLIPTextEncode",
|
||||
inputs=[
|
||||
_in_slot("clip", "CLIP", link=1),
|
||||
_in_slot("text", "STRING", widget="text"),
|
||||
],
|
||||
outputs=[_out("CONDITIONING", "CONDITIONING")],
|
||||
wv=["a cat"],
|
||||
),
|
||||
_node(
|
||||
3,
|
||||
"CLIPTextEncode",
|
||||
inputs=[
|
||||
_in_slot("clip", "CLIP", link=2),
|
||||
_in_slot("text", "STRING", widget="text"),
|
||||
],
|
||||
outputs=[_out("CONDITIONING", "CONDITIONING")],
|
||||
wv=["bad quality"],
|
||||
),
|
||||
_node(
|
||||
4,
|
||||
"EmptyLatentImage",
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width"),
|
||||
_in_slot("height", "INT", widget="height"),
|
||||
_in_slot("batch_size", "INT", widget="batch_size"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[512, 512, 1],
|
||||
),
|
||||
_node(
|
||||
5,
|
||||
"KSampler",
|
||||
inputs=[
|
||||
_in_slot("model", "MODEL", link=3),
|
||||
_in_slot("positive", "CONDITIONING", link=4),
|
||||
_in_slot("negative", "CONDITIONING", link=5),
|
||||
_in_slot("latent_image", "LATENT", link=6),
|
||||
_in_slot("seed", "INT", widget="seed"),
|
||||
_in_slot("steps", "INT", widget="steps"),
|
||||
_in_slot("cfg", "FLOAT", widget="cfg"),
|
||||
_in_slot("sampler_name", "COMBO", widget="sampler_name"),
|
||||
_in_slot("scheduler", "COMBO", widget="scheduler"),
|
||||
_in_slot("denoise", "FLOAT", widget="denoise"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
# seed 42 + control "fixed" rides together, then five widgets
|
||||
wv=[42, "fixed", 20, 7, "euler", "normal", 1.0],
|
||||
),
|
||||
_node(
|
||||
6,
|
||||
"VAEDecode",
|
||||
inputs=[
|
||||
_in_slot("samples", "LATENT", link=7),
|
||||
_in_slot("vae", "VAE", link=8),
|
||||
],
|
||||
outputs=[_out("IMAGE", "IMAGE")],
|
||||
),
|
||||
_node(
|
||||
7,
|
||||
"SaveImage",
|
||||
inputs=[
|
||||
_in_slot("images", "IMAGE", link=9),
|
||||
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
|
||||
],
|
||||
wv=["test"],
|
||||
),
|
||||
]
|
||||
links = [
|
||||
[1, 1, 1, 2, 0, "CLIP"],
|
||||
[2, 1, 1, 3, 0, "CLIP"],
|
||||
[3, 1, 0, 5, 0, "MODEL"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
]
|
||||
return {"nodes": nodes, "links": links, "last_node_id": 7, "last_link_id": 9}
|
||||
|
||||
|
||||
def _convert(graph):
|
||||
return convert_ui_workflow(graph)
|
||||
|
||||
|
||||
def test_clean_conversion_maps_everything():
|
||||
result = _convert(_base_graph())
|
||||
assert result.status == "ok"
|
||||
assert result.needs_confirmation is False
|
||||
wf = result.workflow
|
||||
assert set(wf) == {"1", "2", "3", "4", "5", "6", "7"}
|
||||
ks = wf["5"]
|
||||
assert ks["class_type"] == "KSampler"
|
||||
assert ks["inputs"]["seed"] == 42 # control_after_generate consumed silently
|
||||
assert ks["inputs"]["steps"] == 20
|
||||
assert ks["inputs"]["denoise"] == 1.0
|
||||
assert ks["inputs"]["model"] == ["1", 0]
|
||||
assert ks["inputs"]["latent_image"] == ["4", 0]
|
||||
# lint on the converted graph must be error-free
|
||||
assert not [w for w in workflow_warnings(wf) if w["level"] == "error"]
|
||||
|
||||
|
||||
def test_upload_button_widgets_are_consumed_but_never_emitted():
|
||||
graph = _base_graph()
|
||||
load = _node(
|
||||
8,
|
||||
"LoadImage",
|
||||
inputs=[
|
||||
_in_slot("image", "COMBO", widget="image"),
|
||||
_in_slot("upload", "IMAGEUPLOAD", widget="upload"),
|
||||
],
|
||||
outputs=[_out("IMAGE", "IMAGE")],
|
||||
wv=["sample.png", "image"],
|
||||
)
|
||||
save = _node(
|
||||
9,
|
||||
"PreviewImage",
|
||||
inputs=[
|
||||
_in_slot("images", "IMAGE", link=20),
|
||||
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
|
||||
],
|
||||
wv=["preview"],
|
||||
)
|
||||
graph["nodes"] += [load, save]
|
||||
graph["links"].append([20, 8, 0, 9, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["8"]["inputs"] == {"image": "sample.png"}
|
||||
|
||||
|
||||
def test_reroute_pass_through():
|
||||
graph = _base_graph()
|
||||
reroute = _node(8, "Reroute", inputs=[_in_slot("(in)", "*", link=9)])
|
||||
graph["nodes"].append(reroute)
|
||||
# SaveImage now fed by the reroute instead of VAEDecode directly
|
||||
graph["nodes"][6]["inputs"][0]["link"] = 10
|
||||
graph["links"].append([10, 8, 0, 7, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["7"]["inputs"]["images"] == ["6", 0]
|
||||
assert "8" not in result.workflow
|
||||
|
||||
|
||||
def test_bypass_pass_through_and_mute_drop():
|
||||
graph = _base_graph()
|
||||
# bypass the KSampler: VAEDecode.samples must fall back to the latent
|
||||
graph["nodes"][4]["mode"] = 4
|
||||
result = _convert(graph)
|
||||
assert result.workflow["6"]["inputs"]["samples"] == ["4", 0]
|
||||
# mute the negative prompt: the link input is dropped with a warning
|
||||
graph = _base_graph()
|
||||
graph["nodes"][2]["mode"] = 2
|
||||
result = _convert(graph)
|
||||
assert "negative" not in result.workflow["5"]["inputs"]
|
||||
assert any(i.code == "muted_input_dropped" for i in result.issues)
|
||||
|
||||
|
||||
def test_widget_entry_with_muted_upstream_falls_back_to_value():
|
||||
graph = _base_graph()
|
||||
# feed seed from a muted PrimitiveInt via a converted widget input
|
||||
prim = _node(
|
||||
9,
|
||||
"PrimitiveInt",
|
||||
mode=2,
|
||||
inputs=[_in_slot("value", "INT", widget="value"), _in_slot("control", "COMBO", widget="control")],
|
||||
wv=[7],
|
||||
)
|
||||
graph["nodes"].append(prim)
|
||||
graph["nodes"][4]["inputs"][4]["link"] = 11 # seed ← primitive
|
||||
graph["links"].append([11, 9, 0, 5, 4, "INT"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["5"]["inputs"]["seed"] == 42
|
||||
|
||||
|
||||
def test_subgraph_expansion_and_boundary_wiring():
|
||||
graph = _base_graph()
|
||||
# pack the EmptyLatentImage into a subgraph exposing width/height widgets
|
||||
del graph["nodes"][3]
|
||||
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != 4]
|
||||
sub_id = "aaaa-bbbb-cccc-dddd-eeee"
|
||||
packed = _node(
|
||||
20,
|
||||
sub_id,
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width"),
|
||||
_in_slot("height", "INT", widget="height"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[640, 384],
|
||||
)
|
||||
graph["nodes"].append(packed)
|
||||
# KSampler latent_image ← packed output
|
||||
graph["links"] = [l for l in graph["links"] if l[0] != 6]
|
||||
graph["links"].append([6, 20, 0, 5, 3, "LATENT"])
|
||||
graph["definitions"] = {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": sub_id,
|
||||
"name": "Latent",
|
||||
"inputs": [
|
||||
{"name": "width", "type": "INT"},
|
||||
{"name": "height", "type": "INT"},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT"}],
|
||||
"inputNode": {"id": -10},
|
||||
"outputNode": {"id": -20},
|
||||
"nodes": [
|
||||
_node(
|
||||
5,
|
||||
"EmptyLatentImage",
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width", link=101),
|
||||
_in_slot("height", "INT", widget="height", link=102),
|
||||
_in_slot("batch_size", "INT", widget="batch_size"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[512, 512, 1],
|
||||
),
|
||||
],
|
||||
"links": [
|
||||
{"id": 101, "origin_id": -10, "origin_slot": 0, "target_id": 5, "target_slot": 0, "type": "INT"},
|
||||
{"id": 102, "origin_id": -10, "origin_slot": 1, "target_id": 5, "target_slot": 1, "type": "INT"},
|
||||
{"id": 103, "origin_id": 5, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "LATENT"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
result = _convert(graph)
|
||||
wf = result.workflow
|
||||
assert "20:5" in wf # qualified packed:inner id, ComfyUI's own scheme
|
||||
inner = wf["20:5"]
|
||||
assert inner["class_type"] == "EmptyLatentImage"
|
||||
# exposed widget values from the packed instance, not the inner defaults
|
||||
assert inner["inputs"]["width"] == 640
|
||||
assert inner["inputs"]["height"] == 384
|
||||
assert inner["inputs"]["batch_size"] == 1
|
||||
assert wf["5"]["inputs"]["latent_image"] == ["20:5", 0]
|
||||
# now wire the packed width slot from outside: boundary becomes a link
|
||||
res_node = _node(
|
||||
21,
|
||||
"ResolutionSelector",
|
||||
inputs=[_in_slot("aspect_ratio", "COMBO", widget="aspect_ratio")],
|
||||
outputs=[_out("width", "INT"), _out("height", "INT")],
|
||||
wv=["1:1"],
|
||||
)
|
||||
graph["nodes"].append(res_node)
|
||||
graph["nodes"][-2]["inputs"][0]["link"] = 12 # packed width ← selector
|
||||
graph["links"].append([12, 21, 0, 20, 0, "INT"])
|
||||
wf2 = _convert(graph).workflow
|
||||
assert wf2["20:5"]["inputs"]["width"] == ["21", 0]
|
||||
assert wf2["20:5"]["inputs"]["height"] == 384
|
||||
|
||||
|
||||
def test_set_get_variables_resolve_to_captured_source():
|
||||
graph = _base_graph()
|
||||
setter = _node(8, "SetNode", inputs=[_in_slot("*", "*", link=8)], wv=["the_vae"])
|
||||
getter = _node(9, "GetNode", wv=["the_vae"], outputs=[_out("VAE", "VAE")])
|
||||
graph["nodes"] += [setter, getter]
|
||||
# VAEDecode.vae now fed via the variable
|
||||
graph["nodes"][5]["inputs"][1]["link"] = 13
|
||||
graph["links"] = [l for l in graph["links"] if l[0] != 8]
|
||||
graph["links"] += [
|
||||
[8, 1, 2, 8, 0, "VAE"],
|
||||
[13, 9, 0, 6, 1, "VAE"],
|
||||
]
|
||||
result = _convert(graph)
|
||||
assert "8" not in result.workflow and "9" not in result.workflow
|
||||
assert result.workflow["6"]["inputs"]["vae"] == ["1", 2]
|
||||
|
||||
|
||||
def test_unknown_widget_order_lands_in_needs_confirmation():
|
||||
graph = _base_graph()
|
||||
# an old-frontend node: positional widgets, no widget markers, no registry
|
||||
graph["nodes"].append(
|
||||
_node(10, "MysteryCustomNode", inputs=[], outputs=[_out("IMAGE", "IMAGE")], wv=[1, 2, 3])
|
||||
)
|
||||
save = _node(
|
||||
11,
|
||||
"PreviewImage",
|
||||
inputs=[_in_slot("images", "IMAGE", link=30)],
|
||||
wv=["preview"],
|
||||
)
|
||||
graph["nodes"].append(save)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.needs_confirmation is True
|
||||
assert any(i.code == "widget_unmapped" for i in result.issues)
|
||||
# the node is still converted structurally — the report carries the doubt
|
||||
assert "10" in result.workflow
|
||||
|
||||
|
||||
def test_not_ui_format_fails_fast():
|
||||
result = convert_ui_workflow({"3": {"class_type": "SaveImage", "inputs": {}}})
|
||||
assert result.status == "fail"
|
||||
assert result.workflow is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real corpus: the 146 UI files + ground-truth API cross-checks (对拍).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytestmark_convert = pytest.mark.skipif(
|
||||
not EXAMPLES.is_dir(), reason="example_ComfyUI_workflows not checked out"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_corpus_146_files_all_convert():
|
||||
ui_files = [
|
||||
p
|
||||
for p in sorted(EXAMPLES.rglob("*.json"))
|
||||
if isinstance(json.loads(p.read_text(encoding="utf-8")), dict)
|
||||
and isinstance(json.loads(p.read_text(encoding="utf-8")).get("nodes"), list)
|
||||
]
|
||||
assert len(ui_files) == 146
|
||||
statuses = {"ok": 0, "warn": 0, "fail": 0}
|
||||
needs_confirmation = 0
|
||||
for path in ui_files:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
result = convert_ui_workflow(data)
|
||||
statuses[result.status] += 1
|
||||
needs_confirmation += result.needs_confirmation
|
||||
assert result.workflow, f"{path.name} produced no workflow"
|
||||
# structural sanity: no dangling links in any conversion
|
||||
nodes = result.workflow
|
||||
for nid, node in nodes.items():
|
||||
for name, val in node.get("inputs", {}).items():
|
||||
if isinstance(val, list):
|
||||
assert str(val[0]) in nodes, f"{path.name}: {nid}.{name} dangles"
|
||||
assert statuses["fail"] == 0
|
||||
assert statuses["ok"] + statuses["warn"] == 146
|
||||
# files whose widget order cannot be trusted are flagged, never hidden
|
||||
assert 0 < needs_confirmation < 10
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_cross_check_against_ground_truth_exports():
|
||||
"""The H3 t2v UI export converts to the same node classes the author's
|
||||
own API export carries; shared classes agree on scalar input names."""
|
||||
ui = json.loads(
|
||||
(EXAMPLES / "4-视频生成/Minimax-h3/minimax_h3_t2v_文生视频.json").read_text(encoding="utf-8")
|
||||
)
|
||||
api = json.loads(
|
||||
(EXAMPLES / "minimax_h3-Turbo_Text2Video_20260813_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
result = convert_ui_workflow(ui)
|
||||
assert result.status in ("ok", "warn")
|
||||
converted = result.workflow
|
||||
api_classes = {n["class_type"] for n in api.values()}
|
||||
# Every scalar name the conversion emits for a shared class must be an
|
||||
# input name the author's own export knows — wiring may differ between
|
||||
# versions (a muted branch falls back to its widget value), but the NAME
|
||||
# mapping must never invent or swap inputs.
|
||||
for cls in api_classes & {n["class_type"] for n in converted.values()}:
|
||||
api_names = {
|
||||
key
|
||||
for n in api.values()
|
||||
if n["class_type"] == cls
|
||||
for key in n.get("inputs", {})
|
||||
}
|
||||
conv_names = {
|
||||
key
|
||||
for n in converted.values()
|
||||
if n["class_type"] == cls
|
||||
for key, v in n.get("inputs", {}).items()
|
||||
if not isinstance(v, list)
|
||||
}
|
||||
assert conv_names <= api_names, f"class {cls} invents inputs: {conv_names - api_names}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_packed_krea2_conversion_shape_matches_export_scheme():
|
||||
"""The krea2 turbo file (packed subgraph) converts ok, expands inner nodes
|
||||
under `<packed>:<inner>` ids and maps the sampler widgets exactly — the
|
||||
same scheme the author's own Krea2 API export uses (ids like "171:153")."""
|
||||
ui = json.loads(
|
||||
(EXAMPLES / "3-图像生成/krea2/krea2_turbo_文生图.json").read_text(encoding="utf-8")
|
||||
)
|
||||
api = json.loads(
|
||||
(EXAMPLES / "Krea2_t2i_20260818_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
result = convert_ui_workflow(ui)
|
||||
assert result.status == "ok"
|
||||
assert any(":" in nid for nid in result.workflow) # expanded subgraph nodes
|
||||
assert any("171:" in nid for nid in api) # ground truth uses the same scheme
|
||||
ks = [
|
||||
n
|
||||
for n in result.workflow.values()
|
||||
if n["class_type"] == "KSampler" and not isinstance(n["inputs"].get("seed"), list)
|
||||
]
|
||||
assert ks, "inner KSampler with widget-mapped seed expected"
|
||||
assert {"seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"} <= set(
|
||||
ks[0]["inputs"]
|
||||
)
|
||||
@@ -197,6 +197,39 @@ def _confirm(client, pid: int, key: str, **extra):
|
||||
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
|
||||
|
||||
|
||||
def test_assistant_notes_keep_confirmation_and_survive_artifact_edits(client):
|
||||
pid = _make_project(client)
|
||||
note_url = f"/api/workbench/projects/{pid}/stages/style/note"
|
||||
r = client.patch(note_url, json={"text": "建议先比较冷暖两个方向。"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["status"] == "todo"
|
||||
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
|
||||
assert _confirm(client, pid, "style").status_code == 400
|
||||
# An ordinary structured save must keep the separate advice.
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/style", json={"content": json.loads(STYLE_REPLY)})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
|
||||
# Notes on confirmed content don't change its status or confirmed snapshot.
|
||||
client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
|
||||
before = _stage(_overview(client, pid), "script")
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "下一版建议加强人物动机。"})
|
||||
assert r.status_code == 200
|
||||
after = r.json()["stage"]
|
||||
assert after["status"] == before["status"] == "confirmed"
|
||||
assert after["confirmed_content"] == before["confirmed_content"]
|
||||
assert after["draft_edited"] == before["draft_edited"]
|
||||
assert _confirm(client, pid, "script").status_code == 200
|
||||
confirmed = _stage(_overview(client, pid), "script")
|
||||
assert "assistant_note" not in confirmed["confirmed_content"]
|
||||
client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "保留确认后的新建议。"})
|
||||
restored = client.post(f"/api/workbench/projects/{pid}/stages/script/restore").json()["stage"]
|
||||
assert restored["assistant_note"] == "保留确认后的新建议。"
|
||||
assert restored["confirmed_content"] == confirmed["confirmed_content"]
|
||||
assert client.patch(note_url, json={"text": ""}).json()["stage"]["assistant_note"] == ""
|
||||
assert client.patch(note_url, json={"text": "x" * 200_001}).status_code == 422
|
||||
assert client.patch(f"/api/workbench/projects/{pid}/stages/unknown/note", json={"text": "x"}).status_code == 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""HEL-681 (S2): batch import endpoints, meta list view, migration safety.
|
||||
|
||||
Everything runs on a temp DB via the shared client fixture — the real
|
||||
146-file library is never touched, matching the card's「不导入生产数据」boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.db import migrate_db
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _ui_graph():
|
||||
"""Minimal clean t2i UI graph (same shape as test_ui_convert's)."""
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "ckpt_name", "type": "COMBO", "widget": {"name": "ckpt_name"}, "link": None}],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [3], "slot_index": 0},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 1},
|
||||
{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 2},
|
||||
],
|
||||
"widgets_values": ["qwen_image_2.safetensors"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 1},
|
||||
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [4], "slot_index": 0}],
|
||||
"widgets_values": ["a cat"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2},
|
||||
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}],
|
||||
"widgets_values": ["bad"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": None},
|
||||
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": None},
|
||||
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [6], "slot_index": 0}],
|
||||
"widgets_values": [512, 512, 1],
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 3},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 4},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 6},
|
||||
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": None},
|
||||
{"name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": None},
|
||||
{"name": "cfg", "type": "FLOAT", "widget": {"name": "cfg"}, "link": None},
|
||||
{"name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": None},
|
||||
{"name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": None},
|
||||
{"name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}],
|
||||
"widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1.0],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 7},
|
||||
{"name": "vae", "type": "VAE", "link": 8},
|
||||
],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0}],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": None},
|
||||
],
|
||||
"widgets_values": ["test"],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "CLIP"],
|
||||
[2, 1, 1, 3, 0, "CLIP"],
|
||||
[3, 1, 0, 5, 0, "MODEL"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _api_workflow():
|
||||
return json.loads(
|
||||
(EXAMPLES / "video_minimax_h3_r2v_2ref_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_convert_dry_run_reports_and_stores_nothing(client):
|
||||
payload = {
|
||||
"files": [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
}
|
||||
],
|
||||
"include_workflows": True,
|
||||
}
|
||||
resp = client.post("/api/workflows/convert", json=payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1 and body["ok"] == 1 and body["importable"] == 1
|
||||
entry = body["files"][0]
|
||||
assert entry["name"] == "test_t2i"
|
||||
assert entry["workflow_json"]["5"]["inputs"]["seed"] == 42
|
||||
assert entry["suggested"]["usage"] == "text2image"
|
||||
assert entry["suggested"]["category"] == "image"
|
||||
assert entry["suggested"]["model_family"] == "Qwen"
|
||||
assert entry["suggested"]["media_out"] == ["image"]
|
||||
# dry-run must not write anything
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_convert_needs_confirmation_is_flagged_not_runnable(client):
|
||||
graph = _ui_graph()
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 10,
|
||||
"type": "MysteryCustomNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
|
||||
"widgets_values": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewImage",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
|
||||
"widgets_values": ["p"],
|
||||
}
|
||||
)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
resp = client.post(
|
||||
"/api/workflows/convert",
|
||||
json={"files": [{"source_path": "x/mystery.json", "workflow_json": graph}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["needs_confirmation"] == 1
|
||||
assert body["importable"] == 0
|
||||
assert "需人工确认" in body["files"][0]["skip_reason"]
|
||||
|
||||
|
||||
def test_import_batch_then_rerun_is_idempotent(client):
|
||||
files = [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
}
|
||||
]
|
||||
first = client.post("/api/workflows/import-batch", json={"files": files})
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["imported"] == 1 and body["skipped"] == 0
|
||||
row_id = body["imported_ids"][0]
|
||||
|
||||
# second run with identical file: conflict skip, no duplicates
|
||||
second = client.post("/api/workflows/import-batch", json={"files": files})
|
||||
body2 = second.json()
|
||||
assert body2["imported"] == 0 and body2["skipped"] == 1
|
||||
assert body2["skipped_files"][0]["reason"] == "conflict"
|
||||
listing = client.get("/api/workflows").json()
|
||||
assert len(listing) == 1
|
||||
|
||||
detail = client.get(f"/api/workflows/{row_id}").json()
|
||||
assert detail["usage"] == "text2image"
|
||||
assert detail["is_enabled"] is True
|
||||
assert detail["category"] == "image"
|
||||
assert detail["model_family"] == "Qwen"
|
||||
assert detail["media_io"] == {"in": [], "out": ["image"]}
|
||||
assert detail["source"] == "ui_convert"
|
||||
assert detail["source_path"] == "3-图像生成/Qwen-Image-2.1/test_t2i.json"
|
||||
|
||||
|
||||
def test_import_batch_skips_needs_confirmation_files(client):
|
||||
graph = _ui_graph()
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 10,
|
||||
"type": "MysteryCustomNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
|
||||
"widgets_values": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewImage",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
|
||||
"widgets_values": ["p"],
|
||||
}
|
||||
)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
resp = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "mystery", "workflow_json": graph}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["imported"] == 0
|
||||
assert "需人工确认" in body["skipped_files"][0]["reason"]
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_warn_sample_imports_with_human_confirmation(client):
|
||||
"""A warn file (muted branch → input dropped) still imports when the
|
||||
wizard supplies the settled usage — the manual-correction path."""
|
||||
graph = _ui_graph()
|
||||
graph["nodes"][2]["mode"] = 2 # mute the negative prompt branch
|
||||
dry = client.post(
|
||||
"/api/workflows/convert",
|
||||
json={"files": [{"name": "warned", "workflow_json": graph}]},
|
||||
).json()
|
||||
assert dry["warn"] == 1 and dry["importable"] == 1
|
||||
resp = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "warned", "workflow_json": graph, "usage": "text2image"}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["imported"] == 1
|
||||
detail = client.get(f"/api/workflows/{body['imported_ids'][0]}").json()
|
||||
assert detail["is_enabled"] is True
|
||||
assert "negative" not in detail["workflow_json"]["5"]["inputs"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_import_scan_dry_run_over_repo_library(client):
|
||||
resp = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(EXAMPLES), "dry_run": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 146
|
||||
assert body["fail"] == 0
|
||||
assert body["ok"] + body["warn"] == 146
|
||||
assert body["needs_confirmation"] > 0 # the 3 uncensored files stay honest
|
||||
assert body["importable"] < body["total"]
|
||||
# a dry scan must leave the DB untouched
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_import_scan_commit_from_tmp_directory(client, tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "demo.json").write_text(json.dumps(_ui_graph()), encoding="utf-8")
|
||||
(tmp_path / "plain_api.json").write_text(json.dumps(_api_workflow()), encoding="utf-8")
|
||||
(tmp_path / "not_a_workflow.json").write_text(json.dumps({"hello": 1}), encoding="utf-8")
|
||||
resp = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(tmp_path), "dry_run": False},
|
||||
)
|
||||
body = resp.json()
|
||||
# only the one UI file went through this pipeline; API-format and junk skipped
|
||||
assert body["imported"] == 1
|
||||
listing = client.get("/api/workflows").json()
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["source_path"] == "sub/demo.json"
|
||||
# rerun: idempotent
|
||||
again = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(tmp_path), "dry_run": False},
|
||||
).json()
|
||||
assert again["imported"] == 0
|
||||
assert len(client.get("/api/workflows").json()) == 1
|
||||
|
||||
|
||||
def test_meta_view_has_metadata_but_no_full_json(client):
|
||||
client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
"tags": ["极速"],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert len(meta) == 1
|
||||
row = meta[0]
|
||||
assert "workflow_json" not in row
|
||||
assert "input_schema" not in row
|
||||
assert "output_schema" not in row
|
||||
assert row["category"] == "image"
|
||||
assert row["model_family"] == "Qwen"
|
||||
assert row["media_io"] == {"in": [], "out": ["image"]}
|
||||
assert row["tags"] == ["极速"]
|
||||
assert row["favorite"] is False
|
||||
assert row["needs_mapping"] is False
|
||||
|
||||
|
||||
def test_legacy_list_shape_and_old_records_unchanged(client):
|
||||
"""Old imports (API format via POST /api/workflows) keep working; the
|
||||
legacy list still carries workflow_json and the new columns default."""
|
||||
created = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧导入", "workflow_json": _api_workflow()},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
legacy = client.get("/api/workflows").json()
|
||||
assert len(legacy) == 1
|
||||
row = legacy[0]
|
||||
assert isinstance(row["workflow_json"], dict)
|
||||
assert "usage" in row and "output_map" in row
|
||||
assert row["favorite"] is False
|
||||
assert row["tags"] == []
|
||||
assert row["media_io"] == {"in": [], "out": []}
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
|
||||
assert meta[0]["source"] is None
|
||||
|
||||
|
||||
def test_favorite_tags_patch_and_touch(client):
|
||||
wid = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "fav", "workflow_json": _ui_graph()}]},
|
||||
).json()["imported_ids"][0]
|
||||
resp = client.patch(f"/api/workflows/{wid}", json={"favorite": True, "tags": ["高质量"]})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["favorite"] is True
|
||||
assert resp.json()["tags"] == ["高质量"]
|
||||
touch = client.post(f"/api/workflows/{wid}/touch")
|
||||
assert touch.status_code == 200
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert meta[0]["last_used_at"] is not None
|
||||
assert meta[0]["favorite"] is True
|
||||
|
||||
|
||||
def test_migration_adds_columns_on_old_database(tmp_path):
|
||||
"""A pre-S2 database (workflows table without the metadata columns)
|
||||
migrates in place; existing rows survive with defaults."""
|
||||
db_path = tmp_path / "old.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE workflows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
|
||||
workflow_json TEXT NOT NULL,
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
INSERT INTO workflows (name, kind, workflow_json)
|
||||
VALUES ('legacy', 'video', '{"1": {"class_type": "SaveImage", "inputs": {}}}');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(db_path))
|
||||
conn = sqlite3.connect(db_path)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(workflows)").fetchall()}
|
||||
assert {
|
||||
"category",
|
||||
"model_family",
|
||||
"media_io",
|
||||
"tags",
|
||||
"favorite",
|
||||
"last_used_at",
|
||||
"source",
|
||||
"source_path",
|
||||
} <= cols
|
||||
row = conn.execute("SELECT name, favorite, tags, category FROM workflows").fetchone()
|
||||
assert row == ("legacy", 0, None, None)
|
||||
conn.close()
|
||||
+29
-18
@@ -1,19 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>影工坊</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function () {
|
||||
var saved;
|
||||
try {
|
||||
saved = localStorage.getItem("aigc-theme");
|
||||
} catch (_) {}
|
||||
document.documentElement.dataset.theme =
|
||||
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>影工坊</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
/** A local honeycomb highlight follows the pointer, independent of selected state. */
|
||||
export const spotlight: Action<HTMLElement> = (node) => {
|
||||
const move = (event: PointerEvent) => {
|
||||
if (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
event.pointerType === 'touch'
|
||||
)
|
||||
return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
node.style.setProperty('--pointer-x', `${event.clientX - rect.left}px`);
|
||||
node.style.setProperty('--pointer-y', `${event.clientY - rect.top}px`);
|
||||
};
|
||||
node.addEventListener('pointermove', move);
|
||||
return { destroy: () => node.removeEventListener('pointermove', move) };
|
||||
};
|
||||
@@ -140,6 +140,11 @@ import type {
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -154,6 +159,11 @@ export type {
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -462,6 +472,50 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
/** HEL-682: metadata-only list — 150+ rows without any workflow_json. */
|
||||
listMeta: () => api<WorkflowMeta[]>("/api/workflows?view=meta"),
|
||||
/** HEL-682: dry-run UI→API conversion for a batch of files (nothing stored). */
|
||||
convert: (
|
||||
files: WorkflowUIFileInput[],
|
||||
opts?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConvertReport>("/api/workflows/convert", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
files,
|
||||
...(opts?.endpoint_id ? { endpoint_id: opts.endpoint_id } : {}),
|
||||
...(opts?.refresh ? { refresh: opts.refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
/** HEL-682: commit a batch import (converts server-side, conflicts skipped). */
|
||||
importBatch: (files: WorkflowUIFileInput[]) =>
|
||||
api<WorkflowImportResult>("/api/workflows/import-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
/** HEL-682: scan a server-side directory of UI-format JSONs — dry-run report. */
|
||||
importScanDry: (root: string) =>
|
||||
api<WorkflowConvertReport>("/api/workflows/import-scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ root, dry_run: true }),
|
||||
}),
|
||||
/** HEL-682: scan a server-side directory of UI-format JSONs — commit the import. */
|
||||
importScanCommit: (root: string) =>
|
||||
api<WorkflowImportResult>("/api/workflows/import-scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ root, dry_run: false }),
|
||||
}),
|
||||
/** HEL-682: record a use (最近使用). */
|
||||
touch: (id: number) =>
|
||||
api<{ ok: boolean }>(`/api/workflows/${id}/touch`, { method: "POST" }),
|
||||
/** HEL-682: delete byte-identical duplicates, keeping the lowest id. */
|
||||
dedupe: () =>
|
||||
api<{
|
||||
ok: boolean;
|
||||
removed: number;
|
||||
kept_ids: number[];
|
||||
removed_ids: number[];
|
||||
}>("/api/workflows/dedupe", { method: "POST" }),
|
||||
analyze: (
|
||||
workflow_json: Record<string, unknown>,
|
||||
endpoint_id?: string | null,
|
||||
@@ -537,6 +591,10 @@ export const workflows = {
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_map: WorkflowOutputMapEntry[];
|
||||
output_map_source: "auto" | "user";
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
category: string;
|
||||
model_family: string;
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
@@ -638,6 +696,15 @@ export const playgroundApi = {
|
||||
endpoint_id?: string | null;
|
||||
/** Origin recorded on the job payload (tasks page labels). */
|
||||
source?: "playground" | "studio-image" | "studio-video";
|
||||
/** Optional real workbench source (HEL-684) — validated server-side and
|
||||
* stamped on the job so finished takes can flow back to their row. */
|
||||
context?: {
|
||||
project_id: number;
|
||||
stage: "assets" | "shot";
|
||||
source_kind: "asset" | "shot";
|
||||
source_id: string;
|
||||
label?: string;
|
||||
};
|
||||
}) =>
|
||||
api<{ ok: boolean; job: Job }>("/api/playground/generate", {
|
||||
method: "POST",
|
||||
@@ -655,7 +722,24 @@ export const playgroundApi = {
|
||||
job_id: number;
|
||||
deleted_files: string[];
|
||||
missing_files: string[];
|
||||
removed_workbench_links?: number[];
|
||||
}>(`/api/playground/jobs/${jobId}`, { method: "DELETE" }),
|
||||
/** Flow a finished take back to the workbench row it came from (HEL-684).
|
||||
* The server derives the target from the job's own recorded context —
|
||||
* idempotent, never overwrites, refuses stale source rows. */
|
||||
attachWorkbench: (jobId: number) =>
|
||||
api<{
|
||||
ok: boolean;
|
||||
already_attached: boolean;
|
||||
job_id: number;
|
||||
attached_job_id: number;
|
||||
project_id: number;
|
||||
stage: "assets" | "shot";
|
||||
target_id: string;
|
||||
}>("/api/playground/attach-workbench", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ job_id: jobId }),
|
||||
}),
|
||||
attach: (payload: {
|
||||
path: string;
|
||||
project_id: number;
|
||||
@@ -750,6 +834,7 @@ export interface VideoShot {
|
||||
}
|
||||
|
||||
export interface WorkbenchStage {
|
||||
assistant_note?: string;
|
||||
stage_key: WorkbenchStageKey;
|
||||
status: WorkbenchStageStatus;
|
||||
content: Record<string, unknown> | null;
|
||||
@@ -773,6 +858,11 @@ export interface WorkbenchOverview {
|
||||
}
|
||||
|
||||
export const workbenchApi = {
|
||||
saveNote: (projectId: number, stageKey: WorkbenchStageKey, text: string) =>
|
||||
api<{ ok: boolean; stage: WorkbenchStage }>(
|
||||
`/api/workbench/projects/${projectId}/stages/${stageKey}/note`,
|
||||
{ method: "PATCH", body: JSON.stringify({ text }) },
|
||||
),
|
||||
overview: (projectId: number) =>
|
||||
api<WorkbenchOverview>(`/api/workbench/projects/${projectId}`),
|
||||
patchStage: (
|
||||
|
||||
@@ -98,8 +98,8 @@
|
||||
position: relative;
|
||||
}
|
||||
.artifact-node.running {
|
||||
border-color: rgba(125, 211, 252, 0.6);
|
||||
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
|
||||
border-color: var(--run);
|
||||
box-shadow: 0 0 18px var(--run-soft);
|
||||
}
|
||||
:global(.svelte-flow__node.selected) .artifact-node {
|
||||
border-color: var(--accent);
|
||||
@@ -117,9 +117,9 @@
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #bae6fd;
|
||||
background: rgba(12, 74, 110, 0.55);
|
||||
border: 1px solid rgba(125, 211, 252, 0.35);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
flex-shrink: 0;
|
||||
@@ -141,12 +141,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.running-badge {
|
||||
color: #bae6fd;
|
||||
background: rgba(12, 74, 110, 0.6);
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.failed-badge {
|
||||
color: #fecaca;
|
||||
background: rgba(127, 29, 29, 0.5);
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.play-badge {
|
||||
font-size: 9px;
|
||||
@@ -205,9 +205,9 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
color: var(--text-muted);
|
||||
color: var(--on-media);
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: var(--media-scrim);
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.entity-node:hover {
|
||||
border-color: #3a3a44;
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
/* Svelte Flow marks the selected node wrapper with .selected */
|
||||
:global(.svelte-flow__node.selected) .entity-node {
|
||||
@@ -176,9 +176,9 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
color: var(--text-muted);
|
||||
color: var(--on-media);
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: var(--media-scrim);
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
|
||||
The global --accent stays untouched for v1 pages. */
|
||||
.canvas-root {
|
||||
--accent: #4f46a5;
|
||||
--accent-hover: #6157c9;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--edge: #7dd3fc;
|
||||
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
|
||||
--panel: #17171f;
|
||||
--panel-border: #2a2a35;
|
||||
--fg: #f4f4f5;
|
||||
--fg-dim: #a1a1aa;
|
||||
}
|
||||
@@ -175,6 +175,120 @@ export interface Workflow {
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
|
||||
* point is browsing 150+ workflows without downloading workflow_json.
|
||||
*/
|
||||
export interface WorkflowMediaIO {
|
||||
in: string[];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowMeta {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_source: 'auto' | 'user' | null;
|
||||
is_enabled: boolean;
|
||||
category: string | null;
|
||||
model_family: string | null;
|
||||
media_io: WorkflowMediaIO;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
last_used_at: string | null;
|
||||
description: string | null;
|
||||
source: string | null;
|
||||
source_path: string | null;
|
||||
prompt_profile: string;
|
||||
created_at: string;
|
||||
endpoint_id: string | null;
|
||||
output_map_source: 'auto' | 'user' | null;
|
||||
/** Cheap pending check: usage or output mapping not settled yet. */
|
||||
needs_mapping: boolean;
|
||||
}
|
||||
|
||||
/** One UI-format file submitted to the batch convert/import pipeline. */
|
||||
export interface WorkflowUIFileInput {
|
||||
name?: string;
|
||||
source_path?: string;
|
||||
workflow_json: Record<string, unknown>;
|
||||
usage?: WorkflowUsage;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowConvertIssue {
|
||||
node_id: string;
|
||||
code: string;
|
||||
level: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertSuggested {
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
category: string | null;
|
||||
category_label: string | null;
|
||||
model_family: string | null;
|
||||
media_in: string[];
|
||||
media_out: string[];
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowPreflightFinding {
|
||||
level: string;
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertFileReport {
|
||||
name: string;
|
||||
source_path: string | null;
|
||||
status: 'ok' | 'warn' | 'fail';
|
||||
needs_confirmation: boolean;
|
||||
issues: WorkflowConvertIssue[];
|
||||
importable: boolean;
|
||||
skip_reason?: string | null;
|
||||
suggested?: WorkflowConvertSuggested;
|
||||
preflight?: {
|
||||
endpoint_id: string | null;
|
||||
ok: boolean;
|
||||
findings: WorkflowPreflightFinding[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
|
||||
export interface WorkflowConvertReport {
|
||||
total: number;
|
||||
ok: number;
|
||||
warn: number;
|
||||
fail: number;
|
||||
needs_confirmation: number;
|
||||
importable: number;
|
||||
files: WorkflowConvertFileReport[];
|
||||
}
|
||||
|
||||
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
|
||||
result?: 'imported' | 'skipped' | 'failed';
|
||||
id?: number;
|
||||
existing_id?: number;
|
||||
}
|
||||
|
||||
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
|
||||
export interface WorkflowImportResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
imported_ids: number[];
|
||||
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
|
||||
files: WorkflowImportFileReport[];
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number;
|
||||
project_id: number;
|
||||
@@ -197,6 +311,13 @@ export interface Job {
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
/** HEL-684: present when this take has been flowed back to its
|
||||
* workbench source row (link job id on the source project). */
|
||||
workbench_attach?: {
|
||||
job_id: number;
|
||||
stage: string;
|
||||
target_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
|
||||
@@ -522,7 +522,7 @@
|
||||
}
|
||||
.role {
|
||||
font-weight: 600;
|
||||
color: var(--accent, #a78bfa);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
font-family: ui-monospace, monospace;
|
||||
@@ -567,7 +567,7 @@
|
||||
input.invalid:focus-visible,
|
||||
textarea.invalid:focus-visible,
|
||||
select.invalid:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
|
||||
box-shadow: 0 0 0 3px var(--err-soft);
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 11px;
|
||||
@@ -607,7 +607,7 @@
|
||||
}
|
||||
.clear:hover {
|
||||
color: var(--error);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.clear:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: rgba(18, 18, 24, 0.78);
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
onclick={() => (zoomed = !zoomed)}
|
||||
>
|
||||
<Icon name="zoom-in" size={17} />
|
||||
@@ -108,7 +108,9 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
title={frameBusy
|
||||
? t('lastFrame.exporting')
|
||||
: `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
@@ -179,9 +181,8 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
{alt}
|
||||
kind="video"
|
||||
label={t('lightbox.videoUnavailable')}
|
||||
autoplay
|
||||
controls
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -237,7 +238,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
}
|
||||
.counter {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
color: color-mix(in srgb, var(--on-media) 65%, transparent);
|
||||
margin-right: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -249,13 +250,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--on-media) 12%, transparent);
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tool:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
background: color-mix(in srgb, var(--on-media) 22%, transparent);
|
||||
}
|
||||
.tool:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -272,13 +273,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--on-media) 10%, transparent);
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
background: color-mix(in srgb, var(--on-media) 22%, transparent);
|
||||
}
|
||||
.nav:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -334,16 +335,16 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
.caption {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: color-mix(in srgb, var(--on-media) 85%, transparent);
|
||||
max-width: min(96vw, 720px);
|
||||
text-align: center;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: color-mix(in srgb, var(--on-media) 55%, transparent);
|
||||
}
|
||||
.hint a {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: color-mix(in srgb, var(--on-media) 85%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -504,9 +504,9 @@
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--success, #22c55e);
|
||||
background: var(--ok);
|
||||
border: none;
|
||||
color: #000;
|
||||
color: var(--accent-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-body);
|
||||
|
||||
@@ -95,10 +95,10 @@
|
||||
rgba(255, 255, 255, 0.03) 6px,
|
||||
rgba(255, 255, 255, 0.03) 12px
|
||||
),
|
||||
var(--bg-elevated, #16161d);
|
||||
border: 1px dashed var(--border, #2a2a35);
|
||||
var(--bg-elevated);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: inherit;
|
||||
color: var(--text-muted, #8b8b9a);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
|
||||
}
|
||||
.artifact.failed {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
@@ -249,12 +249,12 @@
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
background: var(--media-scrim);
|
||||
box-shadow: 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.play-badge :global(svg) {
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
color: var(--on-media);
|
||||
fill: currentColor;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
|
||||
margin-left: 2px;
|
||||
|
||||
@@ -668,8 +668,8 @@ let {
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -772,7 +772,7 @@ let {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -785,7 +785,7 @@ let {
|
||||
.send.stop {
|
||||
background: transparent;
|
||||
color: var(--error);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.send.steer {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -431,6 +431,6 @@
|
||||
}
|
||||
.del:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-card.error {
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
/**
|
||||
* Agent identity palette: a stable display name + accent color per agent.
|
||||
* Agent identity palette: a stable display name + identity step per agent.
|
||||
*
|
||||
* Identity colors answer "who is speaking" only (avatar / name / bubble
|
||||
* tint) — they never express task status (spec v2 §3.6). The curated set
|
||||
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
|
||||
* no tailwind rainbow hues leak into the fusion palette; the default
|
||||
* main-loop agent is neutral `--text-2` slate.
|
||||
* Identity answers "who is speaking" only (avatar dot / name / bubble tint)
|
||||
* — it never expresses task status. HEL-685 无彩化:全部改为 app.css 的
|
||||
* 显影灰阶实色阶 --id-1..5(与全站 --avatar-1..5 同一身份语言),
|
||||
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
|
||||
*/
|
||||
|
||||
export const DEFAULT_AGENT_NAME = 'Calliope';
|
||||
|
||||
// Fixed identity map (keyed by the raw agent_name the backend emits).
|
||||
// Step numbers mirror the avatar five-set used across the app
|
||||
// (models/agents/home project cards).
|
||||
const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
|
||||
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
|
||||
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
|
||||
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
|
||||
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
|
||||
planner: { label: 'Planner', color: 'var(--id-2)' },
|
||||
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
|
||||
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
|
||||
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
|
||||
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
|
||||
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
|
||||
// The five named agents (HEL-613) — the runner stamps their zh names on
|
||||
// named-session replies; colors mirror the spec §2.1 avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: '#e8a33d' },
|
||||
视觉设计: { label: '视觉设计', color: '#5b8def' },
|
||||
分镜设计: { label: '分镜设计', color: '#3fb97f' },
|
||||
素材资产: { label: '素材资产', color: '#b98be0' },
|
||||
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
|
||||
// named-session replies; steps mirror the avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
|
||||
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
|
||||
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
|
||||
素材资产: { label: '素材资产', color: 'var(--id-4)' },
|
||||
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
|
||||
};
|
||||
|
||||
// Fallback palette for unknown names (stable hash → deterministic color),
|
||||
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
|
||||
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
|
||||
// Fallback palette for unknown names (stable hash → deterministic step),
|
||||
// spanning the same five grayscale identity steps.
|
||||
const FALLBACK_COLORS = [
|
||||
'var(--id-1)',
|
||||
'var(--id-2)',
|
||||
'var(--id-3)',
|
||||
'var(--id-4)',
|
||||
'var(--id-5)',
|
||||
];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
@@ -39,8 +46,8 @@ function hashString(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = '#aab2c5';
|
||||
/** Neutral step (--id-4) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
|
||||
|
||||
export function agentDisplayName(name: string | null | undefined): string {
|
||||
const key = (name || '').trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
<script lang="ts">
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import {
|
||||
clearOutputMissing,
|
||||
markOutputMissing,
|
||||
missingOutputsOf,
|
||||
} from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from '../generate/schema';
|
||||
import {
|
||||
elapsedSec,
|
||||
fmtClock,
|
||||
fmtDur,
|
||||
inputsOf,
|
||||
isVideoOutput,
|
||||
promptOf,
|
||||
specOf,
|
||||
} from '../generate/takes';
|
||||
|
||||
/**
|
||||
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
|
||||
* stage with its spec, inputs and workflow; 参数 hosts the full form.
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), video playback is always explicit,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
main?: boolean;
|
||||
comparePath?: string | null;
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
nowMs: number;
|
||||
open: boolean;
|
||||
/** Collapsed or off-screen — nothing inside may take focus. */
|
||||
inactive: boolean;
|
||||
/** Current workflow can take an image output (送入当前图片槽). */
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** The stage <video> element, for explicit playback controls. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
canAttach?: boolean;
|
||||
attachBusy?: boolean;
|
||||
onAttach?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
main = false,
|
||||
comparePath = null,
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
canAttach = false,
|
||||
attachBusy = false,
|
||||
onAttach,
|
||||
}: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
let outIndex = $state(0);
|
||||
let compare = $state(false),
|
||||
compareAt = $state(50),
|
||||
lightbox = $state(false);
|
||||
$effect(() => {
|
||||
void job?.id;
|
||||
detailsOpen = false;
|
||||
deleteOpen = false;
|
||||
compare = false;
|
||||
lightbox = false;
|
||||
});
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(
|
||||
job && st?.tone === 'ok'
|
||||
? job.output_paths.filter((p) => !gonePaths.includes(p))
|
||||
: [],
|
||||
);
|
||||
const output = $derived(
|
||||
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
|
||||
);
|
||||
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const compareSource = $derived(
|
||||
inputs.find((i) => i.kind === 'image')?.path ?? comparePath,
|
||||
);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as
|
||||
string | undefined) ?? '',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
let dims = $state('');
|
||||
let naturalWidth = $state(1),
|
||||
naturalHeight = $state(1);
|
||||
let stageWidth = $state(0),
|
||||
stageHeight = $state(0);
|
||||
const fitted = $derived(
|
||||
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
|
||||
);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
dims = '';
|
||||
});
|
||||
function noteImg(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
naturalWidth = img.naturalWidth;
|
||||
naturalHeight = img.naturalHeight;
|
||||
dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
naturalWidth = v.videoWidth;
|
||||
naturalHeight = v.videoHeight;
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
let paused = $state(true);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
untrack(() => {
|
||||
videoEl?.pause();
|
||||
paused = true;
|
||||
});
|
||||
});
|
||||
function togglePlayback() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
const src = assetUrl(output);
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="artboard" aria-label="生成结果">
|
||||
<button
|
||||
class="artboard-toolbar"
|
||||
aria-label="作品详情与操作"
|
||||
onclick={() => (detailsOpen = true)}
|
||||
>
|
||||
<div class="result-title">
|
||||
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
|
||||
</div>
|
||||
</button>
|
||||
<div class="stage" class:comparing={compare}>
|
||||
{#if job && st}{#key mediaKey}
|
||||
<div
|
||||
class="stage-media"
|
||||
bind:clientWidth={stageWidth}
|
||||
bind:clientHeight={stageHeight}
|
||||
>
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<div
|
||||
class="print a-photo"
|
||||
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
|
||||
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
|
||||
>
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
|
||||
>{t('jobst.done')}</span
|
||||
>{/if}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
aria-label="生成的视频,使用播放按钮开始播放"
|
||||
onkeydown={(e) => {
|
||||
if (e.code === 'Space' && e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
togglePlayback();
|
||||
}
|
||||
}}
|
||||
onplay={() => (paused = false)}
|
||||
onpause={() => (paused = true)}
|
||||
onended={() => (paused = true)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
{#if paused}<button
|
||||
class="big-play"
|
||||
aria-label="播放此视频"
|
||||
onclick={() => {
|
||||
videoEl?.focus();
|
||||
togglePlayback();
|
||||
}}>▶</button
|
||||
>{/if}
|
||||
</div>
|
||||
{:else if output}
|
||||
<div
|
||||
class="print a-photo"
|
||||
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
|
||||
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
|
||||
>
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
|
||||
>{t('jobst.done')}</span
|
||||
>{/if}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{#if main && compare && compareSource}<div
|
||||
class="comparison-layer"
|
||||
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
|
||||
>
|
||||
<img src={assetUrl(compareSource)} alt="对比参考画面" />
|
||||
</div>
|
||||
<div
|
||||
class="comparison-divider"
|
||||
style:left={`${compareAt}%`}
|
||||
></div>{/if}
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
<span class="sub">{t('desk.missingSub')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage-empty">
|
||||
<span>{st.label}</span>
|
||||
<span class="sub"
|
||||
>{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span
|
||||
>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}>
|
||||
<i></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}{/if}
|
||||
{#if outputs.length > 1}<div
|
||||
class="output-picker"
|
||||
role="group"
|
||||
aria-label="本次生成的作品"
|
||||
>
|
||||
{#each outputs as p, i}<button
|
||||
aria-pressed={output === p}
|
||||
onclick={() => (outIndex = i)}
|
||||
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
</div>
|
||||
<footer class="artboard-footer">
|
||||
<button
|
||||
class="canvas-btn"
|
||||
disabled={!output}
|
||||
onclick={() =>
|
||||
video
|
||||
? videoEl?.requestFullscreen().catch(() => {})
|
||||
: (lightbox = true)}
|
||||
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
|
||||
>
|
||||
<a
|
||||
class="canvas-btn"
|
||||
href={output ? assetUrl(output) : undefined}
|
||||
aria-disabled={!output}
|
||||
download
|
||||
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
|
||||
>
|
||||
<span class="action-separator" aria-hidden="true"></span>
|
||||
<button
|
||||
class="canvas-btn"
|
||||
disabled={!job || !output}
|
||||
onclick={() => job && onReuse(job)}
|
||||
><Icon name="retry" size={14} />复用参数</button
|
||||
>
|
||||
<button
|
||||
class="canvas-btn"
|
||||
disabled={!output || video || !canSendToSlot}
|
||||
title={canSendToSlot
|
||||
? '放入当前工作流图片输入'
|
||||
: '当前工作流没有图片输入'}
|
||||
onclick={() => output && onSendToSlot(output)}
|
||||
><Icon name="image" size={14} />作为参考图</button
|
||||
>
|
||||
<button
|
||||
class="canvas-btn"
|
||||
disabled
|
||||
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
|
||||
><Icon name="film" size={14} />送去生视频</button
|
||||
>
|
||||
<span class="action-separator" aria-hidden="true"></span>
|
||||
<button
|
||||
class="canvas-btn"
|
||||
disabled={!canAttach || attachBusy}
|
||||
title={canAttach
|
||||
? '保存到来源项目'
|
||||
: '通用选择项目与阶段将在后续批次提供'}
|
||||
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
|
||||
>
|
||||
<a class="canvas-btn" href="/assets"
|
||||
><Icon name="assets" size={14} />素材库</a
|
||||
>
|
||||
<button
|
||||
class="canvas-btn delete-output"
|
||||
aria-label="删除作品记录"
|
||||
title="删除作品记录"
|
||||
disabled={!job || job.status === 'pending' || job.status === 'running'}
|
||||
onclick={() => (deleteOpen = true)}
|
||||
><Icon name="close" size={14} /></button
|
||||
>
|
||||
{#if job?.status === 'pending' || job?.status === 'running'}<button
|
||||
class="canvas-btn"
|
||||
onclick={() => job && onCancel(job)}>取消生成</button
|
||||
>{/if}
|
||||
{#if st?.tone === 'fail'}<button
|
||||
class="canvas-btn"
|
||||
onclick={() => job && onRetry(job)}>重试</button
|
||||
>{/if}
|
||||
{#if compare && output && !video}<label class="compare-slider"
|
||||
>参考<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/>结果</label
|
||||
>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
|
||||
{#if job && st}
|
||||
<div class="detail-actions">
|
||||
{#if video}<button
|
||||
class="canvas-btn play-button"
|
||||
onclick={togglePlayback}
|
||||
aria-label={paused ? '播放视频' : '暂停视频'}
|
||||
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
|
||||
><button
|
||||
class="canvas-btn"
|
||||
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
|
||||
>全屏</button
|
||||
>{/if}
|
||||
<span class="result-spec"
|
||||
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
|
||||
>
|
||||
{#if compare && output && !video}<label class="compare-slider"
|
||||
>参考<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/>结果</label
|
||||
>{/if}
|
||||
{#if output && !video && compareSource && compareSource !== output}<button
|
||||
class="canvas-btn"
|
||||
aria-pressed={compare}
|
||||
onclick={() => {
|
||||
compare = !compare;
|
||||
detailsOpen = false;
|
||||
}}>◫ {compare ? '结束对比' : '对比'}</button
|
||||
>{/if}
|
||||
<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => {
|
||||
onReuse(job);
|
||||
detailsOpen = false;
|
||||
}}>复用到输入区</button
|
||||
>
|
||||
{#if output && !video && canSendToSlot}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => {
|
||||
onSendToSlot(output);
|
||||
detailsOpen = false;
|
||||
}}>送入当前图片槽</button
|
||||
>{/if}
|
||||
{#if video && output}<button
|
||||
class="a-btn secondary sm"
|
||||
disabled={frameBusy}
|
||||
onclick={exportTail}>{frameBusy ? '正在导出…' : '导出尾帧'}</button
|
||||
>{/if}
|
||||
{#if st.tone === 'fail' || fileGone}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => onRetry(job)}>用相同参数重做</button
|
||||
>{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}<button
|
||||
class="a-btn danger sm"
|
||||
onclick={() => {
|
||||
deleteOpen = true;
|
||||
detailsOpen = false;
|
||||
}}>移除记录</button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if st.tone === 'fail' || fileGone}<p class="error-note" role="alert">
|
||||
{fileGone ? t('jobst.missingDetail') : st.detail}
|
||||
</p>{/if}
|
||||
<div class="detail-meta">
|
||||
{#each spec as s}<span>{s.label} · {s.value}</span
|
||||
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img
|
||||
class="mini"
|
||||
src={assetUrl(inp.path)}
|
||||
alt={inp.label}
|
||||
title={inp.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}
|
||||
><Icon
|
||||
name={inp.kind === 'video' ? 'film' : 'music'}
|
||||
size={14}
|
||||
/></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone"
|
||||
>{t('studio.wfDeletedTag')}</span
|
||||
>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
</Modal>
|
||||
<Modal bind:open={deleteOpen} title="删除作品记录" size="md">
|
||||
<p>
|
||||
确认移除这条生成记录及生成文件?关联项目中的回流记录也会移除。确认后有 5
|
||||
秒可撤销,提交后无法恢复。
|
||||
</p>
|
||||
{#snippet footer()}<button
|
||||
class="a-btn secondary"
|
||||
onclick={() => (deleteOpen = false)}>取消</button
|
||||
>
|
||||
<button
|
||||
class="a-btn danger"
|
||||
onclick={() => {
|
||||
if (job) onDelete(job);
|
||||
deleteOpen = false;
|
||||
}}>确认移除</button
|
||||
>{/snippet}
|
||||
</Modal>
|
||||
{#if lightbox && output && !video}<ImageLightbox
|
||||
src={assetUrl(output) ?? ''}
|
||||
alt={prompt || '创作结果'}
|
||||
onClose={() => (lightbox = false)}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.artboard {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 0 minmax(0, 1fr) 40px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
.artboard-toolbar,
|
||||
.artboard-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.artboard-toolbar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
border-radius: 4px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
padding: 2px 6px;
|
||||
max-width: calc(100% - 20px);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.result-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.result-title small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
.canvas-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--desk-action-radius);
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-separator {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
.delete-output {
|
||||
width: 26px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
color: var(--err);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.canvas-btn:disabled,
|
||||
.canvas-btn[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.canvas-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.canvas-btn[aria-pressed='true'],
|
||||
.play-button {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.stage {
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
.print {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.big-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--on-media-line);
|
||||
color: var(--on-media);
|
||||
background: var(--media-scrim);
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.print img,
|
||||
.print video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.print :global(.a-fix) {
|
||||
display: none;
|
||||
}
|
||||
.stage-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.stage-empty .sub {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.stage-bar {
|
||||
width: 200px;
|
||||
}
|
||||
.comparison-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.comparison-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.artboard-footer {
|
||||
grid-row: 3;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.result-spec {
|
||||
margin-left: auto;
|
||||
}
|
||||
.compare-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.compare-slider input {
|
||||
width: 120px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.output-picker {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
max-width: 90%;
|
||||
overflow: auto;
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.output-picker button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.output-picker button[aria-pressed='true'] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.detail-actions,
|
||||
.detail-meta,
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-meta {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
.mini {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.prompt {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-2);
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 90px minmax(0, 1fr);
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error-note {
|
||||
color: var(--warn);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.result-title small {
|
||||
display: none;
|
||||
}
|
||||
.stage-media {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,629 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, apiErrorMessage } from '$lib/api';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import { spotlight } from '$lib/actions/spotlight';
|
||||
import { toast } from '$lib/toast';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
let {
|
||||
kind,
|
||||
initialUsage = 'all',
|
||||
metas,
|
||||
selectedId,
|
||||
pendingCount,
|
||||
onPick,
|
||||
onKind,
|
||||
onDetails,
|
||||
disabled = false,
|
||||
historyCollapsed = false,
|
||||
onToggleHistory,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
historyCollapsed?: boolean;
|
||||
onToggleHistory: () => void;
|
||||
kind: 'image' | 'video';
|
||||
initialUsage?: string;
|
||||
metas: WorkflowMeta[];
|
||||
selectedId: number | null;
|
||||
pendingCount: number;
|
||||
onPick: (id: number) => void;
|
||||
onKind: (kind: 'image' | 'video') => void;
|
||||
onDetails: () => void;
|
||||
} = $props();
|
||||
let searchInput: HTMLInputElement;
|
||||
let searchOrigin: HTMLElement | null = null;
|
||||
function searchKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
|
||||
if (
|
||||
[
|
||||
...document.querySelectorAll('.shell-pop, .modal, [role="dialog"]'),
|
||||
].some((el) => el.getClientRects().length)
|
||||
)
|
||||
return;
|
||||
if (e.key === 'Escape' && e.target === searchInput) {
|
||||
e.preventDefault();
|
||||
const origin = searchOrigin?.isConnected
|
||||
? searchOrigin
|
||||
: searchInput.closest('aside')?.querySelector('button');
|
||||
if (origin instanceof HTMLElement) origin.focus();
|
||||
else searchInput.blur();
|
||||
return;
|
||||
}
|
||||
const el = e.target instanceof HTMLElement ? e.target : null;
|
||||
if (
|
||||
e.key !== '/' ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
e.altKey ||
|
||||
el?.isContentEditable ||
|
||||
el?.closest(
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
|
||||
) ||
|
||||
!searchInput?.getClientRects().length
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
const client = useQueryClient();
|
||||
let search = $state(''),
|
||||
usage = $state(untrack(() => initialUsage)),
|
||||
family = $state('all'),
|
||||
favOnly = $state(false),
|
||||
runnableOnly = $state(true),
|
||||
limit = $state(48),
|
||||
pins = $state<number[]>([]);
|
||||
onMount(() => {
|
||||
try {
|
||||
pins = JSON.parse(
|
||||
localStorage.getItem('calliope.reference.workflowPins') || '[]',
|
||||
);
|
||||
} catch {
|
||||
pins = [];
|
||||
}
|
||||
});
|
||||
const families = $derived([
|
||||
...new Set(metas.map((m) => m.model_family).filter(Boolean)),
|
||||
]);
|
||||
const usages = $derived([
|
||||
...new Set(metas.map((m) => m.usage).filter(Boolean)),
|
||||
]);
|
||||
const filtered = $derived(
|
||||
metas
|
||||
.filter(
|
||||
(m) =>
|
||||
(!runnableOnly || runnable(m)) &&
|
||||
(!favOnly || m.favorite) &&
|
||||
(usage === 'all' || m.usage === usage) &&
|
||||
(family === 'all' || m.model_family === family) &&
|
||||
[
|
||||
m.name,
|
||||
m.description,
|
||||
m.source_path,
|
||||
m.usage ? usageLabel(m.usage) : null,
|
||||
m.model_family,
|
||||
...(m.tags ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
|
||||
Number(b.favorite) - Number(a.favorite) ||
|
||||
(a.model_family ?? '本地工作流').localeCompare(
|
||||
b.model_family ?? '本地工作流',
|
||||
'zh',
|
||||
) ||
|
||||
a.name.localeCompare(b.name, 'zh'),
|
||||
),
|
||||
);
|
||||
$effect(() => {
|
||||
void search;
|
||||
void usage;
|
||||
void family;
|
||||
void favOnly;
|
||||
limit = 48;
|
||||
});
|
||||
$effect(() => {
|
||||
const index = filtered.findIndex((m) => m.id === selectedId);
|
||||
if (index >= limit) limit = index + 24;
|
||||
});
|
||||
function runnable(m: WorkflowMeta) {
|
||||
return m.is_enabled && !!m.usage && !m.needs_mapping;
|
||||
}
|
||||
function choose(m: WorkflowMeta) {
|
||||
if (runnable(m)) onPick(m.id);
|
||||
else void goto('/workflows?manage=1&wf=' + m.id);
|
||||
}
|
||||
function pin(id: number) {
|
||||
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'calliope.reference.workflowPins',
|
||||
JSON.stringify(pins),
|
||||
);
|
||||
} catch {
|
||||
/* Pins remain usable this visit. */
|
||||
}
|
||||
}
|
||||
const favorite = createMutation({
|
||||
mutationFn: (m: WorkflowMeta) =>
|
||||
workflows.update(m.id, { favorite: !m.favorite }),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['workflows-meta'] }),
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={searchKeydown} />
|
||||
|
||||
<aside class="workflow-rail" aria-label="创作工作流列表">
|
||||
<header>
|
||||
<strong>创作台</strong><button
|
||||
class="history-toggle a-btn ghost sm"
|
||||
onclick={onToggleHistory}
|
||||
aria-label={historyCollapsed ? '展开历史栏' : '收起历史栏'}
|
||||
aria-expanded={!historyCollapsed}>◫</button
|
||||
>
|
||||
</header>
|
||||
<div class="rail-tools">
|
||||
<div class="kind-tabs" role="tablist" aria-label="创作类型">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={kind === 'image'}
|
||||
{disabled}
|
||||
onclick={() => onKind('image')}>图片</button
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={kind === 'video'}
|
||||
{disabled}
|
||||
onclick={() => onKind('video')}>视频</button
|
||||
>
|
||||
</div>
|
||||
<div class="rail-search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
type="search"
|
||||
bind:this={searchInput}
|
||||
onfocus={(e) => {
|
||||
searchOrigin =
|
||||
e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
|
||||
}}
|
||||
bind:value={search}
|
||||
aria-label="搜索工作流"
|
||||
placeholder="搜用途、模型或文件名"
|
||||
/>
|
||||
<kbd>/</kbd>
|
||||
</div>
|
||||
<div class="filters" role="group" aria-label="筛选工作流用途">
|
||||
<button aria-pressed={usage === 'all'} onclick={() => (usage = 'all')}
|
||||
>全部</button
|
||||
>
|
||||
{#each usages as u}<button
|
||||
aria-pressed={usage === u}
|
||||
onclick={() => (usage = u!)}>{usageLabel(u!)}</button
|
||||
>{/each}
|
||||
<button aria-pressed={favOnly} onclick={() => (favOnly = !favOnly)}
|
||||
>☆ 收藏</button
|
||||
>
|
||||
</div>
|
||||
{#if families.length}<div
|
||||
class="filters"
|
||||
role="group"
|
||||
aria-label="筛选模型家族"
|
||||
>
|
||||
<button aria-pressed={family === 'all'} onclick={() => (family = 'all')}
|
||||
>全部模型</button
|
||||
>
|
||||
{#each families as f}<button
|
||||
aria-pressed={family === f}
|
||||
onclick={() => (family = f!)}>{f}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
<div class="list-meta">
|
||||
<span title="目录接口不提供仓库文件总数"
|
||||
>{filtered.length} 个 · 仓库文件数未知</span
|
||||
>
|
||||
<label class="runnable-filter"
|
||||
><input type="checkbox" bind:checked={runnableOnly} />仅可运行</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-list">
|
||||
{#each filtered.slice(0, limit) as m, index (m.id)}
|
||||
{#if index === 0 || pins.includes(m.id) !== pins.includes(filtered[index - 1].id) || (!pins.includes(m.id) && m.model_family !== filtered[index - 1].model_family)}<div
|
||||
class="family-label"
|
||||
>
|
||||
{pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'}
|
||||
</div>{/if}
|
||||
<div
|
||||
class="workflow-row reference-option"
|
||||
use:spotlight
|
||||
class:selected={selectedId === m.id}
|
||||
>
|
||||
<button
|
||||
class="workflow-pick"
|
||||
{disabled}
|
||||
onclick={() => choose(m)}
|
||||
aria-pressed={selectedId === m.id}
|
||||
title={m.name}
|
||||
><i class:video={kind === 'video'}
|
||||
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
|
||||
><span
|
||||
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
|
||||
>待配置 · 映射 / 测试 →</small
|
||||
>{/if}<small
|
||||
>{m.description ||
|
||||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
|
||||
></span
|
||||
></button
|
||||
>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
class:pressed={pins.includes(m.id)}
|
||||
aria-pressed={pins.includes(m.id)}
|
||||
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
|
||||
onclick={() => pin(m.id)}>⌖</button
|
||||
><button
|
||||
aria-pressed={!!m.favorite}
|
||||
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
|
||||
disabled={$favorite.isPending}
|
||||
onclick={() => $favorite.mutate(m)}>{m.favorite ? '★' : '☆'}</button
|
||||
>
|
||||
</div>
|
||||
</div>{/each}
|
||||
{#if !filtered.length}<p class="empty">
|
||||
没有符合条件的工作流。可以清空搜索或更改筛选。
|
||||
</p>{/if}
|
||||
{#if filtered.length > limit}<button
|
||||
class="more"
|
||||
onclick={() => (limit += 48)}
|
||||
>再显示 {Math.min(filtered.length - limit, 48)} 个</button
|
||||
>{/if}
|
||||
</div>
|
||||
<footer>
|
||||
{#if pendingCount}<a href="/workflows?manage=1"
|
||||
>{pendingCount} 个工作流待配置 →</a
|
||||
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
|
||||
><span>+</span>
|
||||
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
|
||||
<b>›</b></button
|
||||
><a href="/models">云端模型与接口 ↗</a>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workflow-rail {
|
||||
box-sizing: border-box;
|
||||
width: var(--list);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.workflow-rail > header {
|
||||
padding: 12px 12px 0;
|
||||
height: var(--desk-rail-title-height);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
header strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
.rail-tools {
|
||||
padding: var(--desk-rail-gap) 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.kind-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--desk-seg-radius);
|
||||
background: var(--well);
|
||||
}
|
||||
.kind-tabs button {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12.5px;
|
||||
border: 0;
|
||||
border-radius: var(--desk-seg-selected-radius);
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kind-tabs [aria-selected='true'] {
|
||||
background: var(--bg-3);
|
||||
box-shadow: var(--sh-1);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.kind-tabs button:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
.rail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: var(--desk-rail-gap);
|
||||
height: var(--desk-search-height);
|
||||
box-sizing: border-box;
|
||||
font-size: var(--desk-search-size);
|
||||
padding: 0 10px;
|
||||
background: var(--well);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--desk-search-radius);
|
||||
color: var(--text);
|
||||
}
|
||||
.rail-search input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.rail-search kbd {
|
||||
font: var(--desk-search-key-font);
|
||||
padding: var(--desk-search-key-padding);
|
||||
border-radius: var(--desk-search-key-radius);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--tx-3);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.rail-search:focus-within {
|
||||
border-color: var(--scene);
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.filters button {
|
||||
height: var(--desk-filter-height);
|
||||
padding: 0 9px;
|
||||
font-size: var(--desk-filter-size);
|
||||
border-radius: var(--desk-filter-radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button[aria-pressed='true'] {
|
||||
color: var(--tx-1);
|
||||
border-color: color-mix(in srgb, var(--scene) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--scene) 13%, var(--bg-2));
|
||||
}
|
||||
.runnable-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.runnable-filter input {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
width: var(--desk-switch-width);
|
||||
height: var(--desk-switch-height);
|
||||
border: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.runnable-filter input::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: var(--desk-switch-knob);
|
||||
height: var(--desk-switch-knob);
|
||||
border-radius: 50%;
|
||||
background: var(--tx-2);
|
||||
transition: transform var(--t-base);
|
||||
}
|
||||
.runnable-filter input:checked {
|
||||
background: color-mix(in srgb, var(--scene) 55%, var(--bg-4));
|
||||
}
|
||||
.runnable-filter input:checked::after {
|
||||
transform: translateX(11px);
|
||||
background: var(--on-media);
|
||||
}
|
||||
.pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.list-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 8px 2px 0;
|
||||
font-size: var(--desk-filter-size);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.family-label {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
padding: 8px;
|
||||
}
|
||||
.rail-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 8px 8px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.workflow-row {
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.workflow-row.selected {
|
||||
border-color: color-mix(in srgb, var(--scene) 35%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--scene) 12%, var(--bg-3)),
|
||||
var(--bg-3)
|
||||
);
|
||||
}
|
||||
.workflow-row.selected::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--scene);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.workflow-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 8px;
|
||||
min-height: 55px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-pick i {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--scene);
|
||||
background: var(--bg-3);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
}
|
||||
.workflow-pick > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.workflow-pick b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.workflow-pick small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-actions button {
|
||||
opacity: 0;
|
||||
padding: 2px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
border: 0;
|
||||
color: var(--text-3);
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-actions button[aria-pressed='true'] {
|
||||
color: var(--warn);
|
||||
opacity: 1;
|
||||
}
|
||||
.workflow-row:hover .row-actions button,
|
||||
.workflow-row:focus-within .row-actions button {
|
||||
opacity: 1;
|
||||
}
|
||||
.workflow-row:hover {
|
||||
background-color: var(--bg-2);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
padding: 12px;
|
||||
}
|
||||
.more {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
footer {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
footer a,
|
||||
.manage {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.manage {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 7px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.manage small {
|
||||
display: none;
|
||||
}
|
||||
.manage b {
|
||||
margin-left: auto;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workflow-rail {
|
||||
width: var(--list);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { isVideoOutput } from '../generate/takes';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
let {
|
||||
jobs,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onExpand,
|
||||
collapsed = false,
|
||||
}: {
|
||||
collapsed?: boolean;
|
||||
jobs: Job[];
|
||||
selectedId: number | null;
|
||||
onSelect: (job: Job) => void;
|
||||
onExpand: () => void;
|
||||
} = $props();
|
||||
const sorted = $derived([...jobs].sort((a, b) => b.id - a.id));
|
||||
</script>
|
||||
|
||||
<aside class="history-rail" class:collapsed aria-label="生成历史">
|
||||
<header>历史</header>
|
||||
{#if !collapsed}
|
||||
<div class="history-list">
|
||||
{#each sorted as job (job.id)}
|
||||
{@const status = jobStatusInfo(job)}
|
||||
<button
|
||||
class="history-take"
|
||||
class:selected={selectedId === job.id}
|
||||
onclick={() => onSelect(job)}
|
||||
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
|
||||
aria-pressed={selectedId === job.id}
|
||||
>
|
||||
<div class="thumb">
|
||||
{#if job.output_paths[0] && status.tone === 'ok'}
|
||||
{#if isVideoOutput(job, job.output_paths[0])}<span
|
||||
class="video-mark">▷</span
|
||||
><video
|
||||
src={assetUrl(job.output_paths[0])}
|
||||
muted
|
||||
preload="metadata"
|
||||
aria-label="视频缩略图"
|
||||
></video>
|
||||
{:else}<img
|
||||
src={assetUrl(job.output_paths[0])}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>{/if}
|
||||
{:else}<span class:failed={status.tone === 'fail'}
|
||||
>{status.label}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<small>#{job.id}</small>
|
||||
</button>
|
||||
{/each}
|
||||
{#if !sorted.length}<p>生成后<br />记录留在这里</p>{/if}
|
||||
</div>
|
||||
<footer><button onclick={onExpand}>全部记录</button></footer>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.history-rail {
|
||||
box-sizing: border-box;
|
||||
width: var(--hist);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.history-rail.collapsed {
|
||||
width: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
header {
|
||||
height: var(--head);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
background: none;
|
||||
color: var(--tx-2);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.history-take {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 3px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.history-take.selected {
|
||||
border-color: var(--scene);
|
||||
background: color-mix(in srgb, var(--scene) 10%, transparent);
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-0);
|
||||
border-radius: var(--r-xs);
|
||||
overflow: hidden;
|
||||
font-size: 10px;
|
||||
}
|
||||
img,
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.video-mark {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
color: var(--on-media);
|
||||
font-size: 24px;
|
||||
text-shadow: 0 1px 6px var(--media-scrim);
|
||||
}
|
||||
.failed {
|
||||
color: var(--err);
|
||||
}
|
||||
small {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
p {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
footer {
|
||||
padding: 10px 2px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkflowMediaIO } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
|
||||
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
|
||||
*/
|
||||
interface Props {
|
||||
io: WorkflowMediaIO | null | undefined;
|
||||
/** Compact form for row summaries (no out side when empty-in). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { io, compact = false }: Props = $props();
|
||||
|
||||
type Medium = 'image' | 'video' | 'audio';
|
||||
|
||||
const LABELS: Record<Medium, string> = {
|
||||
image: 'wl.mediaImage',
|
||||
video: 'wl.mediaVideo',
|
||||
audio: 'wl.mediaAudio',
|
||||
};
|
||||
|
||||
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
|
||||
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
|
||||
</script>
|
||||
|
||||
{#if inList.length || outList.length}
|
||||
<span class="req" aria-label={t('desk.bayReqAria')}>
|
||||
{#each inList as m (m)}
|
||||
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
|
||||
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
|
||||
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
|
||||
{#each outList as m (m)}
|
||||
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
|
||||
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
|
||||
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else if !compact}
|
||||
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.req {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-mini);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.in {
|
||||
color: var(--text);
|
||||
}
|
||||
.pill.none .lbl {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
|
||||
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
|
||||
*/
|
||||
interface Props {
|
||||
search: string;
|
||||
source: 'local' | 'cloud';
|
||||
favOnly: boolean;
|
||||
resultHint: string;
|
||||
searchEl?: HTMLInputElement | null;
|
||||
onSetSearch: (v: string) => void;
|
||||
onSetSource: (s: 'local' | 'cloud') => void;
|
||||
onToggleFav: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
search,
|
||||
source,
|
||||
favOnly,
|
||||
resultHint,
|
||||
searchEl = $bindable(null),
|
||||
onSetSearch,
|
||||
onSetSource,
|
||||
onToggleFav,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="filters">
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
bind:this={searchEl}
|
||||
class="search-input"
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder={t('desk.baySearchPh')}
|
||||
aria-label={t('desk.baySearchPh')}
|
||||
aria-keyshortcuts="/"
|
||||
oninput={(e) => onSetSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
|
||||
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
|
||||
{t('desk.baySourceLocal')}
|
||||
</button>
|
||||
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
|
||||
{t('desk.baySourceCloud')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip fav"
|
||||
class:on={favOnly}
|
||||
aria-pressed={favOnly}
|
||||
title={t('desk.bayFavOnly')}
|
||||
onclick={onToggleFav}
|
||||
>
|
||||
<Icon name="star" size={13} />
|
||||
<span class="lbl">{t('desk.bayFavOnly')}</span>
|
||||
</button>
|
||||
<span class="hint">{resultHint}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.source {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fav.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
|
||||
/**
|
||||
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
|
||||
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
|
||||
*/
|
||||
interface Props {
|
||||
meta: WorkflowMeta;
|
||||
current: boolean;
|
||||
active: boolean;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: () => void;
|
||||
onActive: () => void;
|
||||
}
|
||||
|
||||
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
|
||||
|
||||
const endpointName = $derived(
|
||||
meta.endpoint_id
|
||||
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
|
||||
: t('desk.wfDefaultConn'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
class:active
|
||||
class:current
|
||||
role="option"
|
||||
aria-selected={active ? 'true' : undefined}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
onclick={onPick}
|
||||
onpointermove={onActive}
|
||||
onfocus={onActive}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="name">
|
||||
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
|
||||
<span class="name-txt">{meta.name}</span>
|
||||
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
|
||||
</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{usageLabel(meta.usage!)}</span>
|
||||
<InputRequirementBadge io={meta.media_io} compact />
|
||||
</span>
|
||||
</span>
|
||||
<span class="side">
|
||||
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
/* 长列表性能:视口外的行不参与布局/绘制。 */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 52px;
|
||||
transition: background-color var(--d-state) ease;
|
||||
}
|
||||
.row:hover,
|
||||
.row.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.row:focus-visible {
|
||||
outline: none;
|
||||
background: var(--bg-3);
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.name-txt {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fav {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
.cur-mark {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.side {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.conn {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
|
||||
.row.current {
|
||||
position: relative;
|
||||
}
|
||||
.row.current::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { missingOutputsOf, markOutputMissing } from '$lib/missingOutputs.svelte';
|
||||
let {
|
||||
jobs,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onHistory,
|
||||
nameOf,
|
||||
}: {
|
||||
jobs: Job[];
|
||||
selectedId: number | null;
|
||||
onSelect: (job: Job) => void;
|
||||
onHistory: () => void;
|
||||
nameOf: (job: Job) => string;
|
||||
} = $props();
|
||||
const sorted = $derived(
|
||||
[...jobs].sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="result-rail" aria-label="生成结果记录">
|
||||
<header>
|
||||
<strong>生成记录 <small>{jobs.length}</small></strong><button
|
||||
class="a-btn ghost sm"
|
||||
onclick={onHistory}>全部记录与任务 ↗</button
|
||||
>
|
||||
</header>
|
||||
<div class="rail-items">
|
||||
{#each sorted as job (job.id)}
|
||||
{@const path = job.output_paths.find((p) => !missingOutputsOf(job).includes(p))}
|
||||
{@const status = jobStatusInfo(job)}
|
||||
<button
|
||||
class="result-item"
|
||||
class:selected={job.id === selectedId}
|
||||
aria-pressed={job.id === selectedId}
|
||||
aria-label="查看第 {job.id} 次生成,{status.label}"
|
||||
title="{nameOf(job)} · {job.created_at} · {status.label}"
|
||||
onclick={() => onSelect(job)}
|
||||
>
|
||||
<span class="thumb"
|
||||
>{#if path && job.kind === 'image'}<img
|
||||
src={assetUrl(path)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => markOutputMissing(path)}
|
||||
/>{:else}<span>{job.kind === 'video' ? '▶' : '✳'}</span>{/if}</span
|
||||
>
|
||||
<span class="record-label">{nameOf(job)}</span>
|
||||
<span class="record-label"
|
||||
>第 {job.id} 次 · {path
|
||||
? status.label
|
||||
: job.status === 'done'
|
||||
? '文件缺失'
|
||||
: status.label}</span
|
||||
>
|
||||
</button>
|
||||
{:else}<p>生成后,结果会保存在这里。</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.result-rail {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 8px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
gap: 10px;
|
||||
}
|
||||
strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
small {
|
||||
color: var(--text-3);
|
||||
margin-left: 5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.rail-items {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.result-item {
|
||||
width: 122px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.result-item.selected {
|
||||
border: 2px solid var(--accent);
|
||||
padding: 2px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.thumb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 48px;
|
||||
background: var(--bg);
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.record-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
padding: 5px 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 4px 0;
|
||||
}
|
||||
@media (max-height: 650px) {
|
||||
.rail-items {
|
||||
display: none;
|
||||
}
|
||||
.thumb,
|
||||
img {
|
||||
height: 32px;
|
||||
}
|
||||
.result-item {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
|
||||
/**
|
||||
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
|
||||
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
|
||||
*/
|
||||
interface Props {
|
||||
kind: 'image' | 'video';
|
||||
metas: WorkflowMeta[];
|
||||
active: WorkflowUsage | 'all';
|
||||
onSelect: (mode: WorkflowUsage | 'all') => void;
|
||||
onPickWorkflow: (id: number) => void;
|
||||
}
|
||||
|
||||
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
|
||||
|
||||
interface ModeEntry {
|
||||
usage: WorkflowUsage;
|
||||
count: number;
|
||||
}
|
||||
const modes = $derived.by(() => {
|
||||
const counts = new Map<WorkflowUsage, number>();
|
||||
for (const m of metas) {
|
||||
if (!m.usage) continue;
|
||||
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
|
||||
}
|
||||
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
|
||||
});
|
||||
|
||||
const favorites = $derived(
|
||||
metas
|
||||
.filter((m) => m.favorite)
|
||||
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
|
||||
.slice(0, 5),
|
||||
);
|
||||
const recents = $derived(
|
||||
metas
|
||||
.filter((m) => m.last_used_at && !m.favorite)
|
||||
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
|
||||
.slice(0, 5),
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label={t('desk.modeRailAria')}>
|
||||
<p class="rail-label">{t('desk.modeRailTitle')}</p>
|
||||
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
class:on={active === 'all'}
|
||||
aria-pressed={active === 'all'}
|
||||
onclick={() => onSelect('all')}
|
||||
>
|
||||
<span class="mode-lbl">{t('desk.modeAll')}</span>
|
||||
<span class="mode-n">{metas.length}</span>
|
||||
</button>
|
||||
{#each modes as m (m.usage)}
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
class:on={active === m.usage}
|
||||
class:off={m.count === 0}
|
||||
disabled={m.count === 0}
|
||||
aria-pressed={active === m.usage}
|
||||
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
|
||||
onclick={() => onSelect(m.usage)}
|
||||
>
|
||||
<span class="mode-lbl">{usageLabel(m.usage)}</span>
|
||||
<span class="mode-n">{m.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="lists">
|
||||
{#if recents.length}
|
||||
<section class="rail-sec">
|
||||
<p class="rail-label">
|
||||
<Icon name="clock" size={12} />
|
||||
{t('desk.railRecent')}
|
||||
</p>
|
||||
<ul class="pick-list">
|
||||
{#each recents as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
|
||||
<span class="pick-name">{m.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if favorites.length}
|
||||
<section class="rail-sec">
|
||||
<p class="rail-label">
|
||||
<Icon name="star" size={12} />
|
||||
{t('desk.railFav')}
|
||||
</p>
|
||||
<ul class="pick-list">
|
||||
{#each favorites as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
|
||||
<span class="pick-name">{m.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin: 0 0 var(--sp-2);
|
||||
padding: 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.mode:hover:not(:disabled) {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.mode:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.mode.on {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
|
||||
.mode.on {
|
||||
position: relative;
|
||||
}
|
||||
.mode.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.mode.off {
|
||||
color: var(--text-3);
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mode-n {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.rail-sec {
|
||||
min-width: 0;
|
||||
}
|
||||
.pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.pick {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.pick:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.pick:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.pick-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
|
||||
@media (max-width: 760px) {
|
||||
.rail {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
.rail-label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modes {
|
||||
flex-direction: row;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mode {
|
||||
width: auto;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mode.on::after {
|
||||
inset: 1px;
|
||||
}
|
||||
.lists {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, settings, apiErrorMessage } from '$lib/api';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
metas: WorkflowMeta[];
|
||||
pendingCount: number;
|
||||
selectedId: number | null;
|
||||
activeId: number | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (id: number) => void;
|
||||
onClose: () => void;
|
||||
catalogue?: boolean;
|
||||
localOnly?: boolean;
|
||||
onPreview?: (id: number) => void;
|
||||
}
|
||||
let {
|
||||
open,
|
||||
metas,
|
||||
pendingCount,
|
||||
selectedId,
|
||||
activeId = $bindable(null),
|
||||
endpoints,
|
||||
onPick,
|
||||
onClose,
|
||||
catalogue = false,
|
||||
localOnly = false,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
const client = useQueryClient();
|
||||
const modelsQuery = createQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
let search = $state(''),
|
||||
category = $state('all'),
|
||||
family = $state('all'),
|
||||
source = $state('local'),
|
||||
usage = $state('all'),
|
||||
favOnly = $state(false),
|
||||
recentOnly = $state(false),
|
||||
visibleCount = $state(48);
|
||||
let searchEl: HTMLInputElement;
|
||||
let listEl: HTMLDivElement;
|
||||
const labels: Record<string, string> = {
|
||||
basic: '基础工具',
|
||||
audio_tts: '语音',
|
||||
music: '音乐',
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
digital_human: '数字人',
|
||||
motion_transfer: '动作',
|
||||
};
|
||||
const categories = $derived([
|
||||
...new Set(metas.map((m) => m.category).filter((k): k is string => !!k)),
|
||||
]);
|
||||
const families = $derived(
|
||||
[...new Set(metas.map((m) => m.model_family).filter((k): k is string => !!k))].sort(),
|
||||
);
|
||||
const filtered = $derived(
|
||||
metas
|
||||
.filter(
|
||||
(m) =>
|
||||
(category === 'all' || m.category === category) &&
|
||||
(usage === 'all' || m.usage === usage) &&
|
||||
(family === 'all' || m.model_family === family) &&
|
||||
(!favOnly || m.favorite) &&
|
||||
(!recentOnly || m.last_used_at) &&
|
||||
(!search.trim() ||
|
||||
[
|
||||
m.name,
|
||||
m.description,
|
||||
...(m.tags ?? []),
|
||||
m.model_family,
|
||||
m.usage ? usageLabel(m.usage) : '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim())),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
recentOnly
|
||||
? (b.last_used_at ?? '').localeCompare(a.last_used_at ?? '')
|
||||
: Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, 'zh'),
|
||||
),
|
||||
);
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const cloudModels = $derived(
|
||||
($modelsQuery.data?.llm_profiles ?? []).filter(
|
||||
(p) => !search || [p.name, p.model].join(' ').toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
);
|
||||
$effect(() => {
|
||||
void search;
|
||||
void category;
|
||||
void family;
|
||||
void usage;
|
||||
void favOnly;
|
||||
void recentOnly;
|
||||
visibleCount = 48;
|
||||
listEl?.scrollTo({ top: 0 });
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const idx = filtered.findIndex((m) => m.id === selectedId);
|
||||
if (idx >= visibleCount) visibleCount = idx + 48;
|
||||
void tick().then(() => searchEl?.focus());
|
||||
});
|
||||
$effect(() => {
|
||||
if (!filtered.some((m) => m.id === activeId)) activeId = filtered[0]?.id ?? null;
|
||||
});
|
||||
export function focusSearch() {
|
||||
searchEl?.focus();
|
||||
}
|
||||
async function favorite(m: WorkflowMeta) {
|
||||
try {
|
||||
await workflows.update(m.id, { favorite: !m.favorite });
|
||||
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
function pick(m: WorkflowMeta) {
|
||||
if (onPreview) onPreview(m.id);
|
||||
else if (m.is_enabled && !m.needs_mapping && m.usage) onPick(m.id);
|
||||
}
|
||||
function move(delta: number) {
|
||||
const i = Math.max(
|
||||
0,
|
||||
Math.min(filtered.length - 1, filtered.findIndex((m) => m.id === activeId) + delta),
|
||||
);
|
||||
activeId = filtered[i]?.id ?? null;
|
||||
if (i >= visibleCount) visibleCount = i + 24;
|
||||
void tick().then(() =>
|
||||
listEl?.querySelector(`[data-wf-row="${activeId}"]`)?.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
}
|
||||
function key(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === 'Escape' && !catalogue) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (source !== 'local') {
|
||||
return;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
} else if (e.key === 'Enter' && e.target === searchEl) {
|
||||
e.preventDefault();
|
||||
const m = filtered.find((m) => m.id === activeId);
|
||||
if (m) pick(m);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="catalogue" inert={!open} onkeydown={key}>
|
||||
<aside class="categories">
|
||||
<p>按创作任务寻找</p>
|
||||
<button
|
||||
class:active={category === 'all' && !favOnly && !recentOnly}
|
||||
onclick={() => {
|
||||
category = 'all';
|
||||
favOnly = false;
|
||||
recentOnly = false;
|
||||
}}><span>全部工具</span><small>{metas.length}</small></button
|
||||
>
|
||||
{#each categories as cat}<button
|
||||
class:active={category === cat}
|
||||
onclick={() => {
|
||||
category = cat;
|
||||
favOnly = false;
|
||||
recentOnly = false;
|
||||
}}
|
||||
><span>{labels[cat] ?? cat}</span><small
|
||||
>{metas.filter((m) => m.category === cat).length}</small
|
||||
></button
|
||||
>{/each}
|
||||
<div class="divider"></div>
|
||||
<button
|
||||
class:active={favOnly}
|
||||
onclick={() => {
|
||||
favOnly = !favOnly;
|
||||
recentOnly = false;
|
||||
}}><span>☆ 我的收藏</span><small>{metas.filter((m) => m.favorite).length}</small></button
|
||||
><button
|
||||
class:active={recentOnly}
|
||||
onclick={() => {
|
||||
recentOnly = !recentOnly;
|
||||
favOnly = false;
|
||||
}}><span>◷ 最近使用</span></button
|
||||
>
|
||||
</aside>
|
||||
<section class="catalogue-main">
|
||||
<label class="search"
|
||||
><span>⌕</span><input
|
||||
type="search"
|
||||
bind:this={searchEl}
|
||||
bind:value={search}
|
||||
placeholder="搜索工作流、模型或用途…"
|
||||
aria-label="搜索工作流"
|
||||
/></label
|
||||
>
|
||||
<div class="filters">
|
||||
<button class:active={source === 'local'} onclick={() => (source = 'local')}
|
||||
>本地 ComfyUI</button
|
||||
>{#if !localOnly}<button class:active={source === 'cloud'} onclick={() => (source = 'cloud')}
|
||||
>云端与智能体</button
|
||||
>{/if}{#if source === 'local'}<select aria-label="筛选创作任务" bind:value={usage}
|
||||
><option value="all">所有创作任务</option>{#each [...new Set(metas
|
||||
.map((m) => m.usage)
|
||||
.filter(Boolean))] as u}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
|
||||
><select aria-label="筛选模型家族" bind:value={family}
|
||||
><option value="all">全部模型</option>{#each families as f}<option value={f}>{f}</option
|
||||
>{/each}</select
|
||||
>{/if}<span>{source === 'local' ? filtered.length : cloudModels.length} 个结果</span>
|
||||
</div>
|
||||
<div class="results" bind:this={listEl}>
|
||||
{#if source === 'cloud'}
|
||||
<div class="cloud-context">
|
||||
<h3>让专业搭档加入创作。</h3>
|
||||
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
|
||||
<a href="/canvas" class="a-btn primary">打开创作画布 ↗</a>
|
||||
</div>
|
||||
{#each cloudModels as profile}<article class="workflow-card">
|
||||
<div class="family">{profile.model}<span>已配置模型</span></div>
|
||||
<h3>{profile.name}</h3>
|
||||
<a href="/agents" class="tool-link">交给智能体创作 ↗</a>
|
||||
</article>{:else}<p class="empty">
|
||||
还没有匹配的模型。<a href="/models">配置模型 ↗</a>
|
||||
</p>{/each}
|
||||
{:else}
|
||||
{#each visible as m (m.id)}<article
|
||||
class="workflow-card"
|
||||
class:selected={m.id === selectedId}
|
||||
class:roving={m.id === activeId}
|
||||
data-wf-row={m.id}
|
||||
>
|
||||
<button
|
||||
class="workflow-choice"
|
||||
onclick={() => pick(m)}
|
||||
onfocus={() => (activeId = m.id)}
|
||||
disabled={!onPreview && (!m.is_enabled || m.needs_mapping || !m.usage)}
|
||||
aria-label="{onPreview ? '查看' : '使用'} {m.name}"
|
||||
aria-current={m.id === selectedId ? 'true' : undefined}
|
||||
>
|
||||
<div class="family">
|
||||
{m.model_family ?? 'COMFYUI'}<span>本地</span>
|
||||
</div>
|
||||
<h3 title={m.name}>{m.name}</h3>
|
||||
{#if m.description}<p class="description">{m.description}</p>{/if}
|
||||
<div class="card-meta">
|
||||
<span>{m.usage ? usageLabel(m.usage) : '待映射'}</span><InputRequirementBadge
|
||||
io={m.media_io}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="favorite"
|
||||
class:saved={m.favorite}
|
||||
aria-label="{m.favorite ? '取消收藏' : '收藏'} {m.name}"
|
||||
aria-pressed={m.favorite}
|
||||
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
|
||||
>
|
||||
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
|
||||
href="/workflows?manage=1"
|
||||
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
|
||||
>{/if}
|
||||
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
|
||||
{#if visibleCount < filtered.length}<button
|
||||
class="more a-btn secondary"
|
||||
onclick={() => (visibleCount += 48)}
|
||||
>再看 {Math.min(48, filtered.length - visibleCount)} 个工具</button
|
||||
>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if pendingCount > 0}<p class="pending">
|
||||
另有 {pendingCount} 个工作流待映射。<a href="/workflows?manage=1">去整理 ↗</a>
|
||||
</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.description {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.catalogue {
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
}
|
||||
.catalogue-main {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.results {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.catalogue {
|
||||
display: grid;
|
||||
grid-template-columns: 145px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.categories {
|
||||
padding: 16px 15px 20px 0;
|
||||
border-right: 1px solid var(--line);
|
||||
overflow: auto;
|
||||
}
|
||||
.categories p {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-3);
|
||||
margin: 0 9px 15px;
|
||||
}
|
||||
.categories button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
padding: 11px 10px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.categories button.active {
|
||||
background: #e8eafe;
|
||||
color: var(--accent);
|
||||
}
|
||||
.categories small {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.divider {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 20px 8px 12px;
|
||||
}
|
||||
.catalogue-main {
|
||||
padding: 16px 0 0 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.search input {
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.search:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: 16px 0 21px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters button,
|
||||
.filters select {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button.active {
|
||||
color: var(--surface);
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
.filters span {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
.filters select {
|
||||
max-width: 140px;
|
||||
}
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
padding: 2px 5px 15px 2px;
|
||||
}
|
||||
.workflow-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
padding: 14px;
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 132px;
|
||||
}
|
||||
.workflow-card.selected {
|
||||
border-color: var(--accent);
|
||||
background: #f1f3ff;
|
||||
}
|
||||
.workflow-choice {
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 0 0 9px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-choice:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
.family {
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.family span {
|
||||
font: 8px var(--font);
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 9px;
|
||||
font-weight: 550;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
padding-right: 14px;
|
||||
}
|
||||
.favorite {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
font-size: 18px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #838a78;
|
||||
cursor: pointer;
|
||||
}
|
||||
.favorite.saved {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tool-link {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.cloud-context {
|
||||
grid-column: 1/-1;
|
||||
background: #e9e7df;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.cloud-context h3 {
|
||||
font-size: 19px;
|
||||
}
|
||||
.cloud-context p {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.cloud-context .a-btn {
|
||||
font-size: 11px;
|
||||
}
|
||||
.pending {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 0;
|
||||
}
|
||||
.empty {
|
||||
grid-column: 1/-1;
|
||||
padding: 45px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.more {
|
||||
grid-column: 1/-1;
|
||||
justify-self: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.workflow-card:hover {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.categories button:hover {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
|
||||
/**
|
||||
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
|
||||
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
|
||||
* 完整记录槽位摘要),确认后进入创作区。
|
||||
*/
|
||||
interface Props {
|
||||
meta: WorkflowMeta | null;
|
||||
full: Workflow | null;
|
||||
currentId: number | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onUse: () => void;
|
||||
onToggleFav: (fav: boolean) => void;
|
||||
}
|
||||
|
||||
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
|
||||
|
||||
const isCurrent = $derived(meta != null && meta.id === currentId);
|
||||
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
|
||||
const endpointName = $derived.by(() => {
|
||||
const id = meta?.endpoint_id;
|
||||
if (!id) return t('desk.wfDefaultConn');
|
||||
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
|
||||
});
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
basic: 'wfCat.basic',
|
||||
audio_tts: 'wfCat.audioTts',
|
||||
music: 'wfCat.music',
|
||||
image: 'wfCat.image',
|
||||
video: 'wfCat.video',
|
||||
digital_human: 'wfCat.digitalHuman',
|
||||
motion_transfer: 'wfCat.motionTransfer',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="preview">
|
||||
{#if meta}
|
||||
<div class="head">
|
||||
<h3 class="name" title={meta.name}>{meta.name}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="fav"
|
||||
class:on={meta.favorite}
|
||||
aria-pressed={meta.favorite}
|
||||
aria-label={t('desk.bayFavOnly')}
|
||||
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
|
||||
onclick={() => onToggleFav(!meta.favorite)}
|
||||
>
|
||||
<Icon name="star" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
|
||||
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
|
||||
{#if meta.category && CATEGORY_LABELS[meta.category]}
|
||||
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
|
||||
{/if}
|
||||
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
|
||||
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if meta.description}
|
||||
<p class="desc">{meta.description}</p>
|
||||
{/if}
|
||||
|
||||
<section class="sec">
|
||||
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
|
||||
<div class="req-line">
|
||||
<InputRequirementBadge io={meta.media_io} />
|
||||
</div>
|
||||
{#if full}
|
||||
<p class="slot-summary">{slotSummary(full)}</p>
|
||||
{:else}
|
||||
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<dl class="kv">
|
||||
<div class="kv-row">
|
||||
<dt>{t('studio.connLabel')}</dt>
|
||||
<dd>{endpointName}</dd>
|
||||
</div>
|
||||
{#if meta.last_used_at}
|
||||
<div class="kv-row">
|
||||
<dt>{t('desk.pvLastUsed')}</dt>
|
||||
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if (full?.input_schema ?? []).length}
|
||||
<div class="kv-row">
|
||||
<dt>{t('desk.pvParamCount')}</dt>
|
||||
<dd>{(full?.input_schema ?? []).length}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="use">
|
||||
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
|
||||
<Icon name="check" size={13} />
|
||||
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
|
||||
</button>
|
||||
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-empty">
|
||||
<Icon name="search" size={20} />
|
||||
<p>{t('desk.pvEmpty')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: var(--sp-7);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fav {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.fav.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.fav:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.desc {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.sec {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.sec-title {
|
||||
margin: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.req-line {
|
||||
display: flex;
|
||||
}
|
||||
.slot-summary {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.slot-summary.dim {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
margin: 0;
|
||||
}
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv-row dt {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv-row dd {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.use {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-4);
|
||||
}
|
||||
.use-hint {
|
||||
margin: 0;
|
||||
color: var(--err);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: 100%;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
// Same-tab fallback for browsers that deny session storage. No user data leaves the browser.
|
||||
const drafts = new Map<string, string>();
|
||||
export function readDeskDraft(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key) ?? drafts.get(key) ?? null;
|
||||
} catch {
|
||||
return drafts.get(key) ?? null;
|
||||
}
|
||||
}
|
||||
export function writeDeskDraft(key: string, value: string) {
|
||||
drafts.set(key, value);
|
||||
try {
|
||||
sessionStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* In-memory switching still works. */
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,9 @@
|
||||
|
||||
/**
|
||||
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
|
||||
* Says where from, and plainly that takes stay in the desk feed — the
|
||||
* desk's generate call has no shot/asset link to fill back.
|
||||
* Says where from. With a full source identity (HEL-684) results can
|
||||
* be flowed back per take from the result card; otherwise the old
|
||||
* note stands — results stay in the desk feed, nothing is written back.
|
||||
*/
|
||||
interface Props {
|
||||
ctx: DeskContext;
|
||||
@@ -17,6 +18,7 @@
|
||||
let { ctx, projectTitle, onDismiss }: Props = $props();
|
||||
|
||||
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
|
||||
const canFlowBack = $derived(!!ctx.sourceKind && !!ctx.sourceId);
|
||||
</script>
|
||||
|
||||
<div class="context-bar" role="status">
|
||||
@@ -24,7 +26,9 @@
|
||||
<span class="from">
|
||||
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label} · {ctx.label}{/if}
|
||||
</span>
|
||||
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
|
||||
<span class="note">
|
||||
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
|
||||
</span>
|
||||
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
|
||||
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
|
||||
<Icon name="close" size={12} />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import {
|
||||
clearOutputMissing,
|
||||
markOutputMissing,
|
||||
missingOutputsOf,
|
||||
} from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from './schema';
|
||||
@@ -18,11 +23,13 @@
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
|
||||
* ↑↓/J/K 即时无动画), video playback is always explicit,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
main?: boolean;
|
||||
comparePath?: string | null;
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
@@ -34,19 +41,20 @@
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||||
playSignal: number;
|
||||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||||
/** The stage <video> element, for explicit playback controls. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
main = false,
|
||||
comparePath = null,
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
@@ -55,17 +63,25 @@
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
let compare = $state(false),
|
||||
compareAt = $state(50),
|
||||
lightbox = $state(false);
|
||||
$effect(() => {
|
||||
void job?.id;
|
||||
compare = false;
|
||||
lightbox = false;
|
||||
});
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
@@ -76,16 +92,20 @@
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
|
||||
const outputs = $derived(
|
||||
job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : [],
|
||||
);
|
||||
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
|
||||
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const compareSource = $derived(inputs.find((i) => i.kind === 'image')?.path ?? comparePath);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||
'',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
@@ -108,25 +128,18 @@
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||||
$effect(() => {
|
||||
void playSignal;
|
||||
if (inactive || tab !== 'result' || !videoEl) return;
|
||||
videoEl.muted = true;
|
||||
videoEl.play().catch(() => {});
|
||||
let paused = $state(true);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
untrack(() => {
|
||||
videoEl?.pause();
|
||||
paused = true;
|
||||
});
|
||||
});
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
|
||||
function xfade(_node: Element) {
|
||||
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
|
||||
return { duration: ms, css: (t: number) => `opacity: ${t}` };
|
||||
function togglePlayback() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
@@ -144,14 +157,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<aside class="inspector" class:main class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<div class="tabs">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}
|
||||
>{t('desk.tabResult')}</button
|
||||
>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}
|
||||
>{t('desk.tabParams')}</button
|
||||
>
|
||||
</div>
|
||||
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
|
||||
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost sm close"
|
||||
aria-label={t('desk.closeInspector')}
|
||||
onclick={onClose}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,35 +182,58 @@
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
<div class="a-stage stage">
|
||||
<div class="stage-empty">
|
||||
<span class="empty-symbol">✳</span><strong>给想象留一个位置。</strong><span class="sub"
|
||||
>在右侧写下描述,或选择一次已有创作。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#key mediaKey}
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
<div class="stage-media">
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
aria-label="生成的视频,使用播放按钮开始播放"
|
||||
onplay={() => (paused = false)}
|
||||
onpause={() => (paused = true)}
|
||||
onended={() => (paused = true)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
</div>
|
||||
{:else if output}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{#if main && compare && compareSource}<div
|
||||
class="comparison-layer"
|
||||
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
|
||||
>
|
||||
<img src={assetUrl(compareSource)} alt="对比参考画面" />
|
||||
</div>
|
||||
<div class="comparison-divider" style:left={`${compareAt}%`}></div>{/if}
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
@@ -199,7 +244,9 @@
|
||||
<span>{st.label}</span>
|
||||
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}>
|
||||
<i></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -207,6 +254,19 @@
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if output && video}<div class="playback-bar">
|
||||
<button
|
||||
class="a-btn primary sm"
|
||||
onclick={togglePlayback}
|
||||
aria-label={paused ? '播放视频' : '暂停视频'}
|
||||
>{paused ? '▶ 播放视频' : 'Ⅱ 暂停视频'}</button
|
||||
>
|
||||
<span>视频默认暂停,由你控制播放。</span>
|
||||
<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => videoEl?.requestFullscreen().catch(() => {})}>全屏查看 ⤢</button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
@@ -235,16 +295,42 @@
|
||||
<span>{s.label} <b class="mono">{s.value}</b></span>
|
||||
{/if}
|
||||
{/each}
|
||||
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
|
||||
<span
|
||||
>{t('desk.elapsed')}
|
||||
<b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span
|
||||
>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if main && compare && output && !video}<div class="compare-controls">
|
||||
<span>{inputs.some((i) => i.kind === 'image') ? '参考图片' : '上一版'}</span><input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/><span>当前结果</span>
|
||||
</div>{/if}
|
||||
<div class="actions">
|
||||
{#if main && output && !video}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => (lightbox = true)}>放大查看 ⤢</button
|
||||
>{#if compareSource && compareSource !== output}<button
|
||||
class="a-btn secondary sm"
|
||||
aria-pressed={compare}
|
||||
onclick={() => (compare = !compare)}>{compare ? '结束对比' : '对比画面'} ◫</button
|
||||
>{/if}{/if}
|
||||
{#if fileGone && !outputs.length}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
|
||||
>{t('desk.redo')}</button
|
||||
>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
|
||||
>{t('desk.removeRecord')}</button
|
||||
>
|
||||
{:else}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}
|
||||
>{t('desk.reuseToComposer')}</button
|
||||
>
|
||||
{#if output && !video && canSendToSlot}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
|
||||
{t('desk.sendToSlot')}
|
||||
@@ -266,10 +352,18 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if st.tone === 'fail'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
|
||||
>{t('common.retry')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => onCancel(job)}>取消这次生成</button
|
||||
>{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
|
||||
>{t('studio.deleteBtn')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -280,44 +374,61 @@
|
||||
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<details class="result-details">
|
||||
<summary>创作详情与参数</summary>
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img
|
||||
class="mini"
|
||||
src={assetUrl(inp.path)}
|
||||
alt={inp.label}
|
||||
title={inp.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}
|
||||
><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if lightbox && output && !video}<ImageLightbox
|
||||
src={assetUrl(output) ?? ''}
|
||||
alt={prompt || '创作结果'}
|
||||
onClose={() => (lightbox = false)}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
@@ -362,14 +473,24 @@
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* 相纸白边 (HEL-680):检视舞台上的结果按相纸呈现,媒体自适应内含 */
|
||||
.print {
|
||||
position: relative;
|
||||
display: grid;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
--paper-w: 5px;
|
||||
}
|
||||
.print > img,
|
||||
.print > video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.stage-empty {
|
||||
display: grid;
|
||||
@@ -562,4 +683,206 @@
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
|
||||
.inspector.main {
|
||||
position: static;
|
||||
inset: auto;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main .tabs {
|
||||
display: none;
|
||||
}
|
||||
.main .body {
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.main .stage {
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
flex: 1;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #eae9e3;
|
||||
}
|
||||
.main .stage-media {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.main .print {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.main .print img,
|
||||
.main .print video {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
}
|
||||
.main .meta,
|
||||
.main .actions,
|
||||
.main .playback-bar,
|
||||
.main .strip,
|
||||
.main .note,
|
||||
.main .result-details {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.main .meta {
|
||||
font-size: 10px;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
max-height: 30px;
|
||||
overflow: auto;
|
||||
}
|
||||
.main .actions {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.main .result-details {
|
||||
font-size: 11px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
.playback-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.playback-bar span {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
@media (max-height: 650px) {
|
||||
.playback-bar span {
|
||||
display: none;
|
||||
}
|
||||
.main .meta {
|
||||
max-height: 16px;
|
||||
}
|
||||
.main .actions :global(.a-btn) {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
.main .a-fix {
|
||||
display: none;
|
||||
}
|
||||
.main .stage-empty {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-symbol {
|
||||
color: var(--accent);
|
||||
font-size: 55px;
|
||||
font-weight: 300;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.stage-empty strong {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.main .meta {
|
||||
font-size: 9px;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.main .actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-details {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.result-details .prompt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.comparison-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.comparison-layer img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #eae9e3;
|
||||
}
|
||||
.comparison-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 8px #292a2d40;
|
||||
pointer-events: none;
|
||||
}
|
||||
.compare-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.compare-controls input {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { validateForm, classifySchema, type FormValues } from './schema';
|
||||
let {
|
||||
inputs,
|
||||
values,
|
||||
anchor,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
anchor: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onApply: (values: FormValues) => void;
|
||||
} = $props();
|
||||
let draft = $state<FormValues>({});
|
||||
let showErrors = $state(false);
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let x = $state(0),
|
||||
y = $state(0);
|
||||
const errors = $derived(
|
||||
validateForm(inputs, classifySchema(inputs, 'image'), draft),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!anchor || !inputs.length) return;
|
||||
const source = anchor;
|
||||
draft = Object.fromEntries(
|
||||
inputs.map((i) => [i.nodeId, values[i.nodeId] ?? i.defaultValue ?? '']),
|
||||
);
|
||||
showErrors = false;
|
||||
const rect = source.getBoundingClientRect();
|
||||
x = Math.max(12, Math.min(rect.left, innerWidth - 312));
|
||||
y = Math.max(12, innerHeight - rect.top + 8);
|
||||
void tick().then(() =>
|
||||
panel
|
||||
?.querySelector<HTMLElement>('input,select,textarea,button')
|
||||
?.focus(),
|
||||
);
|
||||
const closeOutside = (e: PointerEvent) => {
|
||||
if (
|
||||
!panel?.contains(e.target as Node) &&
|
||||
!source.contains(e.target as Node)
|
||||
)
|
||||
onClose();
|
||||
};
|
||||
const keys = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Tab' && panel) {
|
||||
const all = [
|
||||
...panel.querySelectorAll<HTMLElement>(
|
||||
'input,select,textarea,button',
|
||||
),
|
||||
].filter(
|
||||
(e) => !e.hasAttribute('disabled') && e.getClientRects().length,
|
||||
);
|
||||
if (e.shiftKey && document.activeElement === all[0]) {
|
||||
e.preventDefault();
|
||||
all.at(-1)?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === all.at(-1)) {
|
||||
e.preventDefault();
|
||||
all[0]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
document.addEventListener('keydown', keys, true);
|
||||
window.addEventListener('resize', onClose);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOutside);
|
||||
document.removeEventListener('keydown', keys, true);
|
||||
window.removeEventListener('resize', onClose);
|
||||
if (source.isConnected) source.focus();
|
||||
};
|
||||
});
|
||||
function save() {
|
||||
if (Object.keys(errors).length) {
|
||||
showErrors = true;
|
||||
return;
|
||||
}
|
||||
onApply(draft);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if inputs.length && anchor}
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="parameter-popover"
|
||||
role="dialog"
|
||||
aria-label={inputs.map((i) => i.label).join(' / ')}
|
||||
style:left={`${x}px`}
|
||||
style:bottom={`${y}px`}
|
||||
>
|
||||
<header>{inputs.map((i) => i.label).join(' / ')}</header>
|
||||
<ComfyDynamicForm
|
||||
{inputs}
|
||||
values={draft as Record<string, string | number>}
|
||||
quiet
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => (draft = { ...draft, ...v })}
|
||||
/>
|
||||
<footer>
|
||||
<button class="a-btn ghost sm" onclick={onClose}>取消</button><button
|
||||
class="a-btn secondary sm"
|
||||
onclick={save}>应用参数</button
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.parameter-popover {
|
||||
position: fixed;
|
||||
z-index: 800;
|
||||
width: 300px;
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100dvh - 36px);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
background: var(--float-bg);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
transform-origin: bottom left;
|
||||
animation: appear var(--d-pop) var(--ease-out);
|
||||
}
|
||||
header {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,15 @@
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
|
||||
import {
|
||||
elapsedSec,
|
||||
fmtClock,
|
||||
fmtDur,
|
||||
inputsOf,
|
||||
isVideoOutput,
|
||||
promptOf,
|
||||
workbenchCtxOf,
|
||||
} from './takes';
|
||||
|
||||
/**
|
||||
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
|
||||
@@ -20,6 +28,9 @@
|
||||
* take whose files no longer load turns amber (文件缺失) with
|
||||
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
|
||||
* marked as such instead of pretending nothing changed.
|
||||
* HEL-684: a take generated with a real workbench source offers an
|
||||
* explicit 回流到源项目 action; once flowed back it shows 已回流 and
|
||||
* a direct 返回项目 link instead.
|
||||
*/
|
||||
interface Props {
|
||||
job: Job;
|
||||
@@ -33,11 +44,14 @@
|
||||
flash?: boolean;
|
||||
/** One-shot failure shake. */
|
||||
shake?: boolean;
|
||||
/** Flow-back request in flight (HEL-684). */
|
||||
attachBusy?: boolean;
|
||||
onSelect: () => void;
|
||||
onReuse: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onAttach?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -49,11 +63,13 @@
|
||||
enter = false,
|
||||
flash = false,
|
||||
shake = false,
|
||||
attachBusy = false,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onAttach,
|
||||
}: Props = $props();
|
||||
|
||||
const st = $derived(jobStatusInfo(job));
|
||||
@@ -80,6 +96,16 @@
|
||||
});
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
|
||||
// HEL-684: flow-back only for finished takes with files and a real,
|
||||
// still-recorded workbench source; the attached marker comes from the
|
||||
// server so it survives reloads.
|
||||
const wbCtx = $derived(workbenchCtxOf(job));
|
||||
const attached = $derived(
|
||||
!!job.workbench_attach && !!wbCtx && job.workbench_attach.target_id === wbCtx.sourceId,
|
||||
);
|
||||
const canAttach = $derived(
|
||||
!!onAttach && !!wbCtx && !attached && st.tone === 'ok' && !fileGone && !!output,
|
||||
);
|
||||
|
||||
let portrait = $state(false);
|
||||
let clipSec = $state<number | null>(null);
|
||||
@@ -164,6 +190,25 @@
|
||||
</button>
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
{#if canAttach}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost xs attach"
|
||||
disabled={attachBusy}
|
||||
title={t('desk.attachHint', { label: wbCtx?.label ?? '' })}
|
||||
onclick={() => onAttach?.()}
|
||||
>
|
||||
{attachBusy ? t('desk.attaching') : t('desk.attachToProject')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if attached && wbCtx}
|
||||
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
|
||||
{t('desk.attachedTag')}
|
||||
</span>
|
||||
<a class="a-btn ghost xs" href={`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`}>
|
||||
{t('desk.attachBack')}
|
||||
</a>
|
||||
{/if}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
|
||||
{#if st.tone === 'fail' && job.error !== 'cancelled'}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
|
||||
@@ -183,7 +228,8 @@
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
<div class="thumb a-photo" class:portrait>
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
{#if isVideoOutput(job, output)}
|
||||
<video
|
||||
src={assetUrl(output)}
|
||||
@@ -238,15 +284,42 @@
|
||||
background-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
|
||||
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
|
||||
HEL-680 四角短线取代整框描边) */
|
||||
.sel-mark {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.take.selected {
|
||||
border-color: transparent;
|
||||
@@ -458,6 +531,12 @@
|
||||
background: var(--bg-4);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
/* 相纸白边 (HEL-680):有产出的记录缩略图按相纸呈现,压过占位态样式 */
|
||||
.thumb.a-photo {
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
banner?: Snippet;
|
||||
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
|
||||
inspectorToggle?: Snippet;
|
||||
/** Flow-back to the workbench source row in flight (HEL-684). */
|
||||
attachBusyId?: number | null;
|
||||
onSelect: (job: Job) => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
onAttach?: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,11 +62,13 @@
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
attachBusyId = null,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onAttach,
|
||||
}: Props = $props();
|
||||
|
||||
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
|
||||
@@ -263,6 +268,8 @@
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
attachBusy={attachBusyId === job.id}
|
||||
onAttach={onAttach ? () => onAttach(job) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { slotSummary, usageLabel } from './schema';
|
||||
|
||||
/**
|
||||
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
|
||||
* pop grouped by usage with search; each row shows what the workflow
|
||||
* really takes (slot summary from its JSON), its connection and whether
|
||||
* it last ran. Picking keeps the prompt and carried-over images.
|
||||
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
|
||||
* active row follows ↑↓ (and the pointer), Enter uses it, and the
|
||||
* search's Enter picks the active row instead of always the first.
|
||||
*/
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (wf: Workflow) => void;
|
||||
}
|
||||
|
||||
let { workflows, current, endpoints, onPick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
function statusOf(wf: Workflow): Status {
|
||||
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return { tone: '', label: t('desk.wfUntested') };
|
||||
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
|
||||
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
|
||||
return { tone: '', label: t('desk.wfTesting') };
|
||||
}
|
||||
|
||||
function endpointLabel(wf: Workflow): string {
|
||||
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
|
||||
return ep ? ep.name : t('desk.wfDefaultConn');
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
workflows.map((wf) => ({
|
||||
wf,
|
||||
summary: slotSummary(wf),
|
||||
conn: endpointLabel(wf),
|
||||
status: statusOf(wf),
|
||||
usage: wf.usage ? usageLabel(wf.usage) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (r: (typeof rows)[number]) =>
|
||||
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
|
||||
return WORKFLOW_USAGES.map((u) => ({
|
||||
usage: u,
|
||||
items: rows.filter((r) => r.wf.usage === u && hit(r)),
|
||||
})).filter((g) => g.items.length);
|
||||
});
|
||||
|
||||
/** Flattened rows in display order — the roving selection walks these. */
|
||||
const flat = $derived(groups.flatMap((g) => g.items));
|
||||
|
||||
// Keep the active row real: default to the current workflow, clamp on
|
||||
// filter changes, never point at a row that is no longer listed.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
|
||||
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((r) => r.wf.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
|
||||
if (row) pick(row.wf);
|
||||
}
|
||||
|
||||
function pick(wf: Workflow) {
|
||||
open = false;
|
||||
query = '';
|
||||
if (wf.id !== current?.id) onPick(wf);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
<ShellPop
|
||||
bind:open
|
||||
label={t('desk.pickWorkflow')}
|
||||
placement="top"
|
||||
width="520px"
|
||||
arrowRoving={false}
|
||||
onclose={() => (query = '')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pop-body" onkeydown={onKeydown}>
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('desk.wfSearchPh')}
|
||||
aria-label={t('desk.wfSearchPh')}
|
||||
/>
|
||||
</div>
|
||||
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
|
||||
{#each groups as g (g.usage)}
|
||||
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
|
||||
{#each g.items as r (r.wf.id)}
|
||||
{@const cur = r.wf.id === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item row"
|
||||
class:off={r.status.tone === 'err'}
|
||||
class:active={r.wf.id === activeId}
|
||||
role="option"
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
aria-selected={r.wf.id === activeId ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
onpointermove={() => (activeId = r.wf.id)}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{r.summary}</span>
|
||||
<span class="a-tag">{r.conn}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<a class="a-btn ghost sm" href="/connections">
|
||||
<Icon name="upload" size={13} />
|
||||
{t('desk.wfImport')}
|
||||
</a>
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
|
||||
.list {
|
||||
max-height: min(380px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.main .a-menu-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.row.off .a-menu-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,9 @@ import type { DeskKind } from './schema';
|
||||
|
||||
/**
|
||||
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
|
||||
* The desk only prefills from it; the playground generate API has no
|
||||
* project/shot link, so results stay in the desk feed (no backfill).
|
||||
* The desk prefills from it and, when the full source identity is present
|
||||
* (sourceKind/sourceId), sends it along with the generate request so a
|
||||
* finished take can be flowed back to its source row (HEL-684).
|
||||
*/
|
||||
export interface DeskContext {
|
||||
kind: DeskKind;
|
||||
@@ -15,10 +16,30 @@ export interface DeskContext {
|
||||
prompt?: string;
|
||||
negative?: string;
|
||||
durationSec?: number;
|
||||
/** Real source row identity — only present on links the workbench
|
||||
* stamped with it; without it no flow-back is offered (nothing guessed). */
|
||||
sourceKind?: 'asset' | 'shot';
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
/** The generate-request context payload shape (HEL-684). */
|
||||
export interface DeskWorkbenchContext {
|
||||
project_id: number;
|
||||
stage: 'assets' | 'shot';
|
||||
source_kind: 'asset' | 'shot';
|
||||
source_id: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
|
||||
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
|
||||
* shot → video). Anything else cannot display a flowed-back take. */
|
||||
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
|
||||
assets: 'image',
|
||||
shot: 'video',
|
||||
};
|
||||
|
||||
export function deskHref(ctx: DeskContext): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
@@ -29,6 +50,10 @@ export function deskHref(ctx: DeskContext): string {
|
||||
if (ctx.prompt) q.set('prompt', ctx.prompt);
|
||||
if (ctx.negative) q.set('negative', ctx.negative);
|
||||
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
|
||||
if (ctx.sourceKind && ctx.sourceId) {
|
||||
q.set('source_kind', ctx.sourceKind);
|
||||
q.set('source_id', ctx.sourceId);
|
||||
}
|
||||
return `/${ctx.kind}?${q.toString()}`;
|
||||
}
|
||||
|
||||
@@ -39,6 +64,13 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
const stage = q.get('stage') as WorkbenchStageKey | null;
|
||||
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
|
||||
const duration = Number(q.get('duration'));
|
||||
const rawKind = q.get('source_kind');
|
||||
const rawId = q.get('source_id') ?? '';
|
||||
// Source identity is only kept when it is complete and self-consistent;
|
||||
// old/hand-made links keep today's prefill-only behavior.
|
||||
const expectedKind = stage === 'assets' ? 'asset' : stage === 'shot' ? 'shot' : null;
|
||||
const sourceKind = rawKind === 'asset' || rawKind === 'shot' ? rawKind : null;
|
||||
const hasSource = sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== '';
|
||||
return {
|
||||
kind,
|
||||
projectId,
|
||||
@@ -47,9 +79,29 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
prompt: q.get('prompt') ?? undefined,
|
||||
negative: q.get('negative') ?? undefined,
|
||||
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||
sourceKind: hasSource ? (sourceKind as 'asset' | 'shot') : undefined,
|
||||
sourceId: hasSource ? rawId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The context a generate request carries: only when the full, real source
|
||||
* identity is present AND the desk kind matches what that stage renders.
|
||||
*/
|
||||
export function workbenchContextOf(ctx: DeskContext | null): DeskWorkbenchContext | undefined {
|
||||
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
|
||||
if (ctx.stage !== 'assets' && ctx.stage !== 'shot') return undefined;
|
||||
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
|
||||
const out: DeskWorkbenchContext = {
|
||||
project_id: ctx.projectId,
|
||||
stage: ctx.stage,
|
||||
source_kind: ctx.sourceKind,
|
||||
source_id: ctx.sourceId,
|
||||
};
|
||||
if (ctx.label.trim()) out.label = ctx.label;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function backHref(ctx: DeskContext): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,34 @@ import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from '.
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
/** The workbench source a take was generated with, if any (HEL-684). */
|
||||
export interface TakeWorkbenchCtx {
|
||||
projectId: number;
|
||||
stage: string;
|
||||
sourceKind: 'asset' | 'shot';
|
||||
sourceId: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
|
||||
const stamp = job.payload?.workbench;
|
||||
if (!stamp || typeof stamp !== 'object') return null;
|
||||
const s = stamp as Record<string, unknown>;
|
||||
const projectId = s.project_id;
|
||||
const stage = typeof s.stage === 'string' ? s.stage : '';
|
||||
const sourceKind = s.source_kind === 'asset' || s.source_kind === 'shot' ? s.source_kind : null;
|
||||
const sourceId = typeof s.source_id === 'string' ? s.source_id : '';
|
||||
if (typeof projectId !== 'number' || !stage || !sourceKind || !sourceId) return null;
|
||||
const label = typeof s.label === 'string' && s.label.trim() ? s.label : undefined;
|
||||
return {
|
||||
projectId,
|
||||
stage,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
export function jobValues(job: Job): Record<string, unknown> {
|
||||
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@
|
||||
);
|
||||
|
||||
// Five-stage creation progress (HEL-614) — real stage rows only.
|
||||
const STAGE_SEGMENTS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
|
||||
const STAGE_SEGMENTS = [
|
||||
'script',
|
||||
'style',
|
||||
'storyboard',
|
||||
'assets',
|
||||
'shot',
|
||||
] as const;
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
@@ -46,13 +52,16 @@
|
||||
|
||||
// Spec §2.1 allows the five avatar gradients as cover placeholders.
|
||||
const PLACEHOLDER_GRADIENTS = [
|
||||
'var(--avatar-1)',
|
||||
'var(--avatar-2)',
|
||||
'var(--avatar-3)',
|
||||
'var(--avatar-4)',
|
||||
'var(--avatar-5)',
|
||||
'#1f262c',
|
||||
'#253036',
|
||||
'#2d2333',
|
||||
'#26372c',
|
||||
'#302836',
|
||||
];
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
|
||||
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
|
||||
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
|
||||
const placeholderIconColor = $derived('var(--text-2)');
|
||||
|
||||
const coverUrl = $derived(assetUrl(project.cover_path));
|
||||
|
||||
@@ -64,17 +73,28 @@
|
||||
if (stats.stage_confirmed >= STAGE_SEGMENTS.length) {
|
||||
return { cls: 'st-done', label: t('home.statusDone') };
|
||||
}
|
||||
const current = STAGE_SEGMENTS.find((k) => stats.stage_status?.[k] !== 'confirmed');
|
||||
const current = STAGE_SEGMENTS.find(
|
||||
(k) => stats.stage_status?.[k] !== 'confirmed',
|
||||
);
|
||||
const state = current ? stats.stage_status?.[current] : undefined;
|
||||
const stageName = current ? t(`workbench.stage.${current}`) : '';
|
||||
if (state === 'running') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
return {
|
||||
cls: 'st-progress',
|
||||
label: `${stageName} · ${t('workbench.state.running')}`,
|
||||
};
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
return {
|
||||
cls: 'st-pending',
|
||||
label: `${stageName} · ${t('workbench.state.draft')}`,
|
||||
};
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
return {
|
||||
cls: 'st-progress',
|
||||
label: `${stageName} · ${t('home.statusProgress')}`,
|
||||
};
|
||||
}
|
||||
return { cls: 'st-draft', label: t('home.statusDraft') };
|
||||
});
|
||||
@@ -132,7 +152,9 @@
|
||||
toast.success(t('projectCard.renamedTo', { title: renameValue }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projectCard.renameFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +174,9 @@
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projectCard.deleteFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -174,8 +198,15 @@
|
||||
onerror={() => (coverFailed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
|
||||
<Icon name="film" size={22} />
|
||||
<span
|
||||
class="cover-ph"
|
||||
style:background={placeholder}
|
||||
style:color={placeholderIconColor}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="cover-type"
|
||||
>✳<small>STORY / {String(project.id).padStart(3, '0')}</small></span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="status-tag {cover.cls}">{cover.label}</span>
|
||||
@@ -188,11 +219,14 @@
|
||||
<span class="idea">{project.idea || t('home.noIdea')}</span>
|
||||
<span class="stage-bar" aria-label={t('home.stageProgress')}>
|
||||
{#each STAGE_SEGMENTS as key (key)}
|
||||
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}></span>
|
||||
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}
|
||||
></span>
|
||||
{/each}
|
||||
<span class="seg-count mono">{stats.stage_confirmed}/5</span>
|
||||
{#if stats.active_jobs > 0}
|
||||
<span class="jobs-pill">{t('home.activeJobs', { n: stats.active_jobs })}</span>
|
||||
<span class="jobs-pill"
|
||||
>{t('home.activeJobs', { n: stats.active_jobs })}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="meta">
|
||||
@@ -222,7 +256,12 @@
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={openRename}
|
||||
>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
@@ -256,7 +295,9 @@
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}
|
||||
>{t('common.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
@@ -524,9 +565,81 @@
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: var(--error-dim);
|
||||
} .a-card :global(.field-input) {
|
||||
}
|
||||
.a-card :global(.field-input) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a-card {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.cover {
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.body {
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
.status-tag {
|
||||
top: auto;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
bottom: 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 7px;
|
||||
background: #121218ed;
|
||||
color: var(--text);
|
||||
font-size: 9px;
|
||||
}
|
||||
.cover-type {
|
||||
font-size: 56px;
|
||||
font-weight: 300;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.cover-type small {
|
||||
display: block;
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.cover-img {
|
||||
transition: transform 250ms var(--ease-out);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 100%;
|
||||
}
|
||||
.seg {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
}
|
||||
.meta-stats,
|
||||
.meta-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
.idea {
|
||||
font-size: 11px;
|
||||
min-height: 0;
|
||||
}
|
||||
.menu-wrap {
|
||||
left: auto;
|
||||
right: 10px;
|
||||
}
|
||||
.menu-btn {
|
||||
background: var(--surface);
|
||||
}
|
||||
.menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.a-card:hover .cover-img {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
.tabs button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.tabs button:focus-visible {
|
||||
@@ -341,7 +341,7 @@
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -271,9 +271,9 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
background: var(--media-scrim);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
padding: 0 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--accent-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
.step-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.step-btn:focus-visible {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let { onStart, onImport }: { onStart: () => void; onImport: () => void } =
|
||||
$props();
|
||||
let card: HTMLDivElement;
|
||||
onMount(() => {
|
||||
let raf = 0,
|
||||
x = 0,
|
||||
v = 0,
|
||||
target = 0,
|
||||
time = 0;
|
||||
const reduced = () =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
|
||||
document.documentElement.dataset.reduce === '1';
|
||||
function animate(now: number) {
|
||||
const dt = Math.min((now - time) / 1000 || 0.016, 0.032);
|
||||
time = now;
|
||||
v += ((target - x) * 100 - v * 10) * dt;
|
||||
x += v * dt;
|
||||
card.style.transform = `rotate(${10 + x}deg) translateY(${-Math.abs(x)}px)`;
|
||||
if (Math.abs(v) + Math.abs(target - x) > 0.01)
|
||||
raf = requestAnimationFrame(animate);
|
||||
else raf = 0;
|
||||
}
|
||||
function go(value: number) {
|
||||
if (reduced()) return;
|
||||
target = value;
|
||||
if (!raf) {
|
||||
time = performance.now();
|
||||
raf = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
const enter = () => go(-5),
|
||||
leave = () => go(0);
|
||||
card.addEventListener('pointerenter', enter);
|
||||
card.addEventListener('pointerleave', leave);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
card.removeEventListener('pointerenter', enter);
|
||||
card.removeEventListener('pointerleave', leave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="refract-hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">● A SPACE FOR THE UNEXPECTED</div>
|
||||
<h1>让想象,<br /><span>在这里成形。</span></h1>
|
||||
<p>
|
||||
从一个念头,到一组画面,再到一段故事。<br
|
||||
/>你的模型、素材与灵感,都在同一个工作室。
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<button class="a-btn primary" onclick={onStart}>开始创作 →</button><button
|
||||
class="text-button"
|
||||
onclick={onImport}>导入已有剧本 ↗</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-art">
|
||||
<img
|
||||
class="hero-photo"
|
||||
src="/refract/coast.jpg"
|
||||
alt="海岸与薄雾构成的灵感画面"
|
||||
/>
|
||||
<div class="art-circle"></div>
|
||||
<span class="hero-star">✳</span>
|
||||
<div class="art-label">
|
||||
VISUAL STUDY / 001<br />LIGHT, SPACE & POSSIBILITY
|
||||
</div>
|
||||
<div class="hero-card" bind:this={card}>
|
||||
<img src="/refract/arch.jpg" alt="建筑几何参考" /><span
|
||||
>FOUND IN THE IN-BETWEEN</span
|
||||
>
|
||||
</div>
|
||||
<div class="art-word">Beyond<br />the ordinary.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.refract-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.12fr;
|
||||
min-height: 322px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(ellipse at 0 0, #7850ff1a, transparent 70%), #141419;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.hero-copy {
|
||||
padding: 35px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.3px;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(33px, 3vw, 47px);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -1.6px;
|
||||
}
|
||||
h1 span {
|
||||
color: var(--accent);
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--text-3);
|
||||
margin: 0 0 23px;
|
||||
}
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-buttons .a-btn {
|
||||
height: 41px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hero-art {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #d2dbe4;
|
||||
min-height: 322px;
|
||||
isolation: isolate;
|
||||
}
|
||||
.hero-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 70%;
|
||||
filter: contrast(0.92) saturate(0.7);
|
||||
}
|
||||
.art-circle {
|
||||
position: absolute;
|
||||
width: 270px;
|
||||
height: 270px;
|
||||
border: 1px solid #ffffff88;
|
||||
border-radius: 50%;
|
||||
top: 28px;
|
||||
left: 75px;
|
||||
z-index: 1;
|
||||
}
|
||||
.art-circle:before,
|
||||
.art-circle:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border: 1px solid #ffffff66;
|
||||
border-radius: 50%;
|
||||
inset: 26px;
|
||||
}
|
||||
.art-circle:after {
|
||||
inset: 57px;
|
||||
}
|
||||
.art-word {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 23px;
|
||||
z-index: 2;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 64px;
|
||||
color: white;
|
||||
line-height: 0.8;
|
||||
font-style: italic;
|
||||
letter-spacing: -4px;
|
||||
}
|
||||
.art-label {
|
||||
position: absolute;
|
||||
top: 21px;
|
||||
right: 22px;
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
letter-spacing: 1.4px;
|
||||
}
|
||||
.hero-card {
|
||||
position: absolute;
|
||||
right: 27px;
|
||||
top: 60px;
|
||||
width: 145px;
|
||||
height: 188px;
|
||||
border: 5px solid var(--surface);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 20px 30px #1b2a3530;
|
||||
transform: rotate(10deg);
|
||||
z-index: 3;
|
||||
}
|
||||
.hero-card img {
|
||||
width: 100%;
|
||||
height: 155px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-card span {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 8px;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-star {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 57px;
|
||||
color: var(--lime);
|
||||
font-size: 65px;
|
||||
line-height: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
@media (max-width: 1250px) {
|
||||
.hero-copy {
|
||||
padding: 28px;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 35px;
|
||||
}
|
||||
.hero-buttons {
|
||||
gap: 10px;
|
||||
}
|
||||
.hero-card {
|
||||
width: 125px;
|
||||
height: 164px;
|
||||
right: 18px;
|
||||
}
|
||||
.hero-card img {
|
||||
height: 131px;
|
||||
}
|
||||
.art-circle {
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,218 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { NAV } from './nav';
|
||||
import { goto } from "$app/navigation";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { toStore } from "svelte/store";
|
||||
import { projects } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import type { IconName } from "$lib/components/ui/icons";
|
||||
import { NAV } from "./nav";
|
||||
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: 'pages' | 'projects';
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: "pages" | "projects";
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state("");
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ["projects"],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.id}`,
|
||||
group: 'pages',
|
||||
label: t(item.labelKey),
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: 'projects',
|
||||
label: p.title,
|
||||
icon: 'story',
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.href}`,
|
||||
group: "pages",
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: "projects",
|
||||
label: p.title,
|
||||
icon: "story",
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) query = '';
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open) query = "";
|
||||
});
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list
|
||||
?.querySelector(`#${optionId(activeIndex)}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === 'Enter') {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === "Enter") {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('shell.searchTitle')}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t('shell.searchPlaceholder')}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
|
||||
aria-label={t('shell.searchTitle')}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
|
||||
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
|
||||
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
|
||||
</p>
|
||||
</div>
|
||||
<Modal bind:open title={t("shell.searchTitle")}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t("shell.searchPlaceholder")}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length
|
||||
? optionId(activeIndex)
|
||||
: undefined}
|
||||
aria-label={t("shell.searchTitle")}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="palette-list"
|
||||
id={listId}
|
||||
role="listbox"
|
||||
bind:this={list}
|
||||
aria-label={t("shell.searchTitle")}
|
||||
>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">
|
||||
{t("shell.searchEmpty", { q: query.trim() })}
|
||||
</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === "pages"
|
||||
? t("shell.searchGroupPages")
|
||||
: t("shell.searchGroupProjects")}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t("shell.searchHintMove")}
|
||||
<span class="a-kbd">Enter</span>{t("shell.searchHintOpen")}
|
||||
<span class="a-kbd">Esc</span>{t("shell.searchHintClose")}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
|
||||
let { compact = false }: { compact?: boolean } = $props();
|
||||
function move(event: KeyboardEvent) {
|
||||
const steps: Record<string, number> = {
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
};
|
||||
const step = steps[event.key];
|
||||
if (!step && event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const group = (event.currentTarget as HTMLElement).closest(
|
||||
".choices",
|
||||
) as HTMLElement;
|
||||
const buttons = [...group.querySelectorAll<HTMLButtonElement>("button")];
|
||||
const current = buttons.indexOf(
|
||||
document.activeElement as HTMLButtonElement,
|
||||
);
|
||||
const next =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? buttons.length - 1
|
||||
: (current + step + buttons.length) % buttons.length;
|
||||
buttons[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="choices" class:compact role="group" aria-label="外观主题">
|
||||
{#each THEMES as item}
|
||||
<button
|
||||
class="choice"
|
||||
class:selected={$theme === item.id}
|
||||
aria-pressed={$theme === item.id}
|
||||
onkeydown={move}
|
||||
onclick={() => setTheme(item.id)}
|
||||
>
|
||||
<span class="preview" data-theme={item.id} aria-hidden="true"
|
||||
><span class="rail"></span><span class="list"
|
||||
><i></i><i></i><i></i></span
|
||||
><span class="stage"><i></i><b></b></span></span
|
||||
>
|
||||
<span class="copy"
|
||||
><strong>{item.name}</strong><small>{item.english}</small><span
|
||||
>{item.description}</span
|
||||
></span
|
||||
>
|
||||
<span class="check" aria-hidden="true"
|
||||
>{$theme === item.id ? "✓" : ""}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $themeStorageUnavailable}<p class="notice" role="status">
|
||||
浏览器禁止本地存储:本次有效,其他操作不受影响
|
||||
</p>{/if}
|
||||
|
||||
<style>
|
||||
.choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.choices.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
color: var(--tx-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.choice:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.choice.selected {
|
||||
border-color: var(--gold);
|
||||
}
|
||||
.choice:focus-visible {
|
||||
outline: 2px solid var(--info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.compact .choice {
|
||||
grid-template-columns: 76px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.preview {
|
||||
grid-column: 1 / -1;
|
||||
height: 64px;
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 12px 22px 1fr;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.compact .preview {
|
||||
grid-column: auto;
|
||||
height: 48px;
|
||||
}
|
||||
.rail,
|
||||
.list {
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.rail::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin: 6px auto;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 6px 3px;
|
||||
}
|
||||
.list i {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--line-3);
|
||||
}
|
||||
.list i:first-child {
|
||||
background: var(--sc-image);
|
||||
}
|
||||
.stage {
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stage i {
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.stage b {
|
||||
height: 9px;
|
||||
width: 18px;
|
||||
align-self: end;
|
||||
border-radius: 2px;
|
||||
background: var(--grad-gold);
|
||||
}
|
||||
.copy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.copy strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.copy small {
|
||||
font: 10px var(--mono);
|
||||
color: var(--tx-3);
|
||||
align-self: center;
|
||||
}
|
||||
.copy > span {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.check {
|
||||
color: var(--gold);
|
||||
}
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
@media (max-width: 1366px) {
|
||||
.choices:not(.compact) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +1,130 @@
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import type { IconName } from "$lib/components/ui/icons";
|
||||
|
||||
// IDs are retained for the existing page contracts while B1–B4 replace layouts.
|
||||
export type NavId =
|
||||
| 'projects'
|
||||
| 'workbench'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'tasks'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'library'
|
||||
| 'settings';
|
||||
|
||||
| "projects"
|
||||
| "workbench"
|
||||
| "image"
|
||||
| "video"
|
||||
| "tasks"
|
||||
| "agents"
|
||||
| "connections"
|
||||
| "models"
|
||||
| "library"
|
||||
| "settings"
|
||||
| "canvas"
|
||||
| "workflows";
|
||||
export interface NavItem {
|
||||
id: NavId;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
id: NavId;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
scene: "project" | "image" | "agent" | "manage" | "asset";
|
||||
matches: (path: string) => boolean;
|
||||
}
|
||||
|
||||
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
|
||||
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
|
||||
// and settings entries at the foot of the 60px rail.
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
|
||||
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
|
||||
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
|
||||
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
|
||||
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
|
||||
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
|
||||
{
|
||||
id: "projects",
|
||||
label: "工作室",
|
||||
icon: "home",
|
||||
href: "/",
|
||||
scene: "project",
|
||||
matches: (p) => p === "/",
|
||||
},
|
||||
{
|
||||
id: "workbench",
|
||||
label: "项目",
|
||||
icon: "folder",
|
||||
href: "/workbench",
|
||||
scene: "project",
|
||||
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
label: "创作台",
|
||||
icon: "image",
|
||||
href: "/create",
|
||||
scene: "image",
|
||||
matches: (p) =>
|
||||
["/create", "/image", "/video", "/playground", "/build-scene"].includes(
|
||||
p,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
label: "智能体",
|
||||
icon: "sparkle",
|
||||
href: "/agents",
|
||||
scene: "agent",
|
||||
matches: (p) => p === "/agents",
|
||||
},
|
||||
{
|
||||
id: "canvas",
|
||||
label: "画布",
|
||||
icon: "story",
|
||||
href: "/canvas",
|
||||
scene: "manage",
|
||||
matches: (p) => p.startsWith("/canvas"),
|
||||
},
|
||||
{
|
||||
id: "library",
|
||||
label: "素材库",
|
||||
icon: "assets",
|
||||
href: "/library",
|
||||
scene: "asset",
|
||||
matches: (p) => p === "/library",
|
||||
},
|
||||
];
|
||||
export const NAV_MANAGE: NavItem[] = [
|
||||
{
|
||||
id: "tasks",
|
||||
label: "任务",
|
||||
icon: "clock",
|
||||
href: "/tasks",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/tasks",
|
||||
},
|
||||
{
|
||||
id: "workflows",
|
||||
label: "工作流",
|
||||
icon: "playground",
|
||||
href: "/workflows",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/workflows",
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "模型连接",
|
||||
icon: "link",
|
||||
href: "/model-connections",
|
||||
scene: "manage",
|
||||
matches: (p) =>
|
||||
["/connections", "/models", "/model-connections"].includes(p),
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV_FOOT: NavItem[] = [
|
||||
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
|
||||
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
|
||||
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
|
||||
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
|
||||
{
|
||||
id: "settings",
|
||||
label: "设置",
|
||||
icon: "settings",
|
||||
href: "/settings",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
|
||||
|
||||
export interface TabItem {
|
||||
id: 'projects' | 'workbench' | 'generate';
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
/** Pages that light this tab up. */
|
||||
covers: NavId[];
|
||||
}
|
||||
|
||||
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
|
||||
export const TABS: TabItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
|
||||
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
|
||||
{
|
||||
id: 'generate',
|
||||
labelKey: 'shell.tabGenerate',
|
||||
icon: 'image',
|
||||
href: '/image',
|
||||
covers: ['image', 'video'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
|
||||
* is still its own route, so it is listed there too until the two
|
||||
* generation pages merge.
|
||||
*/
|
||||
export const MORE_ITEMS: NavItem[] = NAV.filter(
|
||||
(item) => !TABS.some((tab) => tab.href === item.href),
|
||||
);
|
||||
// Tools awaiting their replacement keep reachable entries in global search.
|
||||
export const NAV = [
|
||||
...NAV_MAIN,
|
||||
...NAV_MANAGE,
|
||||
...NAV_FOOT,
|
||||
{ id: "video", label: "视频创作", icon: "film", href: "/video" },
|
||||
{ id: "models", label: "大语言模型", icon: "assets", href: "/models" },
|
||||
{
|
||||
id: "connections",
|
||||
label: "ComfyUI 连接",
|
||||
icon: "link",
|
||||
href: "/connections",
|
||||
},
|
||||
{ id: "image", label: "试验台", icon: "playground", href: "/playground" },
|
||||
{ id: "video", label: "三维搭景", icon: "story", href: "/build-scene" },
|
||||
] as const;
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { page } from '$app/state';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import Composer from '$lib/components/generate/Composer.svelte';
|
||||
import ContextBar from '$lib/components/generate/ContextBar.svelte';
|
||||
import Inspector from '$lib/components/generate/Inspector.svelte';
|
||||
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
|
||||
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
|
||||
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
|
||||
import { readDeskContext } from '$lib/components/generate/context';
|
||||
import {
|
||||
changedParams,
|
||||
classifySchema,
|
||||
listPaths,
|
||||
missingMedia,
|
||||
schemaDefaults,
|
||||
validateForm,
|
||||
type BlockReason,
|
||||
type DeskKind,
|
||||
type FormValue,
|
||||
type FormValues,
|
||||
} from '$lib/components/generate/schema';
|
||||
import { matchesFilter, type FeedFilter } from '$lib/components/generate/takes';
|
||||
import { apiErrorMessage, assetUrl, comfy, jobsApi, playgroundApi, projects, settings, workflows } from '$lib/api';
|
||||
import { USAGE_BY_KIND, type Job, type Workflow } from '$lib/comfy/types';
|
||||
import { compactInputValues } from '$lib/comfy/promptInput';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { handleJobEvent } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台 (spec-v2 §3.3, HEL-650 C 批): one desk for images and video — the
|
||||
* take feed with the composer below and the inspector on the right.
|
||||
* `/image` and `/video` only preselect: the default workflow comes from
|
||||
* that kind's usages and the feed opens filtered to it. Slots and params
|
||||
* follow the selected workflow's JSON (see generate/schema.ts).
|
||||
*/
|
||||
interface Props {
|
||||
kind: DeskKind;
|
||||
}
|
||||
|
||||
let { kind }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Data
|
||||
// ------------------------------------------------------------------
|
||||
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ['playground-jobs'],
|
||||
queryFn: playgroundApi.jobs,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
let ctxDismissed = $state(false);
|
||||
const ctx = $derived(ctxDismissed ? null : readDeskContext(page.url, kind));
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({ queryKey: ['projects'], queryFn: projects.list, enabled: !!ctx })),
|
||||
);
|
||||
const ctxProjectTitle = $derived(
|
||||
($projectsQuery.data ?? []).find((p) => p.id === ctx?.projectId)?.title ??
|
||||
t('attach.projectFallback', { id: ctx?.projectId ?? '' }),
|
||||
);
|
||||
|
||||
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
|
||||
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
|
||||
const dryRun = $derived($settingsQuery.data?.dry_run === true);
|
||||
|
||||
// HEL-636/638: only confirmed usages with a settled output mapping.
|
||||
const deskWorkflows = $derived(
|
||||
($workflowsQuery.data ?? []).filter((w) => w.is_enabled && (w.usage ?? null) !== null && !w.needs_mapping),
|
||||
);
|
||||
const workflowById = $derived(new Map(($workflowsQuery.data ?? []).map((w) => [w.id, w])));
|
||||
const workflowOf = (job: Job): Workflow | undefined =>
|
||||
job.workflow_id != null ? workflowById.get(job.workflow_id) : undefined;
|
||||
|
||||
const allJobs = $derived($jobsQuery.data ?? []);
|
||||
|
||||
// Jobs enqueued from this desk — a fast failure can land before the
|
||||
// jobs list refetch knows about them.
|
||||
const submittedIds = new Set<number>();
|
||||
|
||||
// D 批: one-shot completion flash / failure shake (SSE-driven) and the
|
||||
// 5-second undo window for record deletes.
|
||||
const flashIds = new SvelteSet<number>();
|
||||
const shakeIds = new SvelteSet<number>();
|
||||
const deleteQueue = createTakeDeleteQueue(client);
|
||||
const pendingIds = deleteQueue.pending;
|
||||
$effect(() => deleteQueue.destroy);
|
||||
|
||||
function flashTake(id: number, ms: number, set: SvelteSet<number>) {
|
||||
set.add(id);
|
||||
setTimeout(() => set.delete(id), ms);
|
||||
}
|
||||
|
||||
let feed = $state<TakeFeed | null>(null);
|
||||
let orderedIds = $state<number[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
if (ev.type === 'job.failed' && !ev.data.cancelled) {
|
||||
const id = Number(ev.data.job_id);
|
||||
// The SSE backlog replays old failures on every connect.
|
||||
const known = allJobs.find((j) => j.id === id);
|
||||
if (submittedIds.has(id) || (known && known.status !== 'failed')) {
|
||||
toast.error(t('studio.jobFailedToast', { detail: String(ev.data.error ?? '') }));
|
||||
}
|
||||
flashTake(id, 400, shakeIds);
|
||||
}
|
||||
if (ev.type === 'job.completed') {
|
||||
const id = Number(ev.data.job_id);
|
||||
flashTake(id, 950, flashIds);
|
||||
// 完成:检视区若正选中该条且为视频,自动播放。
|
||||
if (selectedJobId === id) playSignal += 1;
|
||||
}
|
||||
if (ev.type === 'job.completed' && submittedIds.has(Number(ev.data.job_id)) && ev.data.dry_run) {
|
||||
toast.info(t('studio.dryRunToast'));
|
||||
}
|
||||
if (ev.type.startsWith('job.') || ev.type === 'events.resync') {
|
||||
void client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
void client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
}
|
||||
});
|
||||
return off;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Form state
|
||||
// ------------------------------------------------------------------
|
||||
let endpointTouched = $state(false);
|
||||
let endpointId = $state('');
|
||||
let workflowId = $state<number | null>(null);
|
||||
let values = $state<FormValues>({});
|
||||
let showErrors = $state(false);
|
||||
let submitting = $state(false);
|
||||
let sent = $state(false);
|
||||
let slotBusy = $state(false);
|
||||
let promptEl = $state<HTMLTextAreaElement | null>(null);
|
||||
let pickerFor = $state<string | null>(null);
|
||||
|
||||
const currentWorkflow = $derived(deskWorkflows.find((w) => w.id === workflowId) ?? null);
|
||||
const schema = $derived(currentWorkflow?.input_schema ?? []);
|
||||
const ds = $derived(classifySchema(schema, currentWorkflow?.kind ?? kind));
|
||||
|
||||
// Default endpoint follows the workflow binding, then the active one —
|
||||
// unless the user picked one explicitly.
|
||||
$effect(() => {
|
||||
if (endpointTouched) return;
|
||||
endpointId = currentWorkflow?.endpoint_id ?? activeId ?? '';
|
||||
});
|
||||
|
||||
// Default workflow: this entry's usages in priority order, runnable ones first.
|
||||
$effect(() => {
|
||||
if (workflowId != null && deskWorkflows.some((w) => w.id === workflowId)) return;
|
||||
const usages = USAGE_BY_KIND[kind];
|
||||
const runnable = deskWorkflows.filter((w) => !(w.warnings ?? []).some((x) => x.level === 'error'));
|
||||
const pick =
|
||||
usages.map((u) => runnable.find((w) => w.usage === u)).find(Boolean) ??
|
||||
deskWorkflows.find((w) => usages.includes(w.usage!)) ??
|
||||
runnable[0] ??
|
||||
deskWorkflows[0];
|
||||
workflowId = pick?.id ?? null;
|
||||
});
|
||||
|
||||
/**
|
||||
* New workflow → its own defaults, keeping what the user already gave:
|
||||
* the prompt / negative prompt (by role) and uploaded images, poured
|
||||
* into the new workflow's image slots in order.
|
||||
*/
|
||||
function carryOver(prev: Workflow | null, prevValues: FormValues, next: Workflow): FormValues {
|
||||
const out = schemaDefaults(next.input_schema ?? []);
|
||||
const nextDs = classifySchema(next.input_schema ?? [], next.kind);
|
||||
if (!prev) return out;
|
||||
const prevDs = classifySchema(prev.input_schema ?? [], prev.kind);
|
||||
for (const [a, b] of [
|
||||
[prevDs.prompt, nextDs.prompt],
|
||||
[prevDs.negative, nextDs.negative],
|
||||
]) {
|
||||
const v = a ? prevValues[a.nodeId] : undefined;
|
||||
if (a && b && typeof v === 'string' && v.trim()) out[b.nodeId] = v;
|
||||
}
|
||||
const images: string[] = [];
|
||||
for (const s of prevDs.slots) {
|
||||
const v = prevValues[s.inp.nodeId];
|
||||
if (s.mediaKind === 'image' && !s.mask && typeof v === 'string' && v) images.push(v);
|
||||
}
|
||||
for (const g of prevDs.lists) if (g.kind === 'image_list') images.push(...listPaths(prevValues, g));
|
||||
for (const s of nextDs.slots) {
|
||||
if (s.mediaKind === 'image' && !s.mask && images.length) out[s.inp.nodeId] = images.shift()!;
|
||||
}
|
||||
const list = nextDs.lists.find((g) => g.kind === 'image_list');
|
||||
if (list && images.length) out[list.nodeId] = images.slice(0, list.max ?? 9);
|
||||
return out;
|
||||
}
|
||||
|
||||
let lastWf = $state<Workflow | null>(null);
|
||||
let ctxApplied = false;
|
||||
$effect(() => {
|
||||
const wf = currentWorkflow;
|
||||
untrack(() => {
|
||||
if (!wf) {
|
||||
if (lastWf) values = {};
|
||||
lastWf = null;
|
||||
return;
|
||||
}
|
||||
if (wf.id === lastWf?.id) return;
|
||||
let next = carryOver(lastWf, values, wf);
|
||||
if (!ctxApplied && ctx) {
|
||||
ctxApplied = true;
|
||||
next = applyContext(wf, next);
|
||||
}
|
||||
lastWf = wf;
|
||||
values = next;
|
||||
showErrors = false;
|
||||
});
|
||||
});
|
||||
|
||||
function applyContext(wf: Workflow, base: FormValues): FormValues {
|
||||
if (!ctx) return base;
|
||||
const d = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
const out = { ...base };
|
||||
if (d.prompt && ctx.prompt) out[d.prompt.nodeId] = ctx.prompt;
|
||||
if (d.negative && ctx.negative) out[d.negative.nodeId] = ctx.negative;
|
||||
if (d.duration && ctx.durationSec != null) {
|
||||
const opts = d.duration.options;
|
||||
const v = String(ctx.durationSec);
|
||||
if (!opts?.length || opts.includes(v)) out[d.duration.nodeId] = d.duration.kind === 'number' ? ctx.durationSec : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setVal(nodeId: string, v: FormValue) {
|
||||
values = { ...values, [nodeId]: v };
|
||||
}
|
||||
|
||||
function restoreAll() {
|
||||
const defaults = schemaDefaults(schema);
|
||||
const next = { ...values };
|
||||
for (const inp of [...ds.common, ...ds.advanced]) {
|
||||
if (defaults[inp.nodeId] !== undefined) next[inp.nodeId] = defaults[inp.nodeId];
|
||||
else delete next[inp.nodeId];
|
||||
}
|
||||
values = next;
|
||||
}
|
||||
|
||||
const changedCount = $derived(changedParams(ds, values).length);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Connection & validation
|
||||
// ------------------------------------------------------------------
|
||||
const probeQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['comfy-probe', endpointId || null],
|
||||
queryFn: () => comfy.testEndpoint({ endpoint_id: endpointId, timeout_sec: 5 }),
|
||||
enabled: !!endpointId,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
})),
|
||||
);
|
||||
const connRec = $derived.by(() => {
|
||||
const data = $probeQuery.data;
|
||||
if (!endpointId || !data) return null;
|
||||
return { ok: data.ok, ms: data.elapsed_ms, at: $probeQuery.dataUpdatedAt };
|
||||
});
|
||||
const connState = $derived.by(() => {
|
||||
if (dryRun) return { state: 'dry' as const };
|
||||
if (connRec?.ok) return { state: 'ok' as const, ms: connRec.ms };
|
||||
if (connRec?.ok === false) return { state: 'fail' as const };
|
||||
return { state: 'untested' as const };
|
||||
});
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const errors = $derived(validateForm(schema, ds, values));
|
||||
const brokenReasons = $derived(
|
||||
(currentWorkflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
|
||||
/** The single reason 生成 is off, in the order a user can fix things. */
|
||||
const block = $derived.by<BlockReason | null>(() => {
|
||||
if (!currentWorkflow) return { text: t('desk.needWorkflow'), tone: 'warn', target: 'desk-wf' };
|
||||
if (!endpoints.length || !endpointId) return { text: t('studio.errNoConn'), tone: 'err', target: 'desk-conn' };
|
||||
if (connRec?.ok === false && !dryRun) {
|
||||
return { text: t('studio.errConnDown', { time: fmtClock(connRec.at) }), tone: 'err', target: 'desk-conn' };
|
||||
}
|
||||
if (brokenReasons.length) return { text: t('studio.errWfBroken'), tone: 'err', params: true };
|
||||
const missing = missingMedia(ds, values);
|
||||
const promptMissing = !!ds.prompt && !!errors[ds.prompt.nodeId] && !String(values[ds.prompt.nodeId] ?? '').trim();
|
||||
if (missing.length || promptMissing) {
|
||||
const labels = [...missing.map((m) => m.label), ...(promptMissing ? [t('studio.promptLabel')] : [])];
|
||||
return {
|
||||
text: t('desk.stillNeed', { what: labels.join('、') }),
|
||||
tone: 'warn',
|
||||
target: missing.length ? `slot-${missing[0].nodeId}` : 'desk-prompt',
|
||||
};
|
||||
}
|
||||
if (errors.firstLast) return { text: errors.firstLast, tone: 'warn', target: `slot-${ds.slots.find((s) => s.firstFrame)?.inp.nodeId}` };
|
||||
const firstErr = Object.entries(errors).find(([, m]) => m.trim());
|
||||
if (firstErr) {
|
||||
const [id, msg] = firstErr;
|
||||
if (ds.prompt && id === ds.prompt.nodeId) return { text: msg, tone: 'err', target: 'desk-prompt' };
|
||||
return { text: t('desk.paramError', { msg }), tone: 'err', params: true };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
async function reveal() {
|
||||
showErrors = true;
|
||||
const b = block;
|
||||
if (!b) return;
|
||||
if (b.params) {
|
||||
openParams();
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
const el = b.target ? document.getElementById(b.target) : null;
|
||||
if (el && !el.getClientRects().length) {
|
||||
// Collapsed narrow composer: focusing inside expands it first.
|
||||
promptEl?.focus();
|
||||
await tick();
|
||||
}
|
||||
el?.focus();
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Submit
|
||||
// ------------------------------------------------------------------
|
||||
let sentTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function submit() {
|
||||
if (submitting || slotBusy) return;
|
||||
if (block) {
|
||||
void reveal();
|
||||
return;
|
||||
}
|
||||
const wf = currentWorkflow!;
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await playgroundApi.generate({
|
||||
workflow_id: wf.id,
|
||||
input_values: compactInputValues(values) as Record<string, unknown>,
|
||||
endpoint_id: endpointId || null,
|
||||
source: wf.kind === 'image' ? 'studio-image' : 'studio-video',
|
||||
});
|
||||
submittedIds.add(res.job.id);
|
||||
selectedJobId = res.job.id;
|
||||
if (!matchesFilter(res.job, filter)) filter = 'all';
|
||||
showErrors = false;
|
||||
sent = true;
|
||||
if (sentTimer) clearTimeout(sentTimer);
|
||||
sentTimer = setTimeout(() => (sent = false), 900);
|
||||
// C 批审核:从「参数」页提交后自动切到「结果」页,进行中进度可见。
|
||||
tab = 'result';
|
||||
if (wide) inspOff = false;
|
||||
else if (!narrow) inspOpen = true;
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.submitFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Feed, selection & inspector
|
||||
// ------------------------------------------------------------------
|
||||
let filter = $state<FeedFilter>(untrack(() => kind));
|
||||
let selectedJobId = $state<number | null>(null);
|
||||
let busyId = $state<number | null>(null);
|
||||
let tab = $state<'result' | 'params'>('result');
|
||||
let inspOff = $state(false);
|
||||
let inspOpen = $state(false);
|
||||
let wide = $state(true);
|
||||
let narrow = $state(false);
|
||||
let crossfade = $state(false);
|
||||
let playSignal = $state(0);
|
||||
let videoEl = $state<HTMLVideoElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1180px)');
|
||||
const mq2 = window.matchMedia('(max-width: 760px)');
|
||||
const sync = () => {
|
||||
wide = mq.matches;
|
||||
narrow = mq2.matches;
|
||||
};
|
||||
sync();
|
||||
mq.addEventListener('change', sync);
|
||||
mq2.addEventListener('change', sync);
|
||||
return () => {
|
||||
mq.removeEventListener('change', sync);
|
||||
mq2.removeEventListener('change', sync);
|
||||
};
|
||||
});
|
||||
|
||||
let nowMs = $state(Date.now());
|
||||
const anyActive = $derived(allJobs.some((j) => j.status === 'pending' || j.status === 'running'));
|
||||
$effect(() => {
|
||||
if (!anyActive) return;
|
||||
const id = setInterval(() => (nowMs = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
|
||||
const selectedJob = $derived(
|
||||
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
|
||||
allJobs
|
||||
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id) && jobStatusInfo(j).tone === 'ok')
|
||||
.sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at))[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* 结果切换 (HEL-648): pointer picks slide the gold frame and cross-fade
|
||||
* the inspector; keyboard ↑↓/J/K switch instantly with no animation.
|
||||
*/
|
||||
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
|
||||
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
|
||||
selectedJobId = job.id;
|
||||
tab = 'result';
|
||||
crossfade = via === 'pointer';
|
||||
if (via === 'pointer') {
|
||||
playSignal += 1;
|
||||
if (!wide) inspOpen = true;
|
||||
void tick().then(() => feed?.slideFrom(from));
|
||||
} else {
|
||||
feed?.scrollToJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (!orderedIds.length) return;
|
||||
let i = orderedIds.indexOf(selectedJob?.id ?? -1);
|
||||
if (i < 0) i = orderedIds.length - 1;
|
||||
i = Math.max(0, Math.min(orderedIds.length - 1, i + delta));
|
||||
const job = allJobs.find((j) => j.id === orderedIds[i]);
|
||||
if (job) selectTake(job, 'keyboard');
|
||||
}
|
||||
|
||||
function openParams() {
|
||||
tab = 'params';
|
||||
if (wide) inspOff = false;
|
||||
else inspOpen = true;
|
||||
}
|
||||
|
||||
function toggleInspector() {
|
||||
if (wide) inspOff = !inspOff;
|
||||
else inspOpen = !inspOpen;
|
||||
}
|
||||
|
||||
function closeInspector() {
|
||||
if (wide) inspOff = true;
|
||||
else inspOpen = false;
|
||||
}
|
||||
|
||||
const imageSlotTarget = $derived(
|
||||
ds.slots.find((s) => s.mediaKind === 'image' && !s.mask && s.fromResults) ?? null,
|
||||
);
|
||||
const imageList = $derived(ds.lists.find((g) => g.kind === 'image_list') ?? null);
|
||||
|
||||
function sendToSlot(path: string) {
|
||||
if (imageSlotTarget && !String(values[imageSlotTarget.inp.nodeId] ?? '')) {
|
||||
setVal(imageSlotTarget.inp.nodeId, path);
|
||||
} else if (imageList) {
|
||||
const cur = listPaths(values, imageList);
|
||||
if (cur.length >= (imageList.max ?? 9)) {
|
||||
toast.error(t('studio.refFull', { max: imageList.max ?? 9 }));
|
||||
return;
|
||||
}
|
||||
setVal(imageList.nodeId, [...cur, path]);
|
||||
} else if (imageSlotTarget) {
|
||||
setVal(imageSlotTarget.inp.nodeId, path);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
toast.success(t('desk.sentToSlotToast'));
|
||||
}
|
||||
|
||||
/** Fill the composer back with a past take's values (on its own workflow). */
|
||||
function reuse(job: Job) {
|
||||
const wf = deskWorkflows.find((w) => w.id === job.workflow_id);
|
||||
if (!wf) {
|
||||
toast.error(t('studio.reuseMissingWf'));
|
||||
return;
|
||||
}
|
||||
const next = schemaDefaults(wf.input_schema ?? []);
|
||||
for (const [k, v] of Object.entries((job.payload?.input_values ?? {}) as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' || typeof v === 'number') next[k] = v;
|
||||
else if (Array.isArray(v) && v.every((p) => typeof p === 'string')) next[k] = v as string[];
|
||||
}
|
||||
workflowId = wf.id;
|
||||
lastWf = wf;
|
||||
values = next;
|
||||
showErrors = false;
|
||||
toast.success(t('studio.reusedToast'));
|
||||
void tick().then(() => promptEl?.focus());
|
||||
}
|
||||
|
||||
async function retry(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
toast.success(t('studio.retryQueuedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
toast.info(t('studio.cancelToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除整条任务 (§四.4 删除两级制): no dialog — the row collapses out,
|
||||
* a 5-second Toast offers 撤销 (row returns with its entrance and is
|
||||
* reselected), and only then does the DELETE really hit the backend.
|
||||
*/
|
||||
function requestDelete(job: Job) {
|
||||
if (job.status === 'pending' || job.status === 'running') return;
|
||||
const name = workflowOf(job)?.name ?? t('studio.jobName', { id: job.id });
|
||||
deleteQueue.request(job, {
|
||||
message: () =>
|
||||
(job.output_paths?.length ?? 0) > 0
|
||||
? t('studio.deletedTakeToast', { name })
|
||||
: t('studio.deletedTakeNoFilesToast', { name }),
|
||||
onUndo: (j) => {
|
||||
feed?.markEnter(j.id);
|
||||
const from = feed?.markRect() ?? null;
|
||||
selectedJobId = j.id;
|
||||
void tick().then(() => {
|
||||
feed?.slideFrom(from);
|
||||
feed?.scrollToJob(j.id);
|
||||
});
|
||||
},
|
||||
onCommitted: (j) => {
|
||||
submittedIds.delete(j.id);
|
||||
if (selectedJobId === j.id) selectedJobId = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 从结果选 (finished image takes → an image slot)
|
||||
// ------------------------------------------------------------------
|
||||
const imageResults = $derived(
|
||||
allJobs
|
||||
.filter((j) => j.kind === 'image' && jobStatusInfo(j).tone === 'ok')
|
||||
.flatMap((j) => j.output_paths.map((p) => ({ job: j, path: p }))),
|
||||
);
|
||||
|
||||
function pickResult(path: string) {
|
||||
const nodeId = pickerFor;
|
||||
pickerFor = null;
|
||||
if (!nodeId) return;
|
||||
const list = ds.lists.find((g) => g.nodeId === nodeId);
|
||||
if (list) {
|
||||
const cur = listPaths(values, list);
|
||||
if (cur.length >= (list.max ?? 9)) toast.error(t('studio.refFull', { max: list.max ?? 9 }));
|
||||
else setVal(nodeId, [...cur, path]);
|
||||
} else {
|
||||
setVal(nodeId, path);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
|
||||
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
|
||||
// ------------------------------------------------------------------
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||
}
|
||||
|
||||
/** Focus sits on something that owns the key (button, link, video…). */
|
||||
function onInteractive(target: EventTarget | null): boolean {
|
||||
const el = target instanceof Element ? target : null;
|
||||
return !!el?.closest('button, a, input, textarea, select, label, video, audio, [contenteditable]');
|
||||
}
|
||||
|
||||
function inLayer(target: EventTarget | null): boolean {
|
||||
const el = target instanceof Element ? target : null;
|
||||
return !!el?.closest('.shell-pop, .modal, [role="dialog"]');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === '/' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
promptEl?.focus();
|
||||
} else if ((e.key === 'ArrowDown' || key === 'j') && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
} else if ((e.key === 'ArrowUp' || key === 'k') && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
} else if (e.key === ' ') {
|
||||
// 空格播放/暂停:检视区可见且有视频时接管;焦点在控件上时走原生行为。
|
||||
if (typing(e.target) || onInteractive(e.target)) return;
|
||||
if (videoEl && (wide ? !inspOff : inspOpen)) {
|
||||
e.preventDefault();
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
} else if (e.key === ']' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
toggleInspector();
|
||||
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !typing(e.target)) {
|
||||
const job = selectedJob;
|
||||
if (job && job.status !== 'pending' && job.status !== 'running') {
|
||||
e.preventDefault();
|
||||
requestDelete(job);
|
||||
}
|
||||
} else if (e.key === 'Escape' && !wide && inspOpen) {
|
||||
inspOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#snippet ctxBanner()}
|
||||
{#if ctx}
|
||||
<ContextBar {ctx} projectTitle={ctxProjectTitle} onDismiss={() => (ctxDismissed = true)} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet inspectorToggle()}
|
||||
{#if !wide || inspOff}
|
||||
<button type="button" class="a-btn ghost sm" aria-keyshortcuts="]" onclick={toggleInspector}>
|
||||
<Icon name="info" size={14} />
|
||||
{t('desk.openInspector')}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet paramsTab()}
|
||||
<ParamsPanel
|
||||
workflow={currentWorkflow}
|
||||
{ds}
|
||||
{values}
|
||||
{errors}
|
||||
{showErrors}
|
||||
{changedCount}
|
||||
onSet={setVal}
|
||||
onRestoreAll={restoreAll}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<AppShell active={kind} title={t('desk.title')}>
|
||||
<div class="desk" class:insp-off={inspOff}>
|
||||
<section class="center" aria-label={t('desk.title')}>
|
||||
<TakeFeed
|
||||
bind:this={feed}
|
||||
jobs={allJobs}
|
||||
{workflowOf}
|
||||
bind:filter
|
||||
selectedId={selectedJob?.id ?? null}
|
||||
{nowMs}
|
||||
{busyId}
|
||||
loading={$jobsQuery.isLoading}
|
||||
{pendingIds}
|
||||
{flashIds}
|
||||
{shakeIds}
|
||||
bind:orderedIds
|
||||
banner={ctxBanner}
|
||||
{inspectorToggle}
|
||||
onSelect={(job) => selectTake(job, 'pointer')}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onCancel={cancel}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
|
||||
<div class="desk-empty" role="note">
|
||||
<Icon name="playground" size={16} />
|
||||
<span>{t('desk.noWorkflows')}</span>
|
||||
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
<Composer
|
||||
{ds}
|
||||
{values}
|
||||
workflows={deskWorkflows}
|
||||
current={currentWorkflow}
|
||||
{endpoints}
|
||||
{endpointId}
|
||||
conn={connState}
|
||||
{errors}
|
||||
{showErrors}
|
||||
{block}
|
||||
{changedCount}
|
||||
{submitting}
|
||||
{sent}
|
||||
bind:slotBusy
|
||||
bind:promptEl
|
||||
onSet={setVal}
|
||||
onPickWorkflow={(wf) => (workflowId = wf.id)}
|
||||
onPickEndpoint={(id) => {
|
||||
endpointTouched = true;
|
||||
endpointId = id;
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onReveal={reveal}
|
||||
onOpenParams={openParams}
|
||||
onFromResults={(nodeId) => (pickerFor = nodeId)}
|
||||
/>
|
||||
</section>
|
||||
<Inspector
|
||||
bind:tab
|
||||
job={selectedJob}
|
||||
wf={selectedJob ? workflowOf(selectedJob) : undefined}
|
||||
{nowMs}
|
||||
open={inspOpen}
|
||||
inactive={wide ? inspOff : !inspOpen}
|
||||
canSendToSlot={!!imageSlotTarget || !!imageList}
|
||||
{crossfade}
|
||||
{playSignal}
|
||||
bind:videoEl
|
||||
params={paramsTab}
|
||||
onClose={closeInspector}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onSendToSlot={sendToSlot}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<Modal open={pickerFor != null} title={t('studio.pickerTitle')} onclose={() => (pickerFor = null)}>
|
||||
{#if imageResults.length}
|
||||
<div class="picker-grid">
|
||||
{#each imageResults as entry (entry.path)}
|
||||
<button type="button" class="picker-item" onclick={() => pickResult(entry.path)}>
|
||||
<img src={assetUrl(entry.path)} alt={t('studio.pickerPick')} loading="lazy" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="picker-empty">{t('studio.pickerEmpty')}</p>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.desk {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.desk.insp-off {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.desk-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
margin: 0 var(--sp-7) var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.picker-item {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.picker-item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.picker-item:hover {
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
}
|
||||
.picker-empty {
|
||||
margin: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.desk,
|
||||
.desk.insp-off {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.desk-empty {
|
||||
margin: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.desk-empty {
|
||||
margin: 0 var(--sp-5) var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { enterModal, leaveModal, topModal } from './modalLayers';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -11,6 +12,9 @@
|
||||
onclose?: () => void;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
/** Open above a dock, retaining the shared dialog focus and keyboard behavior. */
|
||||
anchor?: HTMLElement | null;
|
||||
headerActions?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -21,6 +25,8 @@
|
||||
onclose,
|
||||
footer,
|
||||
children,
|
||||
anchor = null,
|
||||
headerActions,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
@@ -29,6 +35,29 @@
|
||||
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let anchorBox = $state({ left: 0, bottom: 0, width: 620, available: 560 });
|
||||
$effect(() => {
|
||||
if (!open || !anchor) return;
|
||||
const source = anchor;
|
||||
const place = () => {
|
||||
const r = source.getBoundingClientRect();
|
||||
anchorBox = {
|
||||
left: r.left + 12,
|
||||
bottom: innerHeight - r.top + 8,
|
||||
width: r.width - 24,
|
||||
available: Math.max(100, r.top - 64),
|
||||
};
|
||||
};
|
||||
place();
|
||||
const observer = new ResizeObserver(place);
|
||||
observer.observe(source);
|
||||
window.addEventListener('resize', place);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', place);
|
||||
};
|
||||
});
|
||||
const layer = Symbol('dialog');
|
||||
|
||||
function requestClose() {
|
||||
if (!dismissible) return;
|
||||
@@ -36,6 +65,7 @@
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!topModal(layer)) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
@@ -75,20 +105,21 @@
|
||||
if (!open) return;
|
||||
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
enterModal(layer);
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel || panel.contains(document.activeElement)) return;
|
||||
if (!topModal(layer) || !panel || panel.contains(document.activeElement))
|
||||
return;
|
||||
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel).focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const wasTop = topModal(layer);
|
||||
leaveModal(layer);
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
if (invoker?.isConnected) invoker.focus();
|
||||
if (wasTop && invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
@@ -96,10 +127,20 @@
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
class:anchored={!!anchor}
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="modal-panel size-{size}"
|
||||
style:left={anchor ? `${anchorBox.left}px` : undefined}
|
||||
style:bottom={anchor ? `${anchorBox.bottom}px` : undefined}
|
||||
style:width={anchor
|
||||
? `min(var(--desk-pop-width), ${anchorBox.width}px)`
|
||||
: undefined}
|
||||
style:--anchor-available={`${anchorBox.available}px`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
@@ -111,6 +152,7 @@
|
||||
{#if title}
|
||||
<h2 class="modal-title" id={titleId}>{title}</h2>
|
||||
{/if}
|
||||
{@render headerActions?.()}
|
||||
{#if dismissible}
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,6 +206,38 @@
|
||||
.modal-panel.size-xl {
|
||||
width: min(1180px, 100%);
|
||||
}
|
||||
.anchored {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.anchored .modal-panel {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
height: var(--desk-pop-height);
|
||||
max-height: min(64vh, var(--anchor-available));
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--desk-dock-radius);
|
||||
background: var(--desk-pop-bg);
|
||||
box-shadow: var(--desk-pop-shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
.anchored .modal-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.anchored .modal-title {
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
.anchored .modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.anchored .modal-footer {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -92,14 +92,14 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
|
||||
|
||||
`import ProgressBar from '$lib/components/ui/ProgressBar.svelte';`
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --------------- | -------------- | ------- | ----------------------------------------- |
|
||||
| `value` | `number` | `0` | clamped to 0–100 |
|
||||
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
|
||||
| `label` | `string` | — | text above the track, also the aria-label |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
|
||||
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
|
||||
| `bare` | `boolean` | `false` | track only, no label / % row |
|
||||
| Prop | Type | Default | Notes |
|
||||
| --------------- | ---------------------------------- | ---------- | ----------------------------------------- |
|
||||
| `value` | `number` | `0` | clamped to 0–100 |
|
||||
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
|
||||
| `label` | `string` | — | text above the track, also the aria-label |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
|
||||
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
|
||||
| `bare` | `boolean` | `false` | track only, no label / % row |
|
||||
|
||||
```svelte
|
||||
<ProgressBar value={job.progress} label={job.name} />
|
||||
@@ -126,14 +126,16 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
|
||||
|
||||
`import Modal from '$lib/components/ui/Modal.svelte';`
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| ------------- | ------------ | -------- | --------------------------------------------------------------------------- |
|
||||
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
|
||||
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
|
||||
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
|
||||
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
|
||||
| `footer` | `Snippet` | — | right-aligned action row |
|
||||
| `children` | `Snippet` | required | body content |
|
||||
| Prop | Type | Default | Notes |
|
||||
| --------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------- |
|
||||
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
|
||||
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
|
||||
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
|
||||
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
|
||||
| `anchor` | `HTMLElement \| null` | `null` | Optional dock anchor: opens above it, clamps to viewport, retains the dialog focus behavior. |
|
||||
| `headerActions` | `Snippet` | — | Optional controls beside the title (for example restore workflow defaults). |
|
||||
| `footer` | `Snippet` | — | right-aligned action row |
|
||||
| `children` | `Snippet` | required | body content |
|
||||
|
||||
Behavior: Esc and backdrop click close when dismissible; focus is trapped while
|
||||
open; initial focus goes to the first focusable element (or the panel); focus is
|
||||
|
||||
@@ -51,6 +51,7 @@ export const icons = {
|
||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||
// Status
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Only the uppermost dialog handles the keyboard. Nested dialogs keep the
|
||||
// original body overflow until the final layer closes.
|
||||
const layers: symbol[] = [];
|
||||
let bodyOverflow = '';
|
||||
export function enterModal(token: symbol) {
|
||||
if (!layers.length) bodyOverflow = document.body.style.overflow;
|
||||
layers.push(token);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
export function topModal(token: symbol) {
|
||||
return layers.at(-1) === token;
|
||||
}
|
||||
export function leaveModal(token: symbol) {
|
||||
const index = layers.indexOf(token);
|
||||
if (index >= 0) layers.splice(index, 1);
|
||||
document.body.style.overflow = layers.length ? 'hidden' : bodyOverflow;
|
||||
}
|
||||
@@ -132,9 +132,7 @@ let {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(139, 92, 246, 0.08), transparent 55%),
|
||||
#050508;
|
||||
background: var(--stage-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
let criticNotes = $state<string[]>([]);
|
||||
|
||||
const preview = createMutation({
|
||||
mutationFn: async (vars?: { force?: boolean }) => {
|
||||
mutationFn: async (vars: { force?: boolean } | void) => {
|
||||
if (!clip && !scene) throw new Error(t('promptPreview.noClipSelected'));
|
||||
return jobsApi.previewPrompt(projectId, {
|
||||
clip_id: clip?.id,
|
||||
scene_id: clip ? undefined : scene?.id,
|
||||
workflow_id: workflow?.id,
|
||||
input_values: compactInputValues(inputValues),
|
||||
force: Boolean(vars?.force),
|
||||
force: Boolean((vars as { force?: boolean } | undefined)?.force),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -203,7 +203,7 @@ title={t('filmstrip.clipTitle', {
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
z-index: 1;
|
||||
background: #3f3f46;
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.clip.status-done .bar {
|
||||
|
||||
@@ -182,9 +182,11 @@
|
||||
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
|
||||
name: asset.name,
|
||||
}),
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
})}
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
sourceKind: 'asset',
|
||||
sourceId: asset.id,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
|
||||
@@ -165,14 +165,16 @@
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'video',
|
||||
projectId,
|
||||
stage: 'shot',
|
||||
label: t('desk.ctxShot', { no: shot.shot_no }),
|
||||
prompt: shot.prompt,
|
||||
durationSec: shot.duration_sec,
|
||||
})}
|
||||
href={deskHref({
|
||||
kind: 'video',
|
||||
projectId,
|
||||
stage: 'shot',
|
||||
label: t('desk.ctxShot', { no: shot.shot_no }),
|
||||
prompt: shot.prompt,
|
||||
durationSec: shot.duration_sec,
|
||||
sourceKind: 'shot',
|
||||
sourceId: shot.id,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
|
||||
@@ -23,27 +23,14 @@
|
||||
return t(`workbench.stage.${key}`);
|
||||
}
|
||||
|
||||
function charOf(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
script: '剧',
|
||||
style: '视',
|
||||
storyboard: '分',
|
||||
assets: '素',
|
||||
shot: '镜',
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}
|
||||
|
||||
function avatarVar(key: string): string {
|
||||
const idx = ORDER.indexOf(key) + 1;
|
||||
return `var(--avatar-${idx})`;
|
||||
}
|
||||
|
||||
function stateOf(stage: WorkbenchStage): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
function stateOf(
|
||||
stage: WorkbenchStage,
|
||||
): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
if (stage.status === 'running') return 'running';
|
||||
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
|
||||
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
|
||||
if (stage.status === 'awaiting_confirm') return stage.draft_edited ? 'edited' : 'draft';
|
||||
if (stage.status === 'awaiting_confirm')
|
||||
return stage.draft_edited ? 'edited' : 'draft';
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
@@ -70,7 +57,8 @@
|
||||
{@const state = stage ? stateOf(stage) : 'todo'}
|
||||
{@const open = stage ? unlocked(stages, key) : i === 0}
|
||||
<li class="step">
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}></span>{/if}
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
|
||||
></span>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
@@ -80,14 +68,19 @@
|
||||
data-state={state}
|
||||
onclick={() => onSelect(key)}
|
||||
aria-current={activeKey === key ? 'step' : undefined}
|
||||
title={open ? label(key) : t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
title={open
|
||||
? label(key)
|
||||
: t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
>
|
||||
<span class="avatar" style:background={avatarVar(key)}>{charOf(key)}</span>
|
||||
<span class="avatar"
|
||||
>{state === 'confirmed' ? '✓' : String(i + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="step-copy">
|
||||
<span class="step-name">{label(key)}</span>
|
||||
<span class="step-state st-{state}">
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"></span>{/if}
|
||||
{stateLabel(stage!)}
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"
|
||||
></span>{/if}
|
||||
{stage ? stateLabel(stage) : t('workbench.state.todo')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -238,4 +231,113 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.step {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
padding: 12px 10px;
|
||||
border: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
background: none !important;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 50%;
|
||||
font: 9px var(--mono);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #e8eafe;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.step-name {
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.step-state {
|
||||
font-size: 9px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.step-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
border-color: var(--ok);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
border: 1px solid #ffffff10;
|
||||
border-radius: 10px;
|
||||
background: #12171c;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #00cae012;
|
||||
border-color: #00cae080;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: #00cae012 !important;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: #86e8f1;
|
||||
}
|
||||
.step-state {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
color: var(--ok);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.step-btn:hover:not(.active) {
|
||||
background: #ffffff07;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
<script lang="ts">
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type WorkflowConvertFileReport,
|
||||
type WorkflowConvertReport,
|
||||
type WorkflowImportResult,
|
||||
type WorkflowUIFileInput,
|
||||
type WorkflowUsage,
|
||||
} from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
/**
|
||||
* 批量导入向导 (HEL-682 / S4):扫描服务器目录或选择多个本地文件 →
|
||||
* dry-run 逐文件预检报告(ok / warn / fail / 需人工确认,含建议元数据)→
|
||||
* 确认后提交导入。warn 项可补充用途后以启用状态入库;fail 与需人工确认项
|
||||
* 不会被导入,也不得被标作可运行。重复导入按内容去重,全部跳过。
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onimported?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), endpoints, onimported }: Props = $props();
|
||||
|
||||
type Step = 'source' | 'report' | 'result';
|
||||
|
||||
let step = $state<Step>('source');
|
||||
let mode = $state<'scan' | 'files'>('scan');
|
||||
let scanRoot = $state('example_ComfyUI_workflows');
|
||||
let preflightId = $state('');
|
||||
let inputFiles = $state<WorkflowUIFileInput[]>([]);
|
||||
let nonUiSkipped = $state<string[]>([]);
|
||||
let reading = $state(false);
|
||||
let running = $state(false);
|
||||
let importing = $state(false);
|
||||
let error = $state('');
|
||||
let report = $state<WorkflowConvertReport | null>(null);
|
||||
let result = $state<WorkflowImportResult | null>(null);
|
||||
let usageApplied = $state(0);
|
||||
/** Per-file usage chosen in the report step, keyed by name\0source_path. */
|
||||
let usageBy = $state<Record<string, WorkflowUsage>>({});
|
||||
/** Files the user un-checked (file-picker mode only). */
|
||||
let excludedKeys = $state<Set<string>>(new Set());
|
||||
let rSearch = $state('');
|
||||
let rFilter = $state<'all' | 'warn' | 'blocked'>('all');
|
||||
let expandedIssues = $state<Set<string>>(new Set());
|
||||
let visible = $state(60);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function rk(f: { name: string; source_path: string | null }): string {
|
||||
return `${f.name}\u0000${f.source_path ?? ''}`;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step = 'source';
|
||||
inputFiles = [];
|
||||
nonUiSkipped = [];
|
||||
report = null;
|
||||
result = null;
|
||||
usageApplied = 0;
|
||||
usageBy = {};
|
||||
excludedKeys = new Set();
|
||||
rSearch = '';
|
||||
rFilter = 'all';
|
||||
expandedIssues = new Set();
|
||||
visible = 60;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function mediaIcon(kind: string): 'image' | 'film' | 'music' {
|
||||
if (kind === 'video') return 'film';
|
||||
if (kind === 'audio') return 'music';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function mediaTitle(kind: string): string {
|
||||
if (kind === 'video') return t('wl.mediaVideo');
|
||||
if (kind === 'audio') return t('wl.mediaAudio');
|
||||
return t('wl.mediaImage');
|
||||
}
|
||||
|
||||
async function readPickedFiles(list: FileList | null) {
|
||||
if (!list?.length) return;
|
||||
reading = true;
|
||||
error = '';
|
||||
try {
|
||||
const items: WorkflowUIFileInput[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const file of Array.from(list)) {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (!(data && typeof data === 'object' && Array.isArray(data.nodes))) {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
name: file.name.replace(/\.json$/i, ''),
|
||||
source_path: file.name,
|
||||
workflow_json: data,
|
||||
});
|
||||
} catch {
|
||||
skipped.push(file.name);
|
||||
}
|
||||
}
|
||||
inputFiles = items;
|
||||
nonUiSkipped = skipped;
|
||||
} finally {
|
||||
reading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function runDryRun() {
|
||||
running = true;
|
||||
error = '';
|
||||
try {
|
||||
let res: WorkflowConvertReport;
|
||||
if (mode === 'scan') {
|
||||
res = await workflows.importScanDry(scanRoot.trim() || 'example_ComfyUI_workflows');
|
||||
inputFiles = [];
|
||||
nonUiSkipped = [];
|
||||
} else {
|
||||
if (inputFiles.length === 0) {
|
||||
error = t('wiz.noFiles');
|
||||
return;
|
||||
}
|
||||
res = await workflows.convert(
|
||||
inputFiles,
|
||||
preflightId ? { endpoint_id: preflightId } : undefined,
|
||||
);
|
||||
}
|
||||
report = res;
|
||||
usageBy = {};
|
||||
excludedKeys = new Set(res.files.filter((f) => !f.importable).map((f) => rk(f)));
|
||||
rSearch = '';
|
||||
rFilter = 'all';
|
||||
expandedIssues = new Set();
|
||||
visible = 60;
|
||||
step = 'report';
|
||||
} catch (err) {
|
||||
error = apiErrorMessage(err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = $derived(
|
||||
report
|
||||
? mode === 'files'
|
||||
? report.files.filter((f) => f.importable && !excludedKeys.has(rk(f))).length
|
||||
: report.importable
|
||||
: 0,
|
||||
);
|
||||
const blockedCount = $derived(
|
||||
report ? report.files.filter((f) => !f.importable).length : 0,
|
||||
);
|
||||
|
||||
const filteredFiles = $derived.by(() => {
|
||||
if (!report) return [];
|
||||
const q = rSearch.trim().toLowerCase();
|
||||
return report.files.filter((f) => {
|
||||
if (rFilter === 'warn' && f.status !== 'warn') return false;
|
||||
if (rFilter === 'blocked' && f.importable) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
f.name.toLowerCase().includes(q) ||
|
||||
(f.source_path ?? '').toLowerCase().includes(q) ||
|
||||
(f.suggested?.model_family ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function needsUsagePick(f: WorkflowConvertFileReport): boolean {
|
||||
return Boolean(f.importable && (!f.suggested?.usage || f.suggested.usage_ambiguous));
|
||||
}
|
||||
|
||||
function setUsage(f: WorkflowConvertFileReport, usage: string) {
|
||||
const key = rk(f);
|
||||
if (usage) usageBy = { ...usageBy, [key]: usage as WorkflowUsage };
|
||||
else {
|
||||
const next = { ...usageBy };
|
||||
delete next[key];
|
||||
usageBy = next;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIssue(f: WorkflowConvertFileReport) {
|
||||
const key = rk(f);
|
||||
const next = new Set(expandedIssues);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedIssues = next;
|
||||
}
|
||||
|
||||
function skipText(reason: string | null | undefined): string {
|
||||
if (reason === 'conflict') return t('wiz.conflict');
|
||||
return reason ?? '';
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!report) return;
|
||||
importing = true;
|
||||
error = '';
|
||||
try {
|
||||
let res: WorkflowImportResult;
|
||||
if (mode === 'files') {
|
||||
const files = inputFiles
|
||||
.map((item, i) => ({ item, f: report!.files[i] }))
|
||||
.filter(({ f }) => f?.importable && !excludedKeys.has(rk(f)))
|
||||
.map(({ item, f }) => {
|
||||
const usage = usageBy[rk(f)];
|
||||
return usage ? { ...item, usage } : item;
|
||||
});
|
||||
res = await workflows.importBatch(files);
|
||||
} else {
|
||||
res = await workflows.importScanCommit(scanRoot.trim() || 'example_ComfyUI_workflows');
|
||||
}
|
||||
result = res;
|
||||
// Directory commits import everything found server-side; usage
|
||||
// choices from the report step are applied to the fresh rows here.
|
||||
if (mode === 'scan') {
|
||||
let applied = 0;
|
||||
for (const f of res.files) {
|
||||
const usage = usageBy[rk(f)];
|
||||
if (f.result === 'imported' && f.id != null && usage) {
|
||||
try {
|
||||
await workflows.update(f.id, {
|
||||
usage,
|
||||
usage_source: 'user',
|
||||
is_enabled: true,
|
||||
});
|
||||
applied++;
|
||||
} catch {
|
||||
try {
|
||||
await workflows.update(f.id, { usage, usage_source: 'user' });
|
||||
applied++;
|
||||
} catch {
|
||||
/* keep the imported row, skip the override */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
usageApplied = applied;
|
||||
}
|
||||
step = 'result';
|
||||
onimported?.();
|
||||
if (res.imported > 0) toast.success(t('wiz.importedToast', { n: res.imported }));
|
||||
} catch (err) {
|
||||
error = apiErrorMessage(err);
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wiz.title')} width={780} onclose={reset}>
|
||||
<ol class="wz-steps" aria-label={t('wiz.title')}>
|
||||
<li class:active={step === 'source'} class:done={step !== 'source'}>
|
||||
<span class="wz-step-n">1</span>{t('wiz.stepSource')}
|
||||
</li>
|
||||
<li class:active={step === 'report'} class:done={step === 'result'}>
|
||||
<span class="wz-step-n">2</span>{t('wiz.stepReport')}
|
||||
</li>
|
||||
<li class:active={step === 'result'}>
|
||||
<span class="wz-step-n">3</span>{t('wiz.stepResult')}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{#if error}
|
||||
<div class="wz-error" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if step === 'source'}
|
||||
<div class="seg wz-mode" role="radiogroup" aria-label={t('wiz.sourceLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={mode === 'scan'}
|
||||
aria-pressed={mode === 'scan'}
|
||||
onclick={() => (mode = 'scan')}
|
||||
>
|
||||
<Icon name="folder" size={14} />
|
||||
{t('wiz.modeScan')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={mode === 'files'}
|
||||
aria-pressed={mode === 'files'}
|
||||
onclick={() => (mode = 'files')}
|
||||
>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('wiz.modeFiles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'scan'}
|
||||
<label class="wz-field">
|
||||
<span class="wz-label">{t('wiz.rootLabel')}</span>
|
||||
<input
|
||||
class="a-input wz-root"
|
||||
value={scanRoot}
|
||||
spellcheck="false"
|
||||
oninput={(e) => (scanRoot = e.currentTarget.value)}
|
||||
placeholder="example_ComfyUI_workflows"
|
||||
/>
|
||||
<span class="wz-hint">{t('wiz.rootHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<div class="wz-field">
|
||||
<span class="wz-label">{t('wiz.pickFiles')}</span>
|
||||
<button type="button" class="a-btn secondary" onclick={() => fileInput?.click()}>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('wiz.pickFilesBtn')}
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
class="wz-file-hidden"
|
||||
onchange={(e) => void readPickedFiles(e.currentTarget.files)}
|
||||
/>
|
||||
<span class="wz-hint">{t('wiz.pickFilesHint')}</span>
|
||||
{#if reading}
|
||||
<p class="wz-read">{t('common.loading')}…</p>
|
||||
{/if}
|
||||
{#if inputFiles.length}
|
||||
<p class="wz-read ok">
|
||||
<Icon name="check" size={13} />
|
||||
{t('wiz.parsed', { n: inputFiles.length })}
|
||||
</p>
|
||||
{/if}
|
||||
{#if nonUiSkipped.length}
|
||||
<p class="wz-read bad">
|
||||
<Icon name="alert" size={13} />
|
||||
{t('wiz.notUi', { n: nonUiSkipped.length })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'files' && endpoints.length > 0}
|
||||
<label class="wz-field">
|
||||
<span class="wz-label">{t('wiz.preflight')}</span>
|
||||
<select class="a-select wz-preflight" value={preflightId} onchange={(e) => (preflightId = e.currentTarget.value)}>
|
||||
<option value="">{t('wiz.preflightNone')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="wz-hint">{t('wiz.preflightHint')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{:else if step === 'report' && report}
|
||||
<div class="wz-summary">
|
||||
<span class="wz-chip">{t('wiz.total', { n: report.total })}</span>
|
||||
<span class="wz-chip ok">{t('wiz.ok', { n: report.ok })}</span>
|
||||
<span class="wz-chip warn">{t('wiz.warn', { n: report.warn })}</span>
|
||||
<span class="wz-chip fail">{t('wiz.fail', { n: report.fail })}</span>
|
||||
<span class="wz-chip confirm">{t('wiz.confirmCount', { n: report.needs_confirmation })}</span>
|
||||
<span class="wz-chip importable">{t('wiz.importable', { n: report.importable })}</span>
|
||||
</div>
|
||||
<p class="wz-note">{t('wiz.reportHint')}</p>
|
||||
|
||||
<div class="wz-toolbar">
|
||||
<div class="filters" role="group" aria-label={t('wiz.filterLabel')}>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'all'} aria-pressed={rFilter === 'all'} onclick={() => (rFilter = 'all')}>
|
||||
{t('wl.filterAll')}
|
||||
</button>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'warn'} aria-pressed={rFilter === 'warn'} onclick={() => (rFilter = 'warn')}>
|
||||
{t('wiz.filterWarn')}
|
||||
</button>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'blocked'} aria-pressed={rFilter === 'blocked'} onclick={() => (rFilter = 'blocked')}>
|
||||
{t('wiz.filterBlocked')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="a-input wz-search"
|
||||
value={rSearch}
|
||||
oninput={(e) => (rSearch = e.currentTarget.value)}
|
||||
placeholder={t('wiz.searchPh')}
|
||||
aria-label={t('wiz.searchPh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="wz-list">
|
||||
{#each filteredFiles.slice(0, visible) as f (rk(f))}
|
||||
<div class="wz-row" class:blocked={!f.importable}>
|
||||
{#if mode === 'files'}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="wz-check"
|
||||
aria-label={t('wiz.includeAria', { name: f.name })}
|
||||
disabled={!f.importable}
|
||||
checked={f.importable && !excludedKeys.has(rk(f))}
|
||||
onchange={(e) => {
|
||||
const next = new Set(excludedKeys);
|
||||
if (e.currentTarget.checked) next.delete(rk(f));
|
||||
else next.add(rk(f));
|
||||
excludedKeys = next;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="wz-dot {f.status}" title={f.status}></span>
|
||||
<div class="wz-main">
|
||||
<div class="wz-line">
|
||||
<span class="wz-name" title={f.suggested?.description ?? f.name}>{f.name}</span>
|
||||
{#if f.suggested?.model_family}
|
||||
<span class="tag tag-usage">{f.suggested.model_family}</span>
|
||||
{/if}
|
||||
{#if f.suggested}
|
||||
<span class="tag tag-usage">{usageLabel(f.suggested.usage)}</span>
|
||||
<span class="wz-media" aria-hidden="true">
|
||||
{#each f.suggested.media_in as m (m)}
|
||||
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
|
||||
{/each}
|
||||
{#if f.suggested.media_out.length}
|
||||
<span class="wz-arrow">→</span>
|
||||
{#each f.suggested.media_out as m (m)}
|
||||
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if f.status === 'warn'}
|
||||
<span class="tag tag-warn">{t('wiz.warnTag')}</span>
|
||||
{/if}
|
||||
{#if f.needs_confirmation}
|
||||
<span class="tag tag-error">{t('wiz.confirmTag')}</span>
|
||||
{/if}
|
||||
{#if f.preflight && !f.preflight.ok}
|
||||
<span class="tag tag-error" title={f.preflight.findings.map((x) => x.message).join('\n')}>
|
||||
{t('wiz.preflightTag')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if f.source_path}
|
||||
<div class="wz-path mono">{f.source_path}</div>
|
||||
{/if}
|
||||
{#if !f.importable && f.skip_reason}
|
||||
<div class="wz-skip">{t('wiz.skipReason', { reason: skipText(f.skip_reason) })}</div>
|
||||
{/if}
|
||||
{#if needsUsagePick(f)}
|
||||
<label class="wz-usage">
|
||||
<span>{t('wiz.usagePick')}</span>
|
||||
<select
|
||||
class="a-select wz-usage-select"
|
||||
value={usageBy[rk(f)] ?? ''}
|
||||
onchange={(e) => setUsage(f, e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('wiz.usageNone')}</option>
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<option value={u}>{usageLabel(u)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if f.issues.length}
|
||||
<button type="button" class="wz-issues-toggle" onclick={() => toggleIssue(f)} aria-expanded={expandedIssues.has(rk(f))}>
|
||||
<Icon name="alert" size={12} />
|
||||
{t('wiz.issues', { n: f.issues.length })}
|
||||
</button>
|
||||
{#if expandedIssues.has(rk(f))}
|
||||
<ul class="wz-issues">
|
||||
{#each f.issues as iss, i (`${rk(f)}#${i}`)}
|
||||
<li class="lvl-{iss.level}">
|
||||
<span class="mono">{iss.node_id}</span>
|
||||
{iss.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filteredFiles.length > visible}
|
||||
<button type="button" class="a-btn ghost wz-more" onclick={() => (visible += 100)}>
|
||||
{t('wl.moreCount', { n: Math.min(100, filteredFiles.length - visible), m: filteredFiles.length })}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if step === 'result' && result}
|
||||
<div class="wz-summary">
|
||||
<span class="wz-chip ok">{t('wiz.imported', { n: result.imported })}</span>
|
||||
<span class="wz-chip">{t('wiz.skipped', { n: result.skipped })}</span>
|
||||
{#if result.files.some((f) => f.result === 'failed')}
|
||||
<span class="wz-chip fail">{t('wiz.fail', { n: result.files.filter((f) => f.result === 'failed').length })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="wz-note">{t('wiz.resultNote')}</p>
|
||||
{#if usageApplied > 0}
|
||||
<p class="wz-note ok">{t('wiz.usageApplied', { n: usageApplied })}</p>
|
||||
{/if}
|
||||
<div class="wz-list">
|
||||
{#each result.files as f (rk(f))}
|
||||
<div class="wz-row">
|
||||
<span class="wz-dot {f.result === 'imported' ? 'ok' : f.result === 'failed' ? 'fail' : 'warn'}"></span>
|
||||
<div class="wz-main">
|
||||
<div class="wz-line">
|
||||
<span class="wz-name">{f.name}</span>
|
||||
{#if f.result === 'imported'}
|
||||
<span class="tag tag-ok">
|
||||
<Icon name="check" size={12} />
|
||||
{t('wiz.importedTag', { id: f.id ?? '?' })}
|
||||
</span>
|
||||
{:else if f.skip_reason === 'conflict' || f.result === 'skipped'}
|
||||
<span class="tag tag-usage">{skipText(f.skip_reason)}</span>
|
||||
{:else}
|
||||
<span class="tag tag-error">{skipText(f.skip_reason)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if f.source_path}
|
||||
<div class="wz-path mono">{f.source_path}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if step === 'source'}
|
||||
<Button variant="secondary" onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={running} disabled={reading} onclick={() => void runDryRun()}>
|
||||
{running ? t('wiz.running') : t('wiz.runDry')}
|
||||
</Button>
|
||||
{:else if step === 'report'}
|
||||
<span class="wz-footer-note">
|
||||
{t('wiz.selectedN', { n: selectedCount })}
|
||||
{#if blockedCount}· {t('wiz.excludedM', { m: blockedCount })}{/if}
|
||||
</span>
|
||||
<Button variant="secondary" disabled={importing} onclick={() => (step = 'source')}>{t('wiz.back')}</Button>
|
||||
<Button variant="primary" loading={importing} disabled={selectedCount === 0} onclick={() => void runImport()}>
|
||||
{importing ? t('wiz.importing') : t('wiz.import')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={() => { reset(); step = 'source'; }}>{t('wiz.again')}</Button>
|
||||
<Button variant="primary" onclick={() => (open = false)}>{t('wiz.done')}</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
|
||||
<style>
|
||||
.wz-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.wz-steps li {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wz-steps li.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wz-steps li.done {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-step-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 10.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--error-dim);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wz-error :global(.icon) {
|
||||
color: var(--error);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.wz-mode {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.seg-item {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
padding: 8px 6px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg-item.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wz-field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wz-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wz-root,
|
||||
.wz-preflight,
|
||||
.wz-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wz-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wz-file-hidden {
|
||||
display: none;
|
||||
}
|
||||
.wz-read {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-read.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.wz-read.bad {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wz-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wz-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.wz-chip.ok,
|
||||
.wz-chip.importable {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.wz-chip.warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim, var(--bg-4));
|
||||
}
|
||||
.wz-chip.fail,
|
||||
.wz-chip.confirm {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wz-note {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-note.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.wz-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wz-toolbar .filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.wz-search {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
.wz-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wz-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wz-row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wz-row.blocked .wz-name {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-check {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-dot.ok {
|
||||
background: var(--success);
|
||||
}
|
||||
.wz-dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
.wz-dot.fail {
|
||||
background: var(--error);
|
||||
}
|
||||
.wz-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wz-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wz-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim, var(--bg-4));
|
||||
}
|
||||
.tag-error {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.tag-ok {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.wz-media {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-m {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wz-arrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
.wz-path {
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
word-break: break-all;
|
||||
}
|
||||
.wz-skip {
|
||||
margin-top: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wz-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-usage-select {
|
||||
width: 180px;
|
||||
}
|
||||
.wz-issues-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--warn);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wz-issues-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wz-issues {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
list-style: none;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wz-issues li {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wz-issues li .mono {
|
||||
color: var(--text-3);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.wz-issues li.lvl-error {
|
||||
color: var(--error);
|
||||
}
|
||||
.wz-more {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.wz-footer-note {
|
||||
margin-right: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { apiErrorMessage, workflows, type WorkflowMeta } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
/**
|
||||
* 工作流标注 (HEL-682 / S4):单条编辑显示名/说明/标签/分类/模型家族;
|
||||
* 批量模式对选中项做查找替换重命名、统一说明、追加标签与分类标注。
|
||||
* 只有填写了的字段会被提交,空字段一律不动。
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
/** Rows to update — length 1 = single edit (prefilled), >1 = batch. */
|
||||
targets: WorkflowMeta[];
|
||||
/** Known category keys / family names for the datalists. */
|
||||
categories: string[];
|
||||
families: string[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), targets, categories, families, onchanged }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let name = $state('');
|
||||
let nameFind = $state('');
|
||||
let nameReplace = $state('');
|
||||
let description = $state('');
|
||||
let tagsText = $state('');
|
||||
let category = $state('');
|
||||
let modelFamily = $state('');
|
||||
let applying = $state(false);
|
||||
|
||||
const isBatch = $derived(targets.length > 1);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
const first = targets[0];
|
||||
name = targets.length === 1 ? (first?.name ?? '') : '';
|
||||
nameFind = '';
|
||||
nameReplace = '';
|
||||
description = targets.length === 1 ? (first?.description ?? '') : '';
|
||||
tagsText = targets.length === 1 ? (first?.tags?.join(', ') ?? '') : '';
|
||||
category = targets.length === 1 ? (first?.category ?? '') : '';
|
||||
modelFamily = targets.length === 1 ? (first?.model_family ?? '') : '';
|
||||
}
|
||||
});
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
return text
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function payloadFor(row: WorkflowMeta): Record<string, unknown> | null {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (isBatch) {
|
||||
if (nameFind && row.name.includes(nameFind)) {
|
||||
payload.name = row.name.split(nameFind).join(nameReplace);
|
||||
}
|
||||
if (description.trim()) payload.description = description.trim();
|
||||
if (tagsText.trim()) {
|
||||
const add = parseTags(tagsText);
|
||||
const merged = [...row.tags, ...add.filter((x) => !row.tags.includes(x))];
|
||||
if (merged.length !== row.tags.length) payload.tags = merged;
|
||||
}
|
||||
if (category.trim()) payload.category = category.trim();
|
||||
if (modelFamily.trim()) payload.model_family = modelFamily.trim();
|
||||
} else {
|
||||
if (name.trim() && name.trim() !== row.name) payload.name = name.trim();
|
||||
if (description !== (row.description ?? '')) payload.description = description.trim();
|
||||
const nextTags = parseTags(tagsText);
|
||||
if (JSON.stringify(nextTags) !== JSON.stringify(row.tags ?? [])) payload.tags = nextTags;
|
||||
if (category.trim() !== (row.category ?? '')) payload.category = category.trim();
|
||||
if (modelFamily.trim() !== (row.model_family ?? '')) payload.model_family = modelFamily.trim();
|
||||
}
|
||||
return Object.keys(payload).length ? payload : null;
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
applying = true;
|
||||
try {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
for (const row of targets) {
|
||||
const payload = payloadFor(row);
|
||||
if (!payload) {
|
||||
done++; // nothing to change counts as OK (single mode no-op)
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await workflows.update(row.id, payload);
|
||||
done++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
toast.success(isBatch ? t('md.applied', { n: done }) : t('md.appliedOne'));
|
||||
} else {
|
||||
toast.error(t('md.partial', { n: done, m: failed }));
|
||||
}
|
||||
open = false;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
onchanged?.();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={isBatch ? t('md.batchTitle', { n: targets.length }) : t('md.title')} size="md">
|
||||
<div class="md-form">
|
||||
{#if isBatch}
|
||||
<p class="md-hint">
|
||||
<Icon name="info" size={14} />
|
||||
{t('md.batchHint')}
|
||||
</p>
|
||||
<div class="md-grid2">
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.nameFind')}</span>
|
||||
<input class="a-input" value={nameFind} oninput={(e) => (nameFind = e.currentTarget.value)} placeholder={t('md.nameFindPh')} />
|
||||
</label>
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.nameReplace')}</span>
|
||||
<input class="a-input" value={nameReplace} oninput={(e) => (nameReplace = e.currentTarget.value)} placeholder={t('md.nameReplacePh')} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="md-sub">{t('md.nameHint')}</p>
|
||||
{:else}
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.name')}</span>
|
||||
<input class="a-input" value={name} oninput={(e) => (name = e.currentTarget.value)} />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.description')}</span>
|
||||
<textarea
|
||||
class="a-textarea md-desc"
|
||||
rows="2"
|
||||
value={description}
|
||||
oninput={(e) => (description = e.currentTarget.value)}
|
||||
placeholder={t('md.descriptionPh')}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.tags')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={tagsText}
|
||||
oninput={(e) => (tagsText = e.currentTarget.value)}
|
||||
placeholder={t('md.tagsPh')}
|
||||
/>
|
||||
<span class="md-sub">{t('md.tagsHint')}</span>
|
||||
</label>
|
||||
|
||||
<div class="md-grid2">
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.category')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={category}
|
||||
list="md-cat-list"
|
||||
oninput={(e) => (category = e.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="md-cat-list">
|
||||
{#each categories as c (c)}
|
||||
<option value={c}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.modelFamily')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={modelFamily}
|
||||
list="md-family-list"
|
||||
oninput={(e) => (modelFamily = e.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="md-family-list">
|
||||
{#each families as f (f)}
|
||||
<option value={f}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<Button variant="secondary" disabled={applying} onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={applying} onclick={() => void apply()}>{t('md.apply')}</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.md-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.md-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.md-hint :global(.icon) {
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.md-grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.md-field {
|
||||
display: block;
|
||||
}
|
||||
.md-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.md-desc {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
.md-sub {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.md-grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1902,7 +1902,7 @@ export const de: Dict = {
|
||||
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
|
||||
'tasks.sourceWorkbench': 'Kreativ-Fluss',
|
||||
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
|
||||
'jobst.done': 'Fertig',
|
||||
'jobst.done': 'Fixiert',
|
||||
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
|
||||
'jobst.dryRun': 'Platzhalter',
|
||||
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
@@ -2003,6 +2003,15 @@ export const de: Dict = {
|
||||
'desk.ctxDismiss': 'Herkunftshinweis schließen',
|
||||
'desk.ctxFrom': 'Aus „{project}“',
|
||||
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
|
||||
'desk.ctxBackfill': 'Nach Fertigstellung kann jedes Ergebnis über „Zurück ins Projekt“ an {stage} zurückgegeben werden',
|
||||
'desk.attachToProject': 'Zurück ins Projekt',
|
||||
'desk.attachHint': 'Dieses Ergebnis zurückgeben an: {label}',
|
||||
'desk.attaching': 'Wird zurückgegeben…',
|
||||
'desk.attachedTag': 'Zurückgegeben',
|
||||
'desk.attachBack': 'Zum Projekt',
|
||||
'desk.attachDoneToast': 'Zurück ins Quellprojekt übergeben — im Projekt sichtbar',
|
||||
'desk.attachAlreadyToast': 'Dieses Ergebnis wurde bereits zurückgegeben, nichts dupliziert',
|
||||
'desk.attachFailedToast': 'Rückgabe fehlgeschlagen: {detail}',
|
||||
'desk.dayDate': '{d}.{m}.',
|
||||
'desk.download': 'Herunterladen',
|
||||
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
|
||||
@@ -2081,6 +2090,36 @@ export const de: Dict = {
|
||||
'desk.wfUntested': 'Nicht getestet',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Gestern',
|
||||
// --- Dreispaltige Desk (HEL-683) ---
|
||||
'desk.bandChange': "Workflow wechseln",
|
||||
'desk.bayCloudBody': "Cloud-Modelle für Bild / Video (gpt-image2, nano-banana, Jimeng, Kling, …) sind noch nicht angebunden. Nach der Anbindung erscheinen sie mit ihren echten Fähigkeiten — nie als unbenutzbare Einträge.",
|
||||
'desk.bayCloudTitle': "Cloud-Generierung nicht angebunden",
|
||||
'desk.bayCount': "{n} / {total} Workflows",
|
||||
'desk.bayFavOnly': "Favoriten",
|
||||
'desk.bayMore': "{n} weitere anzeigen",
|
||||
'desk.bayNoFamily': "Modell unbekannt",
|
||||
'desk.bayPending': "{n} offen (Zweck / Ausgabe ungeklärt)",
|
||||
'desk.bayReqAria': "Eingabeanforderungen",
|
||||
'desk.bayReqTitle': "Eingabeanforderungen",
|
||||
'desk.baySearchPh': "Name, Beschreibung, Modell, Zweck suchen…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Generierungsquelle",
|
||||
'desk.baySourceLocal': "Lokal",
|
||||
'desk.modeAll': "Alle",
|
||||
'desk.modeEmpty': "Für diesen Modus gibt es noch keinen nutzbaren Workflow",
|
||||
'desk.modeRailAria': "Aufgabenmodi",
|
||||
'desk.modeRailTitle': "Aufgabenmodi",
|
||||
'desk.pvBackCreate': "Zurück zum Erstellen",
|
||||
'desk.pvCurrent': "Aktueller Workflow",
|
||||
'desk.pvEmpty': "Links einen Workflow wählen, um die Eingaben zu sehen",
|
||||
'desk.pvFav': "Zu Favoriten",
|
||||
'desk.pvLastUsed': "Zuletzt genutzt",
|
||||
'desk.pvParamCount': "Parameter",
|
||||
'desk.pvSlotsLoading': "Eingabeslots werden gelesen…",
|
||||
'desk.pvUnfav': "Aus Favoriten entfernen",
|
||||
'desk.pvUse': "Diesen Workflow verwenden",
|
||||
'desk.railFav': "Favoriten",
|
||||
'desk.railRecent': "Zuletzt",
|
||||
'wfd.stepParse': 'JSON parsen',
|
||||
'wfd.stepNodes': 'Knoten erkennen',
|
||||
'wfd.stepModels': 'Modelle prüfen',
|
||||
@@ -2097,4 +2136,136 @@ export const de: Dict = {
|
||||
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
|
||||
'wfd.showRaw': 'Rohes JSON anzeigen',
|
||||
'wfd.hideRaw': 'Rohes JSON ausblenden',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI-Format-Workflows stapelweise importieren; suchen, filtern, annotieren — volles JSON nur bei Bedarf',
|
||||
'wl.batchImport': 'Stapelimport',
|
||||
'wl.dedupe': 'Duplikate entfernen',
|
||||
'wl.dedupeTitle': 'Duplizierte Workflows entfernen',
|
||||
'wl.dedupeConfirm': 'Einträge mit identischem Namen UND Inhalt werden gelöscht (der früheste Import bleibt). Gleicher Name mit anderem Inhalt bleibt unangetastet. Fortfahren?',
|
||||
'wl.dedupeDone': '{n} doppelte Workflows entfernt',
|
||||
'wl.dedupeNone': 'Keine doppelten Workflows gefunden',
|
||||
'wl.favCount': '{n} Favoriten',
|
||||
'wl.searchPh': 'Name, Beschreibung, Tags, Modell suchen… (/ drücken)',
|
||||
'wl.filterLabel': 'Filter',
|
||||
'wl.filterAll': 'Alle',
|
||||
'wl.filterFav': 'Favoriten',
|
||||
'wl.filterPending': 'Offen',
|
||||
'wl.filterDisabled': 'Deaktiviert',
|
||||
'wl.favAria': 'Favorisieren',
|
||||
'wl.unfavAria': 'Favorit entfernen',
|
||||
'wl.categoryLabel': 'Kategorie',
|
||||
'wl.categoryAll': 'Alle Kategorien',
|
||||
'wl.familyLabel': 'Modellfamilie',
|
||||
'wl.familyAll': 'Alle Familien',
|
||||
'wl.mediaLabel': 'Medium',
|
||||
'wl.mediaAll': 'Alle Medien',
|
||||
'wl.mediaImage': 'Bild',
|
||||
'wl.mediaVideo': 'Video',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Eingabe',
|
||||
'wl.mediaOut': 'Ausgabe',
|
||||
'wl.sortLabel': 'Sortierung',
|
||||
'wl.sortRecent': 'Favoriten / zuletzt',
|
||||
'wl.sortName': 'Name',
|
||||
'wl.resetFilters': 'Filter zurücksetzen',
|
||||
'wl.noMatch': 'Keine Workflows entsprechen den Filtern',
|
||||
'wl.resultCount': '{n} von {m}',
|
||||
'wl.selected': '{n} ausgewählt',
|
||||
'wl.batchLabel': 'Stapelaktionen',
|
||||
'wl.batchAnnotate': 'Annotieren',
|
||||
'wl.batchEnable': 'Aktivieren',
|
||||
'wl.batchDisable': 'Deaktivieren',
|
||||
'wl.clearSel': 'Auswahl aufheben',
|
||||
'wl.batchDone': '{n} Workflows aktualisiert',
|
||||
'wl.batchPartial': '{n} erfolgreich, {m} fehlgeschlagen (zuerst Verwendung und Ausgabe klären)',
|
||||
'wl.selectAll': 'Alle aufgelisteten Workflows auswählen',
|
||||
'wl.rowCheckAria': '{name} auswählen',
|
||||
'wl.headMeta': 'Metadaten',
|
||||
'wl.headActions': 'Aktionen',
|
||||
'wl.enableAria': '{name} aktivieren oder deaktivieren',
|
||||
'wl.enableBlocked': 'Verwendung und Ausgabeknoten vor Aktivierung festlegen',
|
||||
'wl.editMeta': 'Metadaten bearbeiten',
|
||||
'wl.lastUsed': 'Zuletzt verwendet',
|
||||
'wl.moreCount': '{n} mehr anzeigen ({m} gesamt)',
|
||||
'wfCat.basic': 'Grundverarbeitung',
|
||||
'wfCat.audioTts': 'Sprachgenerierung',
|
||||
'wfCat.music': 'Musikgenerierung',
|
||||
'wfCat.image': 'Bildgenerierung',
|
||||
'wfCat.video': 'Videogenerierung',
|
||||
'wfCat.digitalHuman': 'Digitaler Mensch',
|
||||
'wfCat.motionTransfer': 'Bewegungsübertragung',
|
||||
'md.title': 'Workflow-Metadaten',
|
||||
'md.batchTitle': 'Stapel-Annotieren ({n})',
|
||||
'md.batchHint': 'Nur ausgefüllte Felder werden angewendet; Namen per Suchen→Ersetzen, Tags werden ergänzt.',
|
||||
'md.name': 'Anzeigename',
|
||||
'md.nameFind': 'Suchen',
|
||||
'md.nameFindPh': 'Zu ersetzender Text…',
|
||||
'md.nameReplace': 'Ersetzen mit',
|
||||
'md.nameReplacePh': 'Ersatztext…',
|
||||
'md.nameHint': 'Leeres „Suchen“ lässt Namen unverändert.',
|
||||
'md.description': 'Beschreibung',
|
||||
'md.descriptionPh': 'Eine Zeile, was dieser Workflow tut…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'z. B. schnell, hohe Qualität',
|
||||
'md.tagsHint': 'Kommagetrennt; im Stapelmodus werden sie ergänzt.',
|
||||
'md.category': 'Kategorie',
|
||||
'md.modelFamily': 'Modellfamilie',
|
||||
'md.apply': 'Anwenden',
|
||||
'md.applied': '{n} Workflows aktualisiert',
|
||||
'md.appliedOne': 'Aktualisiert',
|
||||
'md.partial': '{n} erfolgreich, {m} fehlgeschlagen',
|
||||
'wiz.title': 'Workflows stapelweise importieren',
|
||||
'wiz.stepSource': 'Quelle',
|
||||
'wiz.stepReport': 'Prüfbericht',
|
||||
'wiz.stepResult': 'Ergebnis',
|
||||
'wiz.sourceLabel': 'Importquelle',
|
||||
'wiz.modeScan': 'Serververzeichnis scannen',
|
||||
'wiz.modeFiles': 'Lokale Dateien wählen',
|
||||
'wiz.rootLabel': 'Serververzeichnis',
|
||||
'wiz.rootHint': 'Ein Verzeichnispfad auf dem Server (z. B. example_ComfyUI_workflows des Repos); UI-Format-JSONs werden rekursiv gescannt, API-Format und andere übersprungen.',
|
||||
'wiz.preflight': 'Preflight gegen echtes ComfyUI (optional)',
|
||||
'wiz.preflightNone': 'Überspringen (Offline-Konvertierung)',
|
||||
'wiz.preflightHint': 'Mit gewählter Verbindung werden konvertierte Graphen Knoten für Knoten geprüft.',
|
||||
'wiz.pickFiles': 'Lokale Dateien',
|
||||
'wiz.pickFilesBtn': 'JSON-Dateien wählen (mehrere)',
|
||||
'wiz.pickFilesHint': 'ComfyUI-Workflows im UI-Format wählen; Mehrfachauswahl möglich.',
|
||||
'wiz.parsed': '{n} Dateien gelesen',
|
||||
'wiz.notUi': '{n} Dateien sind kein UI-Format (kein nodes-Array) und wurden übersprungen',
|
||||
'wiz.noFiles': 'Zuerst Dateien zum Import auswählen',
|
||||
'wiz.runDry': 'Probelauf starten (nichts wird gespeichert)',
|
||||
'wiz.running': 'Prüfe…',
|
||||
'wiz.total': '{n} Dateien',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Warnung {n}',
|
||||
'wiz.fail': 'Fehler {n}',
|
||||
'wiz.confirmCount': 'Prüfung nötig {n}',
|
||||
'wiz.importable': 'Importierbar {n}',
|
||||
'wiz.reportHint': 'Der Probelauf speichert nichts. Warnungen werden aktiviert importiert, sobald eine Verwendung angegeben ist; prüfungspflichtige und fehlgeschlagene Dateien werden nie importiert.',
|
||||
'wiz.filterLabel': 'Berichtsfilter',
|
||||
'wiz.filterWarn': 'Nur Warnungen',
|
||||
'wiz.filterBlocked': 'Nicht importierbar',
|
||||
'wiz.searchPh': 'Dateinamen suchen…',
|
||||
'wiz.includeAria': '{name} importieren',
|
||||
'wiz.skipReason': 'Nicht importierbar: {reason}',
|
||||
'wiz.issues': '{n} Konvertierungshinweise',
|
||||
'wiz.usagePick': 'Verwendung:',
|
||||
'wiz.usageNone': 'Vorerst überspringen (nach Import offen)',
|
||||
'wiz.selectedN': '{n} Dateien werden importiert',
|
||||
'wiz.excludedM': '{m} nicht importierbar',
|
||||
'wiz.import': 'Importieren',
|
||||
'wiz.importing': 'Importiere…',
|
||||
'wiz.back': 'Zurück',
|
||||
'wiz.imported': '{n} importiert',
|
||||
'wiz.skipped': '{n} übersprungen',
|
||||
'wiz.conflict': 'Existiert bereits (Duplikat übersprungen)',
|
||||
'wiz.usageApplied': 'Verwendung für {n} neue Importe gesetzt',
|
||||
'wiz.done': 'Fertig',
|
||||
'wiz.again': 'Weitere Charge importieren',
|
||||
'wiz.resultNote': 'Dateien mit identischem Inhalt werden automatisch übersprungen — Wiederholung erzeugt keine Duplikate.',
|
||||
'wiz.importedToast': '{n} Workflows importiert',
|
||||
'wiz.warnTag': 'Warnung',
|
||||
'wiz.confirmTag': 'Prüfung nötig',
|
||||
'wiz.preflightTag': 'Preflight fehlgeschlagen',
|
||||
'wiz.importedTag': 'Importiert #{id}',
|
||||
};
|
||||
|
||||
@@ -1901,7 +1901,7 @@ export const en = {
|
||||
'home.stageProgress': 'Five-stage progress',
|
||||
'tasks.sourceWorkbench': 'Creation flow',
|
||||
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
|
||||
'jobst.done': 'Done',
|
||||
'jobst.done': 'Fixed',
|
||||
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
|
||||
'jobst.dryRun': 'Placeholder',
|
||||
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
@@ -2002,6 +2002,15 @@ export const en = {
|
||||
'desk.ctxDismiss': 'Dismiss source note',
|
||||
'desk.ctxFrom': 'From “{project}”',
|
||||
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
|
||||
'desk.ctxBackfill': 'When a take is done, use “Send back to project” on its card to return it to {stage}',
|
||||
'desk.attachToProject': 'Send back to project',
|
||||
'desk.attachHint': 'Send this take back to its source: {label}',
|
||||
'desk.attaching': 'Sending back…',
|
||||
'desk.attachedTag': 'Sent back',
|
||||
'desk.attachBack': 'Back to project',
|
||||
'desk.attachDoneToast': 'Sent back to the source project — open the project to see it',
|
||||
'desk.attachAlreadyToast': 'This take was already sent back; nothing duplicated',
|
||||
'desk.attachFailedToast': 'Could not send back: {detail}',
|
||||
'desk.dayDate': '{m}/{d}',
|
||||
'desk.download': 'Download',
|
||||
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
|
||||
@@ -2080,6 +2089,36 @@ export const en = {
|
||||
'desk.wfUntested': 'Not tested',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Yesterday',
|
||||
// --- Three-section desk (HEL-683) ---
|
||||
'desk.bandChange': "Change workflow",
|
||||
'desk.bayCloudBody': "Cloud image / video models (gpt-image2, nano-banana, Jimeng, Kling, …) are not connected to this product yet. Once integrated they will be listed with their real capabilities — never as unusable entries.",
|
||||
'desk.bayCloudTitle': "Cloud generation not connected",
|
||||
'desk.bayCount': "{n} / {total} workflows",
|
||||
'desk.bayFavOnly': "Favorites",
|
||||
'desk.bayMore': "Show {n} more",
|
||||
'desk.bayNoFamily': "Unrecognized model",
|
||||
'desk.bayPending': "{n} pending confirmation (usage / output unsettled)",
|
||||
'desk.bayReqAria': "Input requirements",
|
||||
'desk.bayReqTitle': "Input requirements",
|
||||
'desk.baySearchPh': "Search name, description, model, usage…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Generation source",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "All",
|
||||
'desk.modeEmpty': "No runnable workflow for this mode yet",
|
||||
'desk.modeRailAria': "Task modes",
|
||||
'desk.modeRailTitle': "Task modes",
|
||||
'desk.pvBackCreate': "Back to creating",
|
||||
'desk.pvCurrent': "Current workflow",
|
||||
'desk.pvEmpty': "Pick a workflow on the left to see its input requirements",
|
||||
'desk.pvFav': "Add to favorites",
|
||||
'desk.pvLastUsed': "Last used",
|
||||
'desk.pvParamCount': "Parameters",
|
||||
'desk.pvSlotsLoading': "Loading input slots…",
|
||||
'desk.pvUnfav': "Remove from favorites",
|
||||
'desk.pvUse': "Use this workflow",
|
||||
'desk.railFav': "Favorites",
|
||||
'desk.railRecent': "Recent",
|
||||
'wfd.stepParse': 'Parse JSON',
|
||||
'wfd.stepNodes': 'Identify nodes',
|
||||
'wfd.stepModels': 'Verify models',
|
||||
@@ -2096,6 +2135,138 @@ export const en = {
|
||||
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
|
||||
'wfd.showRaw': 'View raw JSON',
|
||||
'wfd.hideRaw': 'Hide raw JSON',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Batch-import UI-format workflows; search, filter and annotate — full JSON loads only per action',
|
||||
'wl.batchImport': 'Batch import',
|
||||
'wl.dedupe': 'Dedupe',
|
||||
'wl.dedupeTitle': 'Remove duplicate workflows',
|
||||
'wl.dedupeConfirm': 'This deletes entries whose name AND content are identical (the earliest import is kept). Same name with different content is your variant and stays untouched. Continue?',
|
||||
'wl.dedupeDone': 'Removed {n} duplicate workflows',
|
||||
'wl.dedupeNone': 'No duplicate workflows found',
|
||||
'wl.favCount': '{n} favorites',
|
||||
'wl.searchPh': 'Search name, description, tags, model… (press /)',
|
||||
'wl.filterLabel': 'Filters',
|
||||
'wl.filterAll': 'All',
|
||||
'wl.filterFav': 'Favorites',
|
||||
'wl.filterPending': 'Pending',
|
||||
'wl.filterDisabled': 'Disabled',
|
||||
'wl.favAria': 'Favorite',
|
||||
'wl.unfavAria': 'Unfavorite',
|
||||
'wl.categoryLabel': 'Category',
|
||||
'wl.categoryAll': 'All categories',
|
||||
'wl.familyLabel': 'Model family',
|
||||
'wl.familyAll': 'All families',
|
||||
'wl.mediaLabel': 'Media',
|
||||
'wl.mediaAll': 'All media',
|
||||
'wl.mediaImage': 'Image',
|
||||
'wl.mediaVideo': 'Video',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Input',
|
||||
'wl.mediaOut': 'Output',
|
||||
'wl.sortLabel': 'Sort',
|
||||
'wl.sortRecent': 'Favorite / recent',
|
||||
'wl.sortName': 'Name',
|
||||
'wl.resetFilters': 'Clear filters',
|
||||
'wl.noMatch': 'No workflows match the current filters',
|
||||
'wl.resultCount': '{n} of {m} match',
|
||||
'wl.selected': '{n} selected',
|
||||
'wl.batchLabel': 'Batch actions',
|
||||
'wl.batchAnnotate': 'Annotate',
|
||||
'wl.batchEnable': 'Enable',
|
||||
'wl.batchDisable': 'Disable',
|
||||
'wl.clearSel': 'Clear selection',
|
||||
'wl.batchDone': 'Updated {n} workflows',
|
||||
'wl.batchPartial': '{n} succeeded, {m} failed (usage and output mapping must be settled first)',
|
||||
'wl.selectAll': 'Select all listed workflows',
|
||||
'wl.rowCheckAria': 'Select {name}',
|
||||
'wl.headMeta': 'Metadata',
|
||||
'wl.headActions': 'Actions',
|
||||
'wl.enableAria': 'Enable or disable {name}',
|
||||
'wl.enableBlocked': 'Settle usage and output nodes before enabling',
|
||||
'wl.editMeta': 'Edit metadata',
|
||||
'wl.lastUsed': 'Last used',
|
||||
'wl.moreCount': 'Show {n} more ({m} total)',
|
||||
'wfCat.basic': 'Basic processing',
|
||||
'wfCat.audioTts': 'Speech generation',
|
||||
'wfCat.music': 'Music generation',
|
||||
'wfCat.image': 'Image generation',
|
||||
'wfCat.video': 'Video generation',
|
||||
'wfCat.digitalHuman': 'Digital human',
|
||||
'wfCat.motionTransfer': 'Motion transfer',
|
||||
'md.title': 'Workflow metadata',
|
||||
'md.batchTitle': 'Batch annotate ({n})',
|
||||
'md.batchHint': 'Only filled fields are applied; names are renamed via find→replace and tags are appended.',
|
||||
'md.name': 'Display name',
|
||||
'md.nameFind': 'Find',
|
||||
'md.nameFindPh': 'Text to replace…',
|
||||
'md.nameReplace': 'Replace with',
|
||||
'md.nameReplacePh': 'Replacement text…',
|
||||
'md.nameHint': 'Leave “Find” empty to keep names unchanged.',
|
||||
'md.description': 'Description',
|
||||
'md.descriptionPh': 'One line about what this workflow does…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'e.g. fast, high quality',
|
||||
'md.tagsHint': 'Comma separated; appended in batch mode.',
|
||||
'md.category': 'Category',
|
||||
'md.modelFamily': 'Model family',
|
||||
'md.apply': 'Apply',
|
||||
'md.applied': 'Updated {n} workflows',
|
||||
'md.appliedOne': 'Updated',
|
||||
'md.partial': '{n} succeeded, {m} failed',
|
||||
'wiz.title': 'Batch import workflows',
|
||||
'wiz.stepSource': 'Source',
|
||||
'wiz.stepReport': 'Dry-run report',
|
||||
'wiz.stepResult': 'Result',
|
||||
'wiz.sourceLabel': 'Import source',
|
||||
'wiz.modeScan': 'Scan server directory',
|
||||
'wiz.modeFiles': 'Pick local files',
|
||||
'wiz.rootLabel': 'Server directory',
|
||||
'wiz.rootHint': 'A directory path on the server (e.g. the repo’s example_ComfyUI_workflows); UI-format ComfyUI JSONs inside are scanned recursively — API-format and other JSONs are skipped.',
|
||||
'wiz.preflight': 'Preflight against a live ComfyUI (optional)',
|
||||
'wiz.preflightNone': 'Skip (offline conversion)',
|
||||
'wiz.preflightHint': 'With a connection selected, converted graphs are checked node-by-node against that ComfyUI.',
|
||||
'wiz.pickFiles': 'Local files',
|
||||
'wiz.pickFilesBtn': 'Choose JSON files (multiple)',
|
||||
'wiz.pickFilesHint': 'Pick ComfyUI UI-format workflow JSONs; multiple selection supported.',
|
||||
'wiz.parsed': '{n} files read',
|
||||
'wiz.notUi': '{n} files are not UI-format (no nodes array) and were skipped',
|
||||
'wiz.noFiles': 'Choose files to import first',
|
||||
'wiz.runDry': 'Run dry-run (nothing stored)',
|
||||
'wiz.running': 'Checking…',
|
||||
'wiz.total': '{n} files',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Warn {n}',
|
||||
'wiz.fail': 'Fail {n}',
|
||||
'wiz.confirmCount': 'Need review {n}',
|
||||
'wiz.importable': 'Importable {n}',
|
||||
'wiz.reportHint': 'The dry-run stores nothing. Warn items import enabled once a usage is supplied; needs-review and failed items are never imported nor marked runnable.',
|
||||
'wiz.filterLabel': 'Report filters',
|
||||
'wiz.filterWarn': 'Warnings only',
|
||||
'wiz.filterBlocked': 'Not importable',
|
||||
'wiz.searchPh': 'Search file name…',
|
||||
'wiz.includeAria': 'Import {name}',
|
||||
'wiz.skipReason': 'Not importable: {reason}',
|
||||
'wiz.issues': '{n} conversion notes',
|
||||
'wiz.usagePick': 'Usage:',
|
||||
'wiz.usageNone': 'Skip for now (pending after import)',
|
||||
'wiz.selectedN': '{n} files will be imported',
|
||||
'wiz.excludedM': '{m} not importable',
|
||||
'wiz.import': 'Import',
|
||||
'wiz.importing': 'Importing…',
|
||||
'wiz.back': 'Back',
|
||||
'wiz.imported': 'Imported {n}',
|
||||
'wiz.skipped': 'Skipped {n}',
|
||||
'wiz.conflict': 'Already exists (duplicate skipped)',
|
||||
'wiz.usageApplied': 'Usage supplied for {n} new imports',
|
||||
'wiz.done': 'Done',
|
||||
'wiz.again': 'Import another batch',
|
||||
'wiz.resultNote': 'Files with identical content are skipped automatically — re-running never creates duplicates.',
|
||||
'wiz.importedToast': 'Imported {n} workflows',
|
||||
'wiz.warnTag': 'Warn',
|
||||
'wiz.confirmTag': 'Needs review',
|
||||
'wiz.preflightTag': 'Preflight failed',
|
||||
'wiz.importedTag': 'Imported #{id}',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -1902,7 +1902,7 @@ export const es: Dict = {
|
||||
'home.stageProgress': 'Progreso de cinco etapas',
|
||||
'tasks.sourceWorkbench': 'Flujo de creación',
|
||||
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
|
||||
'jobst.done': 'Listo',
|
||||
'jobst.done': 'Fijado',
|
||||
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
|
||||
'jobst.dryRun': 'Marcador',
|
||||
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
@@ -2003,6 +2003,15 @@ export const es: Dict = {
|
||||
'desk.ctxDismiss': 'Cerrar aviso de origen',
|
||||
'desk.ctxFrom': 'De «{project}»',
|
||||
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
|
||||
'desk.ctxBackfill': 'Al terminar, usa «Enviar al proyecto» en la tarjeta para devolver el resultado a {stage}',
|
||||
'desk.attachToProject': 'Enviar al proyecto',
|
||||
'desk.attachHint': 'Enviar este resultado a su origen: {label}',
|
||||
'desk.attaching': 'Enviando…',
|
||||
'desk.attachedTag': 'Enviado',
|
||||
'desk.attachBack': 'Ir al proyecto',
|
||||
'desk.attachDoneToast': 'Enviado al proyecto de origen: ábrelo para verlo',
|
||||
'desk.attachAlreadyToast': 'Este resultado ya fue enviado; no se duplicó nada',
|
||||
'desk.attachFailedToast': 'No se pudo enviar: {detail}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Descargar',
|
||||
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
|
||||
@@ -2081,6 +2090,36 @@ export const es: Dict = {
|
||||
'desk.wfUntested': 'Sin probar',
|
||||
'desk.workflow': 'Flujo',
|
||||
'desk.yesterday': 'Ayer',
|
||||
// --- Escritorio de tres secciones (HEL-683) ---
|
||||
'desk.bandChange': "Cambiar flujo",
|
||||
'desk.bayCloudBody': "Los modelos de nube para imagen / vídeo (gpt-image2, nano-banana, Jimeng, Kling, …) aún no están conectados a este producto. Al integrarse se listarán con sus capacidades reales, nunca como entradas inutilizables.",
|
||||
'desk.bayCloudTitle': "Generación en la nube no conectada",
|
||||
'desk.bayCount': "{n} / {total} flujos",
|
||||
'desk.bayFavOnly': "Favoritos",
|
||||
'desk.bayMore': "Mostrar {n} más",
|
||||
'desk.bayNoFamily': "Modelo no identificado",
|
||||
'desk.bayPending': "{n} pendientes de confirmar (uso / salida sin fijar)",
|
||||
'desk.bayReqAria': "Requisitos de entrada",
|
||||
'desk.bayReqTitle': "Requisitos de entrada",
|
||||
'desk.baySearchPh': "Buscar nombre, descripción, modelo, uso…",
|
||||
'desk.baySourceCloud': "Nube",
|
||||
'desk.baySourceLabel': "Origen de generación",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "Todos",
|
||||
'desk.modeEmpty': "Aún no hay flujo utilizable para este modo",
|
||||
'desk.modeRailAria': "Modos de tarea",
|
||||
'desk.modeRailTitle': "Modos de tarea",
|
||||
'desk.pvBackCreate': "Volver a crear",
|
||||
'desk.pvCurrent': "Flujo actual",
|
||||
'desk.pvEmpty': "Elige un flujo a la izquierda para ver sus requisitos",
|
||||
'desk.pvFav': "Añadir a favoritos",
|
||||
'desk.pvLastUsed': "Último uso",
|
||||
'desk.pvParamCount': "Parámetros",
|
||||
'desk.pvSlotsLoading': "Leyendo ranuras de entrada…",
|
||||
'desk.pvUnfav': "Quitar de favoritos",
|
||||
'desk.pvUse': "Usar este flujo",
|
||||
'desk.railFav': "Favoritos",
|
||||
'desk.railRecent': "Recientes",
|
||||
'wfd.stepParse': 'Analizar JSON',
|
||||
'wfd.stepNodes': 'Identificar nodos',
|
||||
'wfd.stepModels': 'Verificar modelos',
|
||||
@@ -2097,4 +2136,136 @@ export const es: Dict = {
|
||||
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
|
||||
'wfd.showRaw': 'Ver JSON original',
|
||||
'wfd.hideRaw': 'Ocultar JSON original',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Importa por lotes flujos en formato UI; busca, filtra y anota — el JSON completo solo se carga al actuar',
|
||||
'wl.batchImport': 'Importar por lotes',
|
||||
'wl.dedupe': 'Deduplicar',
|
||||
'wl.dedupeTitle': 'Eliminar flujos duplicados',
|
||||
'wl.dedupeConfirm': 'Se eliminan las entradas con nombre Y contenido idénticos (se conserva la primera importada). Mismo nombre con contenido distinto no se toca. ¿Continuar?',
|
||||
'wl.dedupeDone': 'Se eliminaron {n} flujos duplicados',
|
||||
'wl.dedupeNone': 'No se encontraron flujos duplicados',
|
||||
'wl.favCount': '{n} favoritos',
|
||||
'wl.searchPh': 'Buscar nombre, descripción, etiquetas, modelo… (pulsa /)',
|
||||
'wl.filterLabel': 'Filtros',
|
||||
'wl.filterAll': 'Todos',
|
||||
'wl.filterFav': 'Favoritos',
|
||||
'wl.filterPending': 'Pendientes',
|
||||
'wl.filterDisabled': 'Desactivados',
|
||||
'wl.favAria': 'Marcar como favorito',
|
||||
'wl.unfavAria': 'Quitar de favoritos',
|
||||
'wl.categoryLabel': 'Categoría',
|
||||
'wl.categoryAll': 'Todas las categorías',
|
||||
'wl.familyLabel': 'Familia de modelo',
|
||||
'wl.familyAll': 'Todas las familias',
|
||||
'wl.mediaLabel': 'Medio',
|
||||
'wl.mediaAll': 'Todos los medios',
|
||||
'wl.mediaImage': 'Imagen',
|
||||
'wl.mediaVideo': 'Vídeo',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Entrada',
|
||||
'wl.mediaOut': 'Salida',
|
||||
'wl.sortLabel': 'Orden',
|
||||
'wl.sortRecent': 'Favoritos / recientes',
|
||||
'wl.sortName': 'Nombre',
|
||||
'wl.resetFilters': 'Limpiar filtros',
|
||||
'wl.noMatch': 'Ningún flujo coincide con los filtros',
|
||||
'wl.resultCount': '{n} de {m}',
|
||||
'wl.selected': '{n} seleccionados',
|
||||
'wl.batchLabel': 'Acciones por lotes',
|
||||
'wl.batchAnnotate': 'Anotar',
|
||||
'wl.batchEnable': 'Activar',
|
||||
'wl.batchDisable': 'Desactivar',
|
||||
'wl.clearSel': 'Limpiar selección',
|
||||
'wl.batchDone': '{n} flujos actualizados',
|
||||
'wl.batchPartial': '{n} con éxito, {m} fallidos (confirma antes uso y salida)',
|
||||
'wl.selectAll': 'Seleccionar todos los flujos listados',
|
||||
'wl.rowCheckAria': 'Seleccionar {name}',
|
||||
'wl.headMeta': 'Metadatos',
|
||||
'wl.headActions': 'Acciones',
|
||||
'wl.enableAria': 'Activar o desactivar {name}',
|
||||
'wl.enableBlocked': 'Confirma uso y nodos de salida antes de activar',
|
||||
'wl.editMeta': 'Editar metadatos',
|
||||
'wl.lastUsed': 'Último uso',
|
||||
'wl.moreCount': 'Mostrar {n} más ({m} en total)',
|
||||
'wfCat.basic': 'Procesamiento básico',
|
||||
'wfCat.audioTts': 'Generación de voz',
|
||||
'wfCat.music': 'Generación de música',
|
||||
'wfCat.image': 'Generación de imágenes',
|
||||
'wfCat.video': 'Generación de vídeo',
|
||||
'wfCat.digitalHuman': 'Humano digital',
|
||||
'wfCat.motionTransfer': 'Transferencia de movimiento',
|
||||
'md.title': 'Metadatos del flujo',
|
||||
'md.batchTitle': 'Anotar por lotes ({n})',
|
||||
'md.batchHint': 'Solo se aplican los campos rellenados; los nombres se renombran con buscar→reemplazar y las etiquetas se añaden.',
|
||||
'md.name': 'Nombre visible',
|
||||
'md.nameFind': 'Buscar',
|
||||
'md.nameFindPh': 'Texto a reemplazar…',
|
||||
'md.nameReplace': 'Reemplazar por',
|
||||
'md.nameReplacePh': 'Texto de reemplazo…',
|
||||
'md.nameHint': 'Deja «Buscar» vacío para no cambiar nombres.',
|
||||
'md.description': 'Descripción',
|
||||
'md.descriptionPh': 'Una línea sobre qué hace este flujo…',
|
||||
'md.tags': 'Etiquetas',
|
||||
'md.tagsPh': 'p. ej. rápido, alta calidad',
|
||||
'md.tagsHint': 'Separadas por comas; en lote se añaden.',
|
||||
'md.category': 'Categoría',
|
||||
'md.modelFamily': 'Familia de modelo',
|
||||
'md.apply': 'Aplicar',
|
||||
'md.applied': '{n} flujos actualizados',
|
||||
'md.appliedOne': 'Actualizado',
|
||||
'md.partial': '{n} con éxito, {m} fallidos',
|
||||
'wiz.title': 'Importar flujos por lotes',
|
||||
'wiz.stepSource': 'Origen',
|
||||
'wiz.stepReport': 'Informe de prueba',
|
||||
'wiz.stepResult': 'Resultado',
|
||||
'wiz.sourceLabel': 'Origen de importación',
|
||||
'wiz.modeScan': 'Escanear carpeta del servidor',
|
||||
'wiz.modeFiles': 'Elegir archivos locales',
|
||||
'wiz.rootLabel': 'Carpeta del servidor',
|
||||
'wiz.rootHint': 'Ruta de carpeta en el servidor (p. ej. example_ComfyUI_workflows del repo); se escanean recursivamente los JSON en formato UI, el formato API y otros JSON se omiten.',
|
||||
'wiz.preflight': 'Preflight contra un ComfyUI real (opcional)',
|
||||
'wiz.preflightNone': 'Omitir (conversión sin conexión)',
|
||||
'wiz.preflightHint': 'Con una conexión elegida, los grafos convertidos se verifican nodo a nodo.',
|
||||
'wiz.pickFiles': 'Archivos locales',
|
||||
'wiz.pickFilesBtn': 'Elegir archivos JSON (varios)',
|
||||
'wiz.pickFilesHint': 'Elige JSON de flujos ComfyUI en formato UI; se admite selección múltiple.',
|
||||
'wiz.parsed': '{n} archivos leídos',
|
||||
'wiz.notUi': '{n} archivos no son formato UI (sin array nodes) y se omitieron',
|
||||
'wiz.noFiles': 'Elige primero los archivos a importar',
|
||||
'wiz.runDry': 'Ejecutar prueba (no se guarda nada)',
|
||||
'wiz.running': 'Comprobando…',
|
||||
'wiz.total': '{n} archivos',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Avisos {n}',
|
||||
'wiz.fail': 'Fallos {n}',
|
||||
'wiz.confirmCount': 'Requieren revisión {n}',
|
||||
'wiz.importable': 'Importables {n}',
|
||||
'wiz.reportHint': 'La prueba no guarda nada. Los avisos se importan activados al indicar un uso; los que requieren revisión o fallaron nunca se importan.',
|
||||
'wiz.filterLabel': 'Filtros del informe',
|
||||
'wiz.filterWarn': 'Solo avisos',
|
||||
'wiz.filterBlocked': 'No importables',
|
||||
'wiz.searchPh': 'Buscar nombre de archivo…',
|
||||
'wiz.includeAria': 'Importar {name}',
|
||||
'wiz.skipReason': 'No importable: {reason}',
|
||||
'wiz.issues': '{n} notas de conversión',
|
||||
'wiz.usagePick': 'Uso:',
|
||||
'wiz.usageNone': 'Omitir por ahora (pendiente tras importar)',
|
||||
'wiz.selectedN': 'Se importarán {n} archivos',
|
||||
'wiz.excludedM': '{m} no importables',
|
||||
'wiz.import': 'Importar',
|
||||
'wiz.importing': 'Importando…',
|
||||
'wiz.back': 'Atrás',
|
||||
'wiz.imported': '{n} importados',
|
||||
'wiz.skipped': '{n} omitidos',
|
||||
'wiz.conflict': 'Ya existe (duplicado omitido)',
|
||||
'wiz.usageApplied': 'Uso indicado para {n} importaciones nuevas',
|
||||
'wiz.done': 'Hecho',
|
||||
'wiz.again': 'Importar otro lote',
|
||||
'wiz.resultNote': 'Los archivos con contenido idéntico se omiten automáticamente; repetir no crea duplicados.',
|
||||
'wiz.importedToast': '{n} flujos importados',
|
||||
'wiz.warnTag': 'Aviso',
|
||||
'wiz.confirmTag': 'Requiere revisión',
|
||||
'wiz.preflightTag': 'Preflight fallido',
|
||||
'wiz.importedTag': 'Importado #{id}',
|
||||
};
|
||||
|
||||
@@ -1902,7 +1902,7 @@ export const fr: Dict = {
|
||||
'home.stageProgress': 'Progression en cinq étapes',
|
||||
'tasks.sourceWorkbench': 'Flux de création',
|
||||
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
|
||||
'jobst.done': 'Terminé',
|
||||
'jobst.done': 'Fixé',
|
||||
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
|
||||
'jobst.dryRun': 'Substitut',
|
||||
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
@@ -2003,6 +2003,15 @@ export const fr: Dict = {
|
||||
'desk.ctxDismiss': 'Fermer la note d’origine',
|
||||
'desk.ctxFrom': 'Depuis « {project} »',
|
||||
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
|
||||
'desk.ctxBackfill': 'Une fois terminé, utilisez « Renvoyer au projet » sur la carte pour renvoyer le résultat vers {stage}',
|
||||
'desk.attachToProject': 'Renvoyer au projet',
|
||||
'desk.attachHint': 'Renvoyer ce résultat à sa source : {label}',
|
||||
'desk.attaching': 'Envoi…',
|
||||
'desk.attachedTag': 'Renvoyé',
|
||||
'desk.attachBack': 'Voir le projet',
|
||||
'desk.attachDoneToast': 'Renvoyé au projet source — ouvrez le projet pour le voir',
|
||||
'desk.attachAlreadyToast': 'Ce résultat a déjà été renvoyé ; rien de dupliqué',
|
||||
'desk.attachFailedToast': 'Échec du renvoi : {detail}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Télécharger',
|
||||
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
|
||||
@@ -2081,6 +2090,36 @@ export const fr: Dict = {
|
||||
'desk.wfUntested': 'Non testé',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Hier',
|
||||
// --- Bureau en trois sections (HEL-683) ---
|
||||
'desk.bandChange': "Changer de flux",
|
||||
'desk.bayCloudBody': "Les modèles cloud d'image / vidéo (gpt-image2, nano-banana, Jimeng, Kling, …) ne sont pas encore connectés à ce produit. Une fois intégrés, ils seront listés avec leurs capacités réelles — jamais en entrées inutilisables.",
|
||||
'desk.bayCloudTitle': "Génération cloud non connectée",
|
||||
'desk.bayCount': "{n} / {total} flux",
|
||||
'desk.bayFavOnly': "Favoris",
|
||||
'desk.bayMore': "Afficher {n} de plus",
|
||||
'desk.bayNoFamily': "Modèle non identifié",
|
||||
'desk.bayPending': "{n} à confirmer (usage / sortie non fixés)",
|
||||
'desk.bayReqAria': "Exigences d'entrée",
|
||||
'desk.bayReqTitle': "Exigences d'entrée",
|
||||
'desk.baySearchPh': "Rechercher nom, description, modèle, usage…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Source de génération",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "Tous",
|
||||
'desk.modeEmpty': "Aucun flux utilisable pour ce mode pour l'instant",
|
||||
'desk.modeRailAria': "Modes de tâche",
|
||||
'desk.modeRailTitle': "Modes de tâche",
|
||||
'desk.pvBackCreate': "Retour à la création",
|
||||
'desk.pvCurrent': "Flux actuel",
|
||||
'desk.pvEmpty': "Choisissez un flux à gauche pour voir ses exigences d'entrée",
|
||||
'desk.pvFav': "Ajouter aux favoris",
|
||||
'desk.pvLastUsed': "Dernière utilisation",
|
||||
'desk.pvParamCount': "Paramètres",
|
||||
'desk.pvSlotsLoading': "Lecture des emplacements d’entrée…",
|
||||
'desk.pvUnfav': "Retirer des favoris",
|
||||
'desk.pvUse': "Utiliser ce flux",
|
||||
'desk.railFav': "Favoris",
|
||||
'desk.railRecent': "Récents",
|
||||
'wfd.stepParse': 'Analyser le JSON',
|
||||
'wfd.stepNodes': 'Identifier les nœuds',
|
||||
'wfd.stepModels': 'Vérifier les modèles',
|
||||
@@ -2097,4 +2136,136 @@ export const fr: Dict = {
|
||||
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
|
||||
'wfd.showRaw': 'Voir le JSON brut',
|
||||
'wfd.hideRaw': 'Masquer le JSON brut',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Importez par lots des flux au format UI ; cherchez, filtrez, annotez — le JSON complet ne se charge qu’à l’action',
|
||||
'wl.batchImport': 'Import par lot',
|
||||
'wl.dedupe': 'Dédupliquer',
|
||||
'wl.dedupeTitle': 'Supprimer les flux dupliqués',
|
||||
'wl.dedupeConfirm': 'Les entrées au nom ET contenu identiques seront supprimées (le premier import est conservé). Même nom mais contenu différent = votre variante, intact. Continuer ?',
|
||||
'wl.dedupeDone': '{n} flux dupliqués supprimés',
|
||||
'wl.dedupeNone': 'Aucun flux dupliqué trouvé',
|
||||
'wl.favCount': '{n} favoris',
|
||||
'wl.searchPh': 'Rechercher nom, description, tags, modèle… (touche /)',
|
||||
'wl.filterLabel': 'Filtres',
|
||||
'wl.filterAll': 'Tous',
|
||||
'wl.filterFav': 'Favoris',
|
||||
'wl.filterPending': 'À confirmer',
|
||||
'wl.filterDisabled': 'Désactivés',
|
||||
'wl.favAria': 'Mettre en favori',
|
||||
'wl.unfavAria': 'Retirer des favoris',
|
||||
'wl.categoryLabel': 'Catégorie',
|
||||
'wl.categoryAll': 'Toutes les catégories',
|
||||
'wl.familyLabel': 'Famille de modèle',
|
||||
'wl.familyAll': 'Toutes les familles',
|
||||
'wl.mediaLabel': 'Média',
|
||||
'wl.mediaAll': 'Tous les médias',
|
||||
'wl.mediaImage': 'Image',
|
||||
'wl.mediaVideo': 'Vidéo',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Entrée',
|
||||
'wl.mediaOut': 'Sortie',
|
||||
'wl.sortLabel': 'Tri',
|
||||
'wl.sortRecent': 'Favoris / récents',
|
||||
'wl.sortName': 'Nom',
|
||||
'wl.resetFilters': 'Effacer les filtres',
|
||||
'wl.noMatch': 'Aucun flux ne correspond aux filtres',
|
||||
'wl.resultCount': '{n} sur {m}',
|
||||
'wl.selected': '{n} sélectionnés',
|
||||
'wl.batchLabel': 'Actions groupées',
|
||||
'wl.batchAnnotate': 'Annoter',
|
||||
'wl.batchEnable': 'Activer',
|
||||
'wl.batchDisable': 'Désactiver',
|
||||
'wl.clearSel': 'Effacer la sélection',
|
||||
'wl.batchDone': '{n} flux mis à jour',
|
||||
'wl.batchPartial': '{n} réussis, {m} échoués (usage et sortie à confirmer d’abord)',
|
||||
'wl.selectAll': 'Sélectionner tous les flux listés',
|
||||
'wl.rowCheckAria': 'Sélectionner {name}',
|
||||
'wl.headMeta': 'Métadonnées',
|
||||
'wl.headActions': 'Actions',
|
||||
'wl.enableAria': 'Activer ou désactiver {name}',
|
||||
'wl.enableBlocked': 'Confirmez l’usage et les nœuds de sortie avant activation',
|
||||
'wl.editMeta': 'Modifier les métadonnées',
|
||||
'wl.lastUsed': 'Dernière utilisation',
|
||||
'wl.moreCount': 'Afficher {n} de plus ({m} au total)',
|
||||
'wfCat.basic': 'Traitement de base',
|
||||
'wfCat.audioTts': 'Génération de voix',
|
||||
'wfCat.music': 'Génération de musique',
|
||||
'wfCat.image': 'Génération d’images',
|
||||
'wfCat.video': 'Génération de vidéo',
|
||||
'wfCat.digitalHuman': 'Humain numérique',
|
||||
'wfCat.motionTransfer': 'Transfert de mouvement',
|
||||
'md.title': 'Métadonnées du flux',
|
||||
'md.batchTitle': 'Annotation groupée ({n})',
|
||||
'md.batchHint': 'Seuls les champs remplis sont appliqués ; renommage par rechercher→remplacer, tags ajoutés.',
|
||||
'md.name': 'Nom affiché',
|
||||
'md.nameFind': 'Rechercher',
|
||||
'md.nameFindPh': 'Texte à remplacer…',
|
||||
'md.nameReplace': 'Remplacer par',
|
||||
'md.nameReplacePh': 'Texte de remplacement…',
|
||||
'md.nameHint': 'Laissez « Rechercher » vide pour ne pas renommer.',
|
||||
'md.description': 'Description',
|
||||
'md.descriptionPh': 'Une ligne sur ce que fait ce flux…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'ex. rapide, haute qualité',
|
||||
'md.tagsHint': 'Séparés par des virgules ; ajoutés en mode groupé.',
|
||||
'md.category': 'Catégorie',
|
||||
'md.modelFamily': 'Famille de modèle',
|
||||
'md.apply': 'Appliquer',
|
||||
'md.applied': '{n} flux mis à jour',
|
||||
'md.appliedOne': 'Mis à jour',
|
||||
'md.partial': '{n} réussis, {m} échoués',
|
||||
'wiz.title': 'Import de flux par lot',
|
||||
'wiz.stepSource': 'Source',
|
||||
'wiz.stepReport': 'Rapport de test',
|
||||
'wiz.stepResult': 'Résultat',
|
||||
'wiz.sourceLabel': 'Source d’import',
|
||||
'wiz.modeScan': 'Scanner un dossier serveur',
|
||||
'wiz.modeFiles': 'Choisir des fichiers locaux',
|
||||
'wiz.rootLabel': 'Dossier serveur',
|
||||
'wiz.rootHint': 'Chemin d’un dossier sur le serveur (ex. example_ComfyUI_workflows du dépôt) ; les JSON au format UI sont scannés récursivement, le format API et les autres sont ignorés.',
|
||||
'wiz.preflight': 'Pré-vérification sur un ComfyUI réel (facultatif)',
|
||||
'wiz.preflightNone': 'Passer (conversion hors ligne)',
|
||||
'wiz.preflightHint': 'Avec une connexion choisie, les graphes convertis sont vérifiés nœud par nœud.',
|
||||
'wiz.pickFiles': 'Fichiers locaux',
|
||||
'wiz.pickFilesBtn': 'Choisir des fichiers JSON (plusieurs)',
|
||||
'wiz.pickFilesHint': 'Choisissez des flux ComfyUI au format UI ; sélection multiple possible.',
|
||||
'wiz.parsed': '{n} fichiers lus',
|
||||
'wiz.notUi': '{n} fichiers ne sont pas au format UI (pas de tableau nodes), ignorés',
|
||||
'wiz.noFiles': 'Choisissez d’abord des fichiers à importer',
|
||||
'wiz.runDry': 'Lancer le test (rien n’est enregistré)',
|
||||
'wiz.running': 'Vérification…',
|
||||
'wiz.total': '{n} fichiers',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Avertissements {n}',
|
||||
'wiz.fail': 'Échecs {n}',
|
||||
'wiz.confirmCount': 'À vérifier {n}',
|
||||
'wiz.importable': 'Importables {n}',
|
||||
'wiz.reportHint': 'Le test n’écrit rien. Les avertissements s’importent activés dès qu’un usage est fourni ; les fichiers à vérifier ou en échec ne sont jamais importés.',
|
||||
'wiz.filterLabel': 'Filtres du rapport',
|
||||
'wiz.filterWarn': 'Avertissements seuls',
|
||||
'wiz.filterBlocked': 'Non importables',
|
||||
'wiz.searchPh': 'Rechercher un nom de fichier…',
|
||||
'wiz.includeAria': 'Importer {name}',
|
||||
'wiz.skipReason': 'Non importable : {reason}',
|
||||
'wiz.issues': '{n} notes de conversion',
|
||||
'wiz.usagePick': 'Usage :',
|
||||
'wiz.usageNone': 'Passer pour l’instant (à confirmer après import)',
|
||||
'wiz.selectedN': '{n} fichiers seront importés',
|
||||
'wiz.excludedM': '{m} non importables',
|
||||
'wiz.import': 'Importer',
|
||||
'wiz.importing': 'Import…',
|
||||
'wiz.back': 'Retour',
|
||||
'wiz.imported': '{n} importés',
|
||||
'wiz.skipped': '{n} ignorés',
|
||||
'wiz.conflict': 'Existe déjà (doublon ignoré)',
|
||||
'wiz.usageApplied': 'Usage fourni pour {n} nouveaux imports',
|
||||
'wiz.done': 'Terminé',
|
||||
'wiz.again': 'Importer un autre lot',
|
||||
'wiz.resultNote': 'Les fichiers au contenu identique sont ignorés automatiquement — relancer ne crée jamais de doublons.',
|
||||
'wiz.importedToast': '{n} flux importés',
|
||||
'wiz.warnTag': 'Avertissement',
|
||||
'wiz.confirmTag': 'À vérifier',
|
||||
'wiz.preflightTag': 'Pré-vérification échouée',
|
||||
'wiz.importedTag': 'Importé #{id}',
|
||||
};
|
||||
|
||||
@@ -1900,7 +1900,7 @@ export const ja: Dict = {
|
||||
'home.stageProgress': '五段階の進捗',
|
||||
'tasks.sourceWorkbench': '制作フロー',
|
||||
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
|
||||
'jobst.done': '完了',
|
||||
'jobst.done': '定着',
|
||||
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
|
||||
'jobst.dryRun': 'プレースホルダー',
|
||||
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
@@ -2001,6 +2001,15 @@ export const ja: Dict = {
|
||||
'desk.ctxDismiss': '出典の表示を閉じる',
|
||||
'desk.ctxFrom': '『{project}』から',
|
||||
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
|
||||
'desk.ctxBackfill': '生成が完了したら、結果カードの「プロジェクトへ戻す」で{stage}に戻せます',
|
||||
'desk.attachToProject': 'プロジェクトへ戻す',
|
||||
'desk.attachHint': 'この結果を戻し先に送る:{label}',
|
||||
'desk.attaching': '戻しています…',
|
||||
'desk.attachedTag': '戻しました',
|
||||
'desk.attachBack': 'プロジェクトへ',
|
||||
'desk.attachDoneToast': '送り元プロジェクトに戻しました。プロジェクトを開くと確認できます',
|
||||
'desk.attachAlreadyToast': 'この結果はすでに戻されています。重複は作られていません',
|
||||
'desk.attachFailedToast': '戻せませんでした:{detail}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': 'ダウンロード',
|
||||
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
|
||||
@@ -2079,6 +2088,36 @@ export const ja: Dict = {
|
||||
'desk.wfUntested': '未テスト',
|
||||
'desk.workflow': 'ワークフロー',
|
||||
'desk.yesterday': '昨日',
|
||||
// --- 三段構成デスク (HEL-683) ---
|
||||
'desk.bandChange': "ワークフローを変更",
|
||||
'desk.bayCloudBody': "クラウドの画像/動画モデル(gpt-image2、nano-banana、即夢、Kling など)はまだ本製品に接続されていません。接続後は実際の機能とともに一覧表示され、使えない項目が並ぶことはありません。",
|
||||
'desk.bayCloudTitle': "クラウド生成は未接続",
|
||||
'desk.bayCount': "{n} / {total} 件のワークフロー",
|
||||
'desk.bayFavOnly': "お気に入り",
|
||||
'desk.bayMore': "さらに {n} 件表示",
|
||||
'desk.bayNoFamily': "モデル未識別",
|
||||
'desk.bayPending': "確認待ち {n} 件(用途/出力未確定)",
|
||||
'desk.bayReqAria': "入力要件",
|
||||
'desk.bayReqTitle': "入力要件",
|
||||
'desk.baySearchPh': "名前・説明・モデル・用途を検索…",
|
||||
'desk.baySourceCloud': "クラウド",
|
||||
'desk.baySourceLabel': "生成ソース",
|
||||
'desk.baySourceLocal': "ローカル",
|
||||
'desk.modeAll': "すべて",
|
||||
'desk.modeEmpty': "このモードで利用できるワークフローはまだありません",
|
||||
'desk.modeRailAria': "タスクモード",
|
||||
'desk.modeRailTitle': "タスクモード",
|
||||
'desk.pvBackCreate': "作成に戻る",
|
||||
'desk.pvCurrent': "現在のワークフロー",
|
||||
'desk.pvEmpty': "左でワークフローを選ぶと入力要件が表示されます",
|
||||
'desk.pvFav': "お気に入りに追加",
|
||||
'desk.pvLastUsed': "最終使用",
|
||||
'desk.pvParamCount': "パラメータ数",
|
||||
'desk.pvSlotsLoading': "入力スロットを読み込み中…",
|
||||
'desk.pvUnfav': "お気に入りから外す",
|
||||
'desk.pvUse': "このワークフローを使う",
|
||||
'desk.railFav': "お気に入り",
|
||||
'desk.railRecent': "最近の使用",
|
||||
'wfd.stepParse': 'JSON を解析',
|
||||
'wfd.stepNodes': 'ノードを識別',
|
||||
'wfd.stepModels': 'モデルを確認',
|
||||
@@ -2095,4 +2134,136 @@ export const ja: Dict = {
|
||||
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
|
||||
'wfd.showRaw': '元の JSON を見る',
|
||||
'wfd.hideRaw': '元の JSON を閉じる',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI 形式ワークフローを一括取り込み、検索・絞り込み・注釈管理。完全な JSON は操作時のみ読み込み',
|
||||
'wl.batchImport': '一括取り込み',
|
||||
'wl.dedupe': '重複を削除',
|
||||
'wl.dedupeTitle': '重複ワークフローの削除',
|
||||
'wl.dedupeConfirm': '名前と内容が完全に同一の項目を削除します(最初に取り込んだものを残す)。名前が同じでも内容が異なるものは触れません。実行しますか?',
|
||||
'wl.dedupeDone': '重複 {n} 件を削除しました',
|
||||
'wl.dedupeNone': '重複するワークフローはありません',
|
||||
'wl.favCount': 'お気に入り {n} 件',
|
||||
'wl.searchPh': '名前・説明・タグ・モデルを検索…(/ で移動)',
|
||||
'wl.filterLabel': '絞り込み',
|
||||
'wl.filterAll': 'すべて',
|
||||
'wl.filterFav': 'お気に入り',
|
||||
'wl.filterPending': '要確認',
|
||||
'wl.filterDisabled': '無効',
|
||||
'wl.favAria': 'お気に入りに追加',
|
||||
'wl.unfavAria': 'お気に入りから外す',
|
||||
'wl.categoryLabel': 'カテゴリ',
|
||||
'wl.categoryAll': 'すべてのカテゴリ',
|
||||
'wl.familyLabel': 'モデルファミリー',
|
||||
'wl.familyAll': 'すべてのファミリー',
|
||||
'wl.mediaLabel': 'メディア',
|
||||
'wl.mediaAll': 'すべてのメディア',
|
||||
'wl.mediaImage': '画像',
|
||||
'wl.mediaVideo': '動画',
|
||||
'wl.mediaAudio': '音声',
|
||||
'wl.mediaIn': '入力',
|
||||
'wl.mediaOut': '出力',
|
||||
'wl.sortLabel': '並び替え',
|
||||
'wl.sortRecent': 'お気に入り / 最近',
|
||||
'wl.sortName': '名前',
|
||||
'wl.resetFilters': '絞り込みを解除',
|
||||
'wl.noMatch': '条件に合うワークフローがありません',
|
||||
'wl.resultCount': '{n} / {m} 件',
|
||||
'wl.selected': '{n} 件選択中',
|
||||
'wl.batchLabel': '一括操作',
|
||||
'wl.batchAnnotate': '一括注釈',
|
||||
'wl.batchEnable': '有効化',
|
||||
'wl.batchDisable': '無効化',
|
||||
'wl.clearSel': '選択を解除',
|
||||
'wl.batchDone': '{n} 件を更新しました',
|
||||
'wl.batchPartial': '{n} 件成功、{m} 件失敗(用途と出力の確認が必要)',
|
||||
'wl.selectAll': '表示中のワークフローをすべて選択',
|
||||
'wl.rowCheckAria': '{name} を選択',
|
||||
'wl.headMeta': 'メタデータ',
|
||||
'wl.headActions': '操作',
|
||||
'wl.enableAria': '{name} を有効化・無効化',
|
||||
'wl.enableBlocked': '用途と出力ノードを確定してから有効化してください',
|
||||
'wl.editMeta': 'メタデータを編集',
|
||||
'wl.lastUsed': '最終使用',
|
||||
'wl.moreCount': 'さらに {n} 件表示(全 {m} 件)',
|
||||
'wfCat.basic': '基本処理',
|
||||
'wfCat.audioTts': '音声生成',
|
||||
'wfCat.music': '音楽生成',
|
||||
'wfCat.image': '画像生成',
|
||||
'wfCat.video': '動画生成',
|
||||
'wfCat.digitalHuman': 'デジタルヒューマン',
|
||||
'wfCat.motionTransfer': 'モーション転送',
|
||||
'md.title': 'ワークフローの注釈',
|
||||
'md.batchTitle': '一括注釈({n} 件)',
|
||||
'md.batchHint': '入力したフィールドのみ適用。名前は検索→置換で変更、タグは追加されます。',
|
||||
'md.name': '表示名',
|
||||
'md.nameFind': '検索',
|
||||
'md.nameFindPh': '置換する文字…',
|
||||
'md.nameReplace': '置換後',
|
||||
'md.nameReplacePh': '置換する文字…',
|
||||
'md.nameHint': '「検索」が空なら名前は変更されません。',
|
||||
'md.description': '説明',
|
||||
'md.descriptionPh': 'このワークフローの用途を一行で…',
|
||||
'md.tags': 'タグ',
|
||||
'md.tagsPh': '例: 高速, 高品質',
|
||||
'md.tagsHint': 'カンマ区切り。一括モードでは追加されます。',
|
||||
'md.category': 'カテゴリ',
|
||||
'md.modelFamily': 'モデルファミリー',
|
||||
'md.apply': '適用',
|
||||
'md.applied': '{n} 件を更新しました',
|
||||
'md.appliedOne': '更新しました',
|
||||
'md.partial': '{n} 件成功、{m} 件失敗',
|
||||
'wiz.title': 'ワークフローの一括取り込み',
|
||||
'wiz.stepSource': '取り込み元',
|
||||
'wiz.stepReport': '事前チェック',
|
||||
'wiz.stepResult': '結果',
|
||||
'wiz.sourceLabel': '取り込み元',
|
||||
'wiz.modeScan': 'サーバーディレクトリをスキャン',
|
||||
'wiz.modeFiles': 'ローカルファイルを選択',
|
||||
'wiz.rootLabel': 'サーバーディレクトリ',
|
||||
'wiz.rootHint': 'サーバー上のディレクトリパス(例: リポジトリの example_ComfyUI_workflows)。UI 形式の JSON を再帰的にスキャンし、API 形式などは自動的にスキップします。',
|
||||
'wiz.preflight': '実機 ComfyUI で事前チェック(任意)',
|
||||
'wiz.preflightNone': 'スキップ(オフライン変換)',
|
||||
'wiz.preflightHint': '接続を選ぶと、変換結果をその ComfyUI のノート・モデルと照合します。',
|
||||
'wiz.pickFiles': 'ローカルファイル',
|
||||
'wiz.pickFilesBtn': 'JSON ファイルを選択(複数可)',
|
||||
'wiz.pickFilesHint': 'ComfyUI UI 形式のワークフロー JSON を選んでください。複数選択できます。',
|
||||
'wiz.parsed': '{n} ファイルを読み込みました',
|
||||
'wiz.notUi': '{n} ファイルは UI 形式でないため(nodes 配列なし)スキップしました',
|
||||
'wiz.noFiles': '先に取り込むファイルを選んでください',
|
||||
'wiz.runDry': '事前チェックを実行(保存しません)',
|
||||
'wiz.running': 'チェック中…',
|
||||
'wiz.total': '全体 {n} ファイル',
|
||||
'wiz.ok': '正常 {n}',
|
||||
'wiz.warn': '警告 {n}',
|
||||
'wiz.fail': '失敗 {n}',
|
||||
'wiz.confirmCount': '要確認 {n}',
|
||||
'wiz.importable': '取り込み可 {n}',
|
||||
'wiz.reportHint': '事前チェックは何も書き込みません。警告は用途を補えば有効な状態で取り込まれ、要確認・失敗のファイルは取り込まれません。',
|
||||
'wiz.filterLabel': 'レポートの絞り込み',
|
||||
'wiz.filterWarn': '警告のみ',
|
||||
'wiz.filterBlocked': '取り込み不可',
|
||||
'wiz.searchPh': 'ファイル名を検索…',
|
||||
'wiz.includeAria': '{name} を取り込む',
|
||||
'wiz.skipReason': '取り込み不可: {reason}',
|
||||
'wiz.issues': '変換メモ {n} 件',
|
||||
'wiz.usagePick': '用途:',
|
||||
'wiz.usageNone': '今はスキップ(取り込み後に要確認)',
|
||||
'wiz.selectedN': '{n} ファイルを取り込みます',
|
||||
'wiz.excludedM': '{m} 件は取り込み不可',
|
||||
'wiz.import': '取り込む',
|
||||
'wiz.importing': '取り込み中…',
|
||||
'wiz.back': '戻る',
|
||||
'wiz.imported': '{n} 件取り込み完了',
|
||||
'wiz.skipped': '{n} 件スキップ',
|
||||
'wiz.conflict': '既に存在(重複は自動スキップ)',
|
||||
'wiz.usageApplied': '新規取り込み {n} 件に用途を設定しました',
|
||||
'wiz.done': '完了',
|
||||
'wiz.again': '続けて取り込む',
|
||||
'wiz.resultNote': '内容が同一のファイルは自動スキップされ、再実行しても重複は作られません。',
|
||||
'wiz.importedToast': '{n} 件のワークフローを取り込みました',
|
||||
'wiz.warnTag': '警告',
|
||||
'wiz.confirmTag': '要確認',
|
||||
'wiz.preflightTag': '事前チェック不合格',
|
||||
'wiz.importedTag': '取り込み済み #{id}',
|
||||
};
|
||||
|
||||
@@ -1900,7 +1900,7 @@ export const ko: Dict = {
|
||||
'home.stageProgress': '5단계 진행',
|
||||
'tasks.sourceWorkbench': '제작 흐름',
|
||||
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
|
||||
'jobst.done': '완료',
|
||||
'jobst.done': '정착',
|
||||
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
|
||||
'jobst.dryRun': '자리표시자',
|
||||
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
@@ -2001,6 +2001,15 @@ export const ko: Dict = {
|
||||
'desk.ctxDismiss': '출처 안내 닫기',
|
||||
'desk.ctxFrom': '《{project}》에서',
|
||||
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
|
||||
'desk.ctxBackfill': '생성이 끝나면 결과 카드의 «프로젝트로 보내기»로 {stage}에 돌려보낼 수 있습니다',
|
||||
'desk.attachToProject': '프로젝트로 보내기',
|
||||
'desk.attachHint': '이 결과를 출처로 보내기: {label}',
|
||||
'desk.attaching': '보내는 중…',
|
||||
'desk.attachedTag': '보냄',
|
||||
'desk.attachBack': '프로젝트로',
|
||||
'desk.attachDoneToast': '원본 프로젝트로 보냈습니다. 프로젝트에서 확인하세요',
|
||||
'desk.attachAlreadyToast': '이 결과는 이미 보내졌습니다. 중복이 만들어지지 않았습니다',
|
||||
'desk.attachFailedToast': '보내기 실패: {detail}',
|
||||
'desk.dayDate': '{m}월 {d}일',
|
||||
'desk.download': '다운로드',
|
||||
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
|
||||
@@ -2079,6 +2088,36 @@ export const ko: Dict = {
|
||||
'desk.wfUntested': '테스트 안 함',
|
||||
'desk.workflow': '워크플로',
|
||||
'desk.yesterday': '어제',
|
||||
// --- 3단 구성 데스크 (HEL-683) ---
|
||||
'desk.bandChange': "워크플로 변경",
|
||||
'desk.bayCloudBody': "클라우드 이미지 / 동영상 모델(gpt-image2, nano-banana, 지멍, 클링 등)은 아직 이 제품에 연결되지 않았습니다. 연결 후 실제 기능과 함께 표시되며, 사용할 수 없는 항목은 나타나지 않습니다.",
|
||||
'desk.bayCloudTitle': "클라우드 생성 미연결",
|
||||
'desk.bayCount': "{n} / {total}개 워크플로",
|
||||
'desk.bayFavOnly': "즐겨찾기",
|
||||
'desk.bayMore': "{n}개 더 보기",
|
||||
'desk.bayNoFamily': "미식별 모델",
|
||||
'desk.bayPending': "확인 대기 {n}개(용도 / 출력 미확정)",
|
||||
'desk.bayReqAria': "입력 요구 사항",
|
||||
'desk.bayReqTitle': "입력 요구 사항",
|
||||
'desk.baySearchPh': "이름, 설명, 모델, 용도 검색…",
|
||||
'desk.baySourceCloud': "클라우드",
|
||||
'desk.baySourceLabel': "생성 소스",
|
||||
'desk.baySourceLocal': "로컬",
|
||||
'desk.modeAll': "전체",
|
||||
'desk.modeEmpty': "이 모드에서 사용 가능한 워크플로가 아직 없습니다",
|
||||
'desk.modeRailAria': "작업 모드",
|
||||
'desk.modeRailTitle': "작업 모드",
|
||||
'desk.pvBackCreate': "만들기로 돌아가기",
|
||||
'desk.pvCurrent': "현재 워크플로",
|
||||
'desk.pvEmpty': "왼쪽에서 워크플로를 선택하면 입력 요구 사항이 표시됩니다",
|
||||
'desk.pvFav': "즐겨찾기에 추가",
|
||||
'desk.pvLastUsed': "최근 사용",
|
||||
'desk.pvParamCount': "매개변수 수",
|
||||
'desk.pvSlotsLoading': "입력 슬롯 읽는 중…",
|
||||
'desk.pvUnfav': "즐겨찾기에서 제거",
|
||||
'desk.pvUse': "이 워크플로 사용",
|
||||
'desk.railFav': "즐겨찾기",
|
||||
'desk.railRecent': "최근 사용",
|
||||
'wfd.stepParse': 'JSON 파싱',
|
||||
'wfd.stepNodes': '노드 식별',
|
||||
'wfd.stepModels': '모델 확인',
|
||||
@@ -2095,4 +2134,136 @@ export const ko: Dict = {
|
||||
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
|
||||
'wfd.showRaw': '원본 JSON 보기',
|
||||
'wfd.hideRaw': '원본 JSON 접기',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI 형식 워크플로 일괄 가져오기, 검색·필터·주석 관리. 전체 JSON은 작업 시에만 로드',
|
||||
'wl.batchImport': '일괄 가져오기',
|
||||
'wl.dedupe': '중복 정리',
|
||||
'wl.dedupeTitle': '중복 워크플로 삭제',
|
||||
'wl.dedupeConfirm': '이름과 내용이 완전히 같은 항목을 삭제합니다(가장 먼저 가져온 것 유지). 이름이 같아도 내용이 다르면 그대로 둡니다. 계속할까요?',
|
||||
'wl.dedupeDone': '중복 워크플로 {n}건 삭제',
|
||||
'wl.dedupeNone': '중복 워크플로가 없습니다',
|
||||
'wl.favCount': '즐겨찾기 {n}건',
|
||||
'wl.searchPh': '이름·설명·태그·모델 검색… (/ 키)',
|
||||
'wl.filterLabel': '필터',
|
||||
'wl.filterAll': '전체',
|
||||
'wl.filterFav': '즐겨찾기',
|
||||
'wl.filterPending': '확인 필요',
|
||||
'wl.filterDisabled': '비활성',
|
||||
'wl.favAria': '즐겨찾기 추가',
|
||||
'wl.unfavAria': '즐겨찾기 해제',
|
||||
'wl.categoryLabel': '분류',
|
||||
'wl.categoryAll': '전체 분류',
|
||||
'wl.familyLabel': '모델 계열',
|
||||
'wl.familyAll': '전체 계열',
|
||||
'wl.mediaLabel': '미디어',
|
||||
'wl.mediaAll': '전체 미디어',
|
||||
'wl.mediaImage': '이미지',
|
||||
'wl.mediaVideo': '영상',
|
||||
'wl.mediaAudio': '오디오',
|
||||
'wl.mediaIn': '입력',
|
||||
'wl.mediaOut': '출력',
|
||||
'wl.sortLabel': '정렬',
|
||||
'wl.sortRecent': '즐겨찾기 / 최근',
|
||||
'wl.sortName': '이름',
|
||||
'wl.resetFilters': '필터 해제',
|
||||
'wl.noMatch': '조건에 맞는 워크플로가 없습니다',
|
||||
'wl.resultCount': '{n} / {m}',
|
||||
'wl.selected': '{n}건 선택됨',
|
||||
'wl.batchLabel': '일괄 작업',
|
||||
'wl.batchAnnotate': '일괄 주석',
|
||||
'wl.batchEnable': '활성화',
|
||||
'wl.batchDisable': '비활성화',
|
||||
'wl.clearSel': '선택 해제',
|
||||
'wl.batchDone': '{n}건 업데이트',
|
||||
'wl.batchPartial': '{n}건 성공, {m}건 실패(용도와 출력 먼저 확정)',
|
||||
'wl.selectAll': '나열된 워크플로 모두 선택',
|
||||
'wl.rowCheckAria': '{name} 선택',
|
||||
'wl.headMeta': '메타데이터',
|
||||
'wl.headActions': '작업',
|
||||
'wl.enableAria': '{name} 활성화/비활성화',
|
||||
'wl.enableBlocked': '활성화 전에 용도와 출력 노드를 확정하세요',
|
||||
'wl.editMeta': '메타데이터 편집',
|
||||
'wl.lastUsed': '마지막 사용',
|
||||
'wl.moreCount': '{n}건 더 보기(전체 {m}건)',
|
||||
'wfCat.basic': '기본 처리',
|
||||
'wfCat.audioTts': '음성 생성',
|
||||
'wfCat.music': '음악 생성',
|
||||
'wfCat.image': '이미지 생성',
|
||||
'wfCat.video': '영상 생성',
|
||||
'wfCat.digitalHuman': '디지털 휴먼',
|
||||
'wfCat.motionTransfer': '모션 전이',
|
||||
'md.title': '워크플로 주석',
|
||||
'md.batchTitle': '일괄 주석({n}건)',
|
||||
'md.batchHint': '입력한 필드만 적용됩니다. 이름은 찾기→바꾸기로 변경, 태그는 추가됩니다.',
|
||||
'md.name': '표시 이름',
|
||||
'md.nameFind': '찾기',
|
||||
'md.nameFindPh': '바꿀 문자…',
|
||||
'md.nameReplace': '바꾸기',
|
||||
'md.nameReplacePh': '대체 문자…',
|
||||
'md.nameHint': '「찾기」를 비우면 이름이 유지됩니다.',
|
||||
'md.description': '설명',
|
||||
'md.descriptionPh': '이 워크플로가 하는 일을 한 줄로…',
|
||||
'md.tags': '태그',
|
||||
'md.tagsPh': '예: 빠름, 고품질',
|
||||
'md.tagsHint': '쉼표로 구분. 일괄 모드에서는 추가됩니다.',
|
||||
'md.category': '분류',
|
||||
'md.modelFamily': '모델 계열',
|
||||
'md.apply': '적용',
|
||||
'md.applied': '{n}건 업데이트',
|
||||
'md.appliedOne': '업데이트됨',
|
||||
'md.partial': '{n}건 성공, {m}건 실패',
|
||||
'wiz.title': '워크플로 일괄 가져오기',
|
||||
'wiz.stepSource': '원본',
|
||||
'wiz.stepReport': '사전 검사',
|
||||
'wiz.stepResult': '결과',
|
||||
'wiz.sourceLabel': '가져오기 원본',
|
||||
'wiz.modeScan': '서버 디렉터리 스캔',
|
||||
'wiz.modeFiles': '로컬 파일 선택',
|
||||
'wiz.rootLabel': '서버 디렉터리',
|
||||
'wiz.rootHint': '서버의 디렉터리 경로(예: 저장소의 example_ComfyUI_workflows). UI 형식 JSON을 재귀적으로 스캔하며 API 형식 등은 건너뜁니다.',
|
||||
'wiz.preflight': '실제 ComfyUI 사전 검사(선택)',
|
||||
'wiz.preflightNone': '건너뛰기(오프라인 변환)',
|
||||
'wiz.preflightHint': '연결을 선택하면 변환 결과를 해당 ComfyUI의 노드·모델과 대조합니다.',
|
||||
'wiz.pickFiles': '로컬 파일',
|
||||
'wiz.pickFilesBtn': 'JSON 파일 선택(여러 개)',
|
||||
'wiz.pickFilesHint': 'ComfyUI UI 형식 워크플로 JSON을 선택하세요. 다중 선택이 가능합니다.',
|
||||
'wiz.parsed': '{n}개 파일 읽음',
|
||||
'wiz.notUi': '{n}개 파일이 UI 형식이 아니어서(nodes 배열 없음) 건너뛰었습니다',
|
||||
'wiz.noFiles': '먼저 가져올 파일을 선택하세요',
|
||||
'wiz.runDry': '사전 검사 실행(저장 안 함)',
|
||||
'wiz.running': '검사 중…',
|
||||
'wiz.total': '총 {n}개 파일',
|
||||
'wiz.ok': '정상 {n}',
|
||||
'wiz.warn': '경고 {n}',
|
||||
'wiz.fail': '실패 {n}',
|
||||
'wiz.confirmCount': '확인 필요 {n}',
|
||||
'wiz.importable': '가져오기 가능 {n}',
|
||||
'wiz.reportHint': '사전 검사는 아무것도 저장하지 않습니다. 경고 항목은 용도를 지정하면 활성화 상태로 가져오며, 확인 필요·실패 항목은 절대 가져오지 않습니다.',
|
||||
'wiz.filterLabel': '보고서 필터',
|
||||
'wiz.filterWarn': '경고만',
|
||||
'wiz.filterBlocked': '가져올 수 없음',
|
||||
'wiz.searchPh': '파일 이름 검색…',
|
||||
'wiz.includeAria': '{name} 가져오기',
|
||||
'wiz.skipReason': '가져올 수 없음: {reason}',
|
||||
'wiz.issues': '변환 메모 {n}건',
|
||||
'wiz.usagePick': '용도:',
|
||||
'wiz.usageNone': '지금은 건너뜀(가져온 뒤 확인 필요)',
|
||||
'wiz.selectedN': '{n}개 파일을 가져옵니다',
|
||||
'wiz.excludedM': '{m}개 가져올 수 없음',
|
||||
'wiz.import': '가져오기',
|
||||
'wiz.importing': '가져오는 중…',
|
||||
'wiz.back': '이전',
|
||||
'wiz.imported': '{n}건 가져옴',
|
||||
'wiz.skipped': '{n}건 건너뜀',
|
||||
'wiz.conflict': '이미 있음(중복 자동 건너뜀)',
|
||||
'wiz.usageApplied': '새로 가져온 {n}건에 용도 지정',
|
||||
'wiz.done': '완료',
|
||||
'wiz.again': '다시 가져오기',
|
||||
'wiz.resultNote': '내용이 같은 파일은 자동으로 건너뛰며, 다시 실행해도 중복이 생기지 않습니다.',
|
||||
'wiz.importedToast': '워크플로 {n}건을 가져왔습니다',
|
||||
'wiz.warnTag': '경고',
|
||||
'wiz.confirmTag': '확인 필요',
|
||||
'wiz.preflightTag': '사전 검사 실패',
|
||||
'wiz.importedTag': '가져옴 #{id}',
|
||||
};
|
||||
|
||||
@@ -1773,11 +1773,11 @@ export const zh: Dict = {
|
||||
'workbench.pickerFootHint': '也可以在项目总览直接「从创意开始」或「导入剧本」进入工作台。',
|
||||
'workbench.stageProgress': '五段进度',
|
||||
'workbench.activeJobs': '{n} 个任务进行中',
|
||||
'workbench.stage.script': '剧本创作',
|
||||
'workbench.stage.style': '视觉设计',
|
||||
'workbench.stage.script': '故事构思',
|
||||
'workbench.stage.style': '视觉方向',
|
||||
'workbench.stage.storyboard': '分镜设计',
|
||||
'workbench.stage.assets': '素材资产',
|
||||
'workbench.stage.shot': '镜头脚本',
|
||||
'workbench.stage.assets': '素材创作',
|
||||
'workbench.stage.shot': '镜头制作',
|
||||
'workbench.lockedStep': '先完成并确认「{stage}」后解锁',
|
||||
'workbench.state.todo': '待开始',
|
||||
'workbench.state.running': '生成中',
|
||||
@@ -1900,8 +1900,8 @@ export const zh: Dict = {
|
||||
'home.stageProgress': '五段进度',
|
||||
'tasks.sourceWorkbench': '创作流程',
|
||||
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
|
||||
'jobst.done': '完成',
|
||||
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
|
||||
'jobst.done': '定影',
|
||||
'jobst.doneDetail': '生成完成,结果已定影保存',
|
||||
'jobst.dryRun': '占位结果',
|
||||
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
|
||||
'jobst.noOutput': '无产出',
|
||||
@@ -2001,6 +2001,15 @@ export const zh: Dict = {
|
||||
'desk.ctxDismiss': '关闭来源提示',
|
||||
'desk.ctxFrom': '来自《{project}》',
|
||||
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
|
||||
'desk.ctxBackfill': '生成完成后,可在结果卡「回流到源项目」把结果送回{stage}',
|
||||
'desk.attachToProject': '回流到源项目',
|
||||
'desk.attachHint': '把该结果回流到来源:{label}',
|
||||
'desk.attaching': '回流中…',
|
||||
'desk.attachedTag': '已回流',
|
||||
'desk.attachBack': '返回项目',
|
||||
'desk.attachDoneToast': '已回流到源项目,返回项目即可查看',
|
||||
'desk.attachAlreadyToast': '该结果已回流过,未产生重复',
|
||||
'desk.attachFailedToast': '回流失败:{detail}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': '下载',
|
||||
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
|
||||
@@ -2009,7 +2018,7 @@ export const zh: Dict = {
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '用时',
|
||||
'desk.emptyFailed': '没有失败的记录',
|
||||
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
|
||||
'desk.emptyFeed': '还没有记录。在右侧写下描述,点「生成」。',
|
||||
'desk.filterAria': '筛选记录',
|
||||
'desk.filterAll': '全部',
|
||||
'desk.filterImage': '图片',
|
||||
@@ -2054,7 +2063,7 @@ export const zh: Dict = {
|
||||
'desk.removeRecord': '移除记录',
|
||||
'desk.restoreOrig': '恢复原值',
|
||||
'desk.reuse': '复用',
|
||||
'desk.reuseToComposer': '复用到输入台',
|
||||
'desk.reuseToComposer': '复用创作参数',
|
||||
'desk.seedRandom': '随机',
|
||||
'desk.sendToSlot': '送入当前图片槽',
|
||||
'desk.sent': '已提交',
|
||||
@@ -2079,6 +2088,36 @@ export const zh: Dict = {
|
||||
'desk.wfUntested': '未试运行',
|
||||
'desk.workflow': '工作流',
|
||||
'desk.yesterday': '昨天',
|
||||
// --- 三段式生成台 (HEL-683) ---
|
||||
'desk.bandChange': "更换工作流",
|
||||
'desk.bayCloudBody': "云端生图 / 生视频模型(gpt-image2、nano-banana、即梦、可灵等)尚未接入本产品。接入后这里会按真实能力列出,不会展示无法使用的条目。",
|
||||
'desk.bayCloudTitle': "云端生成未接入",
|
||||
'desk.bayCount': "{n} / {total} 个工作流",
|
||||
'desk.bayFavOnly': "收藏",
|
||||
'desk.bayMore': "再显示 {n} 个",
|
||||
'desk.bayNoFamily': "未识别模型",
|
||||
'desk.bayPending': "{n} 个待确认(用途 / 输出未定)",
|
||||
'desk.bayReqAria': "输入要求",
|
||||
'desk.bayReqTitle': "输入要求",
|
||||
'desk.baySearchPh': "搜索名称、说明、模型、用途…",
|
||||
'desk.baySourceCloud': "云端",
|
||||
'desk.baySourceLabel': "生成来源",
|
||||
'desk.baySourceLocal': "本机",
|
||||
'desk.modeAll': "全部",
|
||||
'desk.modeEmpty': "该模式暂无可用工作流",
|
||||
'desk.modeRailAria': "任务模式",
|
||||
'desk.modeRailTitle': "任务模式",
|
||||
'desk.pvBackCreate': "返回创作",
|
||||
'desk.pvCurrent': "当前工作流",
|
||||
'desk.pvEmpty': "在左侧选择一个工作流查看输入要求",
|
||||
'desk.pvFav': "加入收藏",
|
||||
'desk.pvLastUsed': "最近使用",
|
||||
'desk.pvParamCount': "参数项",
|
||||
'desk.pvSlotsLoading': "正在读取输入槽位…",
|
||||
'desk.pvUnfav': "取消收藏",
|
||||
'desk.pvUse': "使用此工作流",
|
||||
'desk.railFav': "收藏",
|
||||
'desk.railRecent': "最近使用",
|
||||
'wfd.stepParse': '解析 JSON',
|
||||
'wfd.stepNodes': '识别节点',
|
||||
'wfd.stepModels': '核对模型',
|
||||
@@ -2095,4 +2134,136 @@ export const zh: Dict = {
|
||||
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
|
||||
'wfd.showRaw': '查看原始 JSON',
|
||||
'wfd.hideRaw': '收起原始 JSON',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': '批量导入 UI 格式工作流,搜索筛选与标注管理;完整 JSON 只在试运行等操作时按需加载',
|
||||
'wl.batchImport': '批量导入',
|
||||
'wl.dedupe': '去重',
|
||||
'wl.dedupeTitle': '清理重复工作流',
|
||||
'wl.dedupeConfirm': '将删除名称与内容完全相同的重复条目(保留最早导入的一条)。同名但内容不同的是你的变体,不会被动。确定执行?',
|
||||
'wl.dedupeDone': '已删除 {n} 个重复工作流',
|
||||
'wl.dedupeNone': '没有发现重复工作流',
|
||||
'wl.favCount': '{n} 个收藏',
|
||||
'wl.searchPh': '搜索名称、说明、标签、模型…(按 / 聚焦)',
|
||||
'wl.filterLabel': '筛选',
|
||||
'wl.filterAll': '全部',
|
||||
'wl.filterFav': '收藏',
|
||||
'wl.filterPending': '待确认',
|
||||
'wl.filterDisabled': '已停用',
|
||||
'wl.favAria': '收藏',
|
||||
'wl.unfavAria': '取消收藏',
|
||||
'wl.categoryLabel': '分类',
|
||||
'wl.categoryAll': '全部分类',
|
||||
'wl.familyLabel': '模型家族',
|
||||
'wl.familyAll': '全部模型',
|
||||
'wl.mediaLabel': '媒介',
|
||||
'wl.mediaAll': '全部媒介',
|
||||
'wl.mediaImage': '图像',
|
||||
'wl.mediaVideo': '视频',
|
||||
'wl.mediaAudio': '音频',
|
||||
'wl.mediaIn': '输入',
|
||||
'wl.mediaOut': '输出',
|
||||
'wl.sortLabel': '排序',
|
||||
'wl.sortRecent': '收藏 / 最近使用',
|
||||
'wl.sortName': '名称',
|
||||
'wl.resetFilters': '清除筛选',
|
||||
'wl.noMatch': '没有符合条件的工作流',
|
||||
'wl.resultCount': '匹配 {n} / {m}',
|
||||
'wl.selected': '已选 {n} 项',
|
||||
'wl.batchLabel': '批量操作',
|
||||
'wl.batchAnnotate': '批量标注',
|
||||
'wl.batchEnable': '启用',
|
||||
'wl.batchDisable': '停用',
|
||||
'wl.clearSel': '清除选择',
|
||||
'wl.batchDone': '已更新 {n} 项',
|
||||
'wl.batchPartial': '{n} 项成功,{m} 项失败(需先确认用途与输出映射)',
|
||||
'wl.selectAll': '选择当前列出的全部工作流',
|
||||
'wl.rowCheckAria': '选择 {name}',
|
||||
'wl.headMeta': '标注',
|
||||
'wl.headActions': '操作',
|
||||
'wl.enableAria': '启用或停用 {name}',
|
||||
'wl.enableBlocked': '需先确认用途并指定输出节点,才能启用',
|
||||
'wl.editMeta': '编辑标注',
|
||||
'wl.lastUsed': '最近使用',
|
||||
'wl.moreCount': '再显示 {n} 项(共 {m} 项)',
|
||||
'wfCat.basic': '基础处理',
|
||||
'wfCat.audioTts': '语音生成',
|
||||
'wfCat.music': '音乐生成',
|
||||
'wfCat.image': '图像生成',
|
||||
'wfCat.video': '视频生成',
|
||||
'wfCat.digitalHuman': '数字人',
|
||||
'wfCat.motionTransfer': '动作迁移',
|
||||
'md.title': '标注工作流',
|
||||
'md.batchTitle': '批量标注({n} 项)',
|
||||
'md.batchHint': '只有填写了的字段会被应用;显示名按「查找→替换」批量重命名,标签为追加。',
|
||||
'md.name': '显示名',
|
||||
'md.nameFind': '查找',
|
||||
'md.nameFindPh': '要替换的文字…',
|
||||
'md.nameReplace': '替换为',
|
||||
'md.nameReplacePh': '替换后的文字…',
|
||||
'md.nameHint': '留空「查找」则不改名。',
|
||||
'md.description': '说明',
|
||||
'md.descriptionPh': '一句话说明这个工作流做什么…',
|
||||
'md.tags': '标签',
|
||||
'md.tagsPh': '如:快速, 高质量',
|
||||
'md.tagsHint': '用逗号分隔;批量模式下为追加标签。',
|
||||
'md.category': '分类',
|
||||
'md.modelFamily': '模型家族',
|
||||
'md.apply': '应用',
|
||||
'md.applied': '已更新 {n} 项',
|
||||
'md.appliedOne': '已更新',
|
||||
'md.partial': '{n} 项成功,{m} 项失败',
|
||||
'wiz.title': '批量导入工作流',
|
||||
'wiz.stepSource': '选择来源',
|
||||
'wiz.stepReport': '预检报告',
|
||||
'wiz.stepResult': '导入结果',
|
||||
'wiz.sourceLabel': '导入来源',
|
||||
'wiz.modeScan': '扫描服务器目录',
|
||||
'wiz.modeFiles': '选择本地文件',
|
||||
'wiz.rootLabel': '服务器目录',
|
||||
'wiz.rootHint': '服务器上的目录路径(如仓库的 example_ComfyUI_workflows),将递归扫描其中的 ComfyUI 界面格式 JSON;API 格式与其他 JSON 自动跳过。',
|
||||
'wiz.preflight': '对真实 ComfyUI 预检(可选)',
|
||||
'wiz.preflightNone': '跳过(离线转换)',
|
||||
'wiz.preflightHint': '选择连接后,转换结果会对照该 ComfyUI 的节点与模型做对象级检查。',
|
||||
'wiz.pickFiles': '本地文件',
|
||||
'wiz.pickFilesBtn': '选择 JSON 文件(可多选)',
|
||||
'wiz.pickFilesHint': '选择 ComfyUI 界面格式(UI 格式)的工作流 JSON,可一次选多个。',
|
||||
'wiz.parsed': '已读取 {n} 个文件',
|
||||
'wiz.notUi': '{n} 个文件不是 UI 格式(缺少 nodes 数组),已忽略',
|
||||
'wiz.noFiles': '请先选择要导入的文件',
|
||||
'wiz.runDry': '开始预检(不写入)',
|
||||
'wiz.running': '预检中…',
|
||||
'wiz.total': '共 {n} 个文件',
|
||||
'wiz.ok': '正常 {n}',
|
||||
'wiz.warn': '警告 {n}',
|
||||
'wiz.fail': '失败 {n}',
|
||||
'wiz.confirmCount': '需人工确认 {n}',
|
||||
'wiz.importable': '可导入 {n}',
|
||||
'wiz.reportHint': '预检不写入数据库。warn 项可补充用途后以启用状态导入;「需人工确认」与转换失败项不会被导入,也不会被标为可运行。',
|
||||
'wiz.filterLabel': '报告筛选',
|
||||
'wiz.filterWarn': '仅看警告',
|
||||
'wiz.filterBlocked': '不可导入',
|
||||
'wiz.searchPh': '搜索文件名…',
|
||||
'wiz.includeAria': '导入 {name}',
|
||||
'wiz.skipReason': '不可导入:{reason}',
|
||||
'wiz.issues': '{n} 条转换提示',
|
||||
'wiz.usagePick': '补充用途:',
|
||||
'wiz.usageNone': '暂不补充(导入后待确认)',
|
||||
'wiz.selectedN': '将导入 {n} 个文件',
|
||||
'wiz.excludedM': '{m} 个不可导入',
|
||||
'wiz.import': '确认导入',
|
||||
'wiz.importing': '导入中…',
|
||||
'wiz.back': '上一步',
|
||||
'wiz.imported': '导入成功 {n}',
|
||||
'wiz.skipped': '跳过 {n}',
|
||||
'wiz.conflict': '已存在(重复导入自动跳过)',
|
||||
'wiz.usageApplied': '已为 {n} 个新导入项补充用途',
|
||||
'wiz.done': '完成',
|
||||
'wiz.again': '再导一批',
|
||||
'wiz.resultNote': '内容相同的重复文件会被自动跳过,重复执行不会产生重复条目。',
|
||||
'wiz.importedToast': '已导入 {n} 个工作流',
|
||||
'wiz.warnTag': '警告',
|
||||
'wiz.confirmTag': '需人工确认',
|
||||
'wiz.preflightTag': '预检未通过',
|
||||
'wiz.importedTag': '已导入 #{id}',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Public reference imagery retained for the user-requested interface reconstruction.
|
||||
* Source: https://cn.lk666.ai/home?menu=zhinengti (story agent), 2026-10-14.
|
||||
* No account imagery, private history, authentication data, or remote calls. */
|
||||
export const agentStyles = [
|
||||
{
|
||||
name: '韩漫唯美',
|
||||
image: 'hanmanweimei.jpg',
|
||||
prompt: '细腻唯美的韩漫风格,柔和光线与人物情绪',
|
||||
},
|
||||
{
|
||||
name: '日系动画',
|
||||
image: 'rixidonghua.jpg',
|
||||
prompt: '日系动画风格,清晰线条与富有层次的色彩',
|
||||
},
|
||||
{
|
||||
name: '纯正国风',
|
||||
image: 'chunzhengguofeng.jpg',
|
||||
prompt: '国风水墨意境,传统色彩与写意构图',
|
||||
},
|
||||
{
|
||||
name: '激燃极光',
|
||||
image: 'jiranjiguang.jpg',
|
||||
prompt: '强烈动感、鲜明对比与戏剧化光效',
|
||||
},
|
||||
{
|
||||
name: '3D盲盒',
|
||||
image: '3dmanghe.jpg',
|
||||
prompt: '可爱的三维盲盒人物,圆润造型与柔和材质',
|
||||
},
|
||||
{
|
||||
name: '趣味黏土',
|
||||
image: 'quweiniantu.jpg',
|
||||
prompt: '手工黏土质感,温暖色彩与趣味造型',
|
||||
},
|
||||
{
|
||||
name: '美漫硬朗',
|
||||
image: 'meimanyinglang.jpg',
|
||||
prompt: '硬朗美漫风格,强烈阴影与有力线条',
|
||||
},
|
||||
{
|
||||
name: '写实画报',
|
||||
image: 'xieshihuabao.jpg',
|
||||
prompt: '写实电影画报风格,自然质感与精致光线',
|
||||
},
|
||||
{
|
||||
name: '治愈绘本',
|
||||
image: 'zhiyuhuiben.jpg',
|
||||
prompt: '温暖治愈的手绘绘本,柔和色彩与亲切人物',
|
||||
},
|
||||
{
|
||||
name: '冒险动漫',
|
||||
image: 'haizeiwangfengge.jpg',
|
||||
prompt: '明快的冒险动漫风格,夸张表情与充满活力的画面',
|
||||
},
|
||||
];
|
||||
export const agentThemes: Record<
|
||||
string,
|
||||
{ title: string; hint: string; color: string; hue: number }
|
||||
> = {
|
||||
script: {
|
||||
title: '把一句灵感,\n写成一个好故事。',
|
||||
hint: '打磨人物、冲突与故事节奏',
|
||||
color: '#f5b042',
|
||||
hue: 0,
|
||||
},
|
||||
style: {
|
||||
title: '让脑海里的画面,\n拥有自己的风格。',
|
||||
hint: '确定画风、色彩与光线',
|
||||
color: '#cba0ff',
|
||||
hue: 20,
|
||||
},
|
||||
storyboard: {
|
||||
title: '把故事的每一刻,\n放进恰当的镜头。',
|
||||
hint: '拆解构图、动作与镜头节奏',
|
||||
color: '#73ceee',
|
||||
hue: 85,
|
||||
},
|
||||
assets: {
|
||||
title: '人物、场景与细节,\n一起走进作品里。',
|
||||
hint: '整理角色、场景与参考素材',
|
||||
color: '#69d4bc',
|
||||
hue: 145,
|
||||
},
|
||||
shot: {
|
||||
title: '让静止的想象,\n有了流动的瞬间。',
|
||||
hint: '镜头提示词、图片与视频工具',
|
||||
color: '#ff95b0',
|
||||
hue: 300,
|
||||
},
|
||||
};
|
||||
@@ -85,7 +85,7 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 74px;
|
||||
}
|
||||
.label {
|
||||
@@ -108,7 +108,7 @@
|
||||
.open {
|
||||
display: block;
|
||||
position: relative;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -128,8 +128,8 @@
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
pointer-events: none;
|
||||
@@ -153,7 +153,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
.danger {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
.empty {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -133,10 +133,10 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.danger {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
.add-grid {
|
||||
display: grid;
|
||||
@@ -145,7 +145,7 @@
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
.add-item {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -154,7 +154,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.objects {
|
||||
flex: 1;
|
||||
@@ -171,10 +171,10 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.row.selected {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
@@ -217,8 +217,8 @@
|
||||
}
|
||||
.rename {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
@@ -233,6 +233,6 @@
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.45;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
section {
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.collapse {
|
||||
@@ -257,8 +257,8 @@
|
||||
input[type='number'],
|
||||
select {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
@@ -279,7 +279,7 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.joint {
|
||||
border: 1px solid var(--border, #232733);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
@@ -298,8 +298,8 @@
|
||||
}
|
||||
.capture {
|
||||
margin-top: auto;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
@@ -307,6 +307,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.capture:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.item.active {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
@@ -265,7 +265,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.run {
|
||||
background: var(--accent, #6366f1);
|
||||
background: var(--run);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
@@ -295,6 +295,6 @@
|
||||
display: flex;
|
||||
}
|
||||
.del:hover {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -841,7 +841,7 @@ let exporting = false;
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
background: #14161c;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
}
|
||||
.viewport :global(canvas) {
|
||||
@@ -866,7 +866,7 @@ let exporting = false;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--on-media) 14%, transparent);
|
||||
}
|
||||
.composition-guide .thirds-v:nth-of-type(1) {
|
||||
left: 33.33%;
|
||||
@@ -879,7 +879,7 @@ let exporting = false;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--on-media) 14%, transparent);
|
||||
}
|
||||
.composition-guide .thirds-h:nth-of-type(3) {
|
||||
top: 33.33%;
|
||||
@@ -894,7 +894,7 @@ let exporting = false;
|
||||
width: 14px;
|
||||
height: 1px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: color-mix(in srgb, var(--on-media) 35%, transparent);
|
||||
}
|
||||
.composition-guide .cross-v {
|
||||
position: absolute;
|
||||
@@ -903,6 +903,6 @@ let exporting = false;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: color-mix(in srgb, var(--on-media) 35%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
@@ -207,7 +207,7 @@
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -216,7 +216,7 @@
|
||||
padding: 0 6px;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -227,10 +227,10 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.obj-add {
|
||||
color: #7dd88a;
|
||||
color: var(--ok);
|
||||
}
|
||||
.export {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -240,7 +240,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export:hover:not(:disabled) {
|
||||
border-color: var(--accent, #6366f1);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.export:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -255,8 +255,8 @@
|
||||
}
|
||||
.dur input {
|
||||
width: 46px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
@@ -280,14 +280,14 @@
|
||||
position: absolute;
|
||||
inset: 11px 0;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
background: var(--accent, #6366f1);
|
||||
background: var(--accent);
|
||||
transform: translateX(-1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -298,8 +298,8 @@
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
transform: translate(-5px, -50%) rotate(45deg);
|
||||
background: #e0b34d;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
background: var(--warn);
|
||||
border: 1px solid var(--bg);
|
||||
cursor: grab;
|
||||
padding: 0;
|
||||
z-index: 2;
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: rgba(16, 18, 24, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg-2) 85%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -103,7 +103,7 @@
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border, #2a2f3a);
|
||||
background: var(--border);
|
||||
margin: 0 2px;
|
||||
}
|
||||
.btn {
|
||||
@@ -119,12 +119,12 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn.on {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: var(--accent, #6366f1);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-line);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn:disabled {
|
||||
@@ -132,9 +132,9 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.capture {
|
||||
border-color: var(--border, #2a2f3a);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.capture:hover {
|
||||
border-color: var(--accent, #6366f1);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** Approved HEL-726 themes. Colours live only in static/themes.css. */
|
||||
export const THEMES = [
|
||||
{
|
||||
id: "night",
|
||||
name: "夜片场",
|
||||
english: "Nocturne",
|
||||
description: "墨黑与钨丝金 · 沉浸式创作",
|
||||
},
|
||||
{
|
||||
id: "day",
|
||||
name: "日光棚",
|
||||
english: "Daylight",
|
||||
description: "暖白纸面 · 白天办公与长时间审稿",
|
||||
},
|
||||
{
|
||||
id: "grade",
|
||||
name: "调色间",
|
||||
english: "Grading",
|
||||
description: "中性深灰 · 判断作品色彩",
|
||||
},
|
||||
{
|
||||
id: "bluehour",
|
||||
name: "蓝调时刻",
|
||||
english: "Blue Hour",
|
||||
description: "靛蓝与珊瑚 · 提案与灵感发散",
|
||||
},
|
||||
] as const;
|
||||
export type ThemeId = (typeof THEMES)[number]["id"];
|
||||
export const THEME_KEY = "aigc-theme";
|
||||
export const theme = writable<ThemeId>("night");
|
||||
export const themeStorageUnavailable = writable(false);
|
||||
|
||||
export function isTheme(value: unknown): value is ThemeId {
|
||||
return THEMES.some((item) => item.id === value);
|
||||
}
|
||||
|
||||
export function setTheme(id: ThemeId) {
|
||||
if (typeof document === "undefined" || !isTheme(id)) return;
|
||||
// Only the root attribute changes: editors, uploads and jobs stay mounted.
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, id);
|
||||
themeStorageUnavailable.set(false);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const current = document.documentElement.dataset.theme;
|
||||
theme.set(isTheme(current) ? current : "night");
|
||||
try {
|
||||
localStorage.getItem(THEME_KEY);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
const sync = (event: StorageEvent) => {
|
||||
if (event.key !== THEME_KEY && event.key !== null) return;
|
||||
const id = isTheme(event.newValue) ? event.newValue : "night";
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
};
|
||||
window.addEventListener("storage", sync);
|
||||
return () => window.removeEventListener("storage", sync);
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
import { initReduceMotion } from '$lib/reduceMotion.svelte';
|
||||
import "./app.css";
|
||||
import { initTheme } from "$lib/theme";
|
||||
import { onMount } from "svelte";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
|
||||
import ToastHost from "$lib/components/ToastHost.svelte";
|
||||
import { initReduceMotion } from "$lib/reduceMotion.svelte";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => initReduceMotion());
|
||||
onMount(() => initReduceMotion());
|
||||
onMount(initTheme);
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<slot />
|
||||
<ToastHost />
|
||||
<slot />
|
||||
<ToastHost />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -4,8 +4,21 @@
|
||||
// there is nothing yet, honest Chinese empty states are shown instead of
|
||||
// the sample progress rendered in the mockup.
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, projects, workbenchApi, workflows, type Job, type Project } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/svelte-query';
|
||||
import {
|
||||
jobsApi,
|
||||
projects,
|
||||
workbenchApi,
|
||||
workflows,
|
||||
type Job,
|
||||
type Project,
|
||||
} from '$lib/api';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AProjectCard from '$lib/components/home/AProjectCard.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
@@ -32,7 +45,10 @@
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
const workflowsQuery = createQuery({
|
||||
queryKey: ['workflows-meta'],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
|
||||
const allProjects = $derived($projectsQuery.data ?? []);
|
||||
// 进行中 = started the five-stage flow but not all five confirmed yet.
|
||||
@@ -42,7 +58,9 @@
|
||||
if (!st || st.stage_confirmed >= 5) return false;
|
||||
return (
|
||||
st.stage_confirmed > 0 ||
|
||||
Object.values(st.stage_status ?? {}).some((v) => v === 'running' || v === 'awaiting_confirm')
|
||||
Object.values(st.stage_status ?? {}).some(
|
||||
(v) => v === 'running' || v === 'awaiting_confirm',
|
||||
)
|
||||
);
|
||||
}).length,
|
||||
);
|
||||
@@ -72,7 +90,13 @@
|
||||
'Gritty, realistic',
|
||||
'Epic, sweeping',
|
||||
];
|
||||
const DURATIONS = ['30 seconds', '1 minute', '2 minutes', '5 minutes', '10 minutes'];
|
||||
const DURATIONS = [
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'2 minutes',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
];
|
||||
|
||||
let ideaForm = $state({
|
||||
title: '',
|
||||
@@ -84,6 +108,9 @@
|
||||
let scriptForm = $state({ title: '', script: '' });
|
||||
let showErrors = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (page.url.searchParams.get('new') === '1') openIdea();
|
||||
});
|
||||
function openIdea() {
|
||||
entryMode = 'idea';
|
||||
showErrors = false;
|
||||
@@ -114,7 +141,8 @@
|
||||
const errs: Record<string, string> = {};
|
||||
if (!scriptForm.title.trim()) errs.title = t('home.errTitle');
|
||||
if (!scriptForm.script.trim()) errs.script = t('home.errScript');
|
||||
else if (scriptForm.script.trim().length < 30) errs.script = t('home.errScriptShort');
|
||||
else if (scriptForm.script.trim().length < 30)
|
||||
errs.script = t('home.errScriptShort');
|
||||
return errs;
|
||||
});
|
||||
|
||||
@@ -128,7 +156,10 @@
|
||||
toast.success(t('projects.created', { title: p.title }));
|
||||
// Import the pasted script as the confirmed 剧本 baseline, then
|
||||
// continue from 视觉设计 (导入剧本可跳过剧本创作).
|
||||
$importScriptMutation.mutate({ projectId: p.id, text: scriptForm.script.trim() });
|
||||
$importScriptMutation.mutate({
|
||||
projectId: p.id,
|
||||
text: scriptForm.script.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const importScriptMutation = createMutation({
|
||||
@@ -139,7 +170,9 @@
|
||||
goto(`/workbench/${args.projectId}?stage=style`);
|
||||
},
|
||||
onError: (err, args) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projects.createFailed'),
|
||||
);
|
||||
// Never strand the user: open the workbench at 剧本创作 so the
|
||||
// text can be pasted/edited there even if the import failed.
|
||||
if (args) goto(`/workbench/${args.projectId}?stage=script`);
|
||||
@@ -157,7 +190,9 @@
|
||||
}),
|
||||
onSuccess: onIdeaCreated,
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projects.createFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -169,7 +204,9 @@
|
||||
}),
|
||||
onSuccess: onScriptCreated,
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projects.createFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -191,7 +228,9 @@
|
||||
// ------------------------------------------------------------------
|
||||
const recentJobs = $derived(($jobsQuery.data ?? []).slice(0, 10));
|
||||
const activeJobCount = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.status === 'pending' || j.status === 'running').length,
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.status === 'pending' || j.status === 'running',
|
||||
).length,
|
||||
);
|
||||
|
||||
const projectTitles = $derived.by(() => {
|
||||
@@ -207,7 +246,9 @@
|
||||
toast.success(t('home.jobRequeued'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('home.jobRetryFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('home.jobRetryFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,7 +261,8 @@
|
||||
// a1 task row: 「本机 · 生图」 tag + the project (or workflow) name.
|
||||
function jobTag(job: Job): { cls: string; label: string } {
|
||||
const kind = KIND_KEY[job.kind] ? t(KIND_KEY[job.kind]) : job.kind;
|
||||
const url = (job.payload?.endpoint as { base_url?: string } | undefined)?.base_url;
|
||||
const url = (job.payload?.endpoint as { base_url?: string } | undefined)
|
||||
?.base_url;
|
||||
if (!url) return { cls: 'tag-local', label: kind };
|
||||
const where = classifyEndpoint(url);
|
||||
return {
|
||||
@@ -230,25 +272,41 @@
|
||||
}
|
||||
|
||||
function workflowName(job: Job): string {
|
||||
return ($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ?? '';
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)
|
||||
?.name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(job: Job): string {
|
||||
const project = isStandaloneJob(job) ? undefined : projectTitles.get(job.project_id);
|
||||
return project ? `《${project}》` : workflowName(job) || jobSourceLabel(job);
|
||||
const project = isStandaloneJob(job)
|
||||
? undefined
|
||||
: projectTitles.get(job.project_id);
|
||||
return project
|
||||
? `《${project}》`
|
||||
: workflowName(job) || jobSourceLabel(job);
|
||||
}
|
||||
|
||||
function jobMeta(job: Job): string {
|
||||
const wf = workflowName(job);
|
||||
const parts = [wf === jobTitle(job) ? '' : wf, jobSourceLabel(job), formatJobTime(job.created_at)];
|
||||
if (job.status === 'failed' && job.error && job.error !== 'cancelled') parts.push(job.error);
|
||||
const parts = [
|
||||
wf === jobTitle(job) ? '' : wf,
|
||||
jobSourceLabel(job),
|
||||
formatJobTime(job.created_at),
|
||||
];
|
||||
if (job.status === 'failed' && job.error && job.error !== 'cancelled')
|
||||
parts.push(job.error);
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function jobBar(job: Job): { value: number; tone: 'run' | 'success' | 'error' } {
|
||||
function jobBar(job: Job): {
|
||||
value: number;
|
||||
tone: 'run' | 'success' | 'error';
|
||||
} {
|
||||
if (job.status === 'done') return { value: 100, tone: 'success' };
|
||||
if (job.status === 'failed') return { value: 100, tone: 'error' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
if (job.status === 'running')
|
||||
return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
return { value: 0, tone: 'run' };
|
||||
}
|
||||
|
||||
@@ -263,7 +321,10 @@
|
||||
function formatJobTime(raw: string): string {
|
||||
const ts = parseDbTs(raw);
|
||||
if (!Number.isFinite(ts)) return '';
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// English values are the backend contract; display labels come from the
|
||||
@@ -302,9 +363,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="projects" title={t('home.title')} subtitle={t('home.subtitle')}>
|
||||
<AppShell active="projects" title="概览" subtitle={t('home.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
|
||||
<span class="status-chip"
|
||||
>{t('home.projectCount', {
|
||||
n: allProjects.length,
|
||||
m: inProgressCount,
|
||||
})}</span
|
||||
>
|
||||
<button
|
||||
class="a-btn"
|
||||
@@ -320,24 +385,49 @@
|
||||
<div class="home">
|
||||
<section class="main-col">
|
||||
{#if entryMode === 'none'}
|
||||
<div class="entries">
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
|
||||
<h2>{t('home.entryIdeaTitle')}</h2>
|
||||
<p>{t('home.entryIdeaBody')}</p>
|
||||
<button type="button" class="a-btn secondary full" onclick={openIdea}>
|
||||
{t('home.startIdea')}
|
||||
</button>
|
||||
</article>
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="script" size={18} /></span>
|
||||
<h2>{t('home.entryScriptTitle')}</h2>
|
||||
<p>{t('home.entryScriptBody')}</p>
|
||||
<button type="button" class="a-btn secondary full" onclick={openScript}>
|
||||
{t('home.importScript')}
|
||||
</button>
|
||||
</article>
|
||||
<div class="refract-greeting">
|
||||
<h2>欢迎回到工作室。</h2>
|
||||
<span>给今天的想象,留一点空间。</span>
|
||||
</div>
|
||||
<section class="studio-start">
|
||||
<div>
|
||||
<small>从这里开始今天的创作</small>
|
||||
<h1>让想法,成为下一件作品。</h1>
|
||||
<p>直接生成图片与视频,或与智能体一起推进项目。</p>
|
||||
<div class="start-actions">
|
||||
<button class="a-btn gold" onclick={openIdea}>+ 新建项目</button
|
||||
><button class="a-btn secondary" onclick={openScript}
|
||||
>导入剧本</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<img src="/refract/coast.jpg" alt="海岸光线与画面参考" />
|
||||
</section>
|
||||
{#if allProjects.length}{@const recent = allProjects[0]}<a
|
||||
class="resume-project"
|
||||
href={'/workbench/' + recent.id}
|
||||
><Icon name="folder" size={22} />
|
||||
<div>
|
||||
<small>继续最近的项目</small><strong>{recent.title}</strong>
|
||||
</div>
|
||||
<span>{recent.stats?.stage_confirmed ?? 0} / 5 阶段已确认</span><b
|
||||
>继续创作 →</b
|
||||
></a
|
||||
>{/if}
|
||||
<section class="refract-tools" aria-label="创作工具">
|
||||
{#each [{ href: '/image', icon: 'image', title: '图片创作', desc: '从文字、参考图到新画面' }, { href: '/video', icon: 'film', title: '视频创作', desc: '首尾帧、参考视频与运镜' }, { href: '/canvas', icon: 'story', title: '灵感画布', desc: '把零散的想法连接起来' }, { href: '/agents', icon: 'sparkle', title: '智能体', desc: '让专业搭档加入创作' }] as tool}
|
||||
<a href={tool.href}
|
||||
><span class="tool-icon"
|
||||
><Icon
|
||||
name={tool.icon as 'image' | 'film' | 'story' | 'sparkle'}
|
||||
size={20}
|
||||
/></span
|
||||
><span
|
||||
><strong>{tool.title}</strong><small>{tool.desc}</small></span
|
||||
><span class="tool-arrow">↗</span></a
|
||||
>
|
||||
{/each}
|
||||
</section>
|
||||
{:else if entryMode === 'idea'}
|
||||
<form class="entry-form" onsubmit={submitIdea}>
|
||||
<div class="form-head">
|
||||
@@ -352,14 +442,17 @@
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span
|
||||
>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={showErrors && ideaErrors.title}
|
||||
bind:value={ideaForm.title}
|
||||
placeholder={t('projects.titlePlaceholder')}
|
||||
/>
|
||||
{#if showErrors && ideaErrors.title}<span class="field-error">{ideaErrors.title}</span>{/if}
|
||||
{#if showErrors && ideaErrors.title}<span class="field-error"
|
||||
>{ideaErrors.title}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.ideaField')}</span>
|
||||
@@ -375,14 +468,17 @@
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.genre}>
|
||||
{#each GENRES as v (v)}<option value={v}>{t('genres.' + genreKey(v))}</option
|
||||
{#each GENRES as v (v)}<option value={v}
|
||||
>{t('genres.' + genreKey(v))}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.tone}>
|
||||
{#each TONES as v (v)}<option value={v}>{t('tones.' + toneKey(v))}</option>{/each}
|
||||
{#each TONES as v (v)}<option value={v}
|
||||
>{t('tones.' + toneKey(v))}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
@@ -395,8 +491,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
|
||||
<Button variant="primary" type="submit" loading={$ideaMutation.isPending}>
|
||||
<Button variant="ghost" onclick={closeEntry}
|
||||
>{t('projects.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={$ideaMutation.isPending}
|
||||
>
|
||||
{t('projects.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -415,14 +517,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span
|
||||
>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={showErrors && scriptErrors.title}
|
||||
bind:value={scriptForm.title}
|
||||
placeholder={t('projects.titlePlaceholder')}
|
||||
/>
|
||||
{#if showErrors && scriptErrors.title}<span class="field-error">{scriptErrors.title}</span
|
||||
{#if showErrors && scriptErrors.title}<span class="field-error"
|
||||
>{scriptErrors.title}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
@@ -434,13 +538,20 @@
|
||||
placeholder={t('home.scriptPlaceholder')}
|
||||
rows={9}
|
||||
></textarea>
|
||||
{#if showErrors && scriptErrors.script}<span class="field-error">{scriptErrors.script}</span
|
||||
{#if showErrors && scriptErrors.script}<span class="field-error"
|
||||
>{scriptErrors.script}</span
|
||||
>{/if}
|
||||
<span class="field-hint">{t('home.scriptHint')}</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
|
||||
<Button variant="primary" type="submit" loading={$scriptMutation.isPending}>
|
||||
<Button variant="ghost" onclick={closeEntry}
|
||||
>{t('projects.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={$scriptMutation.isPending}
|
||||
>
|
||||
{t('home.importScript')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -448,7 +559,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="projects-head">
|
||||
<h2>{t('home.myProjects')}</h2>
|
||||
<h2>最近的创作</h2>
|
||||
<a class="refract-all" href="/workbench">全部项目 →</a>
|
||||
{#if allProjects.length > 0}
|
||||
<span class="projects-hint">{t('home.myProjectsHint')}</span>
|
||||
{/if}
|
||||
@@ -483,7 +595,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cards">
|
||||
{#each allProjects as project (project.id)}
|
||||
{#each allProjects.slice(0, 3) as project (project.id)}
|
||||
<AProjectCard {project} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -491,12 +603,12 @@
|
||||
</section>
|
||||
|
||||
<aside class="tasks">
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="status-chip" class:run-chip={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="status-chip" class:run-chip={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $jobsQuery.isLoading}
|
||||
<div class="tasks-loading">{t('common.loading')}…</div>
|
||||
@@ -514,10 +626,14 @@
|
||||
<li class="job-row" class:failed={job.status === 'failed'}>
|
||||
<div class="job-top">
|
||||
<span class="a-tag">{tag.label}</span>
|
||||
<span class="job-name" title={jobTitle(job)}>{jobTitle(job)}</span>
|
||||
<span class="job-name" title={jobTitle(job)}
|
||||
>{jobTitle(job)}</span
|
||||
>
|
||||
<span class="job-side">
|
||||
{#if job.status === 'failed' && job.error !== 'cancelled'}
|
||||
<span class="job-status mono failed-text">{jobStatusLabel(job.status)}</span>
|
||||
<span class="job-status mono failed-text"
|
||||
>{jobStatusLabel(job.status)}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="retry-link"
|
||||
@@ -533,8 +649,12 @@
|
||||
? 'running-text'
|
||||
: 'queued-text'}"
|
||||
>
|
||||
{#if job.status === 'running'}<span class="dot breathe" aria-hidden="true"
|
||||
></span>{Math.round(bar.value)}%{:else}{jobStatusLabel(job.status)}{/if}
|
||||
{#if job.status === 'running'}<span
|
||||
class="dot breathe"
|
||||
aria-hidden="true"
|
||||
></span>{Math.round(bar.value)}%{:else}{jobStatusLabel(
|
||||
job.status,
|
||||
)}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -554,9 +674,9 @@
|
||||
<style>
|
||||
.home {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 344px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px 28px 40px;
|
||||
padding: 28px 36px 56px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -571,52 +691,6 @@
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ---------------- topbar chip + buttons ---------------- */
|
||||
.a-btn.full {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ---------------- entry cards / forms (a1 双入口) ---------------- */
|
||||
.entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 18px var(--sp-7) var(--sp-7);
|
||||
min-height: 168px;
|
||||
}
|
||||
.entry-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.entry-card h2 {
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-card p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-body-sm);
|
||||
line-height: 1.65;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
@@ -912,9 +986,6 @@
|
||||
.tasks {
|
||||
position: static;
|
||||
}
|
||||
.entries {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.f-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@@ -924,4 +995,210 @@
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-8);
|
||||
}
|
||||
}
|
||||
|
||||
.refract-greeting {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.refract-greeting h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.refract-greeting span {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.refract-tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin: -4px 0 8px;
|
||||
}
|
||||
.refract-tools a {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
gap: 13px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.tool-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #00cae016;
|
||||
color: var(--accent);
|
||||
}
|
||||
.refract-tools a:nth-child(2) .tool-icon {
|
||||
background: #f5b04216;
|
||||
color: #f5b042;
|
||||
}
|
||||
.refract-tools a:nth-child(3) .tool-icon {
|
||||
background: #7850ff16;
|
||||
color: #a78bfa;
|
||||
}
|
||||
.refract-tools a:nth-child(4) .tool-icon {
|
||||
background: #10b98116;
|
||||
color: #55d4ad;
|
||||
}
|
||||
.refract-tools strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.refract-tools small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tool-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.refract-all {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tasks {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: none;
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
.job-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.tasks-note {
|
||||
display: none;
|
||||
}
|
||||
.home .cards {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.main-col {
|
||||
gap: 24px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.refract-tools a:hover {
|
||||
background: #ffffff06;
|
||||
}
|
||||
}
|
||||
|
||||
.home {
|
||||
max-width: none;
|
||||
padding: 24px 28px 30px;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 24px;
|
||||
}
|
||||
.studio-start {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #16161b;
|
||||
min-height: 210px;
|
||||
}
|
||||
.studio-start > div {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.studio-start small {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.studio-start h1 {
|
||||
font-size: 27px;
|
||||
font-weight: 550;
|
||||
margin: 12px 0;
|
||||
letter-spacing: -0.8px;
|
||||
}
|
||||
.studio-start p {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.studio-start img {
|
||||
width: 38%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.start-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.resume-project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 15px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
background: #16161b;
|
||||
}
|
||||
.resume-project small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.resume-project strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.resume-project > span {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.resume-project b {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
.refract-tools {
|
||||
gap: 10px;
|
||||
}
|
||||
.refract-tools a {
|
||||
padding: 12px;
|
||||
}
|
||||
.cards {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.tasks {
|
||||
padding: 16px;
|
||||
background: #16161b;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.home {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 20px;
|
||||
}
|
||||
.refract-tools {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.studio-start h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.tasks {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+747
-686
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user