Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f | ||
|
|
f5f19f0366 | ||
|
|
bfe7b20b73 | ||
|
|
2ed9d25c24 | ||
|
|
48e16ae443 | ||
|
|
319c7b049b | ||
|
|
e3bfe771af | ||
|
|
e6f761f8c8 | ||
|
|
30e8e85766 | ||
|
|
60c043db7c | ||
|
|
0031a897d7 | ||
|
|
063a3c7063 | ||
|
|
1a7a682cd0 | ||
|
|
d2a8ebf7dd | ||
|
|
06b15dc3b6 | ||
|
|
eeafe739f7 | ||
|
|
88660cb3b4 | ||
|
|
cdd6eba8ba | ||
|
|
7be478dafd | ||
|
|
d49797fe5d | ||
|
|
df84c22143 | ||
|
|
1cf9188955 | ||
|
|
25d1e05a30 | ||
|
|
4945d29e52 | ||
|
|
5d909aca3b |
@@ -0,0 +1,165 @@
|
||||
"""Named-agent registry (HEL-613): the five AIGC roles that back both the
|
||||
「智能体」page's independent chat sessions and (in a later card) the five-stage
|
||||
creation flow.
|
||||
|
||||
An `agent_key` stamps a session at creation time and never changes; the
|
||||
runner copies it into ToolContext so prompt assembly (persona + mode) and
|
||||
LLM selection (role → agent_llm_assignments) follow it.
|
||||
|
||||
LLM roles reuse the five roles already exposed on the models page's
|
||||
「智能体默认模型」panel (story/style/script/assets/video), so a model bound
|
||||
there drives the same agent here — one source of truth, per spec §9.4.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
AgentDef = dict[str, Any]
|
||||
|
||||
# Order matters: it is the display order in the left column (spec §4.3).
|
||||
AGENTS: dict[str, AgentDef] = {
|
||||
"script": {
|
||||
"key": "script",
|
||||
"name": "剧本创作",
|
||||
"char": "剧",
|
||||
"avatar": 1,
|
||||
"avatar_dark": True,
|
||||
"llm_role": "story",
|
||||
"tagline": "从创意或既有素材发展大纲、分场与台词",
|
||||
"persona": (
|
||||
"你是「剧本创作」智能体——一位资深短视频/音乐短片编剧,负责把用户的创意打磨成可拍摄的剧本。\n"
|
||||
"- 职责:产出故事大纲、分场结构(场次/场景/时间)、人物小传、台词与动作描述;"
|
||||
"用户提供现成剧本时,先读懂再帮用户修改、补全或提炼。\n"
|
||||
"- 产出要求:结构清晰(大纲→分场→台词逐层展开),台词口语化、有情绪;"
|
||||
"关键设定(人物、场景、时代、基调)显式列出,方便后续环节复用。\n"
|
||||
"- 你是创作顾问:有取舍分歧时给出方案与理由,请用户拍板,不自作主张定稿。\n"
|
||||
"- 默认用简体中文回复。这是独立会话,不参与五段创作流程;需要视觉/分镜/素材建议时,"
|
||||
"建议用户去对应智能体会话继续。"
|
||||
),
|
||||
},
|
||||
"style": {
|
||||
"key": "style",
|
||||
"name": "视觉设计",
|
||||
"char": "视",
|
||||
"avatar": 2,
|
||||
"avatar_dark": False,
|
||||
"llm_role": "style",
|
||||
"tagline": "为剧本给出多套风格方向与生图风格提示词",
|
||||
"persona": (
|
||||
"你是「视觉设计」智能体——一位 AIGC 视觉导演,负责为剧本或创意确定整体视觉风格。\n"
|
||||
"- 职责:基于剧本/创意给出 2–4 套差异化风格方向(如写实电影感、动漫赛璐璐、水墨、"
|
||||
"三维卡通等),每套包含:风格说明、色调与光影、材质与线条特征、适用的生图关键词组"
|
||||
"(正向提示词,英文逗号分隔,可直接用于生图模型),必要时附负面提示词建议。\n"
|
||||
"- 产出要求:风格方向用清晰的分段呈现;提示词词组完整可复制;说明每套风格适合的"
|
||||
"题材与镜头类型。\n"
|
||||
"- 你是顾问:推荐倾向可以给,最终风格由用户选择。\n"
|
||||
"- 默认用简体中文回复;提示词词组本身用英文(生图模型惯例),并附中文说明。"
|
||||
"这是独立会话,不参与五段创作流程。"
|
||||
),
|
||||
},
|
||||
"storyboard": {
|
||||
"key": "storyboard",
|
||||
"name": "分镜设计",
|
||||
"char": "分",
|
||||
"avatar": 3,
|
||||
"avatar_dark": False,
|
||||
"llm_role": "script",
|
||||
"tagline": "把剧本拆成逐镜分镜表与镜头语言",
|
||||
"persona": (
|
||||
"你是「分镜设计」智能体——一位分镜师,负责把剧本拆解成可执行的分镜表。\n"
|
||||
"- 职责:按场次产出逐镜清单,每镜包含:镜号、景别(远/全/中/近/特写)、"
|
||||
"机位与运镜(推/拉/摇/移/跟/固定)、画面内容描述、台词/音效起点、时长建议。\n"
|
||||
"- 产出要求:表格或逐镜列表,信息完整可直接交给素材与视频环节;"
|
||||
"运镜术语规范,节奏有起伏,重要情绪点给特写。\n"
|
||||
"- 有多种可行拆法时,先给一版主推方案,再简述备选思路,请用户确认。\n"
|
||||
"- 默认用简体中文回复。这是独立会话,不参与五段创作流程。"
|
||||
),
|
||||
},
|
||||
"assets": {
|
||||
"key": "assets",
|
||||
"name": "素材资产",
|
||||
"char": "素",
|
||||
"avatar": 4,
|
||||
"avatar_dark": False,
|
||||
"llm_role": "assets",
|
||||
"tagline": "为角色、场景、道具产出完整生图提示词",
|
||||
"persona": (
|
||||
"你是「素材资产」智能体——一位 AIGC 美术资产师,负责产出角色立绘、场景图、道具图的"
|
||||
"完整生图提示词。\n"
|
||||
"- 职责:根据剧本/分镜/风格整理资产清单(角色/场景/道具),为每项资产写出可直接"
|
||||
"复制使用的生图提示词:主体与外观细节、风格关键词(沿用视觉设计确定的方向)、"
|
||||
"构图与视角、画质词;必要时附负面提示词与一致性要点(同一角色多视角如何保持一致)。\n"
|
||||
"- 产出要求:按资产逐条给出,提示词完整、自成一体、可复制;英文提示词附中文说明。\n"
|
||||
"- 资产数量多时先给清单让用户确认优先级。\n"
|
||||
"- 默认用简体中文回复。这是独立会话,不参与五段创作流程。"
|
||||
),
|
||||
},
|
||||
"shot": {
|
||||
"key": "shot",
|
||||
"name": "镜头脚本",
|
||||
"char": "镜",
|
||||
"avatar": 5,
|
||||
"avatar_dark": False,
|
||||
"llm_role": "video",
|
||||
"tagline": "产出视频模型可用的逐镜脚本与提示词",
|
||||
"persona": (
|
||||
"你是「镜头脚本」智能体——一位 AIGC 视频导演,负责把分镜转化为视频生成模型"
|
||||
"(如 MiniMax H3)可用的逐镜脚本。\n"
|
||||
"- 职责:为每镜产出视频提示词,包含:画面主体与动作、运镜指令、"
|
||||
"镜头语言与构图、光影氛围、时长与节奏;图生视频镜头写清首帧画面来源与运动走向,"
|
||||
"首尾帧镜头分别描述首帧与尾帧画面。\n"
|
||||
"- 产出要求:逐镜编号、结构统一、提示词完整可复制;同一项目的运镜与风格保持一致。\n"
|
||||
"- 涉及参考视频/遮罩等输入时,说明每镜需要准备的素材。\n"
|
||||
"- 默认用简体中文回复;视频提示词主体用英文(视频模型惯例),并附中文说明。"
|
||||
"这是独立会话,不参与五段创作流程。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
AGENT_KEYS: tuple[str, ...] = tuple(AGENTS.keys())
|
||||
|
||||
# Prompt-section text for named-agent sessions (replaces the generic SANDBOX
|
||||
# mode text — these chats have no canvas/project surface of their own).
|
||||
def mode_text(agent_key: str) -> str:
|
||||
agent = AGENTS.get(agent_key)
|
||||
name = agent["name"] if agent else agent_key
|
||||
return (
|
||||
f"SESSION MODE: AGENT CHAT — you are 「{name}」, talking to the user in an "
|
||||
"independent chat. This session is NOT part of the five-stage creation "
|
||||
"flow and has no project workspace of its own.\n"
|
||||
"- Stay in your professional role; do the thinking and writing yourself.\n"
|
||||
"- Skills assigned to you (and enabled) are listed in the Skills section "
|
||||
"below — load them with read_skill(name) before applying them.\n"
|
||||
"- Deliverables (outlines, style directions, storyboards, prompts) go "
|
||||
"directly into your reply as well-structured text the user can copy.\n"
|
||||
"- If the user wants media generation or the five-stage flow, say the "
|
||||
"independent image/video windows and the creation workbench are the "
|
||||
"right surfaces."
|
||||
)
|
||||
|
||||
|
||||
def persona_text(agent_key: str) -> str | None:
|
||||
agent = AGENTS.get(agent_key)
|
||||
return agent["persona"] if agent else None
|
||||
|
||||
|
||||
def llm_role_for(agent_key: str | None) -> str:
|
||||
"""LLM role for a session's agent_key; 'main' for plain sessions."""
|
||||
agent = AGENTS.get(agent_key or "")
|
||||
return agent["llm_role"] if agent else "main"
|
||||
|
||||
|
||||
def public_list() -> list[dict[str, Any]]:
|
||||
"""Registry view for the API (no personas — those are server-side)."""
|
||||
return [
|
||||
{
|
||||
"key": a["key"],
|
||||
"name": a["name"],
|
||||
"char": a["char"],
|
||||
"avatar": a["avatar"],
|
||||
"avatar_dark": bool(a.get("avatar_dark")),
|
||||
"llm_role": a["llm_role"],
|
||||
"tagline": a["tagline"],
|
||||
}
|
||||
for a in AGENTS.values()
|
||||
]
|
||||
@@ -480,7 +480,11 @@ async def run_turn(
|
||||
}
|
||||
)
|
||||
|
||||
client = _llm_for_role("main")
|
||||
# Named-agent sessions (HEL-613) use the agent's bound LLM role; plain
|
||||
# sessions keep 'main' (Active LLM unless a role assignment says otherwise).
|
||||
from calliope.agent.agents_registry import llm_role_for
|
||||
|
||||
client = _llm_for_role(llm_role_for(getattr(ctx, "agent_key", None)))
|
||||
final_text = ""
|
||||
turn_status = "completed"
|
||||
synthetic_idx: list[int] = [] # positions of budget-nudge messages (stripped after)
|
||||
|
||||
@@ -1305,9 +1305,10 @@ async def t_list_workflows(ctx: ToolContext, args: dict[str, Any]) -> list[dict[
|
||||
|
||||
|
||||
async def t_comfy_server_info(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.comfyui.endpoints import client_for
|
||||
|
||||
client = ComfyUIClient()
|
||||
# Active connection (with its auth headers) — HEL-611 multi-endpoint.
|
||||
client = client_for(None)
|
||||
try:
|
||||
info = await client.health()
|
||||
return {"ok": True, "comfyui": info}
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any
|
||||
|
||||
from calliope.agent.harness.registry import ToolContext, ToolDefinition, ToolRegistry
|
||||
from calliope.agent.skills_store import (
|
||||
effective_skill_dirs,
|
||||
ensure_builtin_skills,
|
||||
list_skills as store_list_skills,
|
||||
read_skill_file,
|
||||
@@ -23,7 +24,12 @@ _PROMPT_CAP = 24
|
||||
|
||||
|
||||
async def t_list_skills(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
|
||||
skills = store_list_skills()
|
||||
agent_key = getattr(ctx, "agent_key", None)
|
||||
if agent_key:
|
||||
effective = effective_skill_dirs(agent_key)
|
||||
skills = [s for s in store_list_skills(cap=None) if s["dir"] in effective]
|
||||
else:
|
||||
skills = store_list_skills()
|
||||
return {"ok": True, "skills": skills, "count": len(skills)}
|
||||
|
||||
|
||||
@@ -31,13 +37,28 @@ async def t_read_skill(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]
|
||||
ensure_builtin_skills()
|
||||
name = str(args.get("name") or "").strip()
|
||||
rel = str(args.get("path") or "SKILL.md").strip()
|
||||
agent_key = getattr(ctx, "agent_key", None)
|
||||
if agent_key:
|
||||
# Named-agent sessions may only load skills assigned to them AND
|
||||
# enabled (HEL-613 gating — the discovery list matches this rule).
|
||||
effective = effective_skill_dirs(agent_key)
|
||||
known = {
|
||||
d for d in effective
|
||||
} | {s["name"] for s in store_list_skills(cap=None) if s["dir"] in effective}
|
||||
if name not in known:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"skill '{name}' is not assigned to this agent or is disabled"
|
||||
),
|
||||
}
|
||||
return read_skill_file(name, rel)
|
||||
|
||||
|
||||
def skills_prompt_text(ctx: ToolContext) -> str | None:
|
||||
"""Discovery section: names + descriptions only (cheap; bodies load via
|
||||
read_skill). None when there are no skills."""
|
||||
lines = skills_prompt_lines()
|
||||
lines = skills_prompt_lines(agent_key=getattr(ctx, "agent_key", None))
|
||||
if not lines:
|
||||
return None
|
||||
return (
|
||||
|
||||
@@ -71,6 +71,11 @@ class SystemPromptService:
|
||||
|
||||
|
||||
async def _persona_section(ctx: ToolContext) -> str | None:
|
||||
from calliope.agent.agents_registry import persona_text
|
||||
|
||||
named = persona_text(getattr(ctx, "agent_key", None) or "")
|
||||
if named:
|
||||
return named
|
||||
return _persona_text()
|
||||
|
||||
|
||||
@@ -86,6 +91,13 @@ async def _mode_section(ctx: ToolContext) -> str | None:
|
||||
|
||||
|
||||
def _mode_text(ctx: ToolContext) -> str:
|
||||
agent_key = getattr(ctx, "agent_key", None)
|
||||
if agent_key:
|
||||
# Named-agent independent chat (HEL-613): not a canvas sandbox —
|
||||
# replace the sandbox/project mode text with the agent-chat contract.
|
||||
from calliope.agent.agents_registry import mode_text
|
||||
|
||||
return mode_text(agent_key)
|
||||
if getattr(ctx, "origin", "chat") == "scene":
|
||||
return (
|
||||
"SESSION MODE: BUILD SCENE — this chat drives the 3D composition.\n"
|
||||
@@ -284,6 +296,10 @@ async def _tool_discipline_section(ctx: ToolContext) -> str | None:
|
||||
|
||||
|
||||
async def _mentions_section(ctx: ToolContext) -> str | None:
|
||||
if getattr(ctx, "agent_key", None):
|
||||
# Named-agent chat (HEL-613): the independent agents page has no
|
||||
# workflow mention affordance; skills arrive via the Skills section.
|
||||
return None
|
||||
if getattr(ctx, "origin", "chat") == "scene":
|
||||
return (
|
||||
"Build Scene sessions ignore workflow tags for generation. "
|
||||
|
||||
@@ -75,11 +75,15 @@ class ToolContext:
|
||||
None means a blind/sandbox session (only project-creating tools allowed).
|
||||
`origin` stamps WHERE the session was born ('chat' | 'scene') — scene
|
||||
sessions are Build Scene's blockout surface and get the shot-tool-only
|
||||
payload; it says nothing about project linking."""
|
||||
payload; it says nothing about project linking.
|
||||
`agent_key` names WHICH of the five AIGC agents (剧本创作 etc.) owns the
|
||||
session (HEL-613) — drives per-agent persona/mode prompt sections and the
|
||||
LLM role. None = a plain harness session."""
|
||||
|
||||
session_id: int
|
||||
project_id: int | None = None
|
||||
origin: str = "chat"
|
||||
agent_key: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -140,7 +140,7 @@ class AgentRunner:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _make_message_sink(self, session_id: int):
|
||||
def _make_message_sink(self, session_id: int, default_agent_name: str | None = None):
|
||||
"""Persistence + broadcast sink for harness messages (planner notes,
|
||||
sub-agent reports). Tool calls/results are already logged by the loop
|
||||
via the event log — the sink only mirrors them into agent_messages."""
|
||||
@@ -157,11 +157,16 @@ class AgentRunner:
|
||||
"truncated": True,
|
||||
"preview": text[: session_log.EVENT_DATA_TRUNCATE] + "…[truncated]",
|
||||
}
|
||||
agent_name = message.get("agent_name")
|
||||
if not agent_name and default_agent_name and message.get("role") == "assistant":
|
||||
# Named-agent sessions (HEL-613): main-loop replies carry no
|
||||
# agent_name — stamp the owning agent so bubbles/labels match.
|
||||
agent_name = default_agent_name
|
||||
persisted = self._persist_message(
|
||||
session_id,
|
||||
role=message.get("role") or "assistant",
|
||||
content=message.get("content") or "",
|
||||
agent_name=message.get("agent_name"),
|
||||
agent_name=agent_name,
|
||||
tool_name=message.get("tool_name"),
|
||||
tool_args=message.get("tool_args")
|
||||
if isinstance(message.get("tool_args"), dict)
|
||||
@@ -366,10 +371,12 @@ class AgentRunner:
|
||||
conn = self._db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT project_id, origin FROM agent_sessions WHERE id = ?", (session_id,)
|
||||
"SELECT project_id, origin, agent_key FROM agent_sessions WHERE id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
project_id = row["project_id"] if row else None
|
||||
origin = (row["origin"] if row else None) or "chat"
|
||||
agent_key = (row["agent_key"] if row else None) or None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -377,13 +384,19 @@ class AgentRunner:
|
||||
session_id=session_id,
|
||||
project_id=project_id,
|
||||
origin=origin,
|
||||
agent_key=agent_key,
|
||||
)
|
||||
from calliope.agent.agents_registry import AGENTS
|
||||
|
||||
default_agent_name = (
|
||||
AGENTS.get(agent_key or "", {}).get("name") if agent_key else None
|
||||
)
|
||||
try:
|
||||
final = await orchestrate(
|
||||
ctx,
|
||||
[], # history derived inside from the event log
|
||||
session_id=session_id,
|
||||
on_message=self._make_message_sink(session_id),
|
||||
on_message=self._make_message_sink(session_id, default_agent_name),
|
||||
)
|
||||
# Final answer is persisted + broadcast by the loop's emit() call
|
||||
# (which goes through the message sink). Only set status here.
|
||||
|
||||
@@ -21,7 +21,24 @@ from calliope.config import settings
|
||||
|
||||
READ_CHAR_CAP = 8000
|
||||
_LIST_CAP = 24
|
||||
VERSIONS_PER_SKILL_CAP = 20
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
_NAME_MAX = 64
|
||||
# Skill types offered by the editor (spec §4.3): 提示词模板 / 参考知识 / 流程规则
|
||||
SKILL_TYPES = ("template", "knowledge", "rules")
|
||||
|
||||
|
||||
def _valid_dir_name(name: str) -> bool:
|
||||
return bool(
|
||||
name
|
||||
and name not in (".", "..")
|
||||
and "/" not in name
|
||||
and "\\" not in name
|
||||
and not name.startswith(".")
|
||||
and len(name) <= _NAME_MAX
|
||||
and all(ch not in name for ch in "\x00\r\n\t")
|
||||
and name == name.strip()
|
||||
)
|
||||
|
||||
|
||||
def skills_root() -> Path:
|
||||
@@ -110,36 +127,334 @@ def skill_summary(skill_dir: Path) -> dict[str, Any] | None:
|
||||
if not meta:
|
||||
return None
|
||||
metadata = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else {}
|
||||
try:
|
||||
updated = skill_file.stat().st_mtime
|
||||
except OSError:
|
||||
updated = None
|
||||
skill_type = str(metadata.get("type") or "") if metadata else ""
|
||||
return {
|
||||
"name": str(meta.get("name") or skill_dir.name),
|
||||
"description": str(meta.get("description") or "").strip(),
|
||||
"version": str(meta.get("version") or ""),
|
||||
"tags": metadata.get("tags") or [],
|
||||
"dir": skill_dir.name,
|
||||
"type": skill_type if skill_type in SKILL_TYPES else "",
|
||||
"updated_at": updated,
|
||||
}
|
||||
|
||||
|
||||
def list_skills() -> list[dict[str, Any]]:
|
||||
"""Frontmatter summaries for every `*/SKILL.md`, sorted by name."""
|
||||
def list_skills(cap: int | None = _LIST_CAP) -> list[dict[str, Any]]:
|
||||
"""Frontmatter summaries for every `*/SKILL.md`, sorted by name.
|
||||
|
||||
`cap=None` lists everything (the management surface); the default keeps
|
||||
the historical 24-item ceiling for the discovery prompt/tool.
|
||||
"""
|
||||
ensure_builtin_skills()
|
||||
root = skills_root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
limit = cap # _LIST_CAP by default; explicit None = unlimited
|
||||
for skill_dir in sorted(root.iterdir()):
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
summary = skill_summary(skill_dir)
|
||||
if summary:
|
||||
out.append(summary)
|
||||
if len(out) >= _LIST_CAP:
|
||||
if limit is not None and len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def skills_prompt_lines() -> list[str]:
|
||||
"""`- name: description` lines for the agent's discovery section."""
|
||||
return [f"- {s['name']}: {s['description']}" for s in list_skills()]
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Admin state (HEL-613): enable flags + per-agent assignment live in the DB;
|
||||
# file content stays the single truth for what a skill IS.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _admin_db():
|
||||
from calliope.db import get_db
|
||||
|
||||
return get_db(settings.db_path)
|
||||
|
||||
|
||||
def skill_enable_states() -> dict[str, bool]:
|
||||
"""skill dir name → global enable (absent row = enabled)."""
|
||||
conn = _admin_db()
|
||||
try:
|
||||
rows = conn.execute("SELECT skill_name, is_enabled FROM skill_overrides").fetchall()
|
||||
return {r["skill_name"]: bool(r["is_enabled"]) for r in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def skill_binding_map() -> dict[str, dict[str, bool]]:
|
||||
"""skill dir name → {agent_key: enabled}."""
|
||||
conn = _admin_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT agent_key, skill_name, is_enabled FROM skill_bindings"
|
||||
).fetchall()
|
||||
out: dict[str, dict[str, bool]] = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["skill_name"], {})[r["agent_key"]] = bool(r["is_enabled"])
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_skill_enabled(name: str, enabled: bool) -> None:
|
||||
conn = _admin_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO skill_overrides(skill_name, is_enabled) VALUES (?, ?) "
|
||||
"ON CONFLICT(skill_name) DO UPDATE SET is_enabled = excluded.is_enabled",
|
||||
(name, 1 if enabled else 0),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_skill_bindings(name: str, bindings: dict[str, bool]) -> None:
|
||||
"""Replace the assignment matrix for one skill (agent_key → enabled)."""
|
||||
conn = _admin_db()
|
||||
try:
|
||||
from calliope.agent.agents_registry import AGENT_KEYS
|
||||
|
||||
conn.execute("DELETE FROM skill_bindings WHERE skill_name = ?", (name,))
|
||||
for key in AGENT_KEYS:
|
||||
if key in bindings:
|
||||
conn.execute(
|
||||
"INSERT INTO skill_bindings(agent_key, skill_name, is_enabled) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(key, name, 1 if bindings[key] else 0),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def effective_skill_dirs(agent_key: str | None) -> set[str]:
|
||||
"""Dir names visible to a session: global enable ∩ enabled binding for
|
||||
the agent. Plain sessions (agent_key None) see all globally-enabled
|
||||
skills — the pre-HEL-613 behavior."""
|
||||
enabled = skill_enable_states()
|
||||
dirs = {s["dir"] for s in list_skills(cap=None) if enabled.get(s["dir"], True)}
|
||||
if not agent_key:
|
||||
return dirs
|
||||
bindings = skill_binding_map()
|
||||
out: set[str] = set()
|
||||
for skill_dir in dirs:
|
||||
if bindings.get(skill_dir, {}).get(agent_key) is True:
|
||||
out.add(skill_dir)
|
||||
return out
|
||||
|
||||
|
||||
def skills_prompt_lines(agent_key: str | None = None) -> list[str]:
|
||||
"""`- name: description` lines for the agent's discovery section,
|
||||
filtered to the skills effective for that session's agent."""
|
||||
effective = effective_skill_dirs(agent_key)
|
||||
return [
|
||||
f"- {s['name']}: {s['description']}"
|
||||
for s in list_skills(cap=None)
|
||||
if s["dir"] in effective
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Write / delete / versions
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _bump_version(prev: str) -> str:
|
||||
prev = (prev or "").strip()
|
||||
if not prev:
|
||||
return "1.0.0"
|
||||
m = re.match(r"(\d+)\.(\d+)\.(\d+)$", prev)
|
||||
if m:
|
||||
return f"{m.group(1)}.{m.group(2)}.{int(m.group(3)) + 1}"
|
||||
return "1.0.1"
|
||||
|
||||
|
||||
def render_skill_md(
|
||||
name: str,
|
||||
*,
|
||||
description: str,
|
||||
version: str,
|
||||
tags: list[str] | None,
|
||||
skill_type: str,
|
||||
body: str,
|
||||
) -> str:
|
||||
desc = " ".join(description.split())[:500]
|
||||
tag_line = ", ".join(str(t).strip() for t in (tags or []) if str(t).strip())
|
||||
lines = ["---", f"name: {name}"]
|
||||
lines.append(f'description: "{desc.replace(chr(34), chr(39))}"')
|
||||
lines.append(f"version: {version or '1.0.0'}")
|
||||
lines.append("metadata:")
|
||||
lines.append(" author: user")
|
||||
if tag_line:
|
||||
lines.append(f" tags: [{tag_line}]")
|
||||
if skill_type in SKILL_TYPES:
|
||||
lines.append(f" type: {skill_type}")
|
||||
lines.append("---")
|
||||
text = "\n".join(lines) + "\n\n" + (body if body.endswith("\n") or not body else body + "\n")
|
||||
return text
|
||||
|
||||
|
||||
def snapshot_version(name: str, content: str) -> None:
|
||||
"""File the current SKILL.md full text as an edit snapshot (cap 20)."""
|
||||
conn = _admin_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO skill_versions(skill_name, content) VALUES (?, ?)",
|
||||
(name, content),
|
||||
)
|
||||
stale = conn.execute(
|
||||
"SELECT id FROM skill_versions WHERE skill_name = ? ORDER BY id DESC "
|
||||
"LIMIT -1 OFFSET ?",
|
||||
(name, VERSIONS_PER_SKILL_CAP),
|
||||
).fetchall()
|
||||
for row in stale:
|
||||
conn.execute("DELETE FROM skill_versions WHERE id = ?", (row["id"],))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_versions(name: str) -> list[dict[str, Any]]:
|
||||
conn = _admin_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, skill_name, content, created_at FROM skill_versions "
|
||||
"WHERE skill_name = ? ORDER BY id DESC",
|
||||
(name,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"skill": r["skill_name"],
|
||||
"created_at": r["created_at"],
|
||||
"length": len(r["content"]),
|
||||
"preview": r["content"][:160],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def version_content(name: str, version_id: int) -> str | None:
|
||||
conn = _admin_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM skill_versions WHERE id = ? AND skill_name = ?",
|
||||
(version_id, name),
|
||||
).fetchone()
|
||||
return row["content"] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_skill(
|
||||
name: str,
|
||||
*,
|
||||
description: str,
|
||||
version: str = "",
|
||||
tags: list[str] | None = None,
|
||||
skill_type: str = "",
|
||||
body: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create/overwrite `<skills>/<name>/SKILL.md`. Snapshots the previous
|
||||
file first (version history). Returns the new summary."""
|
||||
if not _valid_dir_name(name):
|
||||
raise ValueError(f"invalid skill name '{name}'")
|
||||
ensure_builtin_skills()
|
||||
skill_dir = skills_root() / name
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
prev_version = ""
|
||||
if skill_file.is_file():
|
||||
try:
|
||||
old = skill_file.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise ValueError(f"unreadable existing skill: {exc}") from exc
|
||||
prev_meta = _parse_frontmatter(old)
|
||||
prev_version = str(prev_meta.get("version") or "")
|
||||
snapshot_version(name, old)
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
new_version = (version or "").strip() or _bump_version(prev_version)
|
||||
text = render_skill_md(
|
||||
name,
|
||||
description=description,
|
||||
version=new_version,
|
||||
tags=tags,
|
||||
skill_type=skill_type,
|
||||
body=body,
|
||||
)
|
||||
skill_file.write_text(text, encoding="utf-8")
|
||||
summary = skill_summary(skill_dir)
|
||||
return summary or {
|
||||
"name": name,
|
||||
"dir": name,
|
||||
"description": description,
|
||||
"version": new_version,
|
||||
"tags": tags or [],
|
||||
"type": skill_type,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def restore_skill_file(name: str, content: str) -> dict[str, Any]:
|
||||
"""Write a snapshot back verbatim (snapshotting the current file first,
|
||||
so a restore is itself undoable). The snapshot carries its own
|
||||
frontmatter — no re-render."""
|
||||
if not _valid_dir_name(name):
|
||||
raise ValueError(f"invalid skill name '{name}'")
|
||||
skill_dir = skills_root() / name
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
if skill_file.is_file():
|
||||
try:
|
||||
old = skill_file.read_text(encoding="utf-8")
|
||||
snapshot_version(name, old)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"unreadable existing skill: {exc}") from exc
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file.write_text(content, encoding="utf-8")
|
||||
summary = skill_summary(skill_dir)
|
||||
return summary or {
|
||||
"name": name,
|
||||
"dir": name,
|
||||
"description": "",
|
||||
"version": "",
|
||||
"tags": [],
|
||||
"type": "",
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def delete_skill(name: str) -> None:
|
||||
"""Remove a skill dir (realpath-contained) + its DB state."""
|
||||
if not _valid_dir_name(name):
|
||||
raise ValueError(f"invalid skill name '{name}'")
|
||||
skill_dir = skills_root() / name
|
||||
if skill_dir.is_dir():
|
||||
root_real = os.path.realpath(skills_root())
|
||||
dir_real = os.path.realpath(skill_dir)
|
||||
try:
|
||||
contained = Path(dir_real).is_relative_to(Path(root_real))
|
||||
except (OSError, ValueError):
|
||||
contained = False
|
||||
if not contained:
|
||||
raise ValueError(f"invalid skill path '{name}'")
|
||||
shutil.rmtree(dir_real)
|
||||
conn = _admin_db()
|
||||
try:
|
||||
conn.execute("DELETE FROM skill_bindings WHERE skill_name = ?", (name,))
|
||||
conn.execute("DELETE FROM skill_overrides WHERE skill_name = ?", (name,))
|
||||
conn.execute("DELETE FROM skill_versions WHERE skill_name = ?", (name,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def skill_files(name: str) -> list[str] | None:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -13,6 +14,22 @@ from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSE
|
||||
|
||||
logger = logging.getLogger("calliope.comfyui")
|
||||
|
||||
# Reachability probe budget (HEL-619): matches the connections-page test
|
||||
# probe (5s). Without it health() inherits the 120s client default, so a
|
||||
# bad endpoint hangs every queued job for two minutes before failing.
|
||||
HEALTH_TIMEOUT_SEC = 5.0
|
||||
|
||||
# /object_info fetch is a few MB on big instances; the preflight check
|
||||
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
|
||||
# (`force_refresh=True`) bypasses the TTL.
|
||||
OBJECT_INFO_TTL_SEC = 300.0
|
||||
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def clear_object_info_cache() -> None:
|
||||
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
|
||||
_object_info_cache.clear()
|
||||
|
||||
|
||||
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
"""Raise with ComfyUI's own error body instead of an opaque status line.
|
||||
@@ -41,40 +58,82 @@ def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
if not isinstance(node_err, dict):
|
||||
continue
|
||||
errors = node_err.get("errors")
|
||||
node = f"{node_id}({node_err['class_type']})" if node_err.get("class_type") else node_id
|
||||
if isinstance(errors, list):
|
||||
for e in errors:
|
||||
if isinstance(e, dict):
|
||||
msg = e.get("message") or e.get("details")
|
||||
if msg:
|
||||
parts.append(f"node {node_id}: {msg}")
|
||||
detail = "; ".join(parts)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
return RuntimeError(f"{prefix} ({resp.status_code}){suffix}")
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
if e.get("type") == "value_not_in_list":
|
||||
extra = e.get("extra_info") or {}
|
||||
parts.append(
|
||||
f"节点 {node}:ComfyUI 上没有「{extra.get('received_value', '?')}」"
|
||||
f"({extra.get('input_name', '参数')} 不在可选列表中,"
|
||||
"多为模型文件未安装)"
|
||||
)
|
||||
continue
|
||||
msg = e.get("message") or e.get("details")
|
||||
if msg and e.get("details") and e["details"] != msg:
|
||||
msg = f"{msg}({e['details']})"
|
||||
if msg:
|
||||
parts.append(f"节点 {node}:{msg}")
|
||||
detail = ";".join(parts)
|
||||
suffix = f":{detail}" if detail else ""
|
||||
return RuntimeError(f"{prefix}(HTTP {resp.status_code}){suffix}")
|
||||
|
||||
|
||||
class ComfyUIClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
def __init__(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> None:
|
||||
self.base_url = (base_url or settings.comfyui_base_url).rstrip("/")
|
||||
self.client_id = str(uuid.uuid4())
|
||||
self._http = httpx.AsyncClient(timeout=120.0)
|
||||
self._headers = dict(headers or {})
|
||||
self._http = httpx.AsyncClient(timeout=120.0, headers=self._headers)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._http.aclose()
|
||||
|
||||
async def health(self) -> bool:
|
||||
try:
|
||||
resp = await self._http.get(f"{self.base_url}/system_stats")
|
||||
resp = await self._http.get(
|
||||
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _cache_key(self) -> str:
|
||||
auth = self._headers.get("Authorization") or next(
|
||||
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
|
||||
)
|
||||
return f"{self.base_url}|{auth}"
|
||||
|
||||
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
|
||||
|
||||
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
|
||||
connections page "重新检查" button after the user installs nodes).
|
||||
Raises on transport/HTTP errors so callers can report honestly.
|
||||
"""
|
||||
key = self._cache_key()
|
||||
now = time.monotonic()
|
||||
if not force_refresh:
|
||||
hit = _object_info_cache.get(key)
|
||||
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
|
||||
return hit[1]
|
||||
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
|
||||
_object_info_cache[key] = (now, data)
|
||||
return data
|
||||
|
||||
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
|
||||
data = path.read_bytes()
|
||||
files = {"image": (path.name, data, "application/octet-stream")}
|
||||
form = {"overwrite": "true", "subfolder": subfolder}
|
||||
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI image upload failed", resp)
|
||||
raise _surface_error("图片上传到 ComfyUI 失败", resp)
|
||||
result = resp.json()
|
||||
name = result.get("name", path.name)
|
||||
sub = result.get("subfolder") or subfolder
|
||||
@@ -94,7 +153,7 @@ class ComfyUIClient:
|
||||
form["subfolder"] = subfolder
|
||||
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI audio upload failed", resp)
|
||||
raise _surface_error("音频上传到 ComfyUI 失败", resp)
|
||||
result = resp.json()
|
||||
name = result.get("name", path.name)
|
||||
sub = result.get("subfolder") or subfolder
|
||||
@@ -107,7 +166,7 @@ class ComfyUIClient:
|
||||
form = {"overwrite": "true", "subfolder": subfolder, "type": "input"}
|
||||
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI video upload failed", resp)
|
||||
raise _surface_error("视频上传到 ComfyUI 失败", resp)
|
||||
result = resp.json()
|
||||
name = result.get("name", path.name)
|
||||
sub = result.get("subfolder") or subfolder
|
||||
@@ -201,11 +260,13 @@ class ComfyUIClient:
|
||||
payload = {"prompt": workflow, "client_id": self.client_id}
|
||||
resp = await self._http.post(f"{self.base_url}/prompt", json=payload)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI rejected the workflow", resp)
|
||||
raise _surface_error(
|
||||
"ComfyUI 拒绝执行该工作流(常见原因:缺少模型/自定义节点,或参数不合法)", resp
|
||||
)
|
||||
data = resp.json()
|
||||
prompt_id = data.get("prompt_id")
|
||||
if not prompt_id:
|
||||
raise RuntimeError(f"ComfyUI /prompt missing prompt_id: {data}")
|
||||
raise RuntimeError(f"ComfyUI 返回异常:/prompt 响应缺少 prompt_id({str(data)[:200]})")
|
||||
return prompt_id
|
||||
|
||||
async def get_history(self, prompt_id: str) -> dict[str, Any] | None:
|
||||
@@ -214,6 +275,37 @@ class ComfyUIClient:
|
||||
data = resp.json()
|
||||
return data.get(prompt_id)
|
||||
|
||||
async def locate_prompt(self, prompt_id: str) -> tuple[str, int]:
|
||||
"""Where ``prompt_id`` sits in ComfyUI's ``/queue``.
|
||||
|
||||
Returns ``("running", 0)``, ``("pending", <prompts ahead>)``,
|
||||
``("missing", 0)`` when the queue does not list it, or
|
||||
``("unknown", 0)`` when this server has no ``/queue`` route.
|
||||
"""
|
||||
resp = await self._http.get(f"{self.base_url}/queue")
|
||||
if resp.status_code == 404:
|
||||
return "unknown", 0
|
||||
resp.raise_for_status()
|
||||
data = resp.json() if resp.content else {}
|
||||
running = data.get("queue_running") or []
|
||||
pending = data.get("queue_pending") or []
|
||||
|
||||
def pid(item: Any) -> Any:
|
||||
return item[1] if isinstance(item, list) and len(item) > 1 else None
|
||||
|
||||
if any(pid(item) == prompt_id for item in running):
|
||||
return "running", 0
|
||||
mine = next((item for item in pending if pid(item) == prompt_id), None)
|
||||
if mine is None:
|
||||
return "missing", 0
|
||||
ahead = sum(
|
||||
1
|
||||
for item in pending
|
||||
if isinstance(item, list) and item and isinstance(item[0], (int, float))
|
||||
and isinstance(mine[0], (int, float)) and item[0] < mine[0]
|
||||
)
|
||||
return "pending", len(running) + ahead
|
||||
|
||||
async def download_image(
|
||||
self,
|
||||
filename: str,
|
||||
@@ -223,7 +315,10 @@ class ComfyUIClient:
|
||||
) -> Path:
|
||||
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
resp = await self._http.get(f"{self.base_url}/view", params=params)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error(f"从 ComfyUI 下载结果文件 {filename} 失败", resp)
|
||||
if not resp.content:
|
||||
raise RuntimeError(f"从 ComfyUI 下载的结果文件 {filename} 是空文件")
|
||||
if dest is None:
|
||||
dest = settings.assets_dir / "comfy_downloads" / filename
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -236,16 +331,46 @@ class ComfyUIClient:
|
||||
except Exception as exc:
|
||||
logger.warning("ComfyUI interrupt failed: %s", exc)
|
||||
|
||||
def extract_outputs(self, history: dict[str, Any]) -> list[dict[str, str]]:
|
||||
# History items carry no explicit media type — infer from the file
|
||||
# extension first, then the history key the node reported under.
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
|
||||
|
||||
@classmethod
|
||||
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
|
||||
ext = Path(str(filename)).suffix.lower()
|
||||
if ext in cls._VIDEO_EXTS:
|
||||
return "video"
|
||||
if ext in cls._IMAGE_EXTS:
|
||||
return "image"
|
||||
if history_key in ("gifs", "videos"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
def extract_outputs(
|
||||
self, history: dict[str, Any], node_kinds: dict[str, str] | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Media items in a finished prompt's history.
|
||||
|
||||
``node_kinds`` (HEL-638) scopes the harvest to the workflow's
|
||||
confirmed output nodes — {nodeId: image|video} — so results from
|
||||
nodes the user never designated are ignored, and each item carries
|
||||
the node id plus its inferred media kind for mismatch checks.
|
||||
"""
|
||||
outputs: list[dict[str, str]] = []
|
||||
for _node_id, node_out in (history.get("outputs") or {}).items():
|
||||
for node_id, node_out in (history.get("outputs") or {}).items():
|
||||
if node_kinds is not None and str(node_id) not in node_kinds:
|
||||
continue
|
||||
for key in ("images", "gifs", "videos"):
|
||||
for item in node_out.get(key) or []:
|
||||
filename = item.get("filename", "")
|
||||
outputs.append(
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"filename": filename,
|
||||
"subfolder": item.get("subfolder", ""),
|
||||
"type": item.get("type", "output"),
|
||||
"node_id": str(node_id),
|
||||
"media_kind": self._infer_media_kind(filename, key) or "",
|
||||
}
|
||||
)
|
||||
return outputs
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Shared helpers for ComfyUI multi-connection endpoints (HEL-611).
|
||||
|
||||
Every generation path (flow-internal video/asset jobs, playground, agent
|
||||
tool calls) funnels through ``queue_manager.enqueue``, which stamps an
|
||||
endpoint SNAPSHOT ``{id, name, base_url}`` into the job payload. The
|
||||
worker resolves that snapshot at run time: a still-configured endpoint id
|
||||
wins (fresh URL + token), a dangling id falls back to the snapshot URL,
|
||||
and no snapshot at all falls back to the active endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
|
||||
|
||||
def endpoint_snapshot(endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""Connection snapshot for the job payload — never includes the token.
|
||||
|
||||
Dangling ids fall back to the active endpoint (mirrors
|
||||
``resolve_comfyui_endpoint``'s leniency) so an enqueue never hard-fails
|
||||
because a connection was deleted between submit and stamp.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if not endpoint:
|
||||
return None
|
||||
return {
|
||||
"id": endpoint.get("id"),
|
||||
"name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
|
||||
|
||||
def build_auth_headers(endpoint: dict[str, Any]) -> dict[str, str]:
|
||||
"""Authorization headers for a stored endpoint (server-side only)."""
|
||||
auth_type = endpoint.get("auth_type") or "none"
|
||||
token = endpoint.get("auth_token") if isinstance(endpoint.get("auth_token"), str) else None
|
||||
token = (token or "").strip()
|
||||
if not token:
|
||||
return {}
|
||||
if auth_type == "bearer":
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
if auth_type == "header":
|
||||
header = (endpoint.get("header_name") or "X-Api-Key").strip() or "X-Api-Key"
|
||||
return {header: token}
|
||||
return {}
|
||||
|
||||
|
||||
def client_for(endpoint: dict[str, Any] | None) -> ComfyUIClient:
|
||||
"""Build a ComfyUIClient for a stored endpoint (auth headers included).
|
||||
|
||||
``None`` falls back to the active endpoint, so legacy call sites keep
|
||||
their behaviour but gain auth support.
|
||||
"""
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
|
||||
if endpoint is None:
|
||||
endpoint = settings.resolve_comfyui_endpoint(None)
|
||||
if endpoint is None:
|
||||
return ComfyUIClient()
|
||||
return ComfyUIClient(
|
||||
str(endpoint.get("base_url") or ""),
|
||||
headers=build_auth_headers(endpoint),
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Structural checks on an imported ComfyUI API workflow.
|
||||
|
||||
``error`` findings mean ComfyUI will reject the prompt as exported, so the
|
||||
studio blocks generation; ``warning`` findings only explain odd parameters.
|
||||
Nothing here repairs a workflow — a broken export has to be re-exported from
|
||||
a ComfyUI that has the missing custom nodes installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
REEXPORT_HINT = "请在已安装对应自定义节点的 ComfyUI 中打开该工作流,重新导出 API 格式 JSON 后再导入"
|
||||
|
||||
# Nodes that cannot run without a model link.
|
||||
_NEEDS_MODEL = frozenset(
|
||||
{"KSampler", "KSamplerAdvanced", "BasicGuider", "CFGGuider", "BasicScheduler"}
|
||||
)
|
||||
|
||||
|
||||
def _title(node_id: str, node: dict[str, Any]) -> str:
|
||||
title = str((node.get("_meta") or {}).get("title") or "").strip()
|
||||
return f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
|
||||
|
||||
def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
found: list[dict[str, str]] = []
|
||||
nodes = {str(k): v for k, v in workflow.items() if isinstance(v, dict)}
|
||||
|
||||
untyped = [nid for nid, node in nodes.items() if not str(node.get("class_type") or "").strip()]
|
||||
for nid in untyped:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_class_type",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}缺少节点类型(class_type),"
|
||||
"导出时 ComfyUI 未加载该自定义节点",
|
||||
}
|
||||
)
|
||||
|
||||
consumed: set[str] = set()
|
||||
for nid, node in nodes.items():
|
||||
inputs = node.get("inputs") or {}
|
||||
for name, val in inputs.items():
|
||||
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
|
||||
src = str(val[0])
|
||||
consumed.add(src)
|
||||
if src not in nodes:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "dangling_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}的输入「{name}」"
|
||||
f"连到了不存在的节点 {src}",
|
||||
}
|
||||
)
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct in _NEEDS_MODEL and not isinstance(inputs.get("model"), list):
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_model_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}({ct})没有连接模型(model),"
|
||||
"模型加载链路在导出时丢失",
|
||||
}
|
||||
)
|
||||
|
||||
for nid, node in nodes.items():
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct.startswith("Empty") and "Latent" in ct and nid not in consumed:
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "orphan_latent",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
|
||||
"它的宽高不会生效,已不在表单中显示",
|
||||
}
|
||||
)
|
||||
|
||||
# HEL-636/638: a workflow without any recognizable output node can never
|
||||
# return a result — block it with a readable reason instead of letting
|
||||
# ComfyUI run blind. Unknown candidates (needs_mapping) don't trigger
|
||||
# this: the user designates them on the import/connections page, and
|
||||
# until then the row stays disabled.
|
||||
from calliope.comfyui.parser import detect_output_nodes
|
||||
|
||||
outputs = detect_output_nodes(workflow)
|
||||
if not any(o.get("kind") in ("image", "video") for o in outputs):
|
||||
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
|
||||
if not unknown:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "no_output",
|
||||
"node_id": "",
|
||||
"message": "未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点",
|
||||
}
|
||||
)
|
||||
else:
|
||||
names = "、".join(f"节点 {o['nodeId']}" for o in unknown[:4])
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "unknown_output_node",
|
||||
"node_id": names,
|
||||
"message": f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能生成",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def blocking_problem(workflow: dict[str, Any]) -> str | None:
|
||||
"""One Chinese sentence explaining why this workflow cannot be submitted."""
|
||||
errors = [w["message"] for w in workflow_warnings(workflow) if w["level"] == "error"]
|
||||
if not errors:
|
||||
return None
|
||||
shown = ";".join(errors[:4]) + (f";等 {len(errors)} 处" if len(errors) > 4 else "")
|
||||
return f"该工作流文件不完整,ComfyUI 无法执行:{shown}。{REEXPORT_HINT}。"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Stored output-node mapping for imported workflows (HEL-638).
|
||||
|
||||
Unknown custom output nodes are never auto-typed. The import confirm page
|
||||
(or the connections table) stores the user's explicit designation — which
|
||||
node ids are the real outputs and each one's media type — in
|
||||
``workflows.output_map`` with ``output_map_source='user'``. Everything that
|
||||
re-derives schemas (reanalyze, migration, plain field edits) must keep that
|
||||
decision; only a fresh explicit payload replaces it.
|
||||
|
||||
Rows without unknown candidates carry an ``auto`` map derived from the
|
||||
trusted registry/tagged outputs so the generation path always knows which
|
||||
history nodes to harvest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_outputs
|
||||
from calliope.comfyui.registry import (
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
)
|
||||
|
||||
VALID_OUTPUT_KINDS = ("image", "video")
|
||||
|
||||
|
||||
def _wiring_class(class_type: str) -> bool:
|
||||
"""Parameter / wiring classes can never be designated as outputs."""
|
||||
return (
|
||||
not class_type
|
||||
or class_type in TEXT_AREA_CLASSES
|
||||
or class_type in NUMBER_CLASSES
|
||||
or class_type.startswith("Empty")
|
||||
or class_type.startswith("Primitive")
|
||||
)
|
||||
|
||||
|
||||
def _entry_public(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {
|
||||
"nodeId": str(entry.get("nodeId") or ""),
|
||||
"kind": entry.get("kind"),
|
||||
}
|
||||
if entry.get("label"):
|
||||
out["label"] = entry["label"]
|
||||
if entry.get("class_type"):
|
||||
out["class_type"] = entry["class_type"]
|
||||
return out
|
||||
|
||||
|
||||
def auto_output_map(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Trusted outputs only (tagged titles / registry classes)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
return [
|
||||
_entry_public(o)
|
||||
for o in parse_dynamic_outputs(workflow)
|
||||
if o.get("kind") in VALID_OUTPUT_KINDS
|
||||
]
|
||||
|
||||
|
||||
def needs_user_output_map(workflow: dict[str, Any]) -> bool:
|
||||
"""True when the graph has unknown output candidates (HEL-638)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return False
|
||||
return any(
|
||||
o.get("evidence") == "unknown_node" for o in parse_dynamic_outputs(workflow)
|
||||
)
|
||||
|
||||
|
||||
def validate_output_map(
|
||||
workflow: dict[str, Any], entries: Any
|
||||
) -> list[str]:
|
||||
"""Chinese problems with a user-supplied output map; empty = valid."""
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ["输出节点映射不能为空:请至少指定一个输出节点及其媒体类型"]
|
||||
seen: set[str] = set()
|
||||
problems: list[str] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
problems.append("输出节点映射格式有误(应为对象数组)")
|
||||
continue
|
||||
nid = str(entry.get("nodeId") or "")
|
||||
node = workflow.get(nid) if isinstance(workflow, dict) else None
|
||||
if not nid or not isinstance(node, dict):
|
||||
problems.append(f"输出节点 {nid or '?'} 不存在于该工作流中")
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").strip()
|
||||
if not ct:
|
||||
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
|
||||
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
|
||||
if entry.get("kind") not in VALID_OUTPUT_KINDS:
|
||||
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
|
||||
if nid in seen:
|
||||
problems.append(f"输出节点 {nid} 重复指定")
|
||||
seen.add(nid)
|
||||
return problems
|
||||
|
||||
|
||||
def load_output_map(raw: str | None) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def resolve_output_map(
|
||||
output_map_raw: str | None, source: str | None, workflow: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""The map generation harvests, plus whose decision it is.
|
||||
|
||||
A user map wins as long as its node ids still exist in the stored JSON
|
||||
(defensive pruning — PATCH cannot edit the JSON, but keep it safe);
|
||||
otherwise the trusted auto map applies. Empty list back means "not
|
||||
confirmed yet".
|
||||
"""
|
||||
if source == "user":
|
||||
entries = [
|
||||
e for e in load_output_map(output_map_raw) if str(e.get("nodeId") or "") in
|
||||
(workflow if isinstance(workflow, dict) else {})
|
||||
]
|
||||
if entries:
|
||||
return entries, "user"
|
||||
return [], None
|
||||
auto = auto_output_map(workflow)
|
||||
return (auto, "auto") if auto else ([], None)
|
||||
|
||||
|
||||
def mapping_problem(
|
||||
workflow: dict[str, Any], output_map_raw: str | None, source: str | None
|
||||
) -> str | None:
|
||||
"""Chinese reason this workflow's outputs are not confirmed, else None."""
|
||||
if not needs_user_output_map(workflow):
|
||||
return None
|
||||
entries, resolved = resolve_output_map(output_map_raw, source, workflow)
|
||||
if resolved == "user" and entries:
|
||||
return None
|
||||
return (
|
||||
"该工作流含无法识别的自定义输出节点,需在「ComfyUI 连接」页指定"
|
||||
"实际输出节点及其媒体类型(图片/视频)后才能启用或生成"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,47 @@
|
||||
"""Patch user values into ComfyUI API-format workflow by nodeId."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import class_to_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
)
|
||||
|
||||
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
|
||||
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
|
||||
FIELD_KEY_SEP = "@"
|
||||
|
||||
# Media-loader families and the Chinese slot label used on cloned nodes.
|
||||
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
|
||||
(IMAGE_CLASSES, "参考图"),
|
||||
(VIDEO_CLASSES, "参考视频"),
|
||||
(AUDIO_CLASSES, "参考音频"),
|
||||
)
|
||||
|
||||
|
||||
def _loader_label(class_type: str) -> str:
|
||||
for classes, label in _GROUP_LABELS:
|
||||
if class_type in classes:
|
||||
return label
|
||||
return "参考素材"
|
||||
|
||||
|
||||
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
|
||||
"""The widget a loader node of this class stores its file value in."""
|
||||
if class_type in IMAGE_CLASSES:
|
||||
return "image"
|
||||
if class_type in VIDEO_CLASSES:
|
||||
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
|
||||
if class_type in AUDIO_CLASSES:
|
||||
# VHS_LoadAudio names its widget "audio:" (with the colon).
|
||||
return "audio" if "audio" in inputs else "audio:"
|
||||
return "image"
|
||||
|
||||
|
||||
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
@@ -26,6 +63,85 @@ def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
return field
|
||||
|
||||
|
||||
def resolve_patch_field(node: dict[str, Any]) -> str:
|
||||
"""The widget a plain nodeId key writes to on this node."""
|
||||
return _resolve_field(
|
||||
class_to_patch_field(node.get("class_type", "")), dict(node.get("inputs") or {})
|
||||
)
|
||||
|
||||
|
||||
def _next_node_id(workflow: dict[str, Any]) -> str:
|
||||
numeric = [int(k) for k in workflow if str(k).isdigit()]
|
||||
return str(max(numeric, default=0) + 1)
|
||||
|
||||
|
||||
def apply_media_group(
|
||||
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
|
||||
) -> None:
|
||||
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
|
||||
|
||||
``<group>.<prefix>0 … N-1`` each get their own loader node: the
|
||||
workflow's existing ones are reused in slot order, extra ones are clones
|
||||
of the first, and slots/loaders left over from the export are removed so
|
||||
no stale media reaches ComfyUI. File values are local paths —
|
||||
``prepare_media_inputs`` uploads them right before queueing.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
node = workflow.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
for _n, name, link in slots:
|
||||
inputs.pop(name, None)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in (
|
||||
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
|
||||
):
|
||||
loaders.append(str(link[0]))
|
||||
if not loaders:
|
||||
return
|
||||
template = copy.deepcopy(workflow[loaders[0]])
|
||||
label = _loader_label(str(template.get("class_type") or ""))
|
||||
|
||||
still_used: set[str] = set()
|
||||
for nid, other in workflow.items():
|
||||
if nid == node_id or not isinstance(other, dict):
|
||||
continue
|
||||
for val in (other.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2:
|
||||
still_used.add(str(val[0]))
|
||||
for nid in loaders[len(paths):]:
|
||||
if nid not in still_used and nid not in loaders[: len(paths)]:
|
||||
workflow.pop(nid, None)
|
||||
|
||||
for i, path in enumerate(paths):
|
||||
if i < len(loaders):
|
||||
nid = loaders[i]
|
||||
loader = workflow[nid]
|
||||
else:
|
||||
nid = _next_node_id(workflow)
|
||||
loader = copy.deepcopy(template)
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
|
||||
loader_inputs[field] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
# Original name kept: image groups were the first (and most common) family.
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -35,11 +151,27 @@ def patch_workflow(
|
||||
if value is None:
|
||||
continue
|
||||
key = str(node_id)
|
||||
field: str | None = None
|
||||
if key not in patched and FIELD_KEY_SEP in key:
|
||||
key, field = key.rsplit(FIELD_KEY_SEP, 1)
|
||||
node = patched.get(key)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
field = _resolve_field(class_to_patch_field(node.get("class_type", "")), inputs)
|
||||
if field is not None and field not in inputs and any(
|
||||
k.startswith(f"{field}.") for k in inputs
|
||||
):
|
||||
paths = [value] if isinstance(value, str) else value
|
||||
if isinstance(paths, list):
|
||||
clean = [p for p in paths if isinstance(p, str) and p.strip()]
|
||||
if clean:
|
||||
apply_media_group(patched, key, field, clean)
|
||||
continue
|
||||
if field is None:
|
||||
field = resolve_patch_field(node)
|
||||
elif isinstance(inputs.get(field), list):
|
||||
# Linked input ([src_id, slot]) — never overwrite the wiring.
|
||||
continue
|
||||
inputs[field] = value
|
||||
node["inputs"] = inputs
|
||||
patched[key] = node
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
|
||||
|
||||
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
|
||||
against what the target ComfyUI actually has installed. Two generic rules,
|
||||
zero hard-coding for any specific workflow or model name:
|
||||
|
||||
1. Node existence — every ``class_type`` in the JSON must exist as a key of
|
||||
the server's /object_info catalogue; missing ones are custom nodes the
|
||||
user has not installed.
|
||||
2. Combo value legality — for every combo input the server declares (an
|
||||
input whose spec carries a candidate list — unet_name/clip_name/
|
||||
sampler/scheduler/…), the JSON's current widget value must be one of the
|
||||
candidates; a miss usually means a model file or component is missing.
|
||||
|
||||
File-upload widgets (LoadImage.image and friends) are skipped: their combo
|
||||
lists are the server's input directory at export time and the value is
|
||||
replaced by an upload before every submit, so a miss there says nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
|
||||
|
||||
# Media-loader classes and the widget names that hold their file values —
|
||||
# upload-backed combos that cannot be judged before an upload happens.
|
||||
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
|
||||
(IMAGE_CLASSES, frozenset({"image"})),
|
||||
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
|
||||
(VIDEO_CLASSES, frozenset({"file", "video"})),
|
||||
)
|
||||
|
||||
# object_info widget-config keys that mark an input as file-upload backed.
|
||||
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
|
||||
|
||||
_MAX_CANDIDATES_SHOWN = 6
|
||||
|
||||
|
||||
def _node_title(node: dict[str, Any]) -> str:
|
||||
title = (node.get("_meta") or {}).get("title")
|
||||
return str(title).strip() if title else ""
|
||||
|
||||
|
||||
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
|
||||
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
|
||||
return True
|
||||
for classes, fields in _MEDIA_LOADER_FIELDS:
|
||||
if class_type in classes and input_name in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _combo_candidates(spec: Any) -> list[str] | None:
|
||||
"""The candidate list when ``spec`` is a combo input, else None.
|
||||
|
||||
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
|
||||
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
|
||||
"""
|
||||
if not (isinstance(spec, list) and len(spec) >= 1):
|
||||
return None
|
||||
values = spec[0]
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
if not all(isinstance(v, str) for v in values):
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def check_workflow_against(
|
||||
object_info: dict[str, Any], workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Findings for every problem the target server's catalogue reveals.
|
||||
|
||||
Each finding is ``{level, code, node_id, node_title, message, hint}``
|
||||
(``level`` is always ``"error"`` for now — both rules are hard misses).
|
||||
"""
|
||||
findings: list[dict[str, Any]] = []
|
||||
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
|
||||
return findings
|
||||
for node_id, node in workflow.items():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
title = _node_title(node)
|
||||
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
spec = object_info.get(class_type)
|
||||
if not isinstance(spec, dict):
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_node",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
|
||||
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
|
||||
),
|
||||
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
|
||||
inputs = node.get("inputs") or {}
|
||||
for section in ("required", "optional"):
|
||||
entries = declared.get(section)
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for input_name, in_spec in entries.items():
|
||||
value = inputs.get(input_name)
|
||||
# Wired inputs (links) and absent widgets are not combos to judge.
|
||||
if isinstance(value, (list, dict)) or value is None:
|
||||
continue
|
||||
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
|
||||
if _is_upload_backed(class_type, str(input_name), config):
|
||||
continue
|
||||
candidates = _combo_candidates(in_spec)
|
||||
if candidates is None:
|
||||
continue
|
||||
if value in candidates:
|
||||
continue
|
||||
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
|
||||
if len(candidates) > _MAX_CANDIDATES_SHOWN:
|
||||
shown += f" 等 {len(candidates)} 项"
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "value_not_in_list",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
|
||||
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
|
||||
),
|
||||
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
|
||||
}
|
||||
)
|
||||
return findings
|
||||
@@ -20,7 +20,7 @@ TEXT_AREA_CLASSES = frozenset(
|
||||
NUMBER_CLASSES = frozenset(
|
||||
{"INT", "FLOAT", "PrimitiveInt", "PrimitiveFloat", "KSampler", "KSamplerAdvanced"}
|
||||
)
|
||||
IMAGE_CLASSES = frozenset({"LoadImage", "ImageLoader", "ETN_LoadImageBase64"})
|
||||
IMAGE_CLASSES = frozenset({"LoadImage", "LoadImageMask", "ImageLoader", "ETN_LoadImageBase64"})
|
||||
IMAGE_URL_CLASSES = frozenset({"Load Image From Url (mtb)"})
|
||||
AUDIO_CLASSES = frozenset({"LoadAudio", "VHS_LoadAudio"})
|
||||
# Stock LoadVideo uses the `file` widget; VHS uses `video`.
|
||||
@@ -30,7 +30,8 @@ VIDEO_OUTPUT_CLASSES = frozenset(
|
||||
{"VHS_VideoCombine", "SaveVideo", "VideoOutput", "AnimateDiffCombine"}
|
||||
)
|
||||
IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket"}
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
|
||||
"SaveImageAdvanced"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,27 @@ OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
|
||||
"video": frozenset({"video", "vid"}),
|
||||
}
|
||||
|
||||
# The five workflow usages the studio distinguishes (HEL-636). Derived from
|
||||
# graph evidence only (output media + consumed image inputs) — never from
|
||||
# file names, model names or fixed node ids.
|
||||
WorkflowUsage = str
|
||||
USAGE_VALUES: tuple[str, ...] = (
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
)
|
||||
|
||||
|
||||
def usage_to_kind(usage: str | None) -> str | None:
|
||||
"""Output media kind of a usage (image / video); None when unknown."""
|
||||
if usage in ("text2image", "image2image"):
|
||||
return "image"
|
||||
if usage in ("text2video", "image2video", "ref2video"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
|
||||
def parse_title_tag(title: str) -> tuple[str | None, str | None, str]:
|
||||
"""Return (kind, role, label) from a Comfy node title.
|
||||
|
||||
@@ -13,6 +13,13 @@ def _find_by_role(inputs: list[dict[str, Any]], *roles: str) -> dict[str, Any] |
|
||||
return None
|
||||
|
||||
|
||||
def _node_sort_key(node_id: object) -> tuple:
|
||||
"""Numeric order for "12", subgraph "171:154" and field keys "3@seed"."""
|
||||
return tuple(
|
||||
(0, int(p)) if p.isdigit() else (1, p) for p in str(node_id).replace("@", ":").split(":")
|
||||
)
|
||||
|
||||
|
||||
def _find_all_by_role(inputs: list[dict[str, Any]], *roles: str) -> list[dict[str, Any]]:
|
||||
"""All inputs matching any of the roles, sorted by numeric node id.
|
||||
|
||||
@@ -21,7 +28,7 @@ def _find_all_by_role(inputs: list[dict[str, Any]], *roles: str) -> list[dict[st
|
||||
in this order and it defines ``<Subject N>`` numbering in the prompt.
|
||||
"""
|
||||
matches = [inp for inp in inputs if input_has_role(inp, *roles)]
|
||||
return sorted(matches, key=lambda i: int(i["nodeId"]))
|
||||
return sorted(matches, key=lambda i: _node_sort_key(i["nodeId"]))
|
||||
|
||||
|
||||
def ref_image_slots(inputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -151,8 +158,12 @@ def smart_fill_inputs(
|
||||
break
|
||||
|
||||
if ref_images:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
group = next((inp for inp in inputs if inp.get("kind") == "image_list"), None)
|
||||
if group is not None:
|
||||
values[str(group["nodeId"])] = list(ref_images[: group.get("max") or len(ref_images)])
|
||||
else:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
|
||||
if ref_videos:
|
||||
for inp, path in zip(ref_video_slots(inputs), ref_videos):
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Check and coerce studio form values against a workflow's input schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _fmt(n: Any) -> str:
|
||||
return f"{n:g}" if isinstance(n, float) else str(n)
|
||||
|
||||
|
||||
def validate_input_values(
|
||||
schema: list[dict[str, Any]], values: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Return ``(clean_values, errors)``.
|
||||
|
||||
Numbers are coerced to int/float per the schema entry (``integer``,
|
||||
``min``/``max``); select values must be one of ``options``. Blank values
|
||||
are dropped so the workflow keeps its own value. Keys not in the schema
|
||||
pass through untouched (other callers patch extra keys, e.g. uploads).
|
||||
Auto-detected required entries (positive prompt, wired image slots,
|
||||
reference-image lists) must be present — tagged workflows mark every
|
||||
input required, and blank ones fall back to the workflow's value.
|
||||
"""
|
||||
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
|
||||
clean: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
for key, raw in (values or {}).items():
|
||||
entry = by_key.get(str(key))
|
||||
if entry is None:
|
||||
clean[key] = raw
|
||||
continue
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
continue
|
||||
label = str(entry.get("label") or key)
|
||||
kind = entry.get("kind")
|
||||
if kind == "number":
|
||||
if isinstance(raw, bool):
|
||||
errors.append(f"「{label}」必须是数字")
|
||||
continue
|
||||
try:
|
||||
num = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"「{label}」必须是数字")
|
||||
continue
|
||||
if not math.isfinite(num):
|
||||
errors.append(f"「{label}」必须是有限数字")
|
||||
continue
|
||||
if entry.get("integer"):
|
||||
if num != int(num):
|
||||
errors.append(f"「{label}」必须是整数")
|
||||
continue
|
||||
value: int | float = int(num)
|
||||
else:
|
||||
value = int(num) if isinstance(raw, int) else num
|
||||
lo, hi = entry.get("min"), entry.get("max")
|
||||
if lo is not None and value < lo:
|
||||
errors.append(f"「{label}」不能小于 {_fmt(lo)}")
|
||||
continue
|
||||
if hi is not None and value > hi:
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind in ("image_list", "video_list", "audio_list"):
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
unit = _LIST_UNITS[kind]
|
||||
noun = _LIST_NOUNS[kind]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(
|
||||
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
|
||||
)
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _media_file_problem(p, kind))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
continue
|
||||
if paths:
|
||||
clean[key] = paths
|
||||
elif kind == "select":
|
||||
options = entry.get("options") or []
|
||||
if options and raw not in options:
|
||||
errors.append(f"「{label}」的取值「{raw}」不在工作流支持的选项中")
|
||||
continue
|
||||
clean[key] = raw
|
||||
else:
|
||||
clean[key] = raw
|
||||
for key, entry in by_key.items():
|
||||
required = entry.get("auto") and entry.get("required")
|
||||
label = entry.get("label") or key
|
||||
if required and entry.get("role") == "prompt":
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
|
||||
lo = entry.get("min") or 1
|
||||
unit = _LIST_UNITS[entry.get("kind")]
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} {unit}{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
return clean, errors
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
|
||||
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
|
||||
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
|
||||
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
|
||||
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
|
||||
_LIST_FORMAT_HINT = {
|
||||
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
|
||||
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
|
||||
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
|
||||
}
|
||||
|
||||
|
||||
def _media_file_problem(path: str, kind: str) -> str | None:
|
||||
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _LIST_EXTS[kind]:
|
||||
return _LIST_FORMAT_HINT[kind]
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
return _media_file_problem(path, "image_list")
|
||||
@@ -59,7 +59,15 @@ DEFAULT_AGENT_HARDENING_PROMPT = """You are operating under additional operator-
|
||||
# Agent roles that can be pinned to a specific LLM profile. Values live in
|
||||
# Settings.agent_llm_assignments (role -> profile id or None). A missing or
|
||||
# None entry means "use the Active LLM".
|
||||
AGENT_LLM_ROLES: tuple[str, ...] = ("main", "planner", "story", "script", "assets", "video")
|
||||
AGENT_LLM_ROLES: tuple[str, ...] = (
|
||||
"main",
|
||||
"planner",
|
||||
"story",
|
||||
"style",
|
||||
"script",
|
||||
"assets",
|
||||
"video",
|
||||
)
|
||||
|
||||
|
||||
def is_ephemeral_path(path: Path | str | None) -> bool:
|
||||
@@ -124,6 +132,13 @@ class Settings(BaseSettings):
|
||||
|
||||
comfyui_base_url: str = "http://127.0.0.1:8188"
|
||||
# Comfy is HTTP-only (upload / prompt / history / view). No local input/output dirs.
|
||||
# Named ComfyUI endpoints (multi-connection, HEL-611). `comfyui_base_url`
|
||||
# above keeps mirroring the ACTIVE endpoint so legacy readers (worker
|
||||
# fallback, tests) keep working — same pattern as llm_* vs llm_profiles.
|
||||
# Each endpoint: {id, name, base_url, auth_type: none|bearer|header,
|
||||
# auth_token, header_name, notes}. auth_token stays server-side only.
|
||||
comfyui_endpoints: list[dict[str, Any]] = Field(default_factory=list)
|
||||
comfyui_active_id: str | None = None
|
||||
|
||||
queue_concurrency: int = 1
|
||||
queue_poll_interval_sec: float = 2.0
|
||||
@@ -309,6 +324,167 @@ class Settings(BaseSettings):
|
||||
break
|
||||
self.apply_active_llm()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ComfyUI multi-connection endpoints (HEL-611). Mirrors the LLM profile
|
||||
# pattern: list + active id + secret-preserving replace + masked public
|
||||
# view. `comfyui_base_url` always mirrors the active endpoint so legacy
|
||||
# single-address readers keep working.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _normalize_comfy_endpoint(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
base_url = str(raw.get("base_url") or "").strip().rstrip("/")
|
||||
if not base_url:
|
||||
return None
|
||||
auth_type = str(raw.get("auth_type") or "none")
|
||||
if auth_type not in ("none", "bearer", "header"):
|
||||
auth_type = "none"
|
||||
token = raw.get("auth_token")
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
token = None
|
||||
header_name = str(raw.get("header_name") or "").strip() or None
|
||||
return {
|
||||
"id": str(raw.get("id") or uuid.uuid4()),
|
||||
"name": str(raw.get("name") or "").strip() or base_url,
|
||||
"base_url": base_url,
|
||||
"auth_type": auth_type,
|
||||
"auth_token": token,
|
||||
"header_name": header_name,
|
||||
"notes": str(raw.get("notes") or "").strip() or None,
|
||||
}
|
||||
|
||||
def ensure_comfyui_endpoints(self) -> bool:
|
||||
"""Guarantee at least one endpoint and base_url mirroring the active.
|
||||
|
||||
Returns True when the list was created or repaired (caller persists).
|
||||
Existing configs with only comfyui_base_url migrate into one endpoint.
|
||||
"""
|
||||
migrated = False
|
||||
raw_list = self.comfyui_endpoints if isinstance(self.comfyui_endpoints, list) else []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
ep = self._normalize_comfy_endpoint(item)
|
||||
if ep is not None:
|
||||
normalized.append(ep)
|
||||
if not normalized:
|
||||
legacy = (self.comfyui_base_url or "http://127.0.0.1:8188").strip().rstrip("/")
|
||||
pid = str(uuid.uuid4())
|
||||
self.comfyui_endpoints = [
|
||||
{
|
||||
"id": pid,
|
||||
"name": "本机 ComfyUI" if self._is_local_url(legacy) else legacy,
|
||||
"base_url": legacy,
|
||||
"auth_type": "none",
|
||||
"auth_token": None,
|
||||
"header_name": None,
|
||||
"notes": None,
|
||||
}
|
||||
]
|
||||
self.comfyui_active_id = pid
|
||||
migrated = True
|
||||
else:
|
||||
self.comfyui_endpoints = normalized
|
||||
ids = {ep["id"] for ep in normalized}
|
||||
if self.comfyui_active_id not in ids:
|
||||
self.comfyui_active_id = normalized[0]["id"]
|
||||
migrated = True
|
||||
self.apply_active_comfyui()
|
||||
return migrated
|
||||
|
||||
@staticmethod
|
||||
def _is_local_url(url: str) -> bool:
|
||||
host = url.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
|
||||
return host in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
def apply_active_comfyui(self) -> None:
|
||||
"""Mirror the active endpoint onto comfyui_base_url (legacy field)."""
|
||||
endpoints = self.comfyui_endpoints if isinstance(self.comfyui_endpoints, list) else []
|
||||
endpoint = next(
|
||||
(ep for ep in endpoints if ep.get("id") == self.comfyui_active_id), None
|
||||
)
|
||||
if endpoint is None and endpoints:
|
||||
endpoint = endpoints[0]
|
||||
self.comfyui_active_id = str(endpoint.get("id") or "")
|
||||
if endpoint is None:
|
||||
return
|
||||
if endpoint.get("base_url"):
|
||||
self.comfyui_base_url = str(endpoint["base_url"])
|
||||
|
||||
def replace_comfyui_endpoints(self, incoming: list[dict[str, Any]]) -> None:
|
||||
"""Replace the endpoint list, preserving tokens unless a new one is sent."""
|
||||
existing = {
|
||||
str(ep["id"]): ep
|
||||
for ep in (self.comfyui_endpoints or [])
|
||||
if isinstance(ep, dict) and ep.get("id")
|
||||
}
|
||||
new_list: list[dict[str, Any]] = []
|
||||
for raw in incoming:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
pid = str(raw["id"]) if raw.get("id") else str(uuid.uuid4())
|
||||
old = existing.get(pid, {})
|
||||
merged = {**old, **raw, "id": pid}
|
||||
# Blank token on edit = keep the stored one (never echoed back).
|
||||
incoming_token = raw.get("auth_token")
|
||||
if not (isinstance(incoming_token, str) and incoming_token.strip()):
|
||||
merged["auth_token"] = old.get("auth_token")
|
||||
ep = self._normalize_comfy_endpoint(merged)
|
||||
if ep is not None:
|
||||
new_list.append(ep)
|
||||
if not new_list:
|
||||
raise ValueError("至少需要一个 ComfyUI 连接")
|
||||
self.comfyui_endpoints = new_list
|
||||
ids = {ep["id"] for ep in new_list}
|
||||
if self.comfyui_active_id not in ids:
|
||||
self.comfyui_active_id = new_list[0]["id"]
|
||||
self.apply_active_comfyui()
|
||||
|
||||
def sync_legacy_comfyui_into_active(self) -> None:
|
||||
"""Write a legacy comfyui_base_url PATCH onto the active endpoint."""
|
||||
base_url = (self.comfyui_base_url or "").strip().rstrip("/")
|
||||
self.ensure_comfyui_endpoints()
|
||||
for ep in self.comfyui_endpoints:
|
||||
if ep.get("id") == self.comfyui_active_id:
|
||||
if base_url:
|
||||
ep["base_url"] = base_url
|
||||
if not str(ep.get("name") or "").strip():
|
||||
ep["name"] = base_url
|
||||
break
|
||||
self.apply_active_comfyui()
|
||||
|
||||
def resolve_comfyui_endpoint(self, endpoint_id: str | None = None) -> dict[str, Any] | None:
|
||||
"""Full endpoint dict (token included) for an id; None id or a dangling
|
||||
id falls back to the active endpoint. Server-side use only."""
|
||||
self.ensure_comfyui_endpoints()
|
||||
endpoints = self.comfyui_endpoints
|
||||
if endpoint_id:
|
||||
for ep in endpoints:
|
||||
if ep.get("id") == endpoint_id:
|
||||
return ep
|
||||
return next((ep for ep in endpoints if ep.get("id") == self.comfyui_active_id), None) or (
|
||||
endpoints[0] if endpoints else None
|
||||
)
|
||||
|
||||
def _public_comfyui_endpoints(self) -> list[dict[str, Any]]:
|
||||
self.ensure_comfyui_endpoints()
|
||||
out: list[dict[str, Any]] = []
|
||||
for ep in self.comfyui_endpoints:
|
||||
token = ep.get("auth_token") if isinstance(ep.get("auth_token"), str) else None
|
||||
out.append(
|
||||
{
|
||||
"id": ep["id"],
|
||||
"name": ep["name"],
|
||||
"base_url": ep["base_url"],
|
||||
"auth_type": ep.get("auth_type") or "none",
|
||||
"header_name": ep.get("header_name"),
|
||||
"notes": ep.get("notes"),
|
||||
"has_token": bool(token),
|
||||
"token_tail": (token or "")[-4:] or None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def _public_llm_profiles(self) -> list[dict[str, Any]]:
|
||||
self.ensure_llm_profiles()
|
||||
return [
|
||||
@@ -318,6 +494,9 @@ class Settings(BaseSettings):
|
||||
"base_url": p["base_url"],
|
||||
"model": p["model"],
|
||||
"api_key": bool(p.get("api_key")),
|
||||
# Last 4 characters only — enough to recognise a saved key in
|
||||
# the UI without ever exposing it (GitHub/Stripe convention).
|
||||
"api_key_tail": (p.get("api_key") or "")[-4:] or None,
|
||||
}
|
||||
for p in self.llm_profiles
|
||||
]
|
||||
@@ -339,6 +518,8 @@ class Settings(BaseSettings):
|
||||
"llm_active_id": self.llm_active_id,
|
||||
"agent_llm_assignments": dict(self.agent_llm_assignments or {}),
|
||||
"comfyui_base_url": self.comfyui_base_url,
|
||||
"comfyui_endpoints": self._public_comfyui_endpoints(),
|
||||
"comfyui_active_id": self.comfyui_active_id,
|
||||
"queue_concurrency": self.queue_concurrency,
|
||||
"queue_poll_interval_sec": self.queue_poll_interval_sec,
|
||||
"queue_poll_timeout_sec": self.queue_poll_timeout_sec,
|
||||
@@ -397,6 +578,10 @@ class Settings(BaseSettings):
|
||||
self.dry_run = bool(data.get("dry_run", False))
|
||||
self.ensure_storage_dirs()
|
||||
profiles_migrated = self.ensure_llm_profiles()
|
||||
comfy_migrated = self.ensure_comfyui_endpoints()
|
||||
if comfy_migrated:
|
||||
# Legacy single-address config: persist the migrated endpoint list.
|
||||
self.save_config_file()
|
||||
|
||||
# Auto-heal poisoned config (temp paths / quoted dirs / legacy comfy dirs) back to disk
|
||||
needs_rewrite = is_ephemeral_path(normalize_path(data.get("data_dir"))) or any(
|
||||
@@ -434,6 +619,8 @@ class Settings(BaseSettings):
|
||||
"llm_active_id": self.llm_active_id,
|
||||
"agent_llm_assignments": dict(self.agent_llm_assignments or {}),
|
||||
"comfyui_base_url": self.comfyui_base_url,
|
||||
"comfyui_endpoints": self.comfyui_endpoints,
|
||||
"comfyui_active_id": self.comfyui_active_id,
|
||||
"queue_concurrency": self.queue_concurrency,
|
||||
"queue_poll_interval_sec": self.queue_poll_interval_sec,
|
||||
"queue_poll_timeout_sec": self.queue_poll_timeout_sec,
|
||||
|
||||
@@ -121,7 +121,11 @@ CREATE TABLE IF NOT EXISTS workflows (
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
@@ -138,7 +142,9 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
comfy_prompt_id TEXT,
|
||||
phase TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
@@ -147,11 +153,33 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
title TEXT NOT NULL DEFAULT 'New chat',
|
||||
status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle', 'running', 'error')),
|
||||
origin TEXT NOT NULL DEFAULT 'chat' CHECK(origin IN ('chat', 'scene')),
|
||||
agent_key TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_sessions_project ON agent_sessions(project_id);
|
||||
|
||||
-- Named-agent skill state (HEL-613): file content stays the truth in
|
||||
-- <data_dir>/skills/<name>/SKILL.md; these tables only carry enable flags,
|
||||
-- per-agent assignment, and edit snapshots for version/rollback.
|
||||
CREATE TABLE IF NOT EXISTS skill_overrides (
|
||||
skill_name TEXT PRIMARY KEY,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_bindings (
|
||||
agent_key TEXT NOT NULL,
|
||||
skill_name TEXT NOT NULL,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY(agent_key, skill_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skill_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
skill_name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_versions_name ON skill_versions(skill_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
@@ -261,6 +289,41 @@ CREATE TABLE IF NOT EXISTS shot_capture (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_shot_capture_composition ON shot_capture(composition_id);
|
||||
|
||||
-- Five-stage creation workbench (HEL-614): one row per project per stage.
|
||||
-- content_json is the working draft the user can edit; confirmed_content_json
|
||||
-- is the last confirmed snapshot — the only version handed to the next agent
|
||||
-- (unconfirmed drafts are never treated as final).
|
||||
CREATE TABLE IF NOT EXISTS project_stages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
|
||||
status TEXT NOT NULL DEFAULT 'todo' CHECK(status IN ('todo','running','awaiting_confirm','confirmed')),
|
||||
content_json TEXT,
|
||||
confirmed_content_json TEXT,
|
||||
chosen_style_id TEXT,
|
||||
draft_edited INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(project_id, stage_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages(project_id);
|
||||
|
||||
-- Audit trail of agent runs inside the flow (HEL-614): instruction in,
|
||||
-- normalized JSON artifact out, or a Chinese-readable failure reason.
|
||||
CREATE TABLE IF NOT EXISTS stage_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
|
||||
instruction TEXT,
|
||||
output_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','done','failed')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stage_runs_project ON stage_runs(project_id, stage_key);
|
||||
"""
|
||||
|
||||
|
||||
@@ -279,6 +342,74 @@ def _sqlite_connect(path: Path) -> sqlite3.Connection:
|
||||
return sqlite3.connect(path)
|
||||
|
||||
|
||||
def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-636: settle `workflows.usage` for rows that still have none.
|
||||
|
||||
Determinate graphs get ``usage='auto'``; indeterminate ones keep NULL
|
||||
and are disabled (they must be confirmed on the connections page before
|
||||
they can generate). Rows the user already confirmed (usage_source='user')
|
||||
never pass through here again — their usage is set, so the WHERE clause
|
||||
skips them. Old jobs / history / `kind` semantics are untouched.
|
||||
"""
|
||||
from calliope.comfyui.parser import classify_usage
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
|
||||
).fetchall()
|
||||
for row in rows: # plain connect — positional access
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
judgment = classify_usage(workflow) if isinstance(workflow, dict) else {}
|
||||
if judgment.get("usage"):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET usage = ?, usage_source = 'auto' WHERE id = ?",
|
||||
(judgment["usage"], row[0]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET is_enabled = 0 WHERE id = ? AND usage IS NULL",
|
||||
(row[0],),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-638: settle the output-node mapping for existing rows.
|
||||
|
||||
Rows the user already mapped (output_map_source='user') are never
|
||||
touched. Determinate graphs get the trusted ``auto`` map. Rows whose
|
||||
graph now contains unknown custom output candidates get no auto map and
|
||||
are parked disabled — the usage decision survives, but until the user
|
||||
designates the output nodes the workflow must not generate.
|
||||
"""
|
||||
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows"
|
||||
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
if needs_user_output_map(workflow):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = NULL, output_map_source = NULL,"
|
||||
" is_enabled = 0 WHERE id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = ?, output_map_source = 'auto'"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(auto_output_map(workflow)), row[0]),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -319,6 +450,28 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute(
|
||||
"ALTER TABLE workflows ADD COLUMN prompt_profile TEXT NOT NULL DEFAULT 'prose'"
|
||||
)
|
||||
if "endpoint_id" not in cols:
|
||||
# HEL-611: optional binding to a ComfyUI connection (settings'
|
||||
# comfyui_endpoints id). Dangling ids are tolerated — enqueue falls
|
||||
# back to the active connection.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN endpoint_id TEXT")
|
||||
# HEL-636: five-way usage (text2image…ref2video). NULL = pending human
|
||||
# confirmation; `kind` keeps meaning output media for history/paths.
|
||||
if "usage" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage TEXT")
|
||||
if "usage_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage_source TEXT")
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes ({nodeId, kind} JSON + 'user'|'auto'). Idempotent backfill
|
||||
# below parks unmapped unknown-output rows (disabled) like usage does.
|
||||
if "output_map" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
|
||||
if "output_map_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
_backfill_workflow_output_map(conn)
|
||||
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
|
||||
if "location_id" not in scene_cols:
|
||||
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
|
||||
@@ -358,6 +511,18 @@ async def migrate_db(db_path: Path) -> None:
|
||||
WHERE kind = 'video' AND scene_id IS NOT NULL AND clip_id IS NULL
|
||||
"""
|
||||
)
|
||||
# HEL-627: the ComfyUI prompt id a job was accepted under, and where the
|
||||
# job really is (connecting / comfy_queued / comfy_running / saving;
|
||||
# finished = real results saved; dry_run = placeholder, ComfyUI never called).
|
||||
if "comfy_prompt_id" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
|
||||
if "phase" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
|
||||
# HEL-643: where a job came from — 'user' (every normal generation) or
|
||||
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
|
||||
# unchanged; test jobs get isolated artifacts and no entity write-back.
|
||||
if "origin" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
|
||||
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
|
||||
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
|
||||
# Build Scene rail). The session list excludes origin='scene' so scene
|
||||
@@ -367,6 +532,27 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN origin TEXT NOT NULL DEFAULT 'chat'")
|
||||
# Backfill: existing Scene · N sessions were born in Build Scene.
|
||||
conn.execute("UPDATE agent_sessions SET origin = 'scene' WHERE title LIKE 'Scene · %'")
|
||||
if "agent_key" not in session_cols:
|
||||
# HEL-613: named-agent sessions (five AIGC roles). NULL = a plain
|
||||
# chat/scene session (every session created before this column).
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN agent_key TEXT")
|
||||
# Seed per-agent bindings once, when the skill_bindings table is fresh:
|
||||
# every skill that exists at upgrade time (the builtins) becomes visible
|
||||
# to all five agents, so agent chats keep the current out-of-the-box
|
||||
# behavior; new user skills opt in via the editor's 分配给 row.
|
||||
binding_count = conn.execute("SELECT COUNT(*) FROM skill_bindings").fetchone()[0]
|
||||
if binding_count == 0:
|
||||
from calliope.agent.agents_registry import AGENT_KEYS
|
||||
from calliope.agent.skills_store import ensure_builtin_skills, skills_root
|
||||
|
||||
ensure_builtin_skills()
|
||||
for skill_dir in sorted(skills_root().glob("*/SKILL.md")):
|
||||
name = skill_dir.parent.name
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO skill_bindings(agent_key, skill_name, is_enabled) "
|
||||
"VALUES (?, ?, 1)",
|
||||
[(key, name) for key in AGENT_KEYS],
|
||||
)
|
||||
project_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
if "cover_path" not in project_cols:
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN cover_path TEXT")
|
||||
|
||||
@@ -20,6 +20,7 @@ from calliope.routers import (
|
||||
agent,
|
||||
assets,
|
||||
canvas,
|
||||
comfyui as comfy,
|
||||
events,
|
||||
jobs,
|
||||
library,
|
||||
@@ -29,6 +30,7 @@ from calliope.routers import (
|
||||
settings as settings_router,
|
||||
shots,
|
||||
story,
|
||||
workbench,
|
||||
workflows,
|
||||
)
|
||||
|
||||
@@ -135,10 +137,12 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app.include_router(workflows.router, prefix="/api/workflows", tags=["workflows"])
|
||||
app.include_router(jobs.router, prefix="/api/jobs", tags=["jobs"])
|
||||
app.include_router(playground.router, prefix="/api/playground", tags=["playground"])
|
||||
app.include_router(comfy.router, prefix="/api/comfyui", tags=["comfyui"])
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
|
||||
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
|
||||
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
|
||||
app.include_router(workbench.router, prefix="/api/workbench", tags=["workbench"])
|
||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -39,6 +39,10 @@ class ProjectStats(BaseModel):
|
||||
character_count: int
|
||||
asset_ready_count: int
|
||||
asset_total_count: int
|
||||
# Five-stage workbench progress (HEL-614) — real stage rows + live jobs.
|
||||
stage_confirmed: int = 0
|
||||
stage_status: dict[str, str] = Field(default_factory=dict)
|
||||
active_jobs: int = 0
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
@@ -194,12 +198,35 @@ class ExpandClipsRequest(BaseModel):
|
||||
clip_cap: int | None = None
|
||||
|
||||
|
||||
WorkflowUsageKind = Literal[
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
kind: Literal["image", "video"] = "image"
|
||||
kind: Literal["image", "video"] | None = None
|
||||
workflow_json: dict[str, Any]
|
||||
description: str | None = None
|
||||
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
|
||||
# Optional binding to a ComfyUI connection id (settings.comfyui_endpoints).
|
||||
endpoint_id: str | None = None
|
||||
# HEL-636: five-way usage. Given by the import confirm page (possibly a
|
||||
# user correction of the auto judgment); when absent it is classified
|
||||
# from the graph, and indeterminate workflows stay pending (disabled).
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
# Schema corrected on the confirm page (manual role overrides included).
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes — [{nodeId, kind: image|video}]. Required before a workflow
|
||||
# with unknown outputs can be enabled or generated.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
@@ -208,10 +235,35 @@ class WorkflowUpdate(BaseModel):
|
||||
description: str | None = None
|
||||
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
|
||||
is_enabled: bool | None = None
|
||||
endpoint_id: str | None = None
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: set/replace the user output mapping; an explicit empty list
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
workflow_json: dict[str, Any]
|
||||
# HEL-643: optional target connection — given, the analyze response also
|
||||
# carries an L2 connection check (node/model availability via /object_info).
|
||||
endpoint_id: str | None = None
|
||||
# Bypass the /object_info cache (user re-checks after installing nodes).
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowCheckRequest(BaseModel):
|
||||
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowTestRequest(BaseModel):
|
||||
"""Launch one real test run of a workflow (HEL-643 L3)."""
|
||||
endpoint_id: str | None = None
|
||||
input_values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
|
||||
@@ -19,7 +19,8 @@ class QueueManager:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status = 'pending', started_at = NULL WHERE status = 'running'"
|
||||
"UPDATE jobs SET status = 'pending', started_at = NULL, phase = NULL, "
|
||||
"comfy_prompt_id = NULL WHERE status = 'running'"
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
@@ -35,15 +36,18 @@ class QueueManager:
|
||||
scene_id: int | None = None,
|
||||
clip_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
origin: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
|
||||
status, payload_json, origin)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload or {})),
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
@@ -51,6 +55,35 @@ class QueueManager:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _stamp_endpoint(
|
||||
conn, workflow_id: int | None, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Snapshot the target ComfyUI connection into the job payload (HEL-611).
|
||||
|
||||
Every generation path (flow video/assets, playground, agent tools)
|
||||
funnels through here, so they all share one execution route.
|
||||
Priority: an explicit payload ``endpoint_id`` → the workflow's
|
||||
bound connection → the active connection. The snapshot carries
|
||||
id/name/base_url only — the token is resolved server-side by
|
||||
the worker at run time.
|
||||
"""
|
||||
from calliope.comfyui.endpoints import endpoint_snapshot
|
||||
|
||||
if payload.get("endpoint"):
|
||||
return payload
|
||||
endpoint_id = payload.get("endpoint_id")
|
||||
if not endpoint_id and workflow_id:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
endpoint_id = row["endpoint_id"] if row else None
|
||||
except Exception:
|
||||
endpoint_id = None # pre-migration DB: column may be missing
|
||||
payload["endpoint"] = endpoint_snapshot(endpoint_id)
|
||||
return payload
|
||||
|
||||
def claim_next(self) -> dict[str, Any] | None:
|
||||
if self.paused:
|
||||
return None
|
||||
@@ -75,6 +108,20 @@ class QueueManager:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_phase(self, job_id: int, phase: str, comfy_prompt_id: str | None = None) -> None:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if comfy_prompt_id:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET phase = ?, comfy_prompt_id = ? WHERE id = ?",
|
||||
(phase, comfy_prompt_id, job_id),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE jobs SET phase = ? WHERE id = ?", (phase, job_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def mark_done(self, job_id: int, output_paths: list[str]) -> None:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
@@ -112,7 +159,7 @@ class QueueManager:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE jobs SET status = 'pending', error = NULL, started_at = NULL,
|
||||
completed_at = NULL WHERE id = ?
|
||||
completed_at = NULL, phase = NULL, comfy_prompt_id = NULL WHERE id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
@@ -8,9 +8,12 @@ from typing import Any
|
||||
|
||||
from pathlib import Path as _fs_path
|
||||
|
||||
import httpx
|
||||
|
||||
from calliope import config
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.roles import input_has_role
|
||||
@@ -18,10 +21,37 @@ from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.export.runner import run_export
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_ORIGIN,
|
||||
prune_workflow_tests,
|
||||
workflow_test_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("calliope.worker")
|
||||
|
||||
|
||||
def _comfy_messages(messages: list[Any]) -> str:
|
||||
"""Pull the readable part out of ComfyUI's history status messages."""
|
||||
parts: list[str] = []
|
||||
for msg in messages:
|
||||
if not (isinstance(msg, (list, tuple)) and len(msg) == 2):
|
||||
continue
|
||||
body = msg[1] if isinstance(msg[1], dict) else {}
|
||||
node = body.get("node_type") or body.get("node_id") or "?"
|
||||
if msg[0] == "execution_error":
|
||||
reason = body.get("exception_message") or body.get("exception_type") or "未知错误"
|
||||
parts.append(f"节点 {node}:{str(reason).strip()}")
|
||||
elif msg[0] == "execution_interrupted":
|
||||
parts.append(f"任务在节点 {node} 处被中断(在 ComfyUI 端被取消,或 ComfyUI 已重启)")
|
||||
return ";".join(parts) or "ComfyUI 未给出原因"
|
||||
|
||||
|
||||
# /queue lookups that must agree before a prompt counts as lost, and /history
|
||||
# or /queue request failures in a row before the job gives up.
|
||||
_MISSING_POLLS_LIMIT = 3
|
||||
_POLL_ERRORS_LIMIT = 5
|
||||
|
||||
|
||||
class _CancelledByUser(RuntimeError):
|
||||
"""The job row was flipped to 'cancelled' out-of-band (Stop button).
|
||||
The row already carries the terminal state; the loop must not overwrite
|
||||
@@ -79,6 +109,7 @@ class QueueWorker:
|
||||
job_payload = json.loads(job["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
job_payload = {}
|
||||
row = queue_manager.get_job(job["id"]) or {}
|
||||
await event_bus.publish(
|
||||
"job.completed",
|
||||
{
|
||||
@@ -90,6 +121,9 @@ class QueueWorker:
|
||||
# Source stamp lets the UI attribute sandbox jobs
|
||||
# to their enqueuer without racing the jobs poll.
|
||||
"source": job_payload.get("source"),
|
||||
"comfy_prompt_id": row.get("comfy_prompt_id"),
|
||||
# Placeholder output — ComfyUI was never called.
|
||||
"dry_run": row.get("phase") == "dry_run",
|
||||
"message": f"{self._job_label(job)} · {len(outputs)} file(s)",
|
||||
},
|
||||
)
|
||||
@@ -135,6 +169,16 @@ class QueueWorker:
|
||||
"message": f"{self._job_label(job)} failed",
|
||||
},
|
||||
)
|
||||
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
|
||||
# Retention: keep only the newest records (and their
|
||||
# artifacts) per workflow (HEL-643).
|
||||
try:
|
||||
prune_workflow_tests(int(job["workflow_id"]))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pruning workflow-test records for workflow %s failed",
|
||||
job["workflow_id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Worker loop error")
|
||||
await asyncio.sleep(config.settings.queue_poll_interval_sec)
|
||||
@@ -144,6 +188,7 @@ class QueueWorker:
|
||||
payload = json.loads(job["payload_json"] or "{}")
|
||||
project_id = job["project_id"]
|
||||
kind = job["kind"]
|
||||
is_test = str(job.get("origin") or "") == TEST_ORIGIN
|
||||
use_dry = bool(config.settings.dry_run)
|
||||
|
||||
if kind == "export":
|
||||
@@ -151,22 +196,47 @@ class QueueWorker:
|
||||
# and dry-run writes an mp4 placeholder (not the default PNG).
|
||||
return await run_export(job, payload, event_bus, dry_run=use_dry)
|
||||
|
||||
client = ComfyUIClient(config.settings.comfyui_base_url)
|
||||
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
# A workflow test must be a REAL run — a dry-run placeholder that
|
||||
# passes as "测试通过" is exactly the false positive HEL-643 bans.
|
||||
if use_dry and is_test:
|
||||
raise RuntimeError(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后重新测试"
|
||||
)
|
||||
if use_dry:
|
||||
queue_manager.set_phase(job["id"], "dry_run")
|
||||
return await self._dry_run(job, payload)
|
||||
|
||||
queue_manager.set_phase(job["id"], "connecting")
|
||||
healthy = await client.health()
|
||||
if not healthy:
|
||||
raise RuntimeError(
|
||||
f"ComfyUI unreachable at {config.settings.comfyui_base_url}. "
|
||||
"Start ComfyUI, or enable Dry-run in Settings only for placeholder testing."
|
||||
f"ComfyUI 不可达({endpoint_label})。请在「ComfyUI 连接」页测试并修正该连接,"
|
||||
"或在设置中开启 Dry-run 做占位测试。"
|
||||
)
|
||||
|
||||
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
|
||||
workflow = self._load_workflow(workflow_id)
|
||||
if not workflow:
|
||||
raise RuntimeError("No workflow found for job")
|
||||
workflow_row = self._load_workflow_row(workflow_id)
|
||||
if not workflow_row:
|
||||
raise RuntimeError(
|
||||
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
|
||||
)
|
||||
workflow = workflow_row["workflow_json"]
|
||||
broken = blocking_problem(workflow)
|
||||
if broken:
|
||||
raise RuntimeError(broken)
|
||||
|
||||
# HEL-638: harvest only the confirmed output nodes. A workflow
|
||||
# with unknown custom outputs and no user mapping must not run.
|
||||
node_kinds = self._output_node_kinds(workflow_row)
|
||||
if node_kinds is None:
|
||||
raise RuntimeError(
|
||||
"该工作流含无法识别的自定义输出节点,尚未指定输出节点及媒体类型,"
|
||||
"请到「ComfyUI 连接」页确认后再生成"
|
||||
)
|
||||
|
||||
input_values = payload.get("input_values") or {}
|
||||
if payload.get("continue_source") and kind == "video":
|
||||
@@ -176,20 +246,66 @@ class QueueWorker:
|
||||
patched = patch_workflow(workflow, input_values)
|
||||
patched = await client.prepare_media_inputs(patched)
|
||||
prompt_id = await client.queue_prompt(patched)
|
||||
queue_manager.set_phase(job["id"], "comfy_queued", prompt_id)
|
||||
await event_bus.publish(
|
||||
"job.progress",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"prompt_id": prompt_id,
|
||||
"phase": "comfy_queued",
|
||||
"message": f"ComfyUI 已接收任务(ID {prompt_id})",
|
||||
},
|
||||
)
|
||||
|
||||
history = await self._poll_history(client, prompt_id, job["id"])
|
||||
if not history:
|
||||
if queue_manager.is_cancelled(job["id"]):
|
||||
raise _CancelledByUser()
|
||||
raise RuntimeError("Timed out waiting for ComfyUI history")
|
||||
raise RuntimeError(
|
||||
f"等待 ComfyUI 出结果超时(任务 ID {prompt_id}):可在「设置 → 队列」调大超时,"
|
||||
"或到 ComfyUI 检查该任务"
|
||||
)
|
||||
|
||||
status = history.get("status") or {}
|
||||
if status.get("status_str") == "error" or status.get("completed") is False:
|
||||
messages = status.get("messages") or []
|
||||
raise RuntimeError(f"ComfyUI error: {messages}")
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
|
||||
)
|
||||
|
||||
outputs_meta = client.extract_outputs(history)
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
outputs_meta = client.extract_outputs(history, node_kinds=node_kinds)
|
||||
saved = [m for m in outputs_meta if m.get("type") == "output"]
|
||||
outputs_meta = saved or outputs_meta
|
||||
if not outputs_meta:
|
||||
checked = "、".join(sorted(node_kinds))
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
|
||||
"不算生成完成。已按输出映射检查节点:" f"{checked};"
|
||||
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
|
||||
)
|
||||
mismatch = next(
|
||||
(
|
||||
m
|
||||
for m in outputs_meta
|
||||
if m.get("media_kind") and m["media_kind"] != node_kinds.get(m["node_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mismatch:
|
||||
want = node_kinds.get(mismatch["node_id"])
|
||||
raise RuntimeError(
|
||||
f"输出节点 {mismatch['node_id']} 的媒体类型与映射不符"
|
||||
f"(映射为{'视频' if want == 'video' else '图片'},"
|
||||
f"实际返回 {mismatch['filename']}),任务不算生成完成。"
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
if is_test:
|
||||
# Test artifacts live in a scratch dir, isolated from project
|
||||
# media and never written back to entities (HEL-643).
|
||||
dest_dir = workflow_test_dir(int(workflow_id))
|
||||
else:
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[str] = []
|
||||
for meta in outputs_meta:
|
||||
@@ -203,11 +319,44 @@ class QueueWorker:
|
||||
)
|
||||
paths.append(str(dest))
|
||||
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
if not is_test:
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
queue_manager.set_phase(job["id"], "finished")
|
||||
return paths
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_endpoint(payload: dict[str, Any]) -> tuple[str, dict[str, str], str]:
|
||||
"""Pick the ComfyUI address for THIS job (HEL-611).
|
||||
|
||||
Priority: the payload's endpoint snapshot id, still present in
|
||||
config (fresh URL + token win) → the snapshot's URL when the id was
|
||||
deleted (token lost — history still explains the address) → the
|
||||
active connection (snapshots from before multi-connection, or jobs
|
||||
enqueued without one).
|
||||
"""
|
||||
from calliope.comfyui.endpoints import build_auth_headers
|
||||
|
||||
snapshot = payload.get("endpoint") or {}
|
||||
if isinstance(snapshot, dict) and snapshot.get("id"):
|
||||
endpoint = config.settings.resolve_comfyui_endpoint(str(snapshot["id"]))
|
||||
if endpoint is not None and endpoint.get("id") == snapshot.get("id"):
|
||||
base_url = str(endpoint.get("base_url") or "")
|
||||
label = f"{endpoint.get('name') or '连接'} · {base_url}"
|
||||
return base_url, build_auth_headers(endpoint), label
|
||||
if isinstance(snapshot, dict) and snapshot.get("base_url"):
|
||||
# Dangling id: run against the snapshotted address, no token.
|
||||
base_url = str(snapshot["base_url"])
|
||||
label = f"{snapshot.get('name') or '已删除连接'} · {base_url}"
|
||||
return base_url, {}, label
|
||||
endpoint = config.settings.resolve_comfyui_endpoint(None)
|
||||
if endpoint is None:
|
||||
raise RuntimeError("没有可用的 ComfyUI 连接:请到「ComfyUI 连接」页添加。")
|
||||
base_url = str(endpoint.get("base_url") or "")
|
||||
label = f"{endpoint.get('name') or '默认连接'} · {base_url}"
|
||||
return base_url, build_auth_headers(endpoint), label
|
||||
|
||||
async def _resolve_continue_source(
|
||||
self,
|
||||
job: dict[str, Any],
|
||||
@@ -312,6 +461,9 @@ class QueueWorker:
|
||||
# configurable (default 30 min) instead of a hardcoded 600 seconds.
|
||||
attempts = None if timeout <= 0 else int(timeout / interval)
|
||||
n = 0
|
||||
phase = "comfy_queued"
|
||||
missing = 0
|
||||
errors = 0
|
||||
while attempts is None or n < attempts:
|
||||
n += 1
|
||||
if self._stop.is_set():
|
||||
@@ -322,17 +474,51 @@ class QueueWorker:
|
||||
if queue_manager.is_cancelled(job_id):
|
||||
await client.interrupt()
|
||||
return None
|
||||
history = await client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
history = await client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
where, ahead = await client.locate_prompt(prompt_id)
|
||||
except httpx.HTTPError as exc:
|
||||
errors += 1
|
||||
if errors >= _POLL_ERRORS_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"连续 {errors} 次无法从 ComfyUI 查询任务状态(任务 ID {prompt_id}):"
|
||||
f"{exc or type(exc).__name__}"
|
||||
) from exc
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
errors = 0
|
||||
if where == "missing":
|
||||
missing += 1
|
||||
if missing >= _MISSING_POLLS_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 的队列和历史里都找不到该任务(任务 ID {prompt_id}),"
|
||||
"可能 ComfyUI 已重启或任务被清除,没有产出结果"
|
||||
)
|
||||
else:
|
||||
missing = 0
|
||||
if where == "running":
|
||||
phase = "comfy_running"
|
||||
message = f"ComfyUI 正在执行(任务 ID {prompt_id})"
|
||||
elif where == "pending":
|
||||
phase = "comfy_queued"
|
||||
message = f"ComfyUI 排队中,前面还有 {ahead} 个任务(任务 ID {prompt_id})"
|
||||
else:
|
||||
message = f"等待 ComfyUI 返回结果(任务 ID {prompt_id})"
|
||||
if job_id is not None:
|
||||
queue_manager.set_phase(job_id, phase)
|
||||
await event_bus.publish(
|
||||
"job.progress",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"prompt_id": prompt_id,
|
||||
"message": f"Waiting on ComfyUI ({prompt_id[:8]}…)",
|
||||
"phase": phase,
|
||||
"queue_ahead": ahead if where == "pending" else None,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
return None
|
||||
|
||||
def _mark_project_completed(self, project_id: int) -> None:
|
||||
@@ -347,20 +533,45 @@ class QueueWorker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_workflow(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
def _load_workflow_row(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
if not workflow_id:
|
||||
return None
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json FROM workflows WHERE id = ?", (workflow_id,)
|
||||
"SELECT workflow_json, output_map, output_map_source FROM workflows"
|
||||
" WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row["workflow_json"])
|
||||
return {
|
||||
"workflow_json": json.loads(row["workflow_json"]),
|
||||
"output_map": row["output_map"],
|
||||
"output_map_source": row["output_map_source"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _output_node_kinds(workflow_row: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""Confirmed {nodeId: image|video} for this workflow, or None when
|
||||
the outputs are not confirmed (unknown nodes, no user mapping)."""
|
||||
from calliope.comfyui.output_map import mapping_problem, resolve_output_map
|
||||
|
||||
workflow = workflow_row["workflow_json"]
|
||||
problem = mapping_problem(
|
||||
workflow, workflow_row.get("output_map"), workflow_row.get("output_map_source")
|
||||
)
|
||||
if problem:
|
||||
return None
|
||||
entries, _source = resolve_output_map(
|
||||
workflow_row.get("output_map"),
|
||||
workflow_row.get("output_map_source"),
|
||||
workflow,
|
||||
)
|
||||
return {str(e["nodeId"]): str(e["kind"]) for e in entries} or None
|
||||
|
||||
def _job_label(self, job: dict[str, Any]) -> str:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
kind = job.get("kind") or "job"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Workflow-test job helpers (HEL-643).
|
||||
|
||||
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
|
||||
queue → ComfyUI → outputs — but its artifacts are isolated from project
|
||||
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
|
||||
never written back to characters/locations/scenes/clips.
|
||||
|
||||
This module owns the artifact directory, the retention policy (keep the
|
||||
newest N records per workflow) and the purge paths used by "清除测试记录"
|
||||
and workflow deletion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from calliope import config
|
||||
from calliope.db import get_db
|
||||
|
||||
logger = logging.getLogger("calliope.queue")
|
||||
|
||||
TEST_ORIGIN = "workflow_test"
|
||||
TEST_SOURCE = "workflow_test"
|
||||
# Records (and artifacts) kept per workflow before older ones are pruned.
|
||||
TEST_KEEP = 5
|
||||
|
||||
|
||||
def workflow_test_dir(workflow_id: int) -> Path:
|
||||
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
|
||||
|
||||
|
||||
def _safe_unlink(path_str: str) -> bool:
|
||||
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
|
||||
try:
|
||||
target = Path(path_str).resolve()
|
||||
target.relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_job_outputs(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
paths = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(paths, list):
|
||||
return
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
_safe_unlink(p)
|
||||
|
||||
|
||||
def _remove_test_dir_if_empty(workflow_id: int) -> None:
|
||||
d = workflow_test_dir(workflow_id)
|
||||
try:
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT ?",
|
||||
(workflow_id, TEST_ORIGIN, limit),
|
||||
).fetchall()
|
||||
return [_test_job_public(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""The unfinished test job for (workflow, endpoint), if one exists.
|
||||
|
||||
Idempotency guard for the "测试工作流" button: a second click re-uses the
|
||||
in-flight job instead of enqueuing a duplicate.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running') ORDER BY id DESC",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
try:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
snap = payload.get("endpoint") or {}
|
||||
if not isinstance(snap, dict):
|
||||
snap = {}
|
||||
if (snap.get("id") or None) == (endpoint_id or None):
|
||||
return _test_job_public(job)
|
||||
return None
|
||||
|
||||
|
||||
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
|
||||
"""Keep only the newest ``keep`` test records (older rows + files go)."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT -1 OFFSET ?",
|
||||
(workflow_id, TEST_ORIGIN, max(keep, 0)),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
if job.get("status") in ("pending", "running"):
|
||||
continue
|
||||
_unlink_job_outputs(job)
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_test_dir_if_empty(workflow_id)
|
||||
if removed:
|
||||
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
|
||||
"""Delete EVERY test record and artifact for a workflow.
|
||||
|
||||
Returns the removed row count; raises RuntimeError when an unfinished
|
||||
test job is still in flight (unless ``allow_active``).
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if not allow_active:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running')",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchone()["n"]
|
||||
if active:
|
||||
raise RuntimeError(
|
||||
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
_unlink_job_outputs(dict(r))
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
|
||||
return removed
|
||||
@@ -21,6 +21,9 @@ class SessionCreate(BaseModel):
|
||||
# (Build Scene's per-scene sessions). Scene-origin sessions are excluded
|
||||
# from the generic session list so they never appear in the AI Canvas rail.
|
||||
origin: str = "chat"
|
||||
# WHICH named agent (剧本创作 etc., HEL-613) owns the session; None = a
|
||||
# plain harness chat. Locked at creation; drives persona + LLM role.
|
||||
agent_key: str | None = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
@@ -36,6 +39,17 @@ class SessionCreate(BaseModel):
|
||||
raise ValueError("origin must be 'chat' or 'scene'")
|
||||
return v
|
||||
|
||||
@field_validator("agent_key")
|
||||
@classmethod
|
||||
def _bounded_agent_key(cls, v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
from calliope.agent.agents_registry import AGENT_KEYS
|
||||
|
||||
if v not in AGENT_KEYS:
|
||||
raise ValueError(f"agent_key must be one of {list(AGENT_KEYS)}")
|
||||
return v
|
||||
|
||||
|
||||
class SessionPatch(BaseModel):
|
||||
title: str | None = None
|
||||
@@ -171,14 +185,22 @@ async def list_sessions(
|
||||
project_id: int | None = None,
|
||||
origin: str | None = None,
|
||||
include_scene: bool = False,
|
||||
agent_key: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Sessions for the sidebars. Scene-origin sessions (Build Scene's
|
||||
per-scene chats) are excluded unless asked for by origin or flag — they
|
||||
are Build Scene's rail entries, not AI Canvas chats."""
|
||||
are Build Scene's rail entries, not AI Canvas chats. Named-agent
|
||||
sessions (HEL-613) follow the same rule: hidden from the generic list
|
||||
unless the caller filters by agent_key (the 智能体 page does)."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
if agent_key is not None and agent_key != "":
|
||||
where.append("agent_key = ?")
|
||||
params.append(agent_key)
|
||||
else:
|
||||
where.append("agent_key IS NULL")
|
||||
if project_id is not None:
|
||||
where.append("project_id = ?")
|
||||
params.append(project_id)
|
||||
@@ -214,9 +236,10 @@ async def create_session(payload: SessionCreate) -> dict[str, Any]:
|
||||
"SELECT id FROM projects WHERE id = ?", (payload.project_id,)
|
||||
).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
origin = "chat" if payload.agent_key else payload.origin
|
||||
cur = conn.execute(
|
||||
"INSERT INTO agent_sessions (title, project_id, origin) VALUES (?, ?, ?)",
|
||||
(payload.title, payload.project_id, payload.origin),
|
||||
"INSERT INTO agent_sessions (title, project_id, origin, agent_key) VALUES (?, ?, ?, ?)",
|
||||
(payload.title, payload.project_id, origin, payload.agent_key),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_sessions WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
@@ -259,7 +282,19 @@ async def get_session(session_id: int) -> dict[str, Any]:
|
||||
|
||||
session_log.backfill_from_messages(session_id)
|
||||
events = session_log.read_events(session_id)
|
||||
session["messages"] = _rows_to_messages(session_log.derive_chat_rows(events))
|
||||
rows = _rows_to_messages(session_log.derive_chat_rows(events))
|
||||
if session.get("agent_key"):
|
||||
# Named-agent session (HEL-613): the loop logs main replies without
|
||||
# agent_name — stamp the owning agent so bubbles/labels match. (The
|
||||
# live SSE mirror in the runner applies the same stamp.)
|
||||
from calliope.agent.agents_registry import AGENTS
|
||||
|
||||
owner = AGENTS.get(session["agent_key"], {}).get("name")
|
||||
if owner:
|
||||
for row in rows:
|
||||
if row.get("role") == "assistant" and not row.get("agent_name"):
|
||||
row["agent_name"] = owner
|
||||
session["messages"] = rows
|
||||
session["plan"] = session_log.derive_plan(events)
|
||||
return session
|
||||
|
||||
@@ -505,13 +540,166 @@ async def delete_memory(memory_id: int) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills() -> list[dict[str, Any]]:
|
||||
"""Frontmatter-only skill list for the composer's `/` typeahead (same
|
||||
view the agent's list_skills tool sees; no file bodies)."""
|
||||
from calliope.agent.skills_store import list_skills as store_list_skills
|
||||
@router.get("/agents")
|
||||
async def list_named_agents() -> list[dict[str, Any]]:
|
||||
"""The five named agents (HEL-613) with their enabled-skill counts —
|
||||
powers the 智能体 page's left column. Names/personas are server-side."""
|
||||
from calliope.agent.agents_registry import AGENTS, public_list
|
||||
from calliope.agent.skills_store import (
|
||||
skill_binding_map,
|
||||
skill_enable_states,
|
||||
)
|
||||
|
||||
return store_list_skills()
|
||||
bindings = skill_binding_map()
|
||||
enabled = skill_enable_states()
|
||||
out: list[dict[str, Any]] = []
|
||||
for info in public_list():
|
||||
count = sum(
|
||||
1
|
||||
for skill, per_agent in bindings.items()
|
||||
if per_agent.get(info["key"]) is True and enabled.get(skill, True)
|
||||
)
|
||||
info = {**info, "skill_count": count}
|
||||
info["model_profile_id"] = (settings.agent_llm_assignments or {}).get(
|
||||
AGENTS[info["key"]]["llm_role"]
|
||||
)
|
||||
out.append(info)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills(all: bool = False) -> list[dict[str, Any]]:
|
||||
"""Frontmatter-only skill list for the composer's `/` typeahead (same
|
||||
view the agent's list_skills tool sees; no file bodies). `all=true` is
|
||||
the management view (HEL-613): every skill, unlimited, with enable
|
||||
flags and per-agent assignment."""
|
||||
from calliope.agent.skills_store import (
|
||||
list_skills as store_list_skills,
|
||||
)
|
||||
from calliope.agent.skills_store import (
|
||||
skill_binding_map,
|
||||
skill_enable_states,
|
||||
)
|
||||
|
||||
skills = store_list_skills(cap=None) if all else store_list_skills()
|
||||
if not all:
|
||||
return skills
|
||||
enabled = skill_enable_states()
|
||||
bindings = skill_binding_map()
|
||||
for s in skills:
|
||||
s["is_enabled"] = enabled.get(s["dir"], True)
|
||||
s["bindings"] = bindings.get(s["dir"], {})
|
||||
s["assigned_count"] = sum(1 for v in s["bindings"].values() if v)
|
||||
return skills
|
||||
|
||||
|
||||
class SkillUpsert(BaseModel):
|
||||
description: str = Field(default="", max_length=500)
|
||||
version: str = Field(default="", max_length=40)
|
||||
tags: list[str] = Field(default_factory=list, max_length=12)
|
||||
skill_type: str = ""
|
||||
content: str = Field(default="", max_length=200_000)
|
||||
bindings: dict[str, bool] | None = None
|
||||
|
||||
|
||||
class SkillEnablePatch(BaseModel):
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class SkillBindingsPut(BaseModel):
|
||||
bindings: dict[str, bool]
|
||||
|
||||
|
||||
@router.put("/skills/{name}")
|
||||
async def upsert_skill(name: str, payload: SkillUpsert) -> dict[str, Any]:
|
||||
"""Create or edit a user skill: snapshot old → write SKILL.md → apply
|
||||
the assignment matrix in one shot (HEL-613)."""
|
||||
from calliope.agent.agents_registry import AGENT_KEYS
|
||||
from calliope.agent.skills_store import (
|
||||
set_skill_bindings,
|
||||
write_skill,
|
||||
)
|
||||
|
||||
if payload.skill_type and payload.skill_type not in ("template", "knowledge", "rules"):
|
||||
raise HTTPException(status_code=422, detail="skill_type must be template|knowledge|rules")
|
||||
if payload.bindings is not None:
|
||||
unknown = [k for k in payload.bindings if k not in AGENT_KEYS]
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"unknown agent_key(s): {unknown}"
|
||||
)
|
||||
try:
|
||||
summary = write_skill(
|
||||
name,
|
||||
description=payload.description,
|
||||
version=payload.version,
|
||||
tags=payload.tags,
|
||||
skill_type=payload.skill_type,
|
||||
body=payload.content,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if payload.bindings is not None:
|
||||
set_skill_bindings(name, payload.bindings)
|
||||
return {"ok": True, "skill": summary}
|
||||
|
||||
|
||||
@router.patch("/skills/{name}")
|
||||
async def patch_skill(name: str, payload: SkillEnablePatch) -> dict[str, Any]:
|
||||
"""Global enable/disable — an disabled skill disappears from every
|
||||
session's discovery list immediately."""
|
||||
from calliope.agent.skills_store import set_skill_enabled
|
||||
|
||||
set_skill_enabled(name, payload.is_enabled)
|
||||
return {"ok": True, "skill": name, "is_enabled": payload.is_enabled}
|
||||
|
||||
|
||||
@router.put("/skills/{name}/bindings")
|
||||
async def put_skill_bindings(name: str, payload: SkillBindingsPut) -> dict[str, Any]:
|
||||
"""Replace the skill's assignment matrix ({agent_key: enabled})."""
|
||||
from calliope.agent.agents_registry import AGENT_KEYS
|
||||
from calliope.agent.skills_store import set_skill_bindings
|
||||
|
||||
unknown = [k for k in payload.bindings if k not in AGENT_KEYS]
|
||||
if unknown:
|
||||
raise HTTPException(status_code=422, detail=f"unknown agent_key(s): {unknown}")
|
||||
set_skill_bindings(name, payload.bindings)
|
||||
return {"ok": True, "skill": name, "bindings": payload.bindings}
|
||||
|
||||
|
||||
@router.delete("/skills/{name}")
|
||||
async def delete_skill(name: str) -> dict[str, Any]:
|
||||
"""Delete a skill: dir + bindings + overrides + version history."""
|
||||
from calliope.agent.skills_store import delete_skill as store_delete
|
||||
|
||||
try:
|
||||
store_delete(name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return {"ok": True, "skill": name}
|
||||
|
||||
|
||||
@router.get("/skills/{name}/versions")
|
||||
async def get_skill_versions(name: str) -> list[dict[str, Any]]:
|
||||
from calliope.agent.skills_store import list_versions
|
||||
|
||||
return list_versions(name)
|
||||
|
||||
|
||||
@router.post("/skills/{name}/versions/{version_id}/restore")
|
||||
async def restore_skill_version(name: str, version_id: int) -> dict[str, Any]:
|
||||
"""Restore a snapshot: the current file is snapshotted first, then the
|
||||
old content is written back verbatim (its frontmatter comes with it)."""
|
||||
from calliope.agent.skills_store import restore_skill_file, version_content
|
||||
|
||||
content = version_content(name, version_id)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
try:
|
||||
summary = restore_skill_file(name, content)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return {"ok": True, "skill": summary}
|
||||
|
||||
|
||||
@router.get("/skills/path")
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""ComfyUI multi-connection endpoints: CRUD + server-side connectivity test.
|
||||
|
||||
HEL-611 / HEL-601 §3. All probes run from the machine hosting this
|
||||
workbench (the user's rule: connectivity is judged by the SERVER, not the
|
||||
browser). Tokens are stored server-side only and never echoed back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_TEST_TIMEOUT_SEC = 5.0
|
||||
_TEST_TIMEOUT_RANGE = (2.0, 15.0)
|
||||
|
||||
_AUTH_TYPES = ("none", "bearer", "header")
|
||||
|
||||
# SSRF guard (HEL-601 §3.4): http/https only; link-local (incl. cloud
|
||||
# metadata 169.254.169.254) and unspecified/multicast/reserved targets are
|
||||
# rejected. Loopback is EXPLICITLY allowed — a local ComfyUI is a
|
||||
# legitimate target for this desktop tool.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("224.0.0.0/4"),
|
||||
ipaddress.ip_network("240.0.0.0/4"),
|
||||
ipaddress.ip_network("255.255.255.255/32"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
ipaddress.ip_network("ff00::/8"),
|
||||
]
|
||||
|
||||
|
||||
class EndpointIn(BaseModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
base_url: str
|
||||
auth_type: str = "none"
|
||||
# Blank/None on edit keeps the stored token (it is never echoed back).
|
||||
auth_token: str | None = None
|
||||
header_name: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class EndpointPatch(BaseModel):
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
auth_type: str | None = None
|
||||
auth_token: str | None = None
|
||||
header_name: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class EndpointTestIn(BaseModel):
|
||||
endpoint_id: str | None = None
|
||||
# Raw candidate values (unsaved form). Stored values fill the blanks
|
||||
# when endpoint_id is given — the token never needs a round trip.
|
||||
base_url: str | None = None
|
||||
auth_type: str | None = None
|
||||
auth_token: str | None = None
|
||||
header_name: str | None = None
|
||||
timeout_sec: float | None = Field(
|
||||
None, ge=_TEST_TIMEOUT_RANGE[0], le=_TEST_TIMEOUT_RANGE[1]
|
||||
)
|
||||
|
||||
|
||||
def _parsed_url(base_url: str) -> tuple[str, str]:
|
||||
url = (base_url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="API 地址不能为空")
|
||||
# HEL-619: the server validates format as strictly as the UI already
|
||||
# does. A schemeless garbled string used to be silently prefixed with
|
||||
# http:// and then reported as a DNS failure; it is a 400 format error.
|
||||
if "://" not in url or any(ch.isspace() for ch in url):
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
return url.rstrip("/"), parsed.hostname
|
||||
|
||||
|
||||
def _assert_not_ssrf(hostname: str) -> None:
|
||||
"""Reject link-local / metadata / reserved targets (loopback allowed)."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
addr = None
|
||||
candidates: list[str] = [str(addr)] if addr else _resolve_host(hostname)
|
||||
for cand in candidates:
|
||||
try:
|
||||
ip = ipaddress.ip_address(cand)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_loopback:
|
||||
continue
|
||||
for net in _BLOCKED_NETWORKS:
|
||||
if ip in net:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="该地址位于被禁止的网段(链路本地/元数据/保留地址),不允许探测",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_host(hostname: str) -> list[str]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
|
||||
except OSError:
|
||||
return []
|
||||
return sorted({info[4][0] for info in infos})
|
||||
|
||||
|
||||
def _public_payload() -> dict[str, Any]:
|
||||
data = settings.to_public_dict()
|
||||
return {"endpoints": data["comfyui_endpoints"], "active_id": data["comfyui_active_id"]}
|
||||
|
||||
|
||||
def _stored_endpoint(endpoint_id: str) -> dict[str, Any]:
|
||||
settings.ensure_comfyui_endpoints()
|
||||
for ep in settings.comfyui_endpoints:
|
||||
if ep.get("id") == endpoint_id:
|
||||
return ep
|
||||
raise HTTPException(status_code=404, detail="连接不存在")
|
||||
|
||||
|
||||
@router.get("/endpoints")
|
||||
async def list_endpoints() -> dict[str, Any]:
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.post("/endpoints")
|
||||
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
|
||||
base_url, host = _parsed_url(payload.base_url)
|
||||
# HEL-619: the same SSRF guard as the connectivity test applies on
|
||||
# save — otherwise a blocked address can be stored and later probed by
|
||||
# the worker path. Unresolvable hosts still pass (resolution happens
|
||||
# again at probe time).
|
||||
_assert_not_ssrf(host)
|
||||
incoming = [ep for ep in settings.comfyui_endpoints or []]
|
||||
incoming.append(payload.model_dump() | {"base_url": base_url})
|
||||
try:
|
||||
settings.replace_comfyui_endpoints(incoming)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.patch("/endpoints/{endpoint_id}")
|
||||
async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str, Any]:
|
||||
_stored_endpoint(endpoint_id)
|
||||
base_url = payload.base_url
|
||||
if base_url is not None:
|
||||
base_url, host = _parsed_url(base_url)
|
||||
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if base_url is not None:
|
||||
patch["base_url"] = base_url
|
||||
incoming = [
|
||||
{**ep, **patch} if ep.get("id") == endpoint_id else ep
|
||||
for ep in settings.comfyui_endpoints
|
||||
]
|
||||
try:
|
||||
settings.replace_comfyui_endpoints(incoming)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.delete("/endpoints/{endpoint_id}")
|
||||
async def delete_endpoint(endpoint_id: str) -> dict[str, Any]:
|
||||
_stored_endpoint(endpoint_id)
|
||||
remaining = [ep for ep in settings.comfyui_endpoints if ep.get("id") != endpoint_id]
|
||||
if not remaining:
|
||||
raise HTTPException(status_code=400, detail="至少保留一个连接;如需更换请先编辑现有连接")
|
||||
settings.replace_comfyui_endpoints(remaining)
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.post("/endpoints/{endpoint_id}/activate")
|
||||
async def activate_endpoint(endpoint_id: str) -> dict[str, Any]:
|
||||
_stored_endpoint(endpoint_id)
|
||||
settings.comfyui_active_id = endpoint_id
|
||||
settings.apply_active_comfyui()
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
def _classify_connect_error(exc: httpx.ConnectError) -> tuple[str, str]:
|
||||
"""Map a connect failure onto (code, Chinese message).
|
||||
|
||||
httpx collapses socket errors to "All connection attempts failed" on
|
||||
the outer exception — the distinguishing errno lives in the __cause__
|
||||
chain, so classify over the whole chain.
|
||||
"""
|
||||
parts: list[str] = [f"{exc.__class__.__name__}: {exc}"]
|
||||
node = exc.__cause__ or exc.__context__
|
||||
seen = {id(exc)}
|
||||
while node is not None and id(node) not in seen:
|
||||
seen.add(id(node))
|
||||
parts.append(f"{node.__class__.__name__}: {node}")
|
||||
node = node.__cause__ or node.__context__
|
||||
text = " | ".join(parts)
|
||||
lowered = text.lower()
|
||||
if (
|
||||
"name or service not known" in lowered
|
||||
or "getaddrinfo" in lowered
|
||||
or "temporary failure in resolution" in lowered
|
||||
or "nodename nor servname" in lowered
|
||||
):
|
||||
return "dns", "域名解析失败:请检查地址拼写或 DNS"
|
||||
if (
|
||||
"connection refused" in lowered
|
||||
or "errno 111" in lowered
|
||||
or "errno 10061" in lowered
|
||||
or "winerror 10061" in lowered
|
||||
):
|
||||
return "refused", "连接被拒绝:目标端口没有服务在监听"
|
||||
if "ssl" in lowered or "certificate" in lowered:
|
||||
return "tls", "TLS/证书错误:检查 https 配置或改用 http"
|
||||
if "unreachable" in lowered or "no route to host" in lowered:
|
||||
return "refused", "网络不可达:本机无法路由到该地址"
|
||||
return "connect", f"无法建立连接:{str(exc)[:300]}"
|
||||
|
||||
|
||||
@router.post("/endpoints/test")
|
||||
async def test_endpoint(payload: EndpointTestIn) -> dict[str, Any]:
|
||||
"""Server-side ComfyUI probe: GET /system_stats with error classification."""
|
||||
from calliope.comfyui.endpoints import build_auth_headers
|
||||
|
||||
base_url = (payload.base_url or "").strip()
|
||||
auth_type = (payload.auth_type or "").strip() or None
|
||||
auth_token = (payload.auth_token or "").strip() or None
|
||||
header_name = (payload.header_name or "").strip() or None
|
||||
if payload.endpoint_id:
|
||||
stored = _stored_endpoint(payload.endpoint_id)
|
||||
if not base_url:
|
||||
base_url = str(stored.get("base_url") or "")
|
||||
if auth_type is None:
|
||||
auth_type = str(stored.get("auth_type") or "none")
|
||||
if auth_token is None:
|
||||
token = stored.get("auth_token")
|
||||
auth_token = token if isinstance(token, str) and token.strip() else None
|
||||
if header_name is None:
|
||||
header_name = stored.get("header_name")
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail="base_url 和 endpoint_id 至少提供一项")
|
||||
|
||||
url, hostname = _parsed_url(base_url)
|
||||
_assert_not_ssrf(hostname)
|
||||
|
||||
headers = build_auth_headers(
|
||||
{
|
||||
"auth_type": auth_type or "none",
|
||||
"auth_token": auth_token,
|
||||
"header_name": header_name,
|
||||
}
|
||||
)
|
||||
timeout_sec = (
|
||||
payload.timeout_sec if payload.timeout_sec is not None else _TEST_TIMEOUT_SEC
|
||||
)
|
||||
timeout_sec = min(max(timeout_sec, _TEST_TIMEOUT_RANGE[0]), _TEST_TIMEOUT_RANGE[1])
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_sec, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{url}/system_stats", headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "timeout",
|
||||
"message": f"超时:{timeout_sec:.0f} 秒内没有响应",
|
||||
"timeout_sec": timeout_sec,
|
||||
}
|
||||
except httpx.ConnectError as exc:
|
||||
code, message = _classify_connect_error(exc)
|
||||
return {"ok": False, "error": code, "message": message, "detail": str(exc)[:300]}
|
||||
except httpx.HTTPError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "connect",
|
||||
"message": "连接失败",
|
||||
"detail": (str(exc) or exc.__class__.__name__)[:300],
|
||||
}
|
||||
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "auth",
|
||||
"message": f"鉴权失败(HTTP {resp.status_code}):请检查访问令牌或鉴权方式",
|
||||
}
|
||||
if resp.status_code == 404:
|
||||
# Reverse proxies sometimes strip /system_stats — probe "/" so the
|
||||
# user learns "reachable, but not confirmed ComfyUI" (HEL-601 risk ①).
|
||||
reachable = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_sec, follow_redirects=True) as client:
|
||||
root = await client.get(f"{url}/", headers=headers)
|
||||
reachable = root.status_code < 500
|
||||
except httpx.HTTPError:
|
||||
reachable = False
|
||||
if reachable:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "HTTP 可达但未确认是 ComfyUI(/system_stats 不存在)",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "未找到 /system_stats,且根路径不可用",
|
||||
}
|
||||
if resp.status_code >= 500:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "server",
|
||||
"message": f"目标服务返回错误(HTTP {resp.status_code})",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "http",
|
||||
"message": f"请求被拒绝(HTTP {resp.status_code})",
|
||||
"detail": resp.text[:200].strip(),
|
||||
}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "地址连通,但响应不是 ComfyUI(/system_stats 返回非 JSON)",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
system = data.get("system") if isinstance(data, dict) else None
|
||||
version = None
|
||||
if isinstance(system, dict):
|
||||
version = system.get("comfyui_version")
|
||||
if not version:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "地址连通,但响应不像 ComfyUI(/system_stats 缺少版本字段)",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
device_name = None
|
||||
devices = data.get("devices") if isinstance(data, dict) else None
|
||||
if isinstance(devices, list) and devices and isinstance(devices[0], dict):
|
||||
device_name = devices[0].get("name") or devices[0].get("type")
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"comfyui_version": str(version),
|
||||
**({"device_name": str(device_name)} if device_name else {}),
|
||||
}
|
||||
@@ -6,7 +6,6 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from calliope.agent.video_agent import enqueue_video_jobs, preview_clip_prompt
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
@@ -191,7 +190,18 @@ async def cancel_job(job_id: int) -> dict[str, Any]:
|
||||
# Export runs local ffmpeg, not Comfy — kill the process instead.
|
||||
kill_running(job_id)
|
||||
else:
|
||||
client = ComfyUIClient()
|
||||
# Interrupt on the endpoint this job actually runs against (payload
|
||||
# snapshot; falls back to the active connection).
|
||||
from calliope.comfyui.endpoints import client_for
|
||||
|
||||
try:
|
||||
snapshot = (json.loads(job.get("payload_json") or "{}") or {}).get("endpoint")
|
||||
except json.JSONDecodeError:
|
||||
snapshot = None
|
||||
endpoint = None
|
||||
if isinstance(snapshot, dict) and snapshot.get("id"):
|
||||
endpoint = settings.resolve_comfyui_endpoint(str(snapshot["id"]))
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
await client.interrupt()
|
||||
finally:
|
||||
|
||||
@@ -13,6 +13,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.output_map import mapping_problem
|
||||
from calliope.comfyui.parser import effective_input_schema
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.queue.manager import queue_manager
|
||||
@@ -23,9 +27,18 @@ PLAYGROUND_STATUS = "system"
|
||||
PLAYGROUND_TITLE = "Playground Scratch"
|
||||
|
||||
|
||||
# Where the request came from — recorded on the job payload so the tasks
|
||||
# page can label origins ("独立生图" vs canvas Playground) without guessing.
|
||||
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
|
||||
|
||||
|
||||
class PlaygroundGenerate(BaseModel):
|
||||
workflow_id: int
|
||||
input_values: dict[str, Any] = Field(default_factory=dict)
|
||||
# Optional target connection; omitted → the workflow's bound connection,
|
||||
# then the active one (resolved in queue_manager.enqueue).
|
||||
endpoint_id: str | None = None
|
||||
source: PlaygroundSource = "playground"
|
||||
|
||||
|
||||
class PlaygroundAttach(BaseModel):
|
||||
@@ -184,7 +197,14 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
# surface in the studio windows' history — they carry their own dialog.
|
||||
jobs = [
|
||||
j
|
||||
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]
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
@@ -323,27 +343,53 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, is_enabled FROM workflows WHERE id = ?",
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema, output_map,"
|
||||
" output_map_source FROM workflows WHERE id = ?",
|
||||
(payload.workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
raise HTTPException(
|
||||
status_code=404, detail="工作流不存在(可能已被删除),请刷新页面后重新选择"
|
||||
)
|
||||
if not wf["is_enabled"]:
|
||||
raise HTTPException(status_code=400, detail="Workflow is disabled")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
|
||||
)
|
||||
kind = wf["kind"]
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
# HEL-638: a workflow with unknown custom output nodes must have a
|
||||
# confirmed output mapping before anything is queued.
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=kind,
|
||||
workflow_id=payload.workflow_id,
|
||||
scene_id=None,
|
||||
payload={"input_values": payload.input_values, "source": "playground"},
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": payload.source,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": kind, "source": "playground", "project_id": pid},
|
||||
{"job_id": job["id"], "kind": kind, "source": payload.source, "project_id": pid},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job)}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from calliope.models.schemas import Project, ProjectCreate, ProjectUpdate
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _project_stats(project_id: int, conn) -> dict[str, int]:
|
||||
def _project_stats(project_id: int, conn) -> dict:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -18,11 +18,26 @@ def _project_stats(project_id: int, conn) -> dict[str, int]:
|
||||
(SELECT COUNT(*) FROM characters WHERE project_id = :pid AND sheet_path IS NOT NULL)
|
||||
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid AND reference_image_path IS NOT NULL) AS asset_ready_count,
|
||||
(SELECT COUNT(*) FROM characters WHERE project_id = :pid)
|
||||
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid) AS asset_total_count
|
||||
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid) AS asset_total_count,
|
||||
(SELECT COUNT(*) FROM jobs WHERE project_id = :pid
|
||||
AND status IN ('pending','running')) AS active_jobs
|
||||
""",
|
||||
{"pid": project_id},
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
stats = dict(cur.fetchone())
|
||||
# Five-stage creation flow (HEL-614) — real per-stage states. Fresh
|
||||
# projects have no rows yet, which reads as "not started" honestly.
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT stage_key, status FROM project_stages WHERE project_id = ?",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
except Exception: # pragma: no cover — pre-migration DB
|
||||
rows = []
|
||||
stage_status = {r["stage_key"]: r["status"] for r in rows}
|
||||
stats["stage_status"] = stage_status
|
||||
stats["stage_confirmed"] = sum(1 for s in stage_status.values() if s == "confirmed")
|
||||
return stats
|
||||
|
||||
|
||||
@router.post("", response_model=Project)
|
||||
|
||||
@@ -14,6 +14,7 @@ router = APIRouter()
|
||||
_LEGACY_LLM_KEYS = {"llm_base_url", "llm_model", "llm_api_key"}
|
||||
|
||||
_LLM_TEST_TIMEOUT_SEC = 15.0
|
||||
_LLM_TEST_TIMEOUT_RANGE = (5, 60)
|
||||
|
||||
|
||||
class LlmProfileIn(BaseModel):
|
||||
@@ -29,6 +30,11 @@ class LlmTestIn(BaseModel):
|
||||
base_url: str | None = None
|
||||
model: str | None = None
|
||||
api_key: str | None = None
|
||||
# Probe timeout requested by the caller (models settings page dropdown).
|
||||
# Clamped server-side regardless of what the client sends.
|
||||
timeout_sec: float | None = Field(
|
||||
None, ge=_LLM_TEST_TIMEOUT_RANGE[0], le=_LLM_TEST_TIMEOUT_RANGE[1]
|
||||
)
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
@@ -39,6 +45,7 @@ class SettingsUpdate(BaseModel):
|
||||
llm_active_id: str | None = None
|
||||
agent_llm_assignments: dict[str, str | None] | None = None
|
||||
comfyui_base_url: str | None = None
|
||||
comfyui_active_id: str | None = None
|
||||
data_dir: str | None = None
|
||||
assets_dir: str | None = None
|
||||
agent_workspace_dir: str | None = None
|
||||
@@ -66,6 +73,8 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
active_in = data.pop("llm_active_id", None)
|
||||
assignments_in = data.pop("agent_llm_assignments", None)
|
||||
legacy_llm = {k: data.pop(k) for k in list(data) if k in _LEGACY_LLM_KEYS}
|
||||
active_comfy_in = data.pop("comfyui_active_id", None)
|
||||
legacy_comfy_url = data.pop("comfyui_base_url", None)
|
||||
|
||||
for key, value in data.items():
|
||||
if key in {"data_dir", "assets_dir", "agent_workspace_dir"}:
|
||||
@@ -98,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
if assignments_in is not None:
|
||||
settings.ensure_llm_profiles()
|
||||
ids = {p["id"] for p in settings.llm_profiles}
|
||||
cleaned: dict[str, str | None] = {}
|
||||
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
|
||||
# to send whole cached snapshots that fully overwrote the map, so a
|
||||
# stale snapshot could wipe a newer choice. Roles absent from the
|
||||
# payload keep their stored value; a role sent as null is cleared.
|
||||
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
|
||||
for role, pid in assignments_in.items():
|
||||
if role not in AGENT_LLM_ROLES:
|
||||
continue
|
||||
@@ -115,6 +128,20 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
setattr(settings, key, value)
|
||||
settings.sync_legacy_llm_into_active()
|
||||
|
||||
if active_comfy_in is not None:
|
||||
settings.ensure_comfyui_endpoints()
|
||||
ids = {ep["id"] for ep in settings.comfyui_endpoints}
|
||||
if active_comfy_in not in ids:
|
||||
raise HTTPException(status_code=400, detail="Unknown ComfyUI endpoint")
|
||||
settings.comfyui_active_id = active_comfy_in
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
if legacy_comfy_url is not None:
|
||||
# Legacy single-address PATCH (old Settings page): write it onto the
|
||||
# active endpoint so the two views never diverge (HEL-611).
|
||||
setattr(settings, "comfyui_base_url", legacy_comfy_url)
|
||||
settings.sync_legacy_comfyui_into_active()
|
||||
|
||||
settings.save_config_file()
|
||||
return settings.to_public_dict()
|
||||
|
||||
@@ -153,15 +180,21 @@ async def test_llm_connection(payload: LlmTestIn) -> dict[str, Any]:
|
||||
"stream": False,
|
||||
"temperature": 0,
|
||||
}
|
||||
timeout_sec = payload.timeout_sec if payload.timeout_sec is not None else _LLM_TEST_TIMEOUT_SEC
|
||||
timeout_sec = min(
|
||||
max(timeout_sec, _LLM_TEST_TIMEOUT_RANGE[0]),
|
||||
_LLM_TEST_TIMEOUT_RANGE[1],
|
||||
)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_LLM_TEST_TIMEOUT_SEC) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout_sec) as client:
|
||||
resp = await client.post(url, headers=headers, json=body)
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "timeout",
|
||||
"detail": f"no response within {_LLM_TEST_TIMEOUT_SEC:.0f}s",
|
||||
"detail": f"no response within {timeout_sec:.0f}s",
|
||||
"timeout_sec": timeout_sec,
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"ok": False, "error": "connect", "detail": str(exc) or exc.__class__.__name__}
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
"""Five-stage creation workbench (HEL-614).
|
||||
|
||||
剧本创作 → 视觉设计 → 分镜设计 → 素材资产 → 镜头脚本 — one project, five
|
||||
named agents in sequence. Each stage holds a working draft the user can edit
|
||||
(``content_json``) and the last confirmed snapshot
|
||||
(``confirmed_content_json``); only confirmed snapshots flow into the next
|
||||
agent's context, so an unconfirmed draft is never treated as final.
|
||||
|
||||
Stage runs reuse the HEL-613 registry (persona + llm_role + skill bindings)
|
||||
with a workbench-specific mode prompt and a per-stage JSON artifact schema.
|
||||
In-flow generation reuses the HEL-612 shared queue: jobs are project-linked
|
||||
with ``payload.source = "workbench"`` so tasks and results come back to the
|
||||
project (and the tasks page) like every other job.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.agents_registry import AGENTS, AGENT_KEYS
|
||||
from calliope.agent.llm import LLMClient, extract_json
|
||||
from calliope.agent.skills_store import effective_skill_dirs, list_skills
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.smart_fill import smart_fill_inputs
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STAGE_KEYS: tuple[str, ...] = AGENT_KEYS # script, style, storyboard, assets, shot
|
||||
|
||||
StageKey = Literal["script", "style", "storyboard", "assets", "shot"]
|
||||
WORKBENCH_SOURCE = "workbench"
|
||||
|
||||
|
||||
class StagePatch(BaseModel):
|
||||
content: dict[str, Any]
|
||||
chosen_style_id: str | None = None
|
||||
|
||||
|
||||
class StageRunRequest(BaseModel):
|
||||
instruction: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class StageConfirmRequest(BaseModel):
|
||||
chosen_style_id: str | None = None
|
||||
|
||||
|
||||
class ScriptImportRequest(BaseModel):
|
||||
text: str = Field(min_length=1, max_length=200_000)
|
||||
|
||||
|
||||
class WorkbenchGenerateRequest(BaseModel):
|
||||
kind: Literal["image", "video"]
|
||||
stage_key: StageKey
|
||||
target_id: str | None = Field(default=None, max_length=64)
|
||||
prompt: str = Field(min_length=1, max_length=8000)
|
||||
negative_prompt: str | None = Field(default=None, max_length=2000)
|
||||
duration_sec: float | None = None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Content normalization — one defensive normalizer per stage. Accepts mild
|
||||
# LLM shape drift, fills defaults, rejects unusable output with a Chinese
|
||||
# reason that surfaces on the run row.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StageContentError(ValueError):
|
||||
"""Unusable artifact content; str(err) is user-facing Chinese."""
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value if value is not None else "").strip()
|
||||
|
||||
|
||||
def _num(value: Any, default: float) -> float:
|
||||
try:
|
||||
out = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return out if out > 0 else default
|
||||
|
||||
|
||||
def _slug_id(prefix: str, index: int) -> str:
|
||||
return f"{prefix}{index + 1}"
|
||||
|
||||
|
||||
def _norm_script(data: dict[str, Any]) -> dict[str, Any]:
|
||||
text = _text(data.get("text") or data.get("script") or data.get("content"))
|
||||
if len(text) < 30:
|
||||
raise StageContentError("剧本内容太短(不足 30 字),请重试或在下方手动补充。")
|
||||
return {"text": text}
|
||||
|
||||
|
||||
def _norm_style(data: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = data.get("directions") or data.get("styles") or []
|
||||
if not isinstance(raw, list):
|
||||
raise StageContentError("风格数据格式不正确,缺少风格方向列表。")
|
||||
directions = []
|
||||
used_ids: set[str] = set()
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = _text(item.get("name") or item.get("title"))
|
||||
prompt = _text(item.get("prompt") or item.get("keywords"))
|
||||
if not name or not prompt:
|
||||
continue
|
||||
sid = _text(item.get("id"))
|
||||
if not sid or sid in used_ids:
|
||||
sid = _slug_id("style", i)
|
||||
n = 2
|
||||
while sid in used_ids:
|
||||
sid = f"style{i + 1}-{n}"
|
||||
n += 1
|
||||
used_ids.add(sid)
|
||||
directions.append(
|
||||
{
|
||||
"id": sid,
|
||||
"name": name,
|
||||
"description": _text(item.get("description")),
|
||||
"prompt": prompt,
|
||||
"negative_prompt": _text(item.get("negative_prompt")),
|
||||
}
|
||||
)
|
||||
if len(directions) < 2:
|
||||
raise StageContentError("风格方向不足 2 套。请让智能体重试,或手动补充更多方向。")
|
||||
return {"directions": directions}
|
||||
|
||||
|
||||
def _norm_storyboard(data: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = data.get("shots") or data.get("storyboard") or []
|
||||
if not isinstance(raw, list):
|
||||
raise StageContentError("分镜数据格式不正确,缺少逐镜列表。")
|
||||
shots = []
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
description = _text(item.get("description") or item.get("action") or item.get("content"))
|
||||
if not description:
|
||||
continue
|
||||
shots.append(
|
||||
{
|
||||
"id": _text(item.get("id")) or _slug_id("shot", i),
|
||||
"scene": _text(item.get("scene")) or f"{i + 1}",
|
||||
"shot_size": _text(item.get("shot_size") or item.get("size")) or "中景",
|
||||
"camera": _text(item.get("camera") or item.get("movement")) or "固定",
|
||||
"description": description,
|
||||
"dialog": _text(item.get("dialog") or item.get("dialogue")),
|
||||
"duration_sec": _num(item.get("duration_sec") or item.get("duration"), 3.0),
|
||||
}
|
||||
)
|
||||
if not shots:
|
||||
raise StageContentError("分镜表为空。请让智能体重试,或手动添加镜头。")
|
||||
return {"shots": shots}
|
||||
|
||||
|
||||
def _norm_assets(data: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = data.get("assets") or data.get("items") or []
|
||||
if not isinstance(raw, list):
|
||||
raise StageContentError("素材数据格式不正确,缺少资产列表。")
|
||||
assets = []
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = _text(item.get("name") or item.get("title"))
|
||||
prompt = _text(item.get("prompt"))
|
||||
if not name or not prompt:
|
||||
continue
|
||||
kind = _text(item.get("kind") or item.get("type"))
|
||||
if kind not in ("character", "location", "prop"):
|
||||
kind = "prop"
|
||||
assets.append(
|
||||
{
|
||||
"id": _text(item.get("id")) or _slug_id("asset", i),
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"description": _text(item.get("description")),
|
||||
"prompt": prompt,
|
||||
"negative_prompt": _text(item.get("negative_prompt")),
|
||||
}
|
||||
)
|
||||
if not assets:
|
||||
raise StageContentError("素材清单为空。请让智能体重试,或手动添加素材。")
|
||||
return {"assets": assets}
|
||||
|
||||
|
||||
def _norm_shot(data: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = data.get("shots") or []
|
||||
if not isinstance(raw, list):
|
||||
raise StageContentError("镜头脚本数据格式不正确,缺少逐镜列表。")
|
||||
shots = []
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
prompt = _text(item.get("prompt") or item.get("video_prompt"))
|
||||
if not prompt:
|
||||
continue
|
||||
shots.append(
|
||||
{
|
||||
"id": _text(item.get("id")) or _slug_id("vshot", i),
|
||||
"shot_no": _text(item.get("shot_no") or item.get("shot")) or _slug_id("S", i),
|
||||
"description": _text(item.get("description")),
|
||||
"prompt": prompt,
|
||||
"duration_sec": _num(item.get("duration_sec") or item.get("duration"), 5.0),
|
||||
}
|
||||
)
|
||||
if not shots:
|
||||
raise StageContentError("镜头脚本为空。请让智能体重试,或手动添加镜头。")
|
||||
return {"shots": shots}
|
||||
|
||||
|
||||
NORMALIZERS = {
|
||||
"script": _norm_script,
|
||||
"style": _norm_style,
|
||||
"storyboard": _norm_storyboard,
|
||||
"assets": _norm_assets,
|
||||
"shot": _norm_shot,
|
||||
}
|
||||
|
||||
# Short artifact brief embedded in each run prompt (Chinese; keeps the five
|
||||
# agents' deliverables aligned with what the workbench UI renders).
|
||||
STAGE_BRIEFS = {
|
||||
"script": (
|
||||
"产出完整可拍摄的短视频剧本:故事大纲、按场次展开的分场(场景/时间/地点/人物)、"
|
||||
"人物小传要点、逐场台词与动作描述。直接输出剧本正文,不要写「好的」之类的客套话。"
|
||||
),
|
||||
"style": (
|
||||
"给出至少 2 套(建议 3 套)差异化视觉风格方向,每套包含:风格名称、中文风格说明"
|
||||
"(色调/光影/材质/线条)、可直接用于生图模型的英文正向提示词词组(逗号分隔)、"
|
||||
"可选的英文负面提示词。提示词词组必须完整可复制。"
|
||||
),
|
||||
"storyboard": (
|
||||
"把剧本拆成逐镜分镜表,每镜包含:场次、景别(远/全/中/近/特写)、运镜"
|
||||
"(推/拉/摇/移/跟/固定)、画面内容描述、台词或音效起点、建议时长(秒)。"
|
||||
"重要情绪点给特写,节奏有起伏。"
|
||||
),
|
||||
"assets": (
|
||||
"结合剧本、已确认的视觉风格与分镜表,整理需要生图的资产清单(角色/场景/道具),"
|
||||
"每项资产给出:类型、名称、中文说明、完整可直接复制的英文生图提示词"
|
||||
"(主体外观细节+风格关键词+构图视角+画质词)、可选负面提示词。"
|
||||
"同一角色要保持跨视角一致性。"
|
||||
),
|
||||
"shot": (
|
||||
"把分镜表转化为视频生成模型(如 MiniMax H3)可用的逐镜脚本,每镜包含:镜号、"
|
||||
"中文画面说明、完整可直接复制的英文视频提示词(主体与动作+运镜指令+镜头语言+"
|
||||
"光影氛围)、建议时长(秒)。同一项目运镜与风格保持一致。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _stage_name(stage_key: str) -> str:
|
||||
return AGENTS[stage_key]["name"] if stage_key in AGENTS else stage_key
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# DB helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_project(conn, project_id: int) -> dict[str, Any]:
|
||||
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if not row or row["status"] == "system":
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
def _ensure_stage_rows(conn, project_id: int) -> None:
|
||||
for key in STAGE_KEYS:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO project_stages (project_id, stage_key) VALUES (?, ?)",
|
||||
(project_id, key),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM project_stages WHERE project_id = ? AND stage_key = ?",
|
||||
(project_id, stage_key),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="阶段不存在")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"stage_key": row["stage_key"],
|
||||
"status": row["status"],
|
||||
"content": json.loads(row["content_json"]) if row["content_json"] else None,
|
||||
"confirmed_content": (
|
||||
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
|
||||
),
|
||||
"chosen_style_id": row["chosen_style_id"],
|
||||
"draft_edited": bool(row["draft_edited"]),
|
||||
"error": row["error"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def _last_run(conn, project_id: int, stage_key: str) -> dict[str, Any] | None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM stage_runs WHERE project_id = ? AND stage_key = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(project_id, stage_key),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"status": row["status"],
|
||||
"error": row["error"],
|
||||
"instruction": row["instruction"],
|
||||
"created_at": row["created_at"],
|
||||
"completed_at": row["completed_at"],
|
||||
}
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def _workbench_job_outputs(conn, project_id: int) -> dict[str, dict[str, Any]]:
|
||||
"""target_id → latest workbench job (any status) for asset/shot panels."""
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE project_id = ? ORDER BY id ASC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
job = _job_public(row_to_dict(row))
|
||||
payload = job.get("payload") or {}
|
||||
if payload.get("source") != WORKBENCH_SOURCE:
|
||||
continue
|
||||
target = payload.get("target_id")
|
||||
if not target:
|
||||
continue
|
||||
prev = out.get(target)
|
||||
# later rows win; keep the newest per target
|
||||
if prev is None or int(job["id"]) > int(prev["id"]):
|
||||
out[target] = job
|
||||
return out
|
||||
|
||||
|
||||
def _enrich_stage(
|
||||
stage: dict[str, Any], outputs: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
content = stage.get("content") or {}
|
||||
if stage["stage_key"] == "assets" and isinstance(content.get("assets"), list):
|
||||
for asset in content["assets"]:
|
||||
job = outputs.get(asset.get("id"))
|
||||
if job:
|
||||
asset["job"] = _job_brief(job)
|
||||
elif stage["stage_key"] == "shot" and isinstance(content.get("shots"), list):
|
||||
for shot in content["shots"]:
|
||||
job = outputs.get(shot.get("id"))
|
||||
if job:
|
||||
shot["job"] = _job_brief(job)
|
||||
return stage
|
||||
|
||||
|
||||
def _job_brief(job: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": job["id"],
|
||||
"status": job["status"],
|
||||
"kind": job["kind"],
|
||||
"output": (job.get("output_paths") or [None])[0],
|
||||
"error": job.get("error"),
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Stage-run prompt assembly + background execution
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_SKILL_BODY_MAX_CHARS = 1600
|
||||
_SKILL_BODY_MAX_COUNT = 3
|
||||
|
||||
|
||||
def _skills_context(agent_key: str) -> str:
|
||||
try:
|
||||
effective = effective_skill_dirs(agent_key)
|
||||
skills = [s for s in list_skills(cap=None) if s["dir"] in effective]
|
||||
except Exception:
|
||||
return ""
|
||||
if not skills:
|
||||
return ""
|
||||
lines = ["以下 Skill 已分配给该智能体(用户配置),生成时请吸收其要点:"]
|
||||
shown = 0
|
||||
for skill in skills:
|
||||
lines.append(f"- {skill['name']}: {skill['description']}")
|
||||
if shown >= _SKILL_BODY_MAX_COUNT:
|
||||
continue
|
||||
body = _skill_body(skill["dir"])
|
||||
if body:
|
||||
lines.append(f" {skill['name']} 内容摘要:\n{body}")
|
||||
shown += 1
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _skill_body(skill_dir: str) -> str:
|
||||
from calliope.agent.skills_store import skills_root
|
||||
|
||||
path = skills_root() / skill_dir / "SKILL.md"
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
text = re.sub(r"^---.*?---\s*", "", text, flags=re.S)
|
||||
text = text.strip()
|
||||
if len(text) > _SKILL_BODY_MAX_CHARS:
|
||||
text = text[:_SKILL_BODY_MAX_CHARS] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _system_prompt(stage_key: str) -> str:
|
||||
agent = AGENTS[stage_key]
|
||||
idx = STAGE_KEYS.index(stage_key) + 1
|
||||
parts = [
|
||||
agent["persona"],
|
||||
(
|
||||
f"WORKBENCH MODE: 你正在「创作工作台」五段流程中负责第 {idx} 段「{agent['name']}」。"
|
||||
"本次输出会被程序解析入库并在工作台展示给用户编辑、确认,因此:\n"
|
||||
"- 只输出一个 JSON 对象,不要加 Markdown 代码围栏,不要输出 JSON 以外的任何文字。\n"
|
||||
"- 严格遵守用户消息末尾给出的 JSON 结构与字段要求,中文内容用中文写,"
|
||||
"提示词词组按各段惯例(英文逗号分隔)。\n"
|
||||
"- 这是正式交付物:内容要完整、具体、可直接使用,不要输出占位符或「待补充」。"
|
||||
),
|
||||
f"本段任务:{STAGE_BRIEFS[stage_key]}",
|
||||
]
|
||||
skills_ctx = _skills_context(stage_key)
|
||||
if skills_ctx:
|
||||
parts.append(skills_ctx)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _stage_context(conn, project_id: int, stage_key: str) -> str:
|
||||
"""Confirmed upstream artifacts only — drafts never leak downstream."""
|
||||
project = _get_project(conn, project_id)
|
||||
blocks: list[str] = [f"项目名称:{project['title']}"]
|
||||
if project.get("idea") and stage_key != "script":
|
||||
idea = _text(project["idea"])
|
||||
if len(idea) <= 600:
|
||||
blocks.append(f"用户创意:{idea}")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_stages WHERE project_id = ?",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
stages = {r["stage_key"]: row_to_dict(r) for r in rows}
|
||||
|
||||
def confirmed(key: str) -> dict[str, Any] | None:
|
||||
row = stages.get(key)
|
||||
if not row or row["status"] != "confirmed" or not row["confirmed_content_json"]:
|
||||
return None
|
||||
return json.loads(row["confirmed_content_json"])
|
||||
|
||||
script = confirmed("script")
|
||||
if script and stage_key in ("style", "storyboard", "assets", "shot"):
|
||||
blocks.append(f"【已确认的剧本】\n{_text(script.get('text'))}")
|
||||
|
||||
style = confirmed("style")
|
||||
if style and stage_key in ("storyboard", "assets", "shot"):
|
||||
chosen_id = stages.get("style", {}).get("chosen_style_id")
|
||||
directions = style.get("directions") or []
|
||||
chosen = next(
|
||||
(d for d in directions if d.get("id") == chosen_id), directions[0] if directions else None
|
||||
)
|
||||
if chosen:
|
||||
blocks.append(
|
||||
"【已确认的视觉风格】\n"
|
||||
f"{chosen.get('name')}:{chosen.get('description') or ''}\n"
|
||||
f"风格关键词:{chosen.get('prompt')}"
|
||||
)
|
||||
else:
|
||||
names = "、".join(d.get("name", "") for d in directions)
|
||||
blocks.append(f"【已确认的视觉风格】候选方向:{names}")
|
||||
|
||||
board = confirmed("storyboard")
|
||||
if board and stage_key in ("assets", "shot"):
|
||||
lines = []
|
||||
for i, shot in enumerate(board.get("shots") or [], 1):
|
||||
lines.append(
|
||||
f"{i}. 场次{shot.get('scene')}|{shot.get('shot_size')}|{shot.get('camera')}|"
|
||||
f"{shot.get('description')}"
|
||||
+ (f"|台词:{shot.get('dialog')}" if shot.get("dialog") else "")
|
||||
)
|
||||
blocks.append("【已确认的分镜表】\n" + "\n".join(lines))
|
||||
|
||||
assets = confirmed("assets")
|
||||
if assets and stage_key == "shot":
|
||||
lines = [
|
||||
f"- {a.get('kind')}|{a.get('name')}:{a.get('description') or a.get('prompt')}"
|
||||
for a in assets.get("assets") or []
|
||||
]
|
||||
blocks.append("【已确认的素材清单】\n" + "\n".join(lines))
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
_JSON_SCHEMAS = {
|
||||
"script": '{"text": "完整剧本正文(简体中文,含大纲/分场/台词)"}',
|
||||
"style": (
|
||||
'{"directions": [{"id": "style1", "name": "风格名称", '
|
||||
'"description": "中文风格说明", "prompt": "english, style, keywords", '
|
||||
'"negative_prompt": "optional english negative keywords"}]}(至少 2 套)'
|
||||
),
|
||||
"storyboard": (
|
||||
'{"shots": [{"id": "shot1", "scene": "场次", "shot_size": "景别", '
|
||||
'"camera": "运镜", "description": "画面内容", "dialog": "台词(可空)", '
|
||||
'"duration_sec": 3}]}'
|
||||
),
|
||||
"assets": (
|
||||
'{"assets": [{"id": "asset1", "kind": "character|location|prop", '
|
||||
'"name": "名称", "description": "中文说明", '
|
||||
'"prompt": "complete english image prompt", '
|
||||
'"negative_prompt": "可空"}]}'
|
||||
),
|
||||
"shot": (
|
||||
'{"shots": [{"id": "vshot1", "shot_no": "S1", '
|
||||
'"description": "中文画面说明", "prompt": "complete english video prompt", '
|
||||
'"duration_sec": 5}]}'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Strong refs so in-flight runs are never garbage-collected mid-turn
|
||||
# (same pattern as the harness runner).
|
||||
_ACTIVE_RUNS: dict[int, asyncio.Task] = {}
|
||||
|
||||
|
||||
async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction: str | None) -> None:
|
||||
db = config.settings.db_path
|
||||
try:
|
||||
conn = get_db(db)
|
||||
try:
|
||||
context = _stage_context(conn, project_id, stage_key)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
user_parts = [context]
|
||||
if instruction and instruction.strip():
|
||||
user_parts.append(f"用户补充要求:{instruction.strip()}")
|
||||
user_parts.append(
|
||||
f"请产出本段({ _stage_name(stage_key)})的 JSON:{_JSON_SCHEMAS[stage_key]}"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": _system_prompt(stage_key)},
|
||||
{"role": "user", "content": "\n\n".join(user_parts)},
|
||||
]
|
||||
|
||||
role = AGENTS[stage_key]["llm_role"]
|
||||
client = LLMClient.for_role(role, timeout=240.0)
|
||||
try:
|
||||
text = await client.chat(messages, temperature=0.7)
|
||||
finally:
|
||||
await client.close()
|
||||
content = NORMALIZERS[stage_key](extract_json(text))
|
||||
|
||||
conn = get_db(db)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
(json.dumps(content, ensure_ascii=False), run_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?, status='awaiting_confirm', "
|
||||
"draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(project_id,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc: # noqa: BLE001 — any failure becomes a readable run row
|
||||
reason = _friendly_error(exc)
|
||||
try:
|
||||
conn = get_db(db)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE stage_runs SET status='failed', error=?, completed_at=CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
(reason, run_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET status=CASE "
|
||||
"WHEN confirmed_content_json IS NOT NULL THEN 'confirmed' ELSE 'todo' END, "
|
||||
"error=?, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ? AND status = 'running'",
|
||||
(reason, project_id, stage_key),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception: # pragma: no cover — bookkeeping failure only
|
||||
pass
|
||||
|
||||
|
||||
def _friendly_error(exc: Exception) -> str:
|
||||
text = str(exc)
|
||||
lowered = text.lower()
|
||||
if "connect" in lowered or "timeout" in lowered or "timed out" in lowered:
|
||||
return (
|
||||
"连接大模型服务失败:请到「模型设置」检查地址与密钥,或点「重新生成」再试一次。"
|
||||
)
|
||||
if "no json" in lowered or isinstance(exc, ValueError):
|
||||
return "智能体没有返回可解析的结果,请点「重新生成」重试;多次失败可换一个模型。"
|
||||
return f"生成失败:{text[:300]}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Endpoints
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}")
|
||||
async def workbench_overview(project_id: int) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
project = _get_project(conn, project_id)
|
||||
_ensure_stage_rows(conn, project_id)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM project_stages WHERE project_id = ? ORDER BY id ASC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
outputs = _workbench_job_outputs(conn, project_id)
|
||||
stages = []
|
||||
for row in rows:
|
||||
stage = _stage_public(row_to_dict(row))
|
||||
stages.append(_enrich_stage(stage, outputs))
|
||||
runs = {key: _last_run(conn, project_id, key) for key in STAGE_KEYS}
|
||||
for stage in stages:
|
||||
stage["last_run"] = runs.get(stage["stage_key"])
|
||||
return {
|
||||
"project": {
|
||||
"id": project["id"],
|
||||
"title": project["title"],
|
||||
"idea": project.get("idea"),
|
||||
"status": project["status"],
|
||||
"updated_at": project["updated_at"],
|
||||
},
|
||||
"stages": stages,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _stage_or_404(conn, project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
if stage_key not in STAGE_KEYS:
|
||||
raise HTTPException(status_code=404, detail="阶段不存在")
|
||||
_ensure_stage_rows(conn, project_id)
|
||||
return _load_stage(conn, project_id, stage_key)
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/stages/{stage_key}")
|
||||
async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> dict[str, Any]:
|
||||
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="本阶段正在生成中,请稍候再编辑。")
|
||||
try:
|
||||
content = NORMALIZERS[stage_key](payload.content)
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
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;
|
||||
# the confirmed snapshot stays intact for 恢复.
|
||||
next_status = "awaiting_confirm" if stage["status"] == "confirmed" else stage["status"]
|
||||
if stage["status"] == "todo":
|
||||
next_status = "awaiting_confirm"
|
||||
chosen = payload.chosen_style_id
|
||||
if stage_key == "style" and chosen:
|
||||
ids = [d.get("id") for d in content.get("directions", [])]
|
||||
if chosen not in ids:
|
||||
raise HTTPException(status_code=400, detail="所选风格方向不存在,请刷新后重试。")
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?, status=?, draft_edited=1, "
|
||||
"chosen_style_id=COALESCE(?, chosen_style_id), updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(
|
||||
json.dumps(content, ensure_ascii=False),
|
||||
next_status,
|
||||
chosen,
|
||||
project_id,
|
||||
stage_key,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, stage_key)
|
||||
return {"ok": True, "stage": _stage_public(row)}
|
||||
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
|
||||
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="本阶段正在生成中,请稍候。")
|
||||
# Prerequisite: the previous stage must be confirmed (drafts never
|
||||
# unlock the next agent).
|
||||
idx = STAGE_KEYS.index(stage_key)
|
||||
if idx > 0:
|
||||
prev = _load_stage(conn, project_id, STAGE_KEYS[idx - 1])
|
||||
if prev["status"] != "confirmed":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"请先完成并确认上一阶段「{_stage_name(STAGE_KEYS[idx - 1])}」。",
|
||||
)
|
||||
# LLM must be resolvable — fail fast with a fix-it message.
|
||||
try:
|
||||
profile = config.settings.resolve_llm_for_role(AGENTS[stage_key]["llm_role"])
|
||||
except Exception: # pragma: no cover — defensive
|
||||
profile = None
|
||||
if not profile or not (profile.get("base_url") or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="尚未配置可用的大模型:请到「模型设置」添加并测试通过后再生成。",
|
||||
)
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO stage_runs (project_id, stage_key, instruction) VALUES (?, ?, ?)",
|
||||
(project_id, stage_key, instruction),
|
||||
)
|
||||
run_id = int(cur.lastrowid)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET status='running', error=NULL, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(project_id, stage_key),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
task = asyncio.create_task(_execute_run(project_id, stage_key, run_id, instruction))
|
||||
_ACTIVE_RUNS[run_id] = task
|
||||
task.add_done_callback(lambda t: _ACTIVE_RUNS.pop(run_id, None))
|
||||
return {"ok": True, "run_id": run_id}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/stages/{stage_key}/confirm")
|
||||
async def confirm_stage(
|
||||
project_id: int, stage_key: str, payload: StageConfirmRequest | None = None
|
||||
) -> dict[str, Any]:
|
||||
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="本阶段正在生成中,请等待完成。")
|
||||
if not stage["content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
|
||||
content = json.loads(stage["content_json"])
|
||||
chosen = payload.chosen_style_id if payload else None
|
||||
if stage_key == "style":
|
||||
directions = content.get("directions") or []
|
||||
ids = [d.get("id") for d in directions]
|
||||
if chosen and chosen not in ids:
|
||||
raise HTTPException(status_code=400, detail="所选风格方向不存在,请刷新后重试。")
|
||||
if not chosen:
|
||||
chosen = stage["chosen_style_id"] or (ids[0] if ids else None)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET status='confirmed', confirmed_content_json=?, "
|
||||
"chosen_style_id=?, draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(
|
||||
json.dumps(content, ensure_ascii=False),
|
||||
chosen if stage_key == "style" else stage["chosen_style_id"],
|
||||
project_id,
|
||||
stage_key,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, stage_key)
|
||||
return {"ok": True, "stage": _stage_public(row)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/stages/{stage_key}/restore")
|
||||
async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_get_project(conn, project_id)
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if not stage["confirmed_content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=confirmed_content_json, "
|
||||
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(project_id, stage_key),
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, stage_key)
|
||||
return {"ok": True, "stage": _stage_public(row)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/stages/script/import")
|
||||
async def import_script(project_id: int, payload: ScriptImportRequest) -> dict[str, Any]:
|
||||
"""导入现成剧本:直接确认为剧本阶段基线,从视觉设计继续。"""
|
||||
text = payload.text.strip()
|
||||
if len(text) < 30:
|
||||
raise HTTPException(status_code=400, detail="剧本内容太短,请检查粘贴是否完整。")
|
||||
content = {"text": text}
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_get_project(conn, project_id)
|
||||
_stage_or_404(conn, project_id, "script")
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?, confirmed_content_json=?, "
|
||||
"status='confirmed', draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = 'script'",
|
||||
(
|
||||
json.dumps(content, ensure_ascii=False),
|
||||
json.dumps(content, ensure_ascii=False),
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, "script")
|
||||
return {"ok": True, "stage": _stage_public(row)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/generate")
|
||||
async def workbench_generate(project_id: int, payload: WorkbenchGenerateRequest) -> dict[str, Any]:
|
||||
"""In-flow generation (HEL-612 shared queue): one image or video job,
|
||||
project-linked, tagged workbench so results come back to the project."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_get_project(conn, project_id)
|
||||
_stage_or_404(conn, project_id, payload.stage_key)
|
||||
wf = conn.execute(
|
||||
"SELECT * FROM workflows WHERE kind = ? AND is_enabled = 1 ORDER BY id ASC LIMIT 1",
|
||||
(payload.kind,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
label = "生图" if payload.kind == "image" else "生视频"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"没有可用的{label}工作流:请先到「ComfyUI 连接」确认连接可用,"
|
||||
"再到工作流管理导入并启用一个"
|
||||
f"{label}工作流。",
|
||||
)
|
||||
workflow = row_to_dict(wf)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
inputs = parse_dynamic_inputs(json.loads(workflow["workflow_json"] or "{}"))
|
||||
duration = payload.duration_sec if payload.kind == "video" else None
|
||||
input_values = smart_fill_inputs(
|
||||
inputs,
|
||||
prompt=payload.prompt,
|
||||
negative_prompt=payload.negative_prompt or None,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=project_id,
|
||||
kind=payload.kind,
|
||||
workflow_id=int(workflow["id"]),
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"prompt": payload.prompt,
|
||||
"source": WORKBENCH_SOURCE,
|
||||
"stage_key": payload.stage_key,
|
||||
"target_id": payload.target_id,
|
||||
},
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"kind": payload.kind,
|
||||
"source": WORKBENCH_SOURCE,
|
||||
"project_id": project_id,
|
||||
},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job)}
|
||||
@@ -5,11 +5,42 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
mapping_problem,
|
||||
needs_user_output_map,
|
||||
validate_output_map,
|
||||
)
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
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.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_SOURCE,
|
||||
find_active_test_job,
|
||||
list_test_jobs,
|
||||
purge_workflow_tests,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -17,29 +48,104 @@ router = APIRouter()
|
||||
def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data = row_to_dict(row)
|
||||
data["workflow_json"] = json.loads(data["workflow_json"])
|
||||
data["input_schema"] = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
|
||||
# Outputs are pure graph derivation (no manual overrides) — always fresh,
|
||||
# so parser upgrades reach old rows without a re-import.
|
||||
data["output_schema"] = parse_dynamic_outputs(data["workflow_json"])
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["warnings"] = workflow_warnings(data["workflow_json"])
|
||||
raw_map = data.get("output_map")
|
||||
data["output_map"] = load_output_map(raw_map)
|
||||
# Pending flag: unknown output candidates the user has not mapped yet.
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
|
||||
|
||||
An unreachable connection degrades to ``reachable: False`` with an honest
|
||||
「无法核查」 message — it never blocks and never pretends to pass.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"endpoint_id": endpoint_id,
|
||||
"endpoint_name": None,
|
||||
"findings": [],
|
||||
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
|
||||
}
|
||||
base = {
|
||||
"endpoint_id": endpoint.get("id"),
|
||||
"endpoint_name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=refresh)
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"findings": [],
|
||||
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
findings = check_workflow_against(object_info, workflow_json)
|
||||
ok = not any(f.get("level") == "error" for f in findings)
|
||||
return {**base, "ok": ok, "reachable": True, "findings": findings}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
return {
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
result: dict[str, Any] = {
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"suggested_profile": detect_prompt_profile(payload.workflow_json),
|
||||
"warnings": workflow_warnings(payload.workflow_json),
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"needs_mapping": judgment["needs_mapping"],
|
||||
"usage_problem": judgment["problem"],
|
||||
"usage_evidence": judgment["evidence"],
|
||||
"usable_usage": judgment["usable_usage"],
|
||||
}
|
||||
# L2 (optional): with a target connection the analyze response also
|
||||
# reports which nodes/models that ComfyUI actually has (HEL-643).
|
||||
if payload.endpoint_id:
|
||||
result["connection_check"] = await _connection_check(
|
||||
payload.endpoint_id, payload.workflow_json, payload.refresh
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
|
||||
"""Schema to persist: the confirm page's corrected copy, else the auto one."""
|
||||
if payload_schema and all(isinstance(e, dict) for e in payload_schema):
|
||||
return json.dumps(payload_schema)
|
||||
return json.dumps(parse_dynamic_inputs(workflow))
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/reanalyze")
|
||||
async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
"""Recompute input/output schemas from the stored workflow JSON.
|
||||
"""Recompute usage and input/output schemas from the stored workflow JSON.
|
||||
|
||||
Workflows imported before a parser upgrade keep stale (often empty)
|
||||
schemas — this refreshes them without re-importing.
|
||||
schemas — this refreshes them without re-importing. A usage or output
|
||||
mapping the user set by hand is never overwritten; only ``auto`` (and
|
||||
pending NULL) rows get the new judgment.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
@@ -48,11 +154,40 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
data = row_to_dict(row)
|
||||
workflow = json.loads(data["workflow_json"])
|
||||
inputs = parse_dynamic_inputs(workflow)
|
||||
judgment = classify_usage(workflow)
|
||||
usage = data["usage"]
|
||||
usage_source = data["usage_source"]
|
||||
if data["usage_source"] != "user" and judgment["usage"]:
|
||||
usage = judgment["usage"]
|
||||
usage_source = "auto"
|
||||
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
|
||||
schema = effective_input_schema(stored, workflow)
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
if data["output_map_source"] == "user":
|
||||
output_map, output_map_source = data["output_map"], "user"
|
||||
elif needs_user_output_map(workflow):
|
||||
# Unknown output candidates: no auto map, and the row must not
|
||||
# generate until the user designates the output nodes.
|
||||
output_map, output_map_source = None, None
|
||||
else:
|
||||
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
|
||||
is_enabled = data["is_enabled"]
|
||||
if output_map_source is None or usage is None:
|
||||
is_enabled = 0
|
||||
conn.execute(
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ? WHERE id = ?",
|
||||
(json.dumps(inputs), json.dumps(outputs), workflow_id),
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
|
||||
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
|
||||
" is_enabled = ? WHERE id = ?",
|
||||
(
|
||||
json.dumps(schema),
|
||||
json.dumps(outputs),
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
is_enabled,
|
||||
workflow_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
fresh = conn.execute(
|
||||
@@ -63,16 +198,64 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
|
||||
try:
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
outputs = []
|
||||
return {
|
||||
"id": job["id"],
|
||||
"status": job.get("status"),
|
||||
"phase": job.get("phase"),
|
||||
"error": job.get("error"),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"output_count": len(outputs) if isinstance(outputs, list) else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workflows() -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
|
||||
return [_serialize_workflow(r) for r in rows]
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
# no cached column that could go stale (HEL-643).
|
||||
latest: dict[int, dict[str, Any]] = {}
|
||||
for jr in conn.execute(
|
||||
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
|
||||
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
|
||||
).fetchall():
|
||||
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
|
||||
for data in out:
|
||||
data["last_test"] = latest.get(int(data["id"]))
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/check")
|
||||
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
|
||||
"""L2 re-check of a saved workflow against its (or a chosen) connection.
|
||||
|
||||
The user installs missing nodes/models in ComfyUI, comes back here and
|
||||
re-checks — no re-import needed.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
endpoint_id = payload.endpoint_id or row["endpoint_id"]
|
||||
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
|
||||
|
||||
|
||||
@router.post("/dedupe")
|
||||
async def dedupe_workflows() -> dict[str, Any]:
|
||||
"""Delete byte-identical duplicate workflows, keeping the lowest id.
|
||||
@@ -110,36 +293,77 @@ async def dedupe_workflows() -> dict[str, Any]:
|
||||
|
||||
@router.post("")
|
||||
async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
usage = payload.usage or judgment["usage"]
|
||||
usage_source = "user" if payload.usage else "auto"
|
||||
kind = payload.kind or usage_to_kind(usage)
|
||||
if kind is None:
|
||||
# Indeterminate and nothing chosen: classify_output hint keeps old
|
||||
# callers working while the workflow stays pending confirmation.
|
||||
out_kinds = {
|
||||
o.get("kind")
|
||||
for o in parse_dynamic_outputs(payload.workflow_json)
|
||||
if o.get("kind") in ("image", "video")
|
||||
}
|
||||
kind = "video" if out_kinds == {"video"} else "image"
|
||||
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
|
||||
# HEL-638: an explicit user output map wins; otherwise the trusted auto
|
||||
# map. Either way a graph with unknown output candidates is unusable
|
||||
# until the user designates the output nodes.
|
||||
if payload.output_map:
|
||||
problems = validate_output_map(payload.workflow_json, payload.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(payload.output_map)
|
||||
output_map_source = payload.output_map_source or "user"
|
||||
else:
|
||||
auto_map = auto_output_map(payload.workflow_json)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(payload.workflow_json) or bool(payload.output_map)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Byte-identical re-imports are the main way the library fills up with
|
||||
# same-named clones (the "@" typeahead then shows 20 identical rows).
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(payload.name, payload.kind, json.dumps(payload.workflow_json)),
|
||||
(payload.name, kind, json.dumps(payload.workflow_json)),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"An identical workflow already exists (id {existing['id']}, “{existing['name']}”).",
|
||||
detail=(
|
||||
f"An identical workflow already exists (id {existing['id']},"
|
||||
f" “{existing['name']}”)."
|
||||
),
|
||||
)
|
||||
# Unconfirmed workflows (ambiguous usage / unknown outputs without a
|
||||
# user mapping) are stored but disabled: they must not reach the
|
||||
# generate pages until the user confirms usage AND, for unknown
|
||||
# output nodes, designates the outputs on the connections page.
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema,
|
||||
output_schema, description, prompt_profile, is_enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
output_schema, description, prompt_profile,
|
||||
endpoint_id, is_enabled, usage, usage_source,
|
||||
output_map, output_map_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
payload.kind,
|
||||
kind,
|
||||
json.dumps(payload.workflow_json),
|
||||
json.dumps(inputs),
|
||||
json.dumps(outputs),
|
||||
_stored_schema(payload.input_schema, payload.workflow_json),
|
||||
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
|
||||
payload.description,
|
||||
profile,
|
||||
payload.endpoint_id,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -171,7 +395,54 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "is_enabled" in data and data["is_enabled"] is not None:
|
||||
data["is_enabled"] = 1 if data["is_enabled"] else 0
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
workflow_json = json.loads(existing["workflow_json"])
|
||||
# HEL-638: output_map payload. A non-empty list validates against the
|
||||
# stored JSON and becomes the user decision; an explicit empty list
|
||||
# resets the row to pending (disabled).
|
||||
if "output_map" in data:
|
||||
if data["output_map"]:
|
||||
problems = validate_output_map(workflow_json, data["output_map"])
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
data["output_map"] = json.dumps(data["output_map"])
|
||||
data.setdefault("output_map_source", "user")
|
||||
else:
|
||||
data["output_map"] = None
|
||||
data["output_map_source"] = None
|
||||
data["is_enabled"] = 0
|
||||
if data.get("usage"):
|
||||
# Confirming / correcting the usage is an explicit user decision:
|
||||
# kind (output media) follows the usage. Whether it re-enables is
|
||||
# decided by the gate below — with unknown output nodes the user
|
||||
# must also designate the output nodes (HEL-638).
|
||||
data.setdefault("usage_source", "user")
|
||||
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
|
||||
if "input_schema" in data and data["input_schema"] is not None:
|
||||
if not all(isinstance(e, dict) for e in data["input_schema"]):
|
||||
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
|
||||
data["input_schema"] = json.dumps(data["input_schema"])
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips
|
||||
# (an output_map reset keeps its explicit NULLs).
|
||||
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
|
||||
k for k, v in data.items() if v is None
|
||||
}
|
||||
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
|
||||
|
||||
# Enable gate: usage must be settled and, when the graph carries
|
||||
# unknown custom output nodes, a user output mapping must exist.
|
||||
usage_ok = bool(data.get("usage") or existing["usage"])
|
||||
map_raw = data.get("output_map", existing["output_map"])
|
||||
map_source = data.get("output_map_source", existing["output_map_source"])
|
||||
map_problem = mapping_problem(workflow_json, map_raw, map_source)
|
||||
if data.get("is_enabled"):
|
||||
if not usage_ok:
|
||||
raise HTTPException(status_code=400, detail="请先选择工作流用途,再启用该工作流")
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
elif (data.get("usage") or data.get("output_map")) and "is_enabled" not in data:
|
||||
# A usage-confirm or output-map-confirm path: enabled only when
|
||||
# both decisions are settled.
|
||||
data["is_enabled"] = 1 if (usage_ok and not map_problem) else 0
|
||||
if data:
|
||||
fields = ", ".join(f"{k} = :{k}" for k in data)
|
||||
data["id"] = workflow_id
|
||||
@@ -183,14 +454,149 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
conn.close()
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/test")
|
||||
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
|
||||
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
|
||||
|
||||
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
|
||||
only its artifacts and bookkeeping are isolated (origin='workflow_test').
|
||||
A dry-run setting is refused outright — placeholder output must never
|
||||
count as a test pass. Repeat clicks reuse the in-flight job.
|
||||
"""
|
||||
from calliope import config
|
||||
|
||||
if config.settings.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后再测试"
|
||||
),
|
||||
)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
|
||||
" endpoint_id FROM workflows WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
|
||||
snapshot = endpoint_snapshot(endpoint_id)
|
||||
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
|
||||
if active is not None:
|
||||
return {"ok": True, "job": _job_public(active), "reused": True}
|
||||
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=wf["kind"],
|
||||
workflow_id=workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": TEST_SOURCE,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
origin="workflow_test",
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
|
||||
"project_id": job["project_id"], "workflow_test": True},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job), "reused": False}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/tests")
|
||||
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return list_test_jobs(workflow_id)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/clear-tests")
|
||||
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
|
||||
"""Delete every test record and artifact for this workflow."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
try:
|
||||
removed = purge_workflow_tests(workflow_id)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
# HEL-619: refuse to delete while queued/running jobs still use the
|
||||
# workflow — deleting sets their workflow_id NULL and the job dies
|
||||
# with "工作流已被删除" only after the user waits for it.
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs"
|
||||
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
|
||||
(workflow_id,),
|
||||
).fetchone()["n"]
|
||||
if pending:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"该工作流还有 {pending} 个排队或执行中的任务,"
|
||||
"请先等任务完成或取消任务,再删除该工作流"
|
||||
),
|
||||
)
|
||||
# Test records/artifacts belong to the workflow — cascade (HEL-643).
|
||||
# The 409 above guarantees none are in flight here.
|
||||
purge_workflow_tests(workflow_id, allow_active=True)
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -10,6 +10,10 @@ from fastapi.testclient import TestClient
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
# Shared fake-ComfyUI HTTP server (defined in test_studio_generation,
|
||||
# reused by the generation / status / unknown-output-mapping suites).
|
||||
from tests.test_studio_generation import fake_comfy # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE. For image-to-video describe the motion from the first frame; the frame fixes the look. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music)."
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/04_i2v_fl2v_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"first_frame": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ImageToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "first_frame.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE, in the reference contract:\nsubject_definitions:\n<Subject 1> is the person in <Picture 1>; their face, hair and clothing follow <Picture 1>.\n\nsummary:\n[reference generation] one continuous shot of <Subject 1> doing X, appearance taken from <Picture 1>.\n\nretention_analysis:\n<Subject 1> is fully preserved from <Picture 1>.\n\ndetailed_description:\n[Shot 1] ... camera, action, timing ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: N/A"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/05_ref2va_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"ref_image_size": "match",
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"audio_vae": [
|
||||
"120",
|
||||
0
|
||||
],
|
||||
"ref_images.ref_image_0": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ReferenceToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "reference_1.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "REFERENCE IMAGE 1 (<Picture 1>); the node grows more image / video / audio slots"
|
||||
}
|
||||
}
|
||||
}
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
{
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "9:16 (Portrait Widescreen)",
|
||||
"megapixels": 0.6,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "Resolution Selector"
|
||||
}
|
||||
},
|
||||
"207": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax-h3\\minimax_h3_video_vae_fp16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"208": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax-h3\\minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"209": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"213",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"208",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAE Decode Audio"
|
||||
}
|
||||
},
|
||||
"210": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"213",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"207",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"211": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"212": {
|
||||
"inputs": {
|
||||
"scheduler": "beta",
|
||||
"steps": 6,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"6007",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"213": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"217",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"214",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"211",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"212",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"219",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"214": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"6007",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"219",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "Basic Guider"
|
||||
}
|
||||
},
|
||||
"215": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax-h3\\minimax_h3_fl2va_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"216": {
|
||||
"inputs": {
|
||||
"clip_name": "minimax-h3\\qwen3vl_32b_minimax_h3_int8_convrot.comfy.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP"
|
||||
}
|
||||
},
|
||||
"217": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"219": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"6005",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"220",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"6010",
|
||||
1
|
||||
],
|
||||
"vae": [
|
||||
"207",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMax H3 Image to Video"
|
||||
}
|
||||
},
|
||||
"220": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"221",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "Math Expression"
|
||||
}
|
||||
},
|
||||
"221": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "Seconds"
|
||||
}
|
||||
},
|
||||
"223": {
|
||||
"inputs": {
|
||||
"sage_attention": "auto",
|
||||
"allow_compile": false,
|
||||
"model": [
|
||||
"6020",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PathchSageAttentionKJ",
|
||||
"_meta": {
|
||||
"title": "Patch Sage Attention KJ"
|
||||
}
|
||||
},
|
||||
"5970": {
|
||||
"inputs": {
|
||||
"model_name": "film_net_fp16.safetensors"
|
||||
},
|
||||
"class_type": "FrameInterpolationModelLoader",
|
||||
"_meta": {
|
||||
"title": "Load Frame Interpolation Model"
|
||||
}
|
||||
},
|
||||
"5971": {
|
||||
"inputs": {
|
||||
"multiplier": 2,
|
||||
"interp_model": [
|
||||
"5970",
|
||||
0
|
||||
],
|
||||
"images": [
|
||||
"5976",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FrameInterpolate",
|
||||
"_meta": {
|
||||
"title": "Run Frame Interpolation Model"
|
||||
}
|
||||
},
|
||||
"5974": {
|
||||
"inputs": {
|
||||
"expression": "a*2",
|
||||
"a": [
|
||||
"5986",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MathExpression|pysssss",
|
||||
"_meta": {
|
||||
"title": "Math Expression 🐍"
|
||||
}
|
||||
},
|
||||
"5975": {
|
||||
"inputs": {
|
||||
"resize_type": "scale by multiplier",
|
||||
"resize_type.scale": 1.5,
|
||||
"quality": "ULTRA",
|
||||
"images": [
|
||||
"5971",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "RTXVideoSuperResolution",
|
||||
"_meta": {
|
||||
"title": "RTX Video Super Resolution Pro"
|
||||
}
|
||||
},
|
||||
"5976": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"5977",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy clearCacheAll",
|
||||
"_meta": {
|
||||
"title": "Clear Cache All"
|
||||
}
|
||||
},
|
||||
"5977": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"6063:5538",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"_meta": {
|
||||
"title": "Clean VRAM Used"
|
||||
}
|
||||
},
|
||||
"5978": {
|
||||
"inputs": {
|
||||
"frame_rate": [
|
||||
"5974",
|
||||
1
|
||||
],
|
||||
"loop_count": 0,
|
||||
"filename_prefix": "minimax-h3/vid-vfi",
|
||||
"format": "video/h264-mp4",
|
||||
"pix_fmt": "yuv420p",
|
||||
"crf": 19,
|
||||
"save_metadata": true,
|
||||
"trim_to_audio": false,
|
||||
"pingpong": false,
|
||||
"save_output": true,
|
||||
"images": [
|
||||
"5975",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"6063:4848",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"_meta": {
|
||||
"title": "Video Output 2"
|
||||
}
|
||||
},
|
||||
"5986": {
|
||||
"inputs": {
|
||||
"value": 24
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"6005": {
|
||||
"inputs": {
|
||||
"value": "integrated_multimodal_description: [Shot 1] Cinematic, live-action, shot on iPhone, a vertical selfie frame from a phone held at arm's length above eye level. A young woman in her mid-twenties with sun-kissed tan skin and loose wavy chestnut brown hair, wearing a white cropped t-shirt with a gold pendant necklace, looks directly into the phone lens, her face filling the centre of frame. Warm golden hour sunlight hits the right side of her face from a low sun behind the trees, casting a soft amber glow across her skin. Behind her, a city park is blurred into soft green and gold bokeh — trees, dappled light, a hot pink sunset sky bleeding through the leaves. The camera holds static in selfie framing. The young woman's lips move as she speaks with a bright, energetic voice with a slight North American accent (S1): <d>[English] Oh my god, you guys, it is actually perfect out here right now.</d> Her mouth closes after the last word, then she breaks into a wide grin, teeth showing, the skin at the corners of her eyes crinkling. She tilts her head slightly to the right, the grin fading to a soft expression as her lips move and she speaks again, her voice warmer (S1): <d>[English] Like, I didn't want to leave.</d> Her mouth closes. She presses her lips together, then points her index finger at the camera and speaks with a playful tone, her lips forming each word (S1): <d>[English] So I'm not.</d> Her mouth closes, and the wide grin returns. With her free left hand she pushes the windblown hair behind her ear, her gold bracelet sliding down her wrist. She raises her free hand in a quick peace sign next to her cheek, her smile widening, and laughs — a short, genuine laugh, her mouth opening, shoulders shaking once, her eyes squeezing shut for a beat, then opening again. She drops the peace sign and brings the phone slightly closer, her face growing larger in frame, the grin settling into a warm smile. The camera pushes in with small amplitude at slow speed as the phone draws closer.\n\noverall_soundscape: A warm breeze carries birdsong and the distant hum of a city at golden hour. The woman's laugh is clear and close to the phone mic — short, genuine, with a slight exhale. Loose hair rustles as she tucks it behind her ear, and the gold bracelet lightly clinks against itself on her wrist.\n\nnon_diegetic_music: N/A"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "Text Prompt"
|
||||
}
|
||||
},
|
||||
"6006": {
|
||||
"inputs": {
|
||||
"lora_name": "minimax-h3\\minimax_h3_fl2v_turbo_4step_v1.1_768p_comfyui_bf16.safetensors",
|
||||
"strength_model": 0.6,
|
||||
"model": [
|
||||
"215",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"_meta": {
|
||||
"title": "Load LoRA"
|
||||
}
|
||||
},
|
||||
"6007": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"223",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "ModelSamplingMiniMaxH3"
|
||||
}
|
||||
},
|
||||
"6010": {
|
||||
"inputs": {
|
||||
"PowerLoraLoaderHeaderWidget": {
|
||||
"type": "PowerLoraLoaderHeaderWidget"
|
||||
},
|
||||
"lora_1": {
|
||||
"on": true,
|
||||
"lora": "minimax-h3\\h3-realism-people-t2v-i2v-r2v(r34l1sm).safetensors",
|
||||
"strength": 1
|
||||
},
|
||||
"➕ Add Lora": "",
|
||||
"model": [
|
||||
"6006",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"216",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Power Lora Loader (rgthree)",
|
||||
"_meta": {
|
||||
"title": "Power Lora Loader (rgthree)"
|
||||
}
|
||||
},
|
||||
"6020": {
|
||||
"inputs": {
|
||||
"tau": 1.2,
|
||||
"start_percent": 0.2,
|
||||
"end_percent": 0.9,
|
||||
"min_tokens": 4096,
|
||||
"int8_qk": false,
|
||||
"sink_conditioning": "exact_kv",
|
||||
"morton": false,
|
||||
"morton_curve": "3d",
|
||||
"verbose": false,
|
||||
"model": [
|
||||
"6010",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SolAttnPatch",
|
||||
"_meta": {
|
||||
"title": "Patch Sol-Attn"
|
||||
}
|
||||
},
|
||||
"6059": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"6060",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy clearCacheAll",
|
||||
"_meta": {
|
||||
"title": "Clear Cache All"
|
||||
}
|
||||
},
|
||||
"6060": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"210",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"_meta": {
|
||||
"title": "Clean VRAM Used"
|
||||
}
|
||||
},
|
||||
"6062:5570": {
|
||||
"inputs": {
|
||||
"vae_name": "ltx-2.5\\ltx-2.5-audio-vae-bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE - Audio"
|
||||
}
|
||||
},
|
||||
"6062:5571": {
|
||||
"inputs": {
|
||||
"vae_name": "ltx-2.5\\ltx-2.5-video-vae-bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE - Video"
|
||||
}
|
||||
},
|
||||
"6062:5569": {
|
||||
"inputs": {
|
||||
"unet_name": "ltx-2.5\\ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"6062:5577": {
|
||||
"inputs": {
|
||||
"model_name": "ltx-2.5\\ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors"
|
||||
},
|
||||
"class_type": "LatentUpscaleModelLoader",
|
||||
"_meta": {
|
||||
"title": "Load Latent Upscale Model"
|
||||
}
|
||||
},
|
||||
"6062:5574": {
|
||||
"inputs": {
|
||||
"clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors",
|
||||
"type": "ltxv",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP - Text Encoder"
|
||||
}
|
||||
},
|
||||
"6065:2612": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"6062:5574",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Negative Prompt)"
|
||||
}
|
||||
},
|
||||
"6065:2483": {
|
||||
"inputs": {
|
||||
"text": [
|
||||
"6005",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"6062:5574",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Positive Prompt)"
|
||||
}
|
||||
},
|
||||
"6065:1241": {
|
||||
"inputs": {
|
||||
"frame_rate": [
|
||||
"5986",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6065:2483",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"6065:2612",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVConditioning",
|
||||
"_meta": {
|
||||
"title": "LTXVConditioning"
|
||||
}
|
||||
},
|
||||
"6061:4985": {
|
||||
"inputs": {
|
||||
"sigmas": "0.85, 0.7250, 0.4219, 0.0"
|
||||
},
|
||||
"class_type": "ManualSigmas",
|
||||
"_meta": {
|
||||
"title": "ManualSigmas"
|
||||
}
|
||||
},
|
||||
"6061:4976": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"6061:4964": {
|
||||
"inputs": {
|
||||
"cfg": 1,
|
||||
"model": [
|
||||
"6062:5569",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6065:1241",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"6065:1241",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CFGGuider",
|
||||
"_meta": {
|
||||
"title": "CFG Guider"
|
||||
}
|
||||
},
|
||||
"6061:4967": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"6061:4973": {
|
||||
"inputs": {
|
||||
"av_latent": [
|
||||
"6061:4971",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVSeparateAVLatent",
|
||||
"_meta": {
|
||||
"title": "LTXVSeparateAVLatent"
|
||||
}
|
||||
},
|
||||
"6061:4971": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6061:4967",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"6061:4964",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"6061:4976",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"6061:4985",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"6061:4969",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"6061:4969": {
|
||||
"inputs": {
|
||||
"video_latent": [
|
||||
"6061:4970",
|
||||
0
|
||||
],
|
||||
"audio_latent": [
|
||||
"6061:6074",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVConcatAVLatent",
|
||||
"_meta": {
|
||||
"title": "LTXVConcatAVLatent"
|
||||
}
|
||||
},
|
||||
"6061:6072": {
|
||||
"inputs": {
|
||||
"value": 0,
|
||||
"width": [
|
||||
"6061:6073",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"6061:6073",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SolidMask",
|
||||
"_meta": {
|
||||
"title": "Create Solid Mask"
|
||||
}
|
||||
},
|
||||
"6061:4975": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6061:6070",
|
||||
0
|
||||
],
|
||||
"upscale_model": [
|
||||
"6062:5577",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVLatentUpsampler",
|
||||
"_meta": {
|
||||
"title": "LTXVLatentUpsampler"
|
||||
}
|
||||
},
|
||||
"6061:6070": {
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"6059",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": {
|
||||
"title": "VAE Encode"
|
||||
}
|
||||
},
|
||||
"6061:4970": {
|
||||
"inputs": {
|
||||
"strength": 1,
|
||||
"bypass": true,
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"6059",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"6061:4975",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVImgToVideoInplace",
|
||||
"_meta": {
|
||||
"title": "LTXVImgToVideoInplace"
|
||||
}
|
||||
},
|
||||
"6061:6074": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6061:6075",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"6061:6072",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"_meta": {
|
||||
"title": "Set Latent Noise Mask"
|
||||
}
|
||||
},
|
||||
"6061:6073": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"6059",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "GetImageSize",
|
||||
"_meta": {
|
||||
"title": "Get Image Size"
|
||||
}
|
||||
},
|
||||
"6061:6075": {
|
||||
"inputs": {
|
||||
"audio": [
|
||||
"209",
|
||||
0
|
||||
],
|
||||
"audio_vae": [
|
||||
"6062:5570",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVAudioVAEEncode",
|
||||
"_meta": {
|
||||
"title": "LTXV Audio VAE Encode"
|
||||
}
|
||||
},
|
||||
"6063:5538": {
|
||||
"inputs": {
|
||||
"tile_size": 512,
|
||||
"overlap": 64,
|
||||
"temporal_size": 128,
|
||||
"temporal_overlap": 32,
|
||||
"samples": [
|
||||
"6061:4973",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"_meta": {
|
||||
"title": "VAE Decode (Tiled)"
|
||||
}
|
||||
},
|
||||
"6063:4848": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6061:4973",
|
||||
1
|
||||
],
|
||||
"audio_vae": [
|
||||
"6062:5570",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVAudioVAEDecode",
|
||||
"_meta": {
|
||||
"title": "LTXV Audio VAE Decode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "qwen-image-2.1-Q4_K_M.gguf"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Unet Loader (GGUF)"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"prompt": "参考角色五官,重新生成图片:高细节皮肤质感",
|
||||
"negative_prompt": "模糊,低质量,文字乱码,变形",
|
||||
"resolution": 1024,
|
||||
"clip": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"images.image_1": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": {
|
||||
"title": "Text Encode Qwen Image 2.1"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"seed": 278835418249884,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"positive": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"4",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "K采样器"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"filename_prefix": "qwen_image",
|
||||
"images": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "保存图像"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "auto"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Patch Sage Attention KJ"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"image": "3f24a075cd7b4118829be1b222ca1ab9.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 761215360232465,
|
||||
"steps": 20,
|
||||
"cfg": 2.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["75", 0],
|
||||
"positive": ["76", 0],
|
||||
"negative": ["77", 0],
|
||||
"latent_image": ["90", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": ["3", 0],
|
||||
"vae": ["39", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": { "title": "Load Diffusion Model" }
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load CLIP" }
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load VAE" }
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": ["8", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"75": {
|
||||
"inputs": {
|
||||
"shift": 3,
|
||||
"model": ["37", 0]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": { "title": "ModelSamplingAuraFlow" }
|
||||
},
|
||||
"76": {
|
||||
"inputs": {
|
||||
"prompt": "把背景换成夜晚的城市街道",
|
||||
"clip": ["38", 0],
|
||||
"vae": ["39", 0],
|
||||
"image1": ["93", 0]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
|
||||
},
|
||||
"77": {
|
||||
"inputs": {
|
||||
"prompt": "",
|
||||
"clip": ["38", 0],
|
||||
"vae": ["39", 0],
|
||||
"image1": ["93", 0]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
|
||||
},
|
||||
"78": {
|
||||
"inputs": {
|
||||
"image": "example.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": { "title": "Load Image" }
|
||||
},
|
||||
"88": {
|
||||
"inputs": {
|
||||
"pixels": ["93", 0],
|
||||
"vae": ["39", 0]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": { "title": "VAE Encode" }
|
||||
},
|
||||
"90": {
|
||||
"inputs": {
|
||||
"samples": ["88", 0],
|
||||
"mask": ["95", 0]
|
||||
},
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"_meta": { "title": "Set Latent Noise Mask" }
|
||||
},
|
||||
"93": {
|
||||
"inputs": {
|
||||
"upscale_method": "lanczos",
|
||||
"megapixels": 1,
|
||||
"image": ["78", 0]
|
||||
},
|
||||
"class_type": "ImageScaleToTotalPixels",
|
||||
"_meta": { "title": "Scale Image to Total Pixels" }
|
||||
},
|
||||
"95": {
|
||||
"inputs": {
|
||||
"image": "mask.png",
|
||||
"channel": "alpha"
|
||||
},
|
||||
"class_type": "LoadImageMask",
|
||||
"_meta": { "title": "Load Image (as Mask)" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 1125488487853216,
|
||||
"steps": 20,
|
||||
"cfg": 2.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["66", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["58", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "一只橘猫坐在窗台上,午后阳光,电影感",
|
||||
"clip": ["38", 0]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Positive Prompt)" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": ["38", 0]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Negative Prompt)" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": ["3", 0],
|
||||
"vae": ["39", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": { "title": "Load Diffusion Model" }
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load CLIP" }
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load VAE" }
|
||||
},
|
||||
"58": {
|
||||
"inputs": {
|
||||
"width": 1328,
|
||||
"height": 1328,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": { "title": "EmptySD3LatentImage" }
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": ["8", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"66": {
|
||||
"inputs": {
|
||||
"shift": 3.1,
|
||||
"model": ["37", 0]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": { "title": "ModelSamplingAuraFlow" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music). The previews in this repo were made with a ten-shot subway prompt posted by a community member; it is not redistributed here."
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/01_reference_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ImageToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"13": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "1:1 (Square)",
|
||||
"megapixels": 1,
|
||||
"multiple": 8
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "分辨率选择器"
|
||||
}
|
||||
},
|
||||
"461": {
|
||||
"inputs": {
|
||||
"filename_prefix": "Qwen_image_2.1",
|
||||
"format": "png",
|
||||
"format.bit_depth": "8-bit",
|
||||
"format.input_color_space": "sRGB",
|
||||
"images": [
|
||||
"459:457",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImageAdvanced",
|
||||
"_meta": {
|
||||
"title": "保存图像(高级)"
|
||||
}
|
||||
},
|
||||
"459:451": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
},
|
||||
"459:452": {
|
||||
"inputs": {
|
||||
"prompt": "Greyscale fashion editorial portrait of an avant-garde woman tilted upwards in profile, captured with striking high-contrast chiaroscuro lighting. She wears oversized, thick-rimmed circular black sunglasses with matte dark lenses, concealing her upward gaze. Her attire merges high couture with tactical abstraction: a high-necked structural dress tailored from heavyweight fabric featuring a disrupted optical camouflage pattern—interlocking oversized polka dots, pixelated stippling, and organic contour blotches in stark monochrome that mimic military disruptive patterns in a refined runway style. Around her shoulders, asymmetric pleated webbing and tailored cargo straps are integrated seamlessly into the garment’s architecture. In her gloved hand, she clutches a structured matte-leather geometric handbag accented with subtle tactical buckles, utility stitching, and dark brushed-metal hardware. Her skin texture is rendered with hyper-detailed clarity, sculpted by intense, razor-sharp studio key light and deep graphite shadows. The striking background is a vivid mixed-media graphic collage, composed of flat vibrant lime green planes, stark black and white overlapping circles, segmented semi-circles with bold zebra striping, dense micro-halftone dot matrices, and vector topographic contour lines. The clash between monochromatic disruptive camo elements on her clothing and the saturated, retro-futuristic pop-art backdrop creates an intense visual dissonance, emphasizing sharp silhouettes, paper cut-out edges, dynamic compositional balance, and clean graphic novel textures. Absolutely no text, no typography, no letters, no words, no numbers, no logos, no watermark, no captions, pure imagery only.",
|
||||
"negative_prompt": "",
|
||||
"resolution": 1024,
|
||||
"clip": [
|
||||
"459:453",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": {
|
||||
"title": "Text Encode Qwen Image 2.1"
|
||||
}
|
||||
},
|
||||
"459:453": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"459:454": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"459:456": {
|
||||
"inputs": {
|
||||
"width": [
|
||||
"13",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"13",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像"
|
||||
}
|
||||
},
|
||||
"459:457": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"459:458",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"459:454",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"459:458": {
|
||||
"inputs": {
|
||||
"seed": 593103825222985,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"459:451",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"459:452",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"459:452",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"459:456",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "K采样器"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Named-agent sessions (HEL-613): the five AIGC roles. agent_key is stamped
|
||||
at creation, filters the session list, injects a per-agent persona + agent-chat
|
||||
mode section into the assembled system prompt, and routes the main loop's LLM
|
||||
through the agent's bound role (models page 智能体默认模型 panel)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from calliope.agent.harness.registry import ToolContext
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Registry
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_agents_endpoint_lists_five_roles(client):
|
||||
agents = client.get("/api/agent/agents").json()
|
||||
assert [a["key"] for a in agents] == [
|
||||
"script",
|
||||
"style",
|
||||
"storyboard",
|
||||
"assets",
|
||||
"shot",
|
||||
]
|
||||
names = {a["name"] for a in agents}
|
||||
assert names == {"剧本创作", "视觉设计", "分镜设计", "素材资产", "镜头脚本"}
|
||||
# LLM roles reuse the models-page five-role binding surface.
|
||||
assert {a["llm_role"] for a in agents} == {"story", "style", "script", "assets", "video"}
|
||||
# Personas are server-side — never shipped to the browser.
|
||||
assert all("persona" not in a for a in agents)
|
||||
assert all(isinstance(a.get("skill_count"), int) for a in agents)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Session create / list semantics
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_session_with_agent_key(client):
|
||||
s = client.post("/api/agent/sessions", json={"agent_key": "style"}).json()
|
||||
assert s["agent_key"] == "style"
|
||||
assert s["origin"] == "chat"
|
||||
|
||||
|
||||
def test_agent_key_rejects_unknown_value(client):
|
||||
r = client.post("/api/agent/sessions", json={"agent_key": "director"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_agent_sessions_hidden_from_generic_list(client):
|
||||
plain = client.post("/api/agent/sessions", json={"title": "canvas chat"}).json()
|
||||
named = client.post("/api/agent/sessions", json={"agent_key": "script"}).json()
|
||||
listed = client.get("/api/agent/sessions").json()
|
||||
ids = {s["id"] for s in listed}
|
||||
assert plain["id"] in ids
|
||||
assert named["id"] not in ids
|
||||
|
||||
|
||||
def test_agent_key_filter_returns_only_that_agent(client):
|
||||
a = client.post("/api/agent/sessions", json={"agent_key": "script"}).json()
|
||||
client.post("/api/agent/sessions", json={"agent_key": "style"})
|
||||
client.post("/api/agent/sessions", json={"title": "plain"})
|
||||
listed = client.get("/api/agent/sessions?agent_key=script").json()
|
||||
assert {s["id"] for s in listed} == {a["id"]}
|
||||
assert all(s["agent_key"] == "script" for s in listed)
|
||||
|
||||
|
||||
def test_plain_session_has_no_agent_key(client):
|
||||
s = client.post("/api/agent/sessions", json={}).json()
|
||||
assert s["agent_key"] is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Prompt assembly: persona + mode per agent_key
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _assemble(agent_key: str | None) -> str:
|
||||
from calliope.agent.harness import get_prompts
|
||||
|
||||
ctx = ToolContext(session_id=1, project_id=None, origin="chat", agent_key=agent_key)
|
||||
return asyncio.run(get_prompts().assemble(ctx))
|
||||
|
||||
|
||||
def test_persona_section_injected_per_agent(client):
|
||||
prompt = _assemble("script")
|
||||
assert "「剧本创作」" in prompt
|
||||
assert "编剧" in prompt
|
||||
assert "SESSION MODE: AGENT CHAT" in prompt
|
||||
# The generic sandbox mode text must be replaced for named agents.
|
||||
assert "GENERATING IN SANDBOX" not in prompt
|
||||
|
||||
|
||||
def test_each_agent_gets_its_own_persona(client):
|
||||
for key, marker in (
|
||||
("style", "视觉"),
|
||||
("storyboard", "分镜"),
|
||||
("assets", "资产"),
|
||||
("shot", "镜头脚本"),
|
||||
):
|
||||
assert marker in _assemble(key), key
|
||||
|
||||
|
||||
def test_plain_prompt_unchanged(client):
|
||||
prompt = _assemble(None)
|
||||
assert "Calliope's production agent" in prompt
|
||||
assert "SESSION MODE: SANDBOX" in prompt
|
||||
assert "AGENT CHAT" not in prompt
|
||||
|
||||
|
||||
def test_agent_chat_mentions_section_suppressed(client):
|
||||
assert "Tagged workflows" not in _assemble("assets")
|
||||
assert "Tagged workflows" in _assemble(None)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# LLM routing: the main loop uses the agent's bound role
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _RoleSpyClient:
|
||||
instances: list["_RoleSpyClient"] = []
|
||||
|
||||
def __init__(self, role: str | None = None) -> None:
|
||||
self.role = role
|
||||
self.requests: list[list[dict]] = []
|
||||
_RoleSpyClient.instances.append(self)
|
||||
|
||||
@classmethod
|
||||
def for_role(cls, role: str, *, timeout: float = 120.0) -> "_RoleSpyClient":
|
||||
return cls(role=role)
|
||||
|
||||
def chat_stream(self, messages, temperature=0.4, tools=None):
|
||||
self.requests.append(list(messages))
|
||||
return self
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
raise StopAsyncIteration
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_run_turn_uses_agent_llm_role_and_prompt(client):
|
||||
"""End-to-end through run_turn with a scripted LLM: the client must be
|
||||
built for the agent's role (story ← 剧本创作) and receive the per-agent
|
||||
persona in the system message."""
|
||||
import calliope.agent.harness.loop as loop_mod
|
||||
|
||||
session = client.post(
|
||||
"/api/agent/sessions", json={"agent_key": "script", "title": "剧本会话"}
|
||||
).json()
|
||||
|
||||
from calliope.agent.harness import log as session_log
|
||||
|
||||
session_log.append_event(
|
||||
session["id"], session_log.USER_MESSAGE, {"content": "写个三幕式大纲"}
|
||||
)
|
||||
|
||||
orig = loop_mod.LLMClient
|
||||
_RoleSpyClient.instances = []
|
||||
loop_mod.LLMClient = _RoleSpyClient
|
||||
try:
|
||||
ctx = ToolContext(
|
||||
session_id=session["id"], project_id=None, origin="chat", agent_key="script"
|
||||
)
|
||||
asyncio.run(loop_mod.run_turn(ctx, []))
|
||||
finally:
|
||||
loop_mod.LLMClient = orig
|
||||
|
||||
assert _RoleSpyClient.instances, "no LLM client was built"
|
||||
spy = _RoleSpyClient.instances[0]
|
||||
assert spy.role == "story"
|
||||
system = next(
|
||||
(m for m in spy.requests[0] if m.get("role") == "system"), None
|
||||
)
|
||||
assert system is not None
|
||||
assert "「剧本创作」" in system["content"]
|
||||
assert "SESSION MODE: AGENT CHAT" in system["content"]
|
||||
|
||||
|
||||
def test_plain_run_turn_keeps_main_role(client):
|
||||
import calliope.agent.harness.loop as loop_mod
|
||||
|
||||
session = client.post("/api/agent/sessions", json={}).json()
|
||||
from calliope.agent.harness import log as session_log
|
||||
|
||||
session_log.append_event(session["id"], session_log.USER_MESSAGE, {"content": "hi"})
|
||||
|
||||
orig = loop_mod.LLMClient
|
||||
_RoleSpyClient.instances = []
|
||||
loop_mod.LLMClient = _RoleSpyClient
|
||||
try:
|
||||
ctx = ToolContext(session_id=session["id"], project_id=None, origin="chat")
|
||||
asyncio.run(loop_mod.run_turn(ctx, []))
|
||||
finally:
|
||||
loop_mod.LLMClient = orig
|
||||
|
||||
assert _RoleSpyClient.instances[0].role == "main"
|
||||
@@ -0,0 +1,659 @@
|
||||
"""ComfyUI multi-connection endpoints (HEL-611): CRUD, masking, migration,
|
||||
server-side connectivity test classification, workflow binding and job
|
||||
endpoint snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
|
||||
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
server_version = "FakeComfy/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
self.server.requests.append({"path": self.path, "headers": dict(self.headers)})
|
||||
if self.path == "/system_stats":
|
||||
if self.server.require_auth and not self._auth_ok():
|
||||
self._send(401, {"error": "unauthorized"})
|
||||
return
|
||||
if self.server.mode == "not_comfyui_json":
|
||||
self._send(200, {"hello": "world"})
|
||||
return
|
||||
if self.server.mode == "html":
|
||||
self._send_html(200, "<html><body>hi</body></html>")
|
||||
return
|
||||
self._send(
|
||||
200,
|
||||
{
|
||||
"system": {"comfyui_version": "0.3.55-test", "os": "linux"},
|
||||
"devices": [{"name": "FakeGPU RTX 5090", "type": "cuda"}],
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/":
|
||||
self._send_html(200, "<html></html>")
|
||||
return
|
||||
if self.server.mode == "system_stats_404":
|
||||
# reverse-proxy style: /system_stats stripped
|
||||
self._send_html(404, "<html>404</html>")
|
||||
return
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def _auth_ok(self) -> bool:
|
||||
auth = self.headers.get("Authorization") or ""
|
||||
if auth == f"Bearer {self.server.token}":
|
||||
return True
|
||||
if self.headers.get(self.server.header_name or "X-Api-Key") == self.server.token:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _send(self, status: int, payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_html(self, status: int, text: str) -> None:
|
||||
data = text.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeComfyHandler)
|
||||
server.requests = []
|
||||
server.require_auth = False
|
||||
server.token = "tok-1234"
|
||||
server.header_name = "X-Api-Key"
|
||||
server.mode = "ok"
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _url(server) -> str:
|
||||
return f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
|
||||
def _snapshot_settings():
|
||||
return {
|
||||
"comfyui_endpoints": [dict(ep) for ep in (settings.comfyui_endpoints or [])],
|
||||
"comfyui_active_id": settings.comfyui_active_id,
|
||||
"comfyui_base_url": settings.comfyui_base_url,
|
||||
}
|
||||
|
||||
|
||||
def _clear_endpoints() -> None:
|
||||
"""Start from an empty endpoint list (settings is a process singleton)."""
|
||||
settings.comfyui_endpoints = []
|
||||
settings.comfyui_active_id = None
|
||||
|
||||
|
||||
def _reset_endpoints(**kw) -> dict:
|
||||
"""Point settings at one fresh endpoint and return it."""
|
||||
base = {
|
||||
"id": "ep-11111111-1111-1111-1111-111111111111",
|
||||
"name": "测试连接",
|
||||
"base_url": "http://127.0.0.1:8188",
|
||||
"auth_type": "none",
|
||||
"auth_token": None,
|
||||
"header_name": None,
|
||||
"notes": None,
|
||||
}
|
||||
base.update(kw)
|
||||
settings.comfyui_endpoints = [base]
|
||||
settings.comfyui_active_id = base["id"]
|
||||
settings.apply_active_comfyui()
|
||||
return base
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CRUD + masking
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_endpoints_crud_and_masking(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
_clear_endpoints()
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={
|
||||
"name": "本机",
|
||||
"base_url": "http://127.0.0.1:8188",
|
||||
"auth_type": "bearer",
|
||||
"auth_token": "secret-tok-9999",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body["endpoints"]) == 1
|
||||
ep = body["endpoints"][0]
|
||||
assert ep["has_token"] is True
|
||||
assert ep["token_tail"] == "9999"
|
||||
assert "secret" not in r.text # token never echoed
|
||||
eid = ep["id"]
|
||||
|
||||
# edit: blank token keeps the stored one
|
||||
r = client.patch(
|
||||
f"/api/comfyui/endpoints/{eid}",
|
||||
json={"name": "本机改名", "base_url": "http://127.0.0.1:8189"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
ep = r.json()["endpoints"][0]
|
||||
assert ep["name"] == "本机改名"
|
||||
assert ep["base_url"].endswith("8189")
|
||||
assert ep["has_token"] is True
|
||||
|
||||
# comfyui_base_url mirrors the ACTIVE endpoint
|
||||
assert settings.comfyui_base_url.endswith("8189")
|
||||
|
||||
# delete last endpoint is refused
|
||||
r = client.delete(f"/api/comfyui/endpoints/{eid}")
|
||||
assert r.status_code == 400
|
||||
|
||||
# add a second, delete the first: allowed, active falls back
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "局域网", "base_url": "http://192.168.200.15:8188"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
second_id = r.json()["endpoints"][-1]["id"]
|
||||
r = client.delete(f"/api/comfyui/endpoints/{eid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["active_id"] == second_id
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_activate_endpoint(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
_clear_endpoints()
|
||||
r = client.post("/api/comfyui/endpoints", json={"name": "A", "base_url": "http://a.test:8188"})
|
||||
assert r.status_code == 200
|
||||
r = client.post("/api/comfyui/endpoints", json={"name": "B", "base_url": "http://b.test:8188"})
|
||||
assert r.status_code == 200
|
||||
id_b = r.json()["endpoints"][-1]["id"]
|
||||
|
||||
r = client.post(f"/api/comfyui/endpoints/{id_b}/activate")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["active_id"] == id_b
|
||||
assert settings.comfyui_base_url == "http://b.test:8188"
|
||||
|
||||
# legacy single-address PATCH writes onto the ACTIVE endpoint
|
||||
r = client.post("/api/settings", json={"comfyui_base_url": "http://b2.test:8188"})
|
||||
assert r.status_code == 200
|
||||
assert settings.comfyui_base_url == "http://b2.test:8188"
|
||||
stored_b = next(ep for ep in settings.comfyui_endpoints if ep["id"] == id_b)
|
||||
assert stored_b["base_url"] == "http://b2.test:8188"
|
||||
|
||||
r = client.post("/api/comfyui/endpoints/does-not-exist/activate")
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_legacy_base_url_migrates_into_endpoint():
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
settings.comfyui_endpoints = []
|
||||
settings.comfyui_active_id = None
|
||||
settings.comfyui_base_url = "http://192.168.200.15:8188"
|
||||
migrated = settings.ensure_comfyui_endpoints()
|
||||
assert migrated is True
|
||||
assert len(settings.comfyui_endpoints) == 1
|
||||
ep = settings.comfyui_endpoints[0]
|
||||
assert ep["base_url"] == "http://192.168.200.15:8188"
|
||||
assert settings.comfyui_active_id == ep["id"]
|
||||
# idempotent
|
||||
assert settings.ensure_comfyui_endpoints() is False
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# server-side connectivity test
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_comfy_test_success_with_version_and_latency(client, fake_comfy):
|
||||
r = client.post("/api/comfyui/endpoints/test", json={"base_url": _url(fake_comfy)})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["comfyui_version"] == "0.3.55-test"
|
||||
assert "FakeGPU" in body["device_name"]
|
||||
assert isinstance(body["elapsed_ms"], int)
|
||||
assert fake_comfy.requests[0]["path"] == "/system_stats"
|
||||
|
||||
|
||||
def test_comfy_test_bearer_auth_from_stored_endpoint(client, fake_comfy):
|
||||
prev = _snapshot_settings()
|
||||
fake_comfy.require_auth = True
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={
|
||||
"name": "带鉴权",
|
||||
"base_url": _url(fake_comfy),
|
||||
"auth_type": "bearer",
|
||||
"auth_token": "tok-1234",
|
||||
},
|
||||
)
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
# test by id: stored token used server-side, never needs echo
|
||||
r = client.post("/api/comfyui/endpoints/test", json={"endpoint_id": eid})
|
||||
assert r.json()["ok"] is True
|
||||
assert fake_comfy.requests[-1]["headers"].get("Authorization") == "Bearer tok-1234"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_comfy_test_header_auth(client, fake_comfy):
|
||||
fake_comfy.require_auth = True
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test",
|
||||
json={
|
||||
"base_url": _url(fake_comfy),
|
||||
"auth_type": "header",
|
||||
"header_name": "X-Api-Key",
|
||||
"auth_token": "tok-1234",
|
||||
},
|
||||
)
|
||||
assert r.json()["ok"] is True
|
||||
assert fake_comfy.requests[-1]["headers"].get("X-Api-Key") == "tok-1234"
|
||||
|
||||
|
||||
def test_comfy_test_auth_rejected(client, fake_comfy):
|
||||
fake_comfy.require_auth = True
|
||||
r = client.post("/api/comfyui/endpoints/test", json={"base_url": _url(fake_comfy)})
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"] == "auth"
|
||||
assert "鉴权" in body["message"]
|
||||
|
||||
|
||||
def test_comfy_test_connection_refused(client):
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
r = client.post("/api/comfyui/endpoints/test", json={"base_url": f"http://127.0.0.1:{port}"})
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"] == "refused"
|
||||
assert "拒绝" in body["message"]
|
||||
|
||||
|
||||
def test_comfy_test_timeout(client):
|
||||
# 10.255.255.1 is unroutable on this LAN → connect timeout (2s floor)
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test",
|
||||
json={"base_url": "http://10.255.255.1:8188", "timeout_sec": 2},
|
||||
)
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"] == "timeout"
|
||||
assert "超时" in body["message"]
|
||||
|
||||
|
||||
def test_comfy_test_dns_failure(client):
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test",
|
||||
json={"base_url": "http://nonexistent-host-xyz.invalid:8188"},
|
||||
)
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"] == "dns"
|
||||
assert "解析" in body["message"]
|
||||
|
||||
|
||||
def test_comfy_test_reachable_but_not_comfyui(client, fake_comfy):
|
||||
fake_comfy.mode = "not_comfyui_json"
|
||||
r = client.post("/api/comfyui/endpoints/test", json={"base_url": _url(fake_comfy)})
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"] == "not_comfyui"
|
||||
assert "不是 ComfyUI" in body["message"] or "不像 ComfyUI" in body["message"]
|
||||
|
||||
|
||||
def test_comfy_test_html_response_is_not_comfyui(client, fake_comfy):
|
||||
fake_comfy.mode = "html"
|
||||
r = client.post("/api/comfyui/endpoints/test", json={"base_url": _url(fake_comfy)})
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"] == "not_comfyui"
|
||||
|
||||
|
||||
def test_comfy_test_ssrf_guard(client):
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test",
|
||||
json={"base_url": "http://169.254.169.254/latest/meta-data"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "禁止" in r.json()["detail"]
|
||||
# loopback IS allowed (local ComfyUI is a legitimate target)
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test",
|
||||
json={"base_url": "http://127.0.0.1:1", "timeout_sec": 2},
|
||||
)
|
||||
assert r.status_code == 200 # runs the probe (refused), not blocked
|
||||
|
||||
|
||||
def test_comfy_test_requires_url(client):
|
||||
r = client.post("/api/comfyui/endpoints/test", json={})
|
||||
assert r.status_code == 400
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test", json={"base_url": "ftp://x/", "timeout_sec": 2}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_schemeless_url_is_format_error_everywhere(client):
|
||||
"""HEL-619: a schemeless/garbled address is a 400 format error on every
|
||||
entry point (probe + save), not a silent http:// prefix that later
|
||||
surfaces as a DNS-classified probe failure."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_save_endpoint_runs_ssrf_guard(client):
|
||||
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
|
||||
the connectivity probe; loopback stays allowed."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
_clear_endpoints()
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "禁止" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
|
||||
r = client.patch(
|
||||
f"/api/comfyui/endpoints/{eid}",
|
||||
json={"base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# the stored endpoint is untouched
|
||||
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
|
||||
assert stored["base_url"] == "http://127.0.0.1:8188"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# workflow binding + job snapshots
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
|
||||
wf_json = {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "model.safetensors"},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
|
||||
},
|
||||
# HEL-636: a workflow needs a recognizable output node to classify a
|
||||
# usage and stay enabled; without it generation is refused.
|
||||
"9": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["6", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
}
|
||||
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
|
||||
if endpoint_id is not None:
|
||||
payload["endpoint_id"] = endpoint_id
|
||||
return client.post("/api/workflows", json=payload)
|
||||
|
||||
|
||||
def test_workflow_endpoint_binding_roundtrip(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints", json={"name": "局域网机", "base_url": "http://192.168.200.15:8188"}
|
||||
)
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
|
||||
r = _import_workflow(client, endpoint_id=eid)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["endpoint_id"] == eid
|
||||
|
||||
# clear binding with explicit null
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"endpoint_id": None})
|
||||
assert r.json()["endpoint_id"] is None
|
||||
|
||||
# rebind
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"endpoint_id": eid})
|
||||
assert r.json()["endpoint_id"] == eid
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_playground_generate_stamps_endpoint_snapshot(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "目标A", "base_url": "http://192.168.200.15:8188"},
|
||||
)
|
||||
target = r.json()["endpoints"][-1]
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "默认B", "base_url": "http://192.168.200.16:8188"},
|
||||
)
|
||||
default_ep = r.json()["endpoints"][-1]
|
||||
client.post(f"/api/comfyui/endpoints/{default_ep['id']}/activate")
|
||||
|
||||
wf = _import_workflow(client).json()
|
||||
|
||||
# explicit endpoint_id wins
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "endpoint_id": target["id"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
snap = r.json()["job"]["payload"]["endpoint"]
|
||||
assert snap["id"] == target["id"]
|
||||
assert snap["name"] == "目标A"
|
||||
assert snap["base_url"] == "http://192.168.200.15:8188"
|
||||
assert "auth_token" not in snap # token never snapshots into payloads
|
||||
|
||||
# no explicit id + no binding → active endpoint
|
||||
r = client.post("/api/playground/generate", json={"workflow_id": wf["id"]})
|
||||
snap = r.json()["job"]["payload"]["endpoint"]
|
||||
assert snap["id"] == default_ep["id"]
|
||||
|
||||
# workflow binding wins when no explicit id
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"endpoint_id": target["id"]})
|
||||
r = client.post("/api/playground/generate", json={"workflow_id": wf["id"]})
|
||||
snap = r.json()["job"]["payload"]["endpoint"]
|
||||
assert snap["id"] == target["id"]
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_worker_resolves_endpoint_from_snapshot():
|
||||
from calliope.queue.worker import QueueWorker
|
||||
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
ep = _reset_endpoints(
|
||||
id="ep-22222222-2222-2222-2222-222222222222",
|
||||
name="快照机",
|
||||
base_url="http://192.168.200.15:8188",
|
||||
)
|
||||
# snapshot id still configured → current values (fresh URL/token)
|
||||
url, headers, label = QueueWorker._resolve_endpoint(
|
||||
{"endpoint": {"id": ep["id"], "name": "旧名", "base_url": "http://old:1"}}
|
||||
)
|
||||
assert url == "http://192.168.200.15:8188"
|
||||
assert "快照机" in label
|
||||
|
||||
# dangling id → snapshot URL without token
|
||||
url, headers, label = QueueWorker._resolve_endpoint(
|
||||
{"endpoint": {"id": "gone-id", "name": "已删", "base_url": "http://kept:8188"}}
|
||||
)
|
||||
assert url == "http://kept:8188"
|
||||
assert headers == {}
|
||||
assert "已删" in label
|
||||
|
||||
# no snapshot → active endpoint
|
||||
url, _h, label = QueueWorker._resolve_endpoint({})
|
||||
assert url == "http://192.168.200.15:8188"
|
||||
|
||||
# bearer headers built for the active endpoint
|
||||
_reset_endpoints(
|
||||
id="ep-33333333-3333-3333-3333-333333333333",
|
||||
base_url="http://auth.test:8188",
|
||||
auth_type="bearer",
|
||||
auth_token="tok-xyz",
|
||||
)
|
||||
_url2, headers, _label = QueueWorker._resolve_endpoint({})
|
||||
assert headers == {"Authorization": "Bearer tok-xyz"}
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_queue_enqueue_stamps_all_paths(client):
|
||||
"""Flow-internal video enqueue also carries the snapshot (same path)."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints", json={"name": "流程机", "base_url": "http://192.168.200.15:8188"}
|
||||
)
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
wf = _import_workflow(client, endpoint_id=eid).json()
|
||||
|
||||
# enqueue via queue_manager directly (what enqueue_video_jobs calls)
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
project = client.post(
|
||||
"/api/projects", json={"title": "连接快照测试", "idea": "x"}
|
||||
).json()
|
||||
job = queue_manager.enqueue(
|
||||
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
|
||||
)
|
||||
payload = json.loads(job["payload_json"])
|
||||
assert payload["endpoint"]["id"] == eid
|
||||
assert payload["endpoint"]["base_url"] == "http://192.168.200.15:8188"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_workflow_delete_blocked_while_jobs_pending(client):
|
||||
"""HEL-619: deleting a workflow with pending/running jobs is refused
|
||||
with 409 instead of silently nulling the jobs' workflow_id."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
wf = _import_workflow(client).json()
|
||||
project = client.post(
|
||||
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
|
||||
).json()
|
||||
# Pause the lifespan-started worker so the pending job STAYS pending.
|
||||
queue_manager.paused = True
|
||||
try:
|
||||
queue_manager.enqueue(
|
||||
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
|
||||
)
|
||||
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 409
|
||||
assert "排队" in r.json()["detail"]
|
||||
finally:
|
||||
queue_manager.paused = False
|
||||
|
||||
# job finished → deletion allowed again
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_settings_public_dict_masks_endpoint_tokens(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={
|
||||
"name": "云端",
|
||||
"base_url": "https://comfy.example.com",
|
||||
"auth_type": "header",
|
||||
"header_name": "X-Key",
|
||||
"auth_token": "cloud-secret-7777",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/settings")
|
||||
text = r.text
|
||||
assert "cloud-secret" not in text
|
||||
ep = next(
|
||||
ep for ep in r.json()["comfyui_endpoints"] if ep["name"] == "云端"
|
||||
)
|
||||
assert ep["has_token"] is True
|
||||
assert ep["token_tail"] == "7777"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
@@ -0,0 +1,477 @@
|
||||
"""HEL-633: the three image-input workflows the user supplied — Qwen Image 2.1
|
||||
图片编辑, MiniMax H3 首帧图生视频 (04) and H3 参考图生视频 (05, 1–9 refs).
|
||||
|
||||
Slots come from real wiring, every picked image is uploaded to ComfyUI and
|
||||
lands in the node input it belongs to (in order, nothing stale), and a
|
||||
broken export is refused before anything is queued."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import detect_untagged_inputs, parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.smart_fill import smart_fill_inputs
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import (
|
||||
PNG_1PX,
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
_wait_done,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
I2V = "h3_i2v_04_API.json" # 04_i2v_fl2v_4step_sla (1).json
|
||||
REF = "h3_ref2va_05_API.json" # 05_ref2va_4step_sla (1).json
|
||||
QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加速.json
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
REF_KEY = "131@ref_images"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_node(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["nodeId"]: i for i in inputs}
|
||||
|
||||
|
||||
def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
|
||||
"""[(input name, LoadImage image value), …] in slot order."""
|
||||
inputs = wf[node_id]["inputs"]
|
||||
names = sorted(
|
||||
(k for k in inputs if k.startswith("ref_images.ref_image_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[1]),
|
||||
)
|
||||
return [(k, wf[inputs[k][0]]["inputs"]["image"]) for k in names]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
|
||||
inputs = parse_dynamic_inputs(_load(I2V))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert media == [
|
||||
{
|
||||
"nodeId": "901",
|
||||
"label": "首帧",
|
||||
"role": "first_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": True,
|
||||
"auto": True,
|
||||
}
|
||||
]
|
||||
# the title comment about FL2V is not a slot
|
||||
assert not any(i.get("role") == "last_frame" for i in inputs)
|
||||
|
||||
|
||||
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert len(media) == 1
|
||||
group = media[0]
|
||||
assert group["nodeId"] == REF_KEY
|
||||
assert group["kind"] == "image_list"
|
||||
assert group["role"] == "reference_image"
|
||||
assert group["label"] == "参考图"
|
||||
assert (group["min"], group["max"], group["required"]) == (1, 9, True)
|
||||
# the parameters HEL-627 wired stay exposed
|
||||
by_node = _by_node(inputs)
|
||||
for key in ("1", "115@aspect_ratio", "115@megapixels", "124@steps", "124@denoise",
|
||||
"129@noise_seed", "133"):
|
||||
assert key in by_node, key
|
||||
assert not any(i["kind"] == "video" for i in inputs)
|
||||
|
||||
|
||||
def test_example_five_ref_workflow_folds_into_the_same_group():
|
||||
# The repo example is (Input:…)-tagged and keeps its tagged slots; stripped
|
||||
# of tags (as a plain ComfyUI export) its five loaders become one group.
|
||||
wf = json.loads((EXAMPLES / "video_minimax_h3_r2v_5ref_API.json").read_text(encoding="utf-8"))
|
||||
inputs = detect_untagged_inputs(wf)
|
||||
groups = [i for i in inputs if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["178@ref_images"]
|
||||
# its five LoadImages are the group, not five separate slots
|
||||
assert not any(i["kind"] == "image" for i in inputs if i.get("role") != "mask")
|
||||
|
||||
|
||||
def test_qwen_edit_exposes_input_image_real_resolution_and_hides_orphan_latent():
|
||||
inputs = parse_dynamic_inputs(_load(QWEN_EDIT))
|
||||
by_node = _by_node(inputs)
|
||||
img = by_node["10"]
|
||||
assert (img["kind"], img["role"], img["label"]) == ("image", "image", "输入图")
|
||||
assert img["required"] is True
|
||||
res = by_node["4@resolution"]
|
||||
assert (res["role"], res["defaultValue"], res["min"], res["max"], res["step"]) == (
|
||||
"resolution", 1024, 0, 4096, 32,
|
||||
)
|
||||
assert by_node["4@prompt"]["role"] == "prompt"
|
||||
assert by_node["4@negative_prompt"]["role"] == "negative"
|
||||
for key in ("6@seed", "6@steps", "6@cfg", "6@denoise"):
|
||||
assert key in by_node
|
||||
# EmptyLatentImage #5 feeds nothing — its width/height would do nothing
|
||||
assert not any(i["nodeId"].startswith("5@") for i in inputs)
|
||||
|
||||
|
||||
def test_unwired_load_image_gets_no_slot():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "last.png"},
|
||||
"_meta": {"title": "LAST"},
|
||||
}
|
||||
assert "950" not in _by_node(parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Broken exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_broken_qwen_export_is_flagged_and_blocked():
|
||||
warnings = workflow_warnings(_load(QWEN_EDIT))
|
||||
errors = {(w["code"], w["node_id"]) for w in warnings if w["level"] == "error"}
|
||||
assert errors == {
|
||||
("missing_class_type", "1"),
|
||||
("missing_class_type", "9"),
|
||||
("missing_model_link", "6"),
|
||||
}
|
||||
assert ("orphan_latent", "5") in {(w["code"], w["node_id"]) for w in warnings}
|
||||
msg = blocking_problem(_load(QWEN_EDIT))
|
||||
assert msg and "Unet Loader (GGUF)" in msg and "重新导出" in msg
|
||||
|
||||
|
||||
def test_h3_workflows_are_clean():
|
||||
assert workflow_warnings(_load(I2V)) == []
|
||||
assert workflow_warnings(_load(REF)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patching 1 / 4 / 9 reference images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_patch_wires_each_reference_image_in_order(count):
|
||||
base = _load(REF)
|
||||
paths = [f"/assets/uploads/ref-{i}.png" for i in range(1, count + 1)]
|
||||
wf = patch_workflow(base, {REF_KEY: paths})
|
||||
links = _ref_links(wf)
|
||||
assert links == [(f"ref_images.ref_image_{i}", p) for i, p in enumerate(paths)]
|
||||
# every loader is a real, distinct LoadImage feeding node 131
|
||||
loader_ids = [wf["131"]["inputs"][name][0] for name, _ in links]
|
||||
assert len(set(loader_ids)) == count
|
||||
assert all(wf[n]["class_type"] == "LoadImage" for n in loader_ids)
|
||||
assert loader_ids[0] == "901"
|
||||
# the base workflow is untouched
|
||||
assert _ref_links(base) == [("ref_images.ref_image_0", "reference_1.png")]
|
||||
|
||||
|
||||
def test_patch_shrinking_removes_stale_slots_and_loaders():
|
||||
nine = patch_workflow(_load(REF), {REF_KEY: [f"/u/{i}.png" for i in range(9)]})
|
||||
two = patch_workflow(nine, {REF_KEY: ["/u/b.png", "/u/a.png"]})
|
||||
assert _ref_links(two) == [
|
||||
("ref_images.ref_image_0", "/u/b.png"),
|
||||
("ref_images.ref_image_1", "/u/a.png"),
|
||||
]
|
||||
load_images = [n for n, v in two.items() if v.get("class_type") == "LoadImage"]
|
||||
assert len(load_images) == 2
|
||||
assert not any("/u/8.png" in json.dumps(v) for v in two.values())
|
||||
|
||||
|
||||
def test_single_slot_values_patch_first_frame_and_qwen_input():
|
||||
i2v = patch_workflow(_load(I2V), {"901": "/assets/uploads/first.png"})
|
||||
assert i2v["901"]["inputs"]["image"] == "/assets/uploads/first.png"
|
||||
assert i2v["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
qwen = patch_workflow(_load(QWEN_EDIT), {"10": "/assets/uploads/in.png", "4@resolution": 1536})
|
||||
assert qwen["10"]["inputs"]["image"] == "/assets/uploads/in.png"
|
||||
assert qwen["4"]["inputs"]["resolution"] == 1536
|
||||
|
||||
|
||||
def test_legacy_job_values_keyed_by_load_image_still_patch():
|
||||
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
|
||||
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
|
||||
assert _ref_links(wf) == [("ref_images.ref_image_0", "/assets/uploads/old.png")]
|
||||
|
||||
|
||||
def test_smart_fill_hands_reference_list_to_the_group():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
refs = [f"/r/{i}.png" for i in range(12)]
|
||||
values = smart_fill_inputs(inputs, prompt="p", ref_images=refs)
|
||||
assert values[REF_KEY] == refs[:9]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _pngs(d: Path, n: int) -> list[str]:
|
||||
out = []
|
||||
for i in range(n):
|
||||
p = d / f"ref{i + 1}.png"
|
||||
p.write_bytes(PNG_1PX)
|
||||
out.append(str(p))
|
||||
return out
|
||||
|
||||
|
||||
def test_validate_reference_count_limits(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
base = {"1": "prompt"}
|
||||
for n in (1, 4, 9):
|
||||
clean, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, n)})
|
||||
assert errs == [] and len(clean[REF_KEY]) == n
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, 10)})
|
||||
assert errs == ["参考图最多 9 张(当前 10 张),请删除多余的图片"]
|
||||
_, errs = validate_input_values(schema, base)
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: []})
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
|
||||
|
||||
def test_validate_rejects_bad_reference_files(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
gif = uploads_dir / "anim.gif"
|
||||
gif.write_bytes(b"GIF89a")
|
||||
ok = _pngs(uploads_dir, 1)[0]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [ok, str(gif)]})
|
||||
assert errs == ["参考图 2:格式不支持,请使用 PNG / JPG / WebP"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: ["/etc/passwd.png"]})
|
||||
assert errs == ["参考图 1:只能使用在本页上传或从生成结果选入的图片"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [str(uploads_dir / "gone.png")]})
|
||||
assert errs == ["参考图 1:文件已不存在,请重新上传"]
|
||||
|
||||
|
||||
def test_validate_requires_first_frame_and_input_image():
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
|
||||
assert errs == ["请上传首帧"]
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
|
||||
assert errs == ["请上传输入图"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full chain against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _NamedUploadHandler(_FakeComfyHandler):
|
||||
"""/upload/image answers with the uploaded file's own name, like ComfyUI."""
|
||||
|
||||
def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler hook name
|
||||
if self.path != "/upload/image":
|
||||
super().do_POST()
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.server.requests.append(self.path)
|
||||
self.server.uploads.append(body)
|
||||
m = re.search(rb'filename="([^"]+)"', body)
|
||||
name = m.group(1).decode() if m else "x.png"
|
||||
self.server.upload_names.append(name)
|
||||
self._json(200, {"name": name, "subfolder": "calliope", "type": "input"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def named_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _NamedUploadHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.upload_names = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "ref2v_00001_.mp4"
|
||||
server.outputs_key = "images"
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _upload(client, name: str) -> str:
|
||||
r = client.post("/api/playground/uploads", files={"file": (name, PNG_1PX, "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["path"]
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-video"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_ref2v_chain_uploads_every_image_and_wires_it_in_order(client, named_comfy, count):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"角色{i}.png") for i in range(1, count + 1)]
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{"1": "<Subject 1> 走进雨夜码头", REF_KEY: paths, "124@steps": 4, "133": 2},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["payload"]["input_values"][REF_KEY] == paths
|
||||
|
||||
assert len(named_comfy.prompts) == 1
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
expected = [f"calliope/{Path(p).name}" for p in paths]
|
||||
assert [img for _k, img in _ref_links(prompt)] == expected
|
||||
assert named_comfy.upload_names == [Path(p).name for p in paths]
|
||||
assert prompt["1"]["inputs"]["value"] == "<Subject 1> 走进雨夜码头"
|
||||
assert prompt["124"]["inputs"]["steps"] == 4
|
||||
assert prompt["133"]["inputs"]["value"] == 2
|
||||
# no leftover default reference and no unconsumed LoadImage
|
||||
assert "reference_1.png" not in json.dumps(prompt)
|
||||
loaders = {n for n, v in prompt.items() if v["class_type"] == "LoadImage"}
|
||||
wired = {v[0] for k, v in prompt["131"]["inputs"].items() if k.startswith("ref_images.")}
|
||||
assert loaders == wired
|
||||
|
||||
|
||||
def test_ref2v_replace_and_delete_leave_no_stale_image(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
a, b, c = (_upload(client, n) for n in ("a.png", "b.png", "c.png"))
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, b, c]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
# user replaced #2 with d and deleted #3
|
||||
d = _upload(client, "d.png")
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, d]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
second = named_comfy.prompts[1]["prompt"]
|
||||
assert [img for _k, img in _ref_links(second)] == [
|
||||
f"calliope/{Path(a).name}",
|
||||
f"calliope/{Path(d).name}",
|
||||
]
|
||||
dumped = json.dumps(second)
|
||||
assert Path(b).name not in dumped and Path(c).name not in dumped
|
||||
|
||||
|
||||
def test_ref2v_ten_images_rejected_before_queue(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"r{i}.png") for i in range(10)]
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: paths})
|
||||
assert r.status_code == 400
|
||||
assert "参考图最多 9 张" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"1": "x"})
|
||||
assert r.status_code == 400
|
||||
assert "需要至少 1 张参考图" in r.json()["detail"]
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_i2v_chain_uploads_first_frame(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 图生视频", "video", _load(I2V))
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近"})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近", "901": first, "124@denoise": 0.8})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
assert prompt["124"]["inputs"]["denoise"] == 0.8
|
||||
|
||||
|
||||
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
|
||||
assert analyzed.status_code == 200
|
||||
codes = {w["code"] for w in analyzed.json()["warnings"]}
|
||||
assert {"missing_class_type", "missing_model_link", "orphan_latent"} <= codes
|
||||
# importing is allowed (the user may fix it later) …
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert any(w["level"] == "error" for w in listed["warnings"])
|
||||
# … but it never reaches ComfyUI and never shows as done
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(client, wf_id, {"4@prompt": "换招牌", "10": img}, source="studio-image")
|
||||
assert r.status_code == 400
|
||||
assert "重新导出" in r.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_repaired_qwen_export_runs_with_its_input_image(client, named_comfy):
|
||||
wf = _load(QWEN_EDIT)
|
||||
wf["1"] = {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
}
|
||||
del wf["9"]
|
||||
wf["6"]["inputs"]["model"] = ["1", 0]
|
||||
with _real_run(client, named_comfy):
|
||||
named_comfy.out_name = "qwen_image_00001_.png"
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑(修复版)", "image", wf)
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(
|
||||
client, wf_id, {"4@prompt": "换招牌", "10": img, "4@resolution": 1536, "6@steps": 20},
|
||||
source="studio-image",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done" and job["output_paths"]
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["10"]["inputs"]["image"] == f"calliope/{Path(img).name}"
|
||||
assert prompt["4"]["inputs"]["images.image_1"] == ["10", 0]
|
||||
assert prompt["4"]["inputs"]["resolution"] == 1536
|
||||
assert prompt["6"]["inputs"]["steps"] == 20
|
||||
|
||||
|
||||
def test_worker_refuses_broken_workflow_from_other_callers(client, named_comfy):
|
||||
"""Workbench/agent jobs skip the playground check — the worker still stops them."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid, kind="image", workflow_id=wf_id, scene_id=None,
|
||||
payload={"input_values": {"4@prompt": "x"}, "source": "playground"},
|
||||
)
|
||||
done = _wait_done(client, job["id"])
|
||||
assert done["status"] == "failed"
|
||||
assert "重新导出" in (done["error"] or "")
|
||||
assert named_comfy.prompts == []
|
||||
@@ -166,3 +166,95 @@ def test_llm_test_validation(client):
|
||||
assert r.status_code == 400
|
||||
r = client.post("/api/settings/llm-test", json={"profile_id": "does-not-exist"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_llm_test_accepts_timeout_sec(client, fake_openai):
|
||||
r = client.post(
|
||||
"/api/settings/llm-test",
|
||||
json={
|
||||
"base_url": _url(fake_openai),
|
||||
"model": "fake-model",
|
||||
"api_key": "sk-test",
|
||||
"timeout_sec": 10,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
def test_llm_test_timeout_sec_bounds(client):
|
||||
for bad in (0, 4, 61):
|
||||
r = client.post(
|
||||
"/api/settings/llm-test",
|
||||
json={"base_url": "http://127.0.0.1:1/v1", "model": "m", "timeout_sec": bad},
|
||||
)
|
||||
assert r.status_code == 422, bad
|
||||
|
||||
|
||||
def test_settings_hide_key_but_expose_tail(client, fake_openai):
|
||||
prev = _snapshot()
|
||||
try:
|
||||
pid = "dddddddd-dddd-dddd-dddd-dddddddddddd"
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={
|
||||
"llm_profiles": [
|
||||
{
|
||||
"id": pid,
|
||||
"name": "Tail",
|
||||
"base_url": _url(fake_openai),
|
||||
"model": "fake-model",
|
||||
"api_key": "sk-tail-3f2a",
|
||||
}
|
||||
],
|
||||
"llm_active_id": pid,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
profile = r.json()["llm_profiles"][0]
|
||||
assert profile["api_key"] is True
|
||||
assert profile["api_key_tail"] == "3f2a"
|
||||
assert "sk-tail" not in r.text
|
||||
finally:
|
||||
_restore(prev)
|
||||
|
||||
|
||||
def test_style_role_assignment_round_trip(client, fake_openai):
|
||||
"""视觉设计 (style) is one of the five AIGC agents on the models page."""
|
||||
prev = _snapshot()
|
||||
prev_assign = dict(settings.agent_llm_assignments or {})
|
||||
try:
|
||||
pid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={
|
||||
"llm_profiles": [
|
||||
{
|
||||
"id": pid,
|
||||
"name": "StyleLLM",
|
||||
"base_url": _url(fake_openai),
|
||||
"model": "fake-model",
|
||||
}
|
||||
],
|
||||
"llm_active_id": pid,
|
||||
"agent_llm_assignments": {
|
||||
"main": None,
|
||||
"planner": None,
|
||||
"story": pid,
|
||||
"style": pid,
|
||||
"script": None,
|
||||
"assets": None,
|
||||
"video": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["agent_llm_assignments"]["style"] == pid
|
||||
assert body["agent_llm_assignments"]["story"] == pid
|
||||
|
||||
resolved = settings.resolve_llm_for_role("style")
|
||||
assert resolved["id"] == pid
|
||||
finally:
|
||||
_restore(prev)
|
||||
settings.agent_llm_assignments = prev_assign
|
||||
|
||||
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
|
||||
# Unknown role keys are dropped, known roles stored (incl. explicit None)
|
||||
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
|
||||
|
||||
# HEL-619: updates are per-role incremental — a partial payload must
|
||||
# not wipe roles it does not mention (rapid-switch race).
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"main": a_id}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["agent_llm_assignments"]["main"] == a_id
|
||||
assert body["agent_llm_assignments"]["video"] == a_id
|
||||
assert body["agent_llm_assignments"]["story"] is None
|
||||
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"video": "no-such-profile"}},
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
||||
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
||||
|
||||
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
||||
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import PNG_1PX, _real_run
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
||||
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
||||
wf: dict = {
|
||||
"1": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "提示词"},
|
||||
},
|
||||
"92": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["131", 0]},
|
||||
},
|
||||
"131": {
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"inputs": {"prompt": ["1", 0]},
|
||||
},
|
||||
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
||||
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
||||
}
|
||||
ins = wf["131"]["inputs"]
|
||||
for i in range(images):
|
||||
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
||||
for i in range(videos):
|
||||
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
||||
for i in range(audios):
|
||||
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
||||
return wf
|
||||
|
||||
|
||||
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
||||
for e in schema:
|
||||
if e["nodeId"].endswith("@" + key_suffix):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: 入口只由实际连线决定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ref2va_schema_exposes_three_media_groups():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img = _entry(schema, "ref_images")
|
||||
vid = _entry(schema, "ref_videos")
|
||||
aud = _entry(schema, "ref_audios")
|
||||
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
||||
assert img["max"] == 9 and img["label"] == "参考图"
|
||||
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
||||
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
||||
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
||||
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
||||
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
||||
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
||||
# 被组吸收的 loader 不再单独出现
|
||||
assert not any(e["nodeId"] == "902" for e in schema)
|
||||
assert not any(e["nodeId"] == "903" for e in schema)
|
||||
|
||||
|
||||
def test_unwired_media_kinds_produce_no_entry():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
||||
assert _entry(schema, "ref_images") is not None
|
||||
assert _entry(schema, "ref_videos") is None
|
||||
assert _entry(schema, "ref_audios") is None
|
||||
|
||||
|
||||
def test_generic_class_gets_group_without_hint():
|
||||
wf = _ref2va_workflow(videos=0, audios=0)
|
||||
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
||||
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
||||
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
||||
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
||||
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
g = _entry(schema, "media")
|
||||
assert g and g["kind"] == "video_list" and g["max"] == 9
|
||||
assert "media_hint" not in g
|
||||
|
||||
|
||||
def test_05_ref2va_fixture_stays_image_only():
|
||||
schema = parse_dynamic_inputs(_load_05())
|
||||
lists = [e for e in schema if e["kind"].endswith("_list")]
|
||||
assert len(lists) == 1
|
||||
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
||||
assert lists[0]["nodeId"] == "131@ref_images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _media_file(path: str, data: bytes) -> str:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_validate_video_and_audio_lists(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img_key = _entry(schema, "ref_images")["nodeId"]
|
||||
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
||||
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
||||
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
||||
|
||||
clean, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
||||
|
||||
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
||||
)
|
||||
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
||||
|
||||
# 上限(H3 官方 3 段)与最少 1 段
|
||||
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["需要至少 1 段参考视频"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patcher + full chain against fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
||||
wf = _ref2va_workflow()
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
||||
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
||||
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{
|
||||
_entry(schema, "ref_images")["nodeId"]: pngs,
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
},
|
||||
)
|
||||
ins = patched["131"]["inputs"]
|
||||
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
||||
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
||||
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
||||
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
||||
loaders = {nid: n for nid, n in patched.items()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
||||
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
||||
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
||||
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
||||
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
||||
assert vids and vids[0]["inputs"]["file"] == mp4
|
||||
assert auds and auds[0]["inputs"]["audio"] == mp3
|
||||
# 克隆出的节点带中文槽位标题
|
||||
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
||||
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
||||
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
||||
|
||||
|
||||
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
||||
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
||||
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
||||
wf = _ref2va_workflow()
|
||||
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf_id = r.json()["id"]
|
||||
schema = r.json()["input_schema"]
|
||||
|
||||
with _real_run(client, fake_comfy):
|
||||
fake_comfy.out_name = "media_out.mp4"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={
|
||||
"input_values": {
|
||||
_entry(schema, "ref_images")["nodeId"]: [png],
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
deadline = time.time() + 25
|
||||
job = {}
|
||||
while time.time() < deadline:
|
||||
job = client.get(f"/api/jobs/{job_id}").json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
|
||||
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
loaders = [n for n in queued.values()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
||||
by_field = {
|
||||
(n["class_type"], k): v
|
||||
for n in loaders
|
||||
for k, v in n["inputs"].items()
|
||||
if isinstance(v, str) and "uploaded-input" in v
|
||||
}
|
||||
assert ("LoadImage", "image") in by_field
|
||||
assert ("LoadVideo", "file") in by_field
|
||||
assert ("LoadAudio", "audio") in by_field
|
||||
# 组连线数与提交数一致
|
||||
ins = queued["131"]["inputs"]
|
||||
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|
||||
@@ -114,7 +114,8 @@ def test_queue_prompt_surfaces_comfy_error_body(monkeypatch):
|
||||
|
||||
msg = asyncio.run(run())
|
||||
assert "prompt_outputs_failed_validation" in msg
|
||||
assert "node 12" in msg
|
||||
assert "节点 12" in msg
|
||||
assert "HTTP 400" in msg
|
||||
assert "Invalid audio file" in msg
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ def test_list_projects_includes_stats(client):
|
||||
"character_count": 0,
|
||||
"asset_ready_count": 0,
|
||||
"asset_total_count": 0,
|
||||
# Five-stage workbench progress (HEL-614): fresh project = not started.
|
||||
"stage_confirmed": 0,
|
||||
"stage_status": {},
|
||||
"active_jobs": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""HEL-627: the two workflows the user runs (Qwen Image 2.1 文生图 and
|
||||
MiniMax H3 reference 文生视频) expose every widget they really have, the
|
||||
values land in the /prompt payload, and a job is only "done" once ComfyUI
|
||||
returned real files — empty, failed, lost and placeholder runs never are."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import (
|
||||
RESOLUTION_ASPECT_RATIOS,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
resolution_selector_size,
|
||||
)
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from tests.test_studio_generation import (
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
T2I = "wen_sheng_tu_API.json" # image_qwen_image_2_1_t2i (2).json
|
||||
T2V = "wen_sheng_shipin_API.json" # 01_reference_4step_sla (1).json
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_role(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["role"]: i for i in inputs if i.get("role")}
|
||||
|
||||
|
||||
class _StatusHandler(_FakeComfyHandler):
|
||||
"""Fake ComfyUI with /queue and scriptable history outcomes."""
|
||||
|
||||
def do_GET(self):
|
||||
srv = self.server
|
||||
if self.path == "/queue":
|
||||
if srv.queue_mode is None:
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
self.server.requests.append(self.path)
|
||||
items = [[i, pid, {}, {}, []] for i, pid in enumerate(srv.history)]
|
||||
if srv.queue_mode == "running":
|
||||
self._json(200, {"queue_running": items, "queue_pending": []})
|
||||
elif srv.queue_mode == "pending":
|
||||
blocker = [[-1, "someone-else", {}, {}, []]]
|
||||
self._json(200, {"queue_running": blocker, "queue_pending": items})
|
||||
else:
|
||||
self._json(200, {"queue_running": [], "queue_pending": []})
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
if srv.lose_history or srv.hidden_history_polls > 0:
|
||||
srv.hidden_history_polls = max(0, srv.hidden_history_polls - 1)
|
||||
self.server.requests.append(self.path)
|
||||
self._json(200, {})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
super().do_POST()
|
||||
if self.path == "/prompt" and self.server.history_patch:
|
||||
for entry in self.server.history.values():
|
||||
entry.update(self.server.history_patch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def status_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _StatusHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.queue_mode = None
|
||||
server.lose_history = False
|
||||
server.hidden_history_polls = 0
|
||||
server.history_patch = None
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _wait_final(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for j in client.get("/api/playground/jobs").json():
|
||||
if j["id"] == job_id and j["status"] in ("done", "failed"):
|
||||
return j
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"job {job_id} did not finish")
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-image"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parameter detection on the two real workflows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_real_t2i_exposes_ratio_megapixels_steps_denoise():
|
||||
roles = _by_role(parse_dynamic_inputs(_load(T2I)))
|
||||
assert set(roles) == {
|
||||
"prompt", "negative", "aspect_ratio", "megapixels", "batch",
|
||||
"seed", "steps", "cfg", "denoise",
|
||||
}
|
||||
ratio = roles["aspect_ratio"]
|
||||
assert ratio["nodeId"] == "13@aspect_ratio"
|
||||
assert ratio["kind"] == "select"
|
||||
assert ratio["defaultValue"] == "1:1 (Square)"
|
||||
assert ratio["options"] == list(RESOLUTION_ASPECT_RATIOS)
|
||||
assert ratio["resolution"] == {"megapixels": "13@megapixels", "multiple": 8}
|
||||
assert roles["megapixels"]["defaultValue"] == 1
|
||||
assert roles["steps"]["nodeId"] == "459:458@steps"
|
||||
assert roles["steps"]["defaultValue"] == 25 and roles["steps"]["integer"] is True
|
||||
assert roles["denoise"]["nodeId"] == "459:458@denoise"
|
||||
assert (roles["denoise"]["min"], roles["denoise"]["max"]) == (0.0, 1.0)
|
||||
# Width/height are wired from ResolutionSelector; they are not faked as
|
||||
# free inputs, and the encoder's reference-image `resolution` is not exposed.
|
||||
assert "width" not in roles and "height" not in roles
|
||||
assert all("resolution" not in i["nodeId"] for i in parse_dynamic_inputs(_load(T2I)))
|
||||
|
||||
|
||||
def test_real_t2v_exposes_ratio_steps_denoise_seed_duration():
|
||||
inputs = parse_dynamic_inputs(_load(T2V))
|
||||
roles = _by_role(inputs)
|
||||
assert set(roles) == {
|
||||
"prompt", "aspect_ratio", "megapixels", "steps", "denoise", "seed", "duration",
|
||||
}
|
||||
assert roles["aspect_ratio"]["defaultValue"] == "16:9 (Widescreen)"
|
||||
assert roles["aspect_ratio"]["resolution"]["multiple"] == 32
|
||||
assert roles["megapixels"]["defaultValue"] == 0.7
|
||||
assert roles["steps"]["nodeId"] == "124@steps" and roles["steps"]["defaultValue"] == 4
|
||||
assert roles["seed"]["nodeId"] == "129@noise_seed"
|
||||
assert roles["duration"]["nodeId"] == "133" and roles["duration"]["defaultValue"] == 10
|
||||
# BasicGuider has no CFG; the frame expression hard-codes 24 fps, so no fps field.
|
||||
assert "cfg" not in roles and "fps" not in roles
|
||||
|
||||
|
||||
def test_resolution_matches_comfyui_resolution_selector():
|
||||
# The H3 workflow's own title documents 1152x640 for 16:9 · 0.7 MP · ×32.
|
||||
assert resolution_selector_size("16:9 (Widescreen)", 0.7, 32) == (1152, 640)
|
||||
assert resolution_selector_size("1:1 (Square)", 1, 8) == (1024, 1024)
|
||||
assert resolution_selector_size("9:16 (Portrait Widescreen)", 1, 8) == (768, 1368)
|
||||
|
||||
|
||||
def test_validation_rejects_bad_values_in_chinese():
|
||||
schema = parse_dynamic_inputs(_load(T2I))
|
||||
clean, errors = validate_input_values(
|
||||
schema,
|
||||
{
|
||||
"459:452@prompt": "猫",
|
||||
"13@aspect_ratio": "5:4",
|
||||
"459:458@steps": 12.5,
|
||||
"459:458@denoise": 1.5,
|
||||
"13@megapixels": "abc",
|
||||
},
|
||||
)
|
||||
assert any("画面比例" in e and "5:4" in e for e in errors)
|
||||
assert any("采样步数" in e and "整数" in e for e in errors)
|
||||
assert any("降噪强度" in e and "不能大于 1" in e for e in errors)
|
||||
assert any("总像素" in e and "数字" in e for e in errors)
|
||||
clean, errors = validate_input_values(schema, {"459:458@steps": "30", "13@megapixels": 2})
|
||||
assert errors == [] # an omitted prompt falls back to the workflow's own text
|
||||
assert clean["459:458@steps"] == 30 and isinstance(clean["459:458@steps"], int)
|
||||
|
||||
blank = [dict(e, defaultValue="") if e.get("role") == "prompt" else e for e in schema]
|
||||
_, errors = validate_input_values(blank, {"459:452@prompt": " "})
|
||||
assert errors == ["请填写「正向提示词」"]
|
||||
|
||||
|
||||
def test_stale_auto_schema_is_refreshed_without_reimport(client):
|
||||
wf = _load(T2I)
|
||||
stale = [
|
||||
{"nodeId": "459:452@prompt", "label": "正向提示词", "role": "prompt",
|
||||
"kind": "textarea", "defaultValue": "x", "required": True, "auto": True},
|
||||
]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workflows"
|
||||
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
|
||||
" VALUES (?, 'image', ?, ?, '[]', 1)",
|
||||
("旧导入·Qwen2.1", json.dumps(wf), json.dumps(stale)),
|
||||
)
|
||||
conn.commit()
|
||||
wf_id = cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
|
||||
assert {"aspect_ratio", "megapixels", "steps", "denoise"} <= set(roles)
|
||||
|
||||
|
||||
def test_tagged_schema_is_kept_as_stored():
|
||||
stored = [{"nodeId": "6", "label": "P", "role": "prompt", "kind": "textarea",
|
||||
"required": True}]
|
||||
assert effective_input_schema(stored, _load(T2I)) == stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit payload + job status against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_t2i_values_reach_the_prompt_payload(client, status_comfy):
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(参数)", "image", _load(T2I))
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{
|
||||
"459:452@prompt": "雨夜霓虹街道",
|
||||
"459:452@negative_prompt": "模糊",
|
||||
"13@aspect_ratio": "16:9 (Widescreen)",
|
||||
"13@megapixels": 2,
|
||||
"459:458@steps": "30",
|
||||
"459:458@cfg": 2.5,
|
||||
"459:458@denoise": 0.8,
|
||||
"459:458@seed": 7,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_final(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = status_comfy.prompts[-1]["prompt"]
|
||||
assert queued["13"]["inputs"]["aspect_ratio"] == "16:9 (Widescreen)"
|
||||
assert queued["13"]["inputs"]["megapixels"] == 2
|
||||
assert queued["459:458"]["inputs"]["steps"] == 30
|
||||
assert queued["459:458"]["inputs"]["cfg"] == 2.5
|
||||
assert queued["459:458"]["inputs"]["denoise"] == 0.8
|
||||
assert queued["459:458"]["inputs"]["seed"] == 7
|
||||
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
|
||||
# Wiring intact: the latent still takes its size from the selector.
|
||||
assert queued["459:456"]["inputs"]["width"] == ["13", 0]
|
||||
assert queued["459:456"]["inputs"]["height"] == ["13", 1]
|
||||
assert job["comfy_prompt_id"] in status_comfy.history
|
||||
assert job["phase"] == "finished"
|
||||
assert job["output_paths"] and Path(job["output_paths"][0]).stat().st_size > 0
|
||||
|
||||
|
||||
def test_t2v_values_reach_the_prompt_payload(client, status_comfy):
|
||||
status_comfy.outputs_key = "images" # SaveVideo reports under "images" + animated
|
||||
status_comfy.out_name = "clip.mp4"
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·H3 参考生视频", "video", _load(T2V))
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{
|
||||
"1": "地铁车厢里的长镜头",
|
||||
"115@aspect_ratio": "9:16 (Portrait Widescreen)",
|
||||
"115@megapixels": 0.5,
|
||||
"124@steps": 6,
|
||||
"124@denoise": 1,
|
||||
"129@noise_seed": 1234,
|
||||
"133": 5,
|
||||
},
|
||||
source="studio-video",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_final(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = status_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["value"] == "地铁车厢里的长镜头"
|
||||
assert queued["115"]["inputs"]["aspect_ratio"] == "9:16 (Portrait Widescreen)"
|
||||
assert queued["115"]["inputs"]["megapixels"] == 0.5
|
||||
assert queued["124"]["inputs"]["steps"] == 6
|
||||
assert queued["129"]["inputs"]["noise_seed"] == 1234
|
||||
assert queued["133"]["inputs"]["value"] == 5
|
||||
assert queued["132"]["inputs"]["values.a"] == ["133", 0]
|
||||
|
||||
|
||||
def test_invalid_values_are_rejected_before_queueing(client, status_comfy):
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(校验)", "image", _load(T2I))
|
||||
r = _generate(client, wf_id, {"459:452@prompt": "猫", "459:458@steps": 0})
|
||||
assert r.status_code == 400
|
||||
assert "采样步数" in r.json()["detail"] and "不能小于 1" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"459:452@prompt": "猫", "13@megapixels": 99})
|
||||
assert r.status_code == 400 and "总像素" in r.json()["detail"]
|
||||
assert "/prompt" not in status_comfy.requests
|
||||
|
||||
|
||||
def test_empty_outputs_never_count_as_done(client, status_comfy):
|
||||
status_comfy.history_patch = {"outputs": {}}
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(空结果)", "image", _load(T2I))
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in job["error"]
|
||||
assert job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_comfy_execution_error_is_failed_with_reason(client, status_comfy):
|
||||
status_comfy.history_patch = {
|
||||
"status": {
|
||||
"status_str": "error",
|
||||
"completed": False,
|
||||
"messages": [
|
||||
["execution_start", {}],
|
||||
["execution_error", {"node_type": "UNETLoader",
|
||||
"exception_message": "模型文件不存在"}],
|
||||
],
|
||||
},
|
||||
"outputs": {},
|
||||
}
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(执行错)", "image", _load(T2I))
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "ComfyUI 执行出错" in job["error"] and "UNETLoader" in job["error"]
|
||||
assert "模型文件不存在" in job["error"]
|
||||
|
||||
|
||||
def test_prompt_lost_from_queue_and_history_fails(client, status_comfy):
|
||||
status_comfy.lose_history = True
|
||||
status_comfy.queue_mode = "missing"
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(丢失)", "image", _load(T2I))
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "队列和历史里都找不到该任务" in job["error"]
|
||||
|
||||
|
||||
def test_running_then_finished_reports_real_phase(client, status_comfy):
|
||||
status_comfy.queue_mode = "running"
|
||||
status_comfy.hidden_history_polls = 3
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(执行中)", "image", _load(T2I))
|
||||
job_id = _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"]
|
||||
seen: set[str] = set()
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
row = next(j for j in client.get("/api/playground/jobs").json() if j["id"] == job_id)
|
||||
if row.get("phase"):
|
||||
seen.add(row["phase"])
|
||||
if row["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert row["status"] == "done", row.get("error")
|
||||
assert "comfy_running" in seen
|
||||
assert "/queue" in status_comfy.requests
|
||||
|
||||
|
||||
def test_missing_model_rejection_names_the_file():
|
||||
"""Body captured from the user's ComfyUI 0.37.4 when Qwen 2.1 models are absent."""
|
||||
import httpx
|
||||
|
||||
from calliope.comfyui.client import _surface_error
|
||||
|
||||
body = {
|
||||
"error": {"type": "prompt_outputs_failed_validation"},
|
||||
"node_errors": {
|
||||
"459:451": {
|
||||
"errors": [{
|
||||
"type": "value_not_in_list",
|
||||
"message": "Value not in list",
|
||||
"details": "unet_name: 'qwen_image_2.1_int8_convrot.safetensors' not in [...]",
|
||||
"extra_info": {
|
||||
"input_name": "unet_name",
|
||||
"received_value": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
},
|
||||
}],
|
||||
"class_type": "UNETLoader",
|
||||
}
|
||||
},
|
||||
}
|
||||
msg = str(_surface_error("ComfyUI 拒绝执行该工作流", httpx.Response(400, json=body)))
|
||||
assert "节点 459:451(UNETLoader)" in msg
|
||||
assert "qwen_image_2.1_int8_convrot.safetensors" in msg and "模型文件未安装" in msg
|
||||
|
||||
|
||||
def test_dry_run_result_is_marked_placeholder(client):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(dry)", "image", _load(T2I))
|
||||
prev = settings.dry_run
|
||||
settings.dry_run = True
|
||||
try:
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
finally:
|
||||
settings.dry_run = prev
|
||||
assert job["status"] == "done"
|
||||
assert job["phase"] == "dry_run"
|
||||
assert job["comfy_prompt_id"] is None
|
||||
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
|
||||
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _interpreter_on_path():
|
||||
"""Make the bare interpreter name resolvable via PATH (HEL-619).
|
||||
|
||||
The guard resolves the command with ``shutil.which`` against PATH —
|
||||
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
|
||||
with no ``python`` alias) leaves the stem unresolvable and every
|
||||
exec-path test fails for environmental reasons only. Prepending the
|
||||
interpreter's own bin dir (restored afterwards) pins the lookup to the
|
||||
interpreter actually running the suite, on every OS.
|
||||
"""
|
||||
prev = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
|
||||
yield
|
||||
os.environ["PATH"] = prev
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _never_touch_real_db():
|
||||
"""Redirect data_dir before ANY write in this module (repo test rule)."""
|
||||
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
|
||||
|
||||
|
||||
def test_cwd_outside_workspace_denied(enabled):
|
||||
"""Guard denial tests use PYTHON + a structured approval card (not
|
||||
ffprobe): the interpreter resolves on every machine, and the card
|
||||
clears the approval gate so the LATER containment check is what fires
|
||||
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
|
||||
reasons)."""
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
|
||||
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
secret = tmp_path / "calliope_config.json"
|
||||
secret.write_text("{}", encoding="utf-8")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(secret)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
db = tmp_path / "calliope.db"
|
||||
db.write_bytes(b"")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(db)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
|
||||
def test_env_file_arg_denied(enabled):
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["--", ".env"]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
outside = tmp_path / "movie.mp4"
|
||||
outside.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(outside)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
|
||||
registry, _ = build_harness()
|
||||
ref = settings.assets_dir / "ref.mp4"
|
||||
ref.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
|
||||
{
|
||||
# The asset path is a real argv entry the guard must allow
|
||||
# (and the interpreter actually reads it).
|
||||
"command": PYTHON,
|
||||
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
|
||||
},
|
||||
)
|
||||
)
|
||||
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
|
||||
assert out.get("reason_code") is None
|
||||
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
|
||||
assert out["ok"] is True, out
|
||||
assert "00" in out["stdout"]
|
||||
|
||||
|
||||
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Skill management surface (HEL-613): create/edit/enable/assign/delete user
|
||||
skills, version snapshots with restore, and the effective-skill rule —
|
||||
a named-agent session sees exactly: globally enabled ∩ enabled binding for
|
||||
its agent_key. Plain sessions keep the legacy behavior (global enable only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from calliope.agent.skills_store import (
|
||||
effective_skill_dirs,
|
||||
skills_prompt_lines,
|
||||
)
|
||||
|
||||
|
||||
def _mk(client, name: str, bindings: dict | None = None, body: str = "正文 v1") -> dict:
|
||||
payload = {
|
||||
"description": f"{name} 描述",
|
||||
"skill_type": "template",
|
||||
"content": body,
|
||||
"tags": ["a", "b"],
|
||||
}
|
||||
if bindings is not None:
|
||||
payload["bindings"] = bindings
|
||||
r = client.put(f"/api/agent/skills/{name}", json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Create / list
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_skill_and_admin_list(client):
|
||||
_mk(client, "my-style-kit", bindings={"style": True, "script": False})
|
||||
skills = client.get("/api/agent/skills?all=true").json()
|
||||
mine = next(s for s in skills if s["dir"] == "my-style-kit")
|
||||
assert mine["name"] == "my-style-kit"
|
||||
assert mine["is_enabled"] is True
|
||||
assert mine["bindings"] == {"style": True, "script": False}
|
||||
assert mine["assigned_count"] == 1
|
||||
assert mine["type"] == "template"
|
||||
assert mine["version"] == "1.0.0"
|
||||
# Non-admin list stays capped + without admin fields (composer contract).
|
||||
plain = client.get("/api/agent/skills").json()
|
||||
mine_plain = next(s for s in plain if s["dir"] == "my-style-kit")
|
||||
assert "bindings" not in mine_plain
|
||||
|
||||
|
||||
def test_migration_seeded_builtin_bindings_for_all_agents(client):
|
||||
"""At upgrade, every pre-existing skill (the builtins) is bound to all
|
||||
five agents so agent chats keep the out-of-the-box experience."""
|
||||
skills = client.get("/api/agent/skills?all=true").json()
|
||||
builtin = next(s for s in skills if s["dir"] == "h3-video-prompt-enhancer")
|
||||
assert set(builtin["bindings"].keys()) == {
|
||||
"script",
|
||||
"style",
|
||||
"storyboard",
|
||||
"assets",
|
||||
"shot",
|
||||
}
|
||||
assert builtin["assigned_count"] == 5
|
||||
|
||||
|
||||
def test_invalid_names_rejected(client):
|
||||
for bad in ("../escape", "a/b", "", " ", ".hidden", "x" * 65):
|
||||
r = client.put(f"/api/agent/skills/{bad}", json={"content": "x"})
|
||||
assert r.status_code in (404, 422), bad
|
||||
|
||||
|
||||
def test_invalid_type_rejected(client):
|
||||
r = client.put("/api/agent/skills/ok-name", json={"content": "x", "skill_type": "wild"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Effective rule: global enable ∩ per-agent binding
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_effective_rule_for_agent_sessions(client):
|
||||
_mk(client, "gated-skill", bindings={"style": True, "script": True})
|
||||
assert "gated-skill" in effective_skill_dirs("style")
|
||||
assert "gated-skill" in effective_skill_dirs("script")
|
||||
assert "gated-skill" not in effective_skill_dirs("assets")
|
||||
lines = skills_prompt_lines(agent_key="assets")
|
||||
assert not any("gated-skill" in ln for ln in lines)
|
||||
lines = skills_prompt_lines(agent_key="style")
|
||||
assert any("gated-skill" in ln for ln in lines)
|
||||
|
||||
|
||||
def test_unassigned_skill_invisible_everywhere(client):
|
||||
_mk(client, "orphan-skill", bindings={})
|
||||
for key in ("script", "style", "storyboard", "assets", "shot"):
|
||||
assert "orphan-skill" not in effective_skill_dirs(key)
|
||||
# Plain sessions still see it (legacy behavior).
|
||||
assert "orphan-skill" in effective_skill_dirs(None)
|
||||
|
||||
|
||||
def test_global_disable_hides_from_all_sessions(client):
|
||||
_mk(client, "kill-switch", bindings={"style": True})
|
||||
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": False})
|
||||
assert r.status_code == 200
|
||||
assert "kill-switch" not in effective_skill_dirs("style")
|
||||
assert "kill-switch" not in effective_skill_dirs(None)
|
||||
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": True})
|
||||
assert "kill-switch" in effective_skill_dirs("style")
|
||||
|
||||
|
||||
def test_binding_matrix_replace(client):
|
||||
_mk(client, "matrix-skill", bindings={"style": True})
|
||||
r = client.put(
|
||||
"/api/agent/skills/matrix-skill/bindings",
|
||||
json={"bindings": {"shot": True, "style": False}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "matrix-skill" in effective_skill_dirs("shot")
|
||||
assert "matrix-skill" not in effective_skill_dirs("style")
|
||||
|
||||
|
||||
def test_unknown_agent_key_in_bindings_rejected(client):
|
||||
r = client.put(
|
||||
"/api/agent/skills/some-skill/bindings",
|
||||
json={"bindings": {"director": True}},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_read_skill_tool_gates_by_assignment(client):
|
||||
"""The read_skill tool denies skills not effective for the agent."""
|
||||
from calliope.agent.harness.plugins.skills import t_read_skill
|
||||
from calliope.agent.harness.registry import ToolContext
|
||||
|
||||
_mk(client, "gated-read", bindings={"style": True})
|
||||
style_ctx = ToolContext(session_id=1, agent_key="style")
|
||||
shot_ctx = ToolContext(session_id=2, agent_key="shot")
|
||||
ok = asyncio_run(t_read_skill(style_ctx, {"name": "gated-read"}))
|
||||
assert ok["ok"] is True
|
||||
denied = asyncio_run(t_read_skill(shot_ctx, {"name": "gated-read"}))
|
||||
assert denied["ok"] is False
|
||||
assert "not assigned" in denied["error"]
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Versions: snapshot on save, list, restore
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_edit_creates_snapshot_and_version_bumps(client):
|
||||
first = _mk(client, "versioned", bindings={"script": True}, body="# v1 正文")
|
||||
v1 = first["skill"]["version"]
|
||||
second = _mk(client, "versioned", bindings={"script": True}, body="# v2 正文改了")
|
||||
v2 = second["skill"]["version"]
|
||||
assert v2 != v1 # auto-bumped when the editor leaves version empty
|
||||
versions = client.get("/api/agent/skills/versioned/versions").json()
|
||||
assert len(versions) == 1 # the pre-edit snapshot
|
||||
assert "# v1 正文" in versions[0]["preview"]
|
||||
# File now holds v2 content.
|
||||
body = client.get(
|
||||
"/api/agent/skills/versioned/file?path=SKILL.md"
|
||||
).json()["content"]
|
||||
assert "# v2 正文改了" in body
|
||||
|
||||
|
||||
def test_restore_writes_snapshot_back(client):
|
||||
_mk(client, "rollback", bindings={"script": True}, body="第一版")
|
||||
_mk(client, "rollback", bindings={"script": True}, body="第二版")
|
||||
versions = client.get("/api/agent/skills/rollback/versions").json()
|
||||
old_id = versions[0]["id"] # newest first: snapshot of 第一版
|
||||
r = client.post(f"/api/agent/skills/rollback/versions/{old_id}/restore")
|
||||
assert r.status_code == 200
|
||||
body = client.get("/api/agent/skills/rollback/file?path=SKILL.md").json()["content"]
|
||||
assert "第一版" in body
|
||||
# The restore itself snapshotted 第二版 — undoable.
|
||||
versions = client.get("/api/agent/skills/rollback/versions").json()
|
||||
assert any("第二版" in v["preview"] for v in versions)
|
||||
|
||||
|
||||
def test_restore_unknown_version_404(client):
|
||||
_mk(client, "nope-skill", bindings={"script": True})
|
||||
r = client.post("/api/agent/skills/nope-skill/versions/99999/restore")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Delete
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_removes_everything(client):
|
||||
_mk(client, "doomed", bindings={"script": True}, body="拜拜")
|
||||
r = client.delete("/api/agent/skills/doomed")
|
||||
assert r.status_code == 200
|
||||
skills = client.get("/api/agent/skills?all=true").json()
|
||||
assert all(s["dir"] != "doomed" for s in skills)
|
||||
assert client.get("/api/agent/skills/doomed/versions").json() == []
|
||||
assert "doomed" not in effective_skill_dirs("script")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Agents endpoint skill counts
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_agents_endpoint_counts_effective_skills(client):
|
||||
_mk(client, "count-me", bindings={"style": True, "shot": True})
|
||||
_mk(client, "count-me-disabled", bindings={"style": True})
|
||||
client.patch("/api/agent/skills/count-me-disabled", json={"is_enabled": False})
|
||||
agents = {a["key"]: a for a in client.get("/api/agent/agents").json()}
|
||||
style_count = agents["style"]["skill_count"]
|
||||
shot_count = agents["shot"]["skill_count"]
|
||||
assert style_count >= 1 and shot_count >= 1
|
||||
# A globally disabled skill never counts.
|
||||
skills = client.get("/api/agent/skills?all=true").json()
|
||||
disabled_dirs = {s["dir"] for s in skills if not s["is_enabled"]}
|
||||
assert "count-me-disabled" in disabled_dirs
|
||||
@@ -0,0 +1,398 @@
|
||||
"""独立生图/生视频 full-chain tests (HEL-612): studio window jobs run
|
||||
through the real playground → queue → ComfyUI HTTP → downloaded-output
|
||||
path against a fake ComfyUI server (real HTTP round trip), plus the
|
||||
source label, upload slot flow, retry and delete behaviours."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
PNG_1PX = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d4944415478da63f8cfc0f01f0005000105ab5e30760000000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
|
||||
|
||||
History outputs are keyed by the prompt's real output node id (what
|
||||
ComfyUI does); a known output class that is absent falls back to
|
||||
``server.forced_output_node`` (default "9"). Scripted tests can pin
|
||||
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
|
||||
"""
|
||||
|
||||
server_version = "FakeComfy/1.0"
|
||||
KNOWN_OUTPUT_CLASSES = {
|
||||
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
||||
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
self.server.requests.append(self.path)
|
||||
if self.path == "/system_stats":
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"system": {"comfyui_version": "0.3.72-fake", "os": "linux"},
|
||||
"devices": [{"name": "FakeGPU", "type": "cuda"}],
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/") :]
|
||||
entry = self.server.history.get(pid)
|
||||
if entry is None:
|
||||
self._json(200, {})
|
||||
return
|
||||
self._json(200, {pid: entry})
|
||||
return
|
||||
if self.path.startswith("/view"):
|
||||
self._raw(200, PNG_1PX, "image/png")
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.server.requests.append(self.path)
|
||||
if self.path == "/prompt":
|
||||
if self.server.fail_prompt:
|
||||
self._json(500, {"error": "boom", "node_errors": []})
|
||||
return
|
||||
prompt_id = uuid.uuid4().hex
|
||||
outputs_key = self.server.outputs_key
|
||||
out_entry = {
|
||||
outputs_key: [
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
outputs = (
|
||||
{} if getattr(self.server, "drop_outputs", False) else
|
||||
{self._output_node(body): out_entry}
|
||||
)
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
self._json(200, {"prompt_id": prompt_id})
|
||||
return
|
||||
if self.path == "/upload/image":
|
||||
self.server.uploads.append(body)
|
||||
self._json(200, {"name": "uploaded-input.png", "subfolder": "calliope"})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _output_node(self, body: bytes) -> str:
|
||||
forced = getattr(self.server, "forced_output_node", None)
|
||||
if forced:
|
||||
return forced
|
||||
try:
|
||||
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
||||
except json.JSONDecodeError:
|
||||
prompt = {}
|
||||
for nid, node in prompt.items():
|
||||
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
|
||||
return str(nid)
|
||||
return "9"
|
||||
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _raw(self, status: int, data: bytes, ctype: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeComfyHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
server.object_info = {}
|
||||
server.drop_outputs = False
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _url(server) -> str:
|
||||
return f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _real_run(client, server, endpoint_name="测试桩连接"):
|
||||
"""One registered connection pointing at the fake server; dry_run off."""
|
||||
prev_dry = settings.dry_run
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
settings.dry_run = False
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": endpoint_name, "base_url": _url(server), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
# keep only the new endpoint for deterministic snapshot resolution
|
||||
settings.comfyui_endpoints = [dict(ep) for ep in endpoints if ep["base_url"] == _url(server)]
|
||||
settings.comfyui_active_id = settings.comfyui_endpoints[0]["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield settings.comfyui_endpoints[0]
|
||||
finally:
|
||||
settings.dry_run = prev_dry
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
IMAGE_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "默认提示词"},
|
||||
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PrimitiveInt",
|
||||
"inputs": {"value": 42},
|
||||
"_meta": {"title": "(Input:seed) 种子"},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": "x"},
|
||||
"_meta": {"title": "(Output:image) 图片输出"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _video_workflow(first_last: bool) -> dict:
|
||||
wf = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "镜头缓慢推近"},
|
||||
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PrimitiveFloat",
|
||||
"inputs": {"value": 6},
|
||||
"_meta": {"title": "(Input:duration) 时长"},
|
||||
},
|
||||
}
|
||||
if first_last:
|
||||
wf["3"] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": ""},
|
||||
"_meta": {"title": "(Input:image) 首帧"},
|
||||
}
|
||||
wf["4"] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": ""},
|
||||
"_meta": {"title": "(Input:image) 尾帧"},
|
||||
}
|
||||
wf["9"] = {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {},
|
||||
"_meta": {"title": "(Output:video) 视频输出"},
|
||||
}
|
||||
return wf
|
||||
|
||||
|
||||
def _import_workflow(client, name: str, kind: str, workflow: dict) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "kind": kind, "workflow_json": workflow},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _wait_done(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
jobs = client.get("/api/playground/jobs").json()
|
||||
for job in jobs:
|
||||
if job["id"] == job_id:
|
||||
if job["status"] in ("done", "failed"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
pytest.fail(f"job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def test_studio_image_full_chain(client, fake_comfy):
|
||||
"""独立生图: upload → generate(source=studio-image) → worker → output file."""
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(client, "假·文生图", "image", IMAGE_WORKFLOW)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {"1": "霓虹灯下的雨夜街道", "2": 7},
|
||||
"source": "studio-image",
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
job = _wait_done(client, job_id)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["kind"] == "image"
|
||||
assert job["payload"]["source"] == "studio-image"
|
||||
|
||||
# endpoint snapshot: id/name/url only, never a token
|
||||
snap = job["payload"]["endpoint"]
|
||||
assert snap["id"] == settings.comfyui_active_id
|
||||
assert snap["base_url"] == _url(fake_comfy)
|
||||
assert "token" not in json.dumps(snap)
|
||||
|
||||
# output downloaded into assets and served via /api/file
|
||||
assert len(job["output_paths"]) == 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.exists() and out.read_bytes() == PNG_1PX
|
||||
r = client.get("/api/file", params={"path": str(out)})
|
||||
assert r.status_code == 200
|
||||
|
||||
# the workflow actually reached the fake ComfyUI /prompt
|
||||
assert any(p.startswith("/prompt") for p in map(str, fake_comfy.requests))
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "霓虹灯下的雨夜街道"
|
||||
assert queued["2"]["inputs"]["value"] == 7
|
||||
|
||||
|
||||
def test_studio_video_full_chain_with_frame_uploads(client, fake_comfy):
|
||||
"""独立生视频: first/last frame slots upload to ComfyUI before /prompt."""
|
||||
fake_comfy.outputs_key = "videos"
|
||||
fake_comfy.out_name = "out-clip.mp4"
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(client, "假·首尾帧生视频", "video", _video_workflow(True))
|
||||
|
||||
# two upload-slot files via the playground upload API
|
||||
paths = []
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/playground/uploads",
|
||||
files={"file": ("frame.png", io.BytesIO(PNG_1PX), "image/png")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
paths.append(r.json()["path"])
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {"1": "海上日出延时", "2": 6, "3": paths[0], "4": paths[1]},
|
||||
"source": "studio-video",
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["payload"]["source"] == "studio-video"
|
||||
assert job["output_paths"][0].endswith(".mp4")
|
||||
|
||||
# both frames were uploaded to ComfyUI's /upload/image (media slots)
|
||||
assert len(fake_comfy.uploads) == 2
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["3"]["inputs"]["image"] == "calliope/uploaded-input.png"
|
||||
assert queued["4"]["inputs"]["image"] == "calliope/uploaded-input.png"
|
||||
|
||||
|
||||
def test_studio_retry_after_failure(client, fake_comfy):
|
||||
"""失败原因进历史 → retry re-queues → success."""
|
||||
fake_comfy.fail_prompt = True
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(client, "假·文生图(重试)", "image", IMAGE_WORKFLOW)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "x"}, "source": "studio-image"},
|
||||
)
|
||||
job_id = r.json()["job"]["id"]
|
||||
job = _wait_done(client, job_id)
|
||||
assert job["status"] == "failed"
|
||||
assert job["error"]
|
||||
|
||||
fake_comfy.fail_prompt = False
|
||||
r = client.post(f"/api/jobs/{job_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, job_id)
|
||||
assert job["status"] == "done"
|
||||
assert job["retry_count"] >= 1
|
||||
|
||||
|
||||
def test_studio_delete_removes_outputs(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(client, "假·文生图(删除)", "image", IMAGE_WORKFLOW)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "y"}, "source": "studio-image"},
|
||||
)
|
||||
job_id = r.json()["job"]["id"]
|
||||
job = _wait_done(client, job_id)
|
||||
assert job["status"] == "done"
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.exists()
|
||||
|
||||
r = client.delete(f"/api/playground/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert not out.exists()
|
||||
jobs = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in jobs)
|
||||
|
||||
|
||||
def test_studio_jobs_listed_on_tasks_api(client, fake_comfy):
|
||||
"""/api/jobs (生成任务页) carries both studio kinds with payload.source."""
|
||||
with _real_run(client, fake_comfy):
|
||||
img_wf = _import_workflow(client, "假·文生图A", "image", IMAGE_WORKFLOW)
|
||||
fake_comfy.outputs_key = "videos"
|
||||
fake_comfy.out_name = "v.mp4"
|
||||
vid_wf = _import_workflow(client, "假·生视频A", "video", _video_workflow(False))
|
||||
for wf_id, source in ((img_wf, "studio-image"), (vid_wf, "studio-video")):
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "z"}, "source": source},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
_wait_done(client, r.json()["job"]["id"])
|
||||
|
||||
jobs = client.get("/api/jobs").json()
|
||||
sources = {j["payload"].get("source"): j["kind"] for j in jobs}
|
||||
assert sources.get("studio-image") == "image"
|
||||
assert sources.get("studio-video") == "video"
|
||||
@@ -0,0 +1,371 @@
|
||||
"""HEL-638: unknown custom output nodes close the loop — no name-based
|
||||
auto-typing, feature-less sinks become mappable candidates, a usage choice
|
||||
alone never bypasses the output designation, and generation harvests (and
|
||||
checks) exactly the nodes the user mapped.
|
||||
|
||||
Two brand-new class names drive the cases:
|
||||
|
||||
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
|
||||
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
|
||||
and enabled the workflow.
|
||||
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
|
||||
HEL-638 such a graph only reported ``no_output`` with no way to map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
from tests.test_studio_generation import _real_run, _wait_done
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _vault_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "NebulaGlaxVideoVault",
|
||||
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _muffin_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _analyze(client, wf: dict) -> dict:
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Unknown outputs stay pending — names never decide (gap 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_video_semantic_unknown_node_is_never_auto_typed(client):
|
||||
body = _analyze(client, _vault_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
|
||||
assert entry["evidence"] == "unknown_node"
|
||||
assert entry["kind"] is None # the user picks the media type
|
||||
assert entry["suggested_kind"] == "video" # only a form default
|
||||
assert body["needs_mapping"] is True
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "指定实际输出节点" in body["usage_problem"]
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
|
||||
"""create/PATCH with a usage but no output map keeps the row disabled."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video", "usage_source": "user"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
|
||||
assert wf["needs_mapping"] is True and not wf["output_map"]
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
# forcing the switch on is refused with the mapping reason
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
|
||||
assert r.status_code == 400
|
||||
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
|
||||
body = _analyze(client, _muffin_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
|
||||
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
|
||||
assert body["needs_mapping"] is True
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
body = _analyze(client, wf)
|
||||
assert body["usage"] is None and body["needs_mapping"] is False
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
|
||||
fake_comfy.outputs_key = "gifs"
|
||||
fake_comfy.out_name = "clip.mp4"
|
||||
fake_comfy.forced_output_node = "12" # the mapped node reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·视频库",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
"output_map_source": "user",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is True
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
|
||||
"source": "studio-video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["output_paths"][0].endswith(".mp4")
|
||||
|
||||
|
||||
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
|
||||
"""Results that only land on an unmapped node are NOT a completion."""
|
||||
fake_comfy.forced_output_node = "7" # something else reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·空手而归",
|
||||
"workflow_json": _muffin_workflow(),
|
||||
"usage": "text2image",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "15", "kind": "image"}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["is_enabled"] is True
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in job["error"]
|
||||
assert "15" in job["error"] # names the node it waited on
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
|
||||
"""A video mapping that only yields a png is a real failure."""
|
||||
fake_comfy.outputs_key = "images"
|
||||
fake_comfy.out_name = "still.png"
|
||||
fake_comfy.forced_output_node = "12"
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·类型不符",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "媒体类型与映射不符" in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Bad / cancelled mappings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_mappings_are_rejected_in_chinese(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "999", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "audio"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
|
||||
|
||||
# a prompt/parameter node can never be an output
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "1", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
|
||||
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
r = client.patch(
|
||||
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_clearing_the_mapping_resets_to_pending(client):
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
wf = client.get(f"/api/workflows/{wid}").json()
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is False and not wf["output_map"]
|
||||
assert wf["usage"] == "text2video" # the usage decision survives
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Saved mappings survive reanalyze / field edits / migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
|
||||
"description": "编辑其他字段"})
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
|
||||
"""Backend-direct generation on an unconfirmed row is refused."""
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "强开", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video"},
|
||||
).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "输出节点" in gen.json()["detail"]
|
||||
|
||||
|
||||
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
|
||||
import asyncio
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
|
||||
# graph and the old PATCH re-enabled it.
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled, usage, usage_source)"
|
||||
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
|
||||
("存量·未知输出", json.dumps(_vault_workflow())),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
parked = wfs["存量·未知输出"]
|
||||
assert parked["is_enabled"] is False
|
||||
assert parked["usage"] == "text2video" # decision preserved
|
||||
assert not parked["output_map"] and parked["needs_mapping"] is True
|
||||
|
||||
# A trusted five-type import keeps working untouched.
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "存量·可信", "kind": "image",
|
||||
"workflow_json": json.loads(
|
||||
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
|
||||
)},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
|
||||
assert trusted["is_enabled"] is True
|
||||
assert trusted["output_map_source"] == "auto"
|
||||
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
|
||||
@@ -0,0 +1,289 @@
|
||||
"""HEL-623: workflows exported straight from ComfyUI (no (Input:…) title tags)
|
||||
still expose a prompt and their fillable widgets, patch back onto the right
|
||||
widget, and run the full 导入→填参→生成→历史 chain against a fake ComfyUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.smart_fill import ref_image_slots
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from tests.test_studio_generation import ( # noqa: F401 — fixture re-export
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
_wait_done,
|
||||
fake_comfy,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_role(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["role"]: i for i in inputs if i.get("role")}
|
||||
|
||||
|
||||
def test_native_qwen_t2i_exposes_prompt_and_sampler_widgets():
|
||||
inputs = parse_dynamic_inputs(_load("qwen_image_t2i_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "6"
|
||||
assert roles["prompt"]["required"] is True
|
||||
assert roles["prompt"]["defaultValue"].startswith("一只橘猫")
|
||||
assert roles["negative"]["nodeId"] == "7"
|
||||
assert roles["seed"]["nodeId"] == "3@seed"
|
||||
assert roles["steps"]["nodeId"] == "3@steps"
|
||||
assert roles["cfg"]["nodeId"] == "3@cfg"
|
||||
assert roles["width"]["nodeId"] == "58@width"
|
||||
assert roles["height"]["nodeId"] == "58@height"
|
||||
assert roles["batch"]["nodeId"] == "58@batch_size"
|
||||
assert all(i["auto"] for i in inputs)
|
||||
assert [i for i in inputs if i["required"]] == [roles["prompt"]]
|
||||
|
||||
|
||||
def test_native_qwen_edit_detects_image_and_mask_slots():
|
||||
inputs = parse_dynamic_inputs(_load("qwen_image_edit_mask_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
# TextEncodeQwenImageEditPlus writes `prompt`, not the class-derived field
|
||||
assert roles["prompt"]["nodeId"] == "76@prompt"
|
||||
assert roles["negative"]["nodeId"] == "77@prompt"
|
||||
media = {i["nodeId"]: i for i in inputs if i["kind"] == "image"}
|
||||
assert set(media) == {"78", "95"}
|
||||
assert media["95"]["role"] == "mask"
|
||||
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
|
||||
# HEL-633: a wired input image must be uploaded (the export's own file name
|
||||
# rarely exists on the target ComfyUI); the mask stays optional
|
||||
assert media["78"]["required"] and not media["95"]["required"]
|
||||
|
||||
|
||||
def test_untagged_h3_video_detects_prompt_duration_seed():
|
||||
inputs = parse_dynamic_inputs(_load("minimax_h3_t2v_untagged_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "6005"
|
||||
assert roles["duration"]["nodeId"] == "221"
|
||||
assert roles["seed"]["nodeId"] == "217@noise_seed"
|
||||
|
||||
|
||||
def test_real_qwen21_t2i_splits_single_encoder_into_both_prompts():
|
||||
"""白栖知的 wen-sheng-tu.json:一个 TextEncodeQwenImage21 同时喂 positive/negative."""
|
||||
wf = _load("wen_sheng_tu_API.json")
|
||||
inputs = parse_dynamic_inputs(wf)
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "459:452@prompt"
|
||||
assert roles["prompt"]["label"] == "正向提示词"
|
||||
assert roles["prompt"]["required"] is True
|
||||
assert roles["prompt"]["defaultValue"].startswith("Greyscale fashion editorial")
|
||||
assert roles["negative"]["nodeId"] == "459:452@negative_prompt"
|
||||
assert roles["negative"]["label"] == "反向提示词"
|
||||
assert roles["negative"]["required"] is False
|
||||
assert roles["seed"]["nodeId"] == "459:458@seed"
|
||||
assert roles["steps"]["nodeId"] == "459:458@steps"
|
||||
assert roles["cfg"]["nodeId"] == "459:458@cfg"
|
||||
assert roles["batch"]["nodeId"] == "459:456@batch_size"
|
||||
# width/height are wired from the resolution selector — not exposed
|
||||
assert "width" not in roles and "height" not in roles
|
||||
assert [i for i in inputs if i["required"]] == [roles["prompt"]]
|
||||
|
||||
patched = patch_workflow(
|
||||
wf, {roles["prompt"]["nodeId"]: "新正向", roles["negative"]["nodeId"]: "模糊"}
|
||||
)
|
||||
enc = patched["459:452"]["inputs"]
|
||||
assert enc["prompt"] == "新正向"
|
||||
assert enc["negative_prompt"] == "模糊"
|
||||
assert enc["clip"] == ["459:453", 0]
|
||||
|
||||
|
||||
def test_real_h3_t2v_duration_from_expression_primitive():
|
||||
"""wen-sheng-shipin.json:无标题 PrimitiveFloat 经表达式换算帧数 → 时长(秒)."""
|
||||
inputs = parse_dynamic_inputs(_load("wen_sheng_shipin_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "1"
|
||||
assert roles["prompt"]["required"] is True
|
||||
assert roles["duration"]["nodeId"] == "133"
|
||||
assert roles["duration"]["label"] == "时长(秒)"
|
||||
assert roles["duration"]["defaultValue"] == 10
|
||||
assert roles["seed"]["nodeId"] == "129@noise_seed"
|
||||
assert roles["steps"]["nodeId"] == "124@steps"
|
||||
assert "negative" not in roles
|
||||
|
||||
|
||||
def _encoder_graph(encoder: dict, positive_slot: list, negative_slot: list) -> dict:
|
||||
return {
|
||||
"1": encoder,
|
||||
"2": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"seed": 1, "steps": 20, "cfg": 4, "positive": positive_slot,
|
||||
"negative": negative_slot},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_custom_encoder_with_text_and_negative_prompt_is_split():
|
||||
wf = _encoder_graph(
|
||||
{"class_type": "MyTextEncodeDual", "inputs": {"text": "猫", "negative_prompt": "糊"},
|
||||
"_meta": {"title": "MyTextEncodeDual"}},
|
||||
["1", 0],
|
||||
["1", 1],
|
||||
)
|
||||
roles = _by_role(parse_dynamic_inputs(wf))
|
||||
assert roles["prompt"]["nodeId"] == "1"
|
||||
assert roles["prompt"]["defaultValue"] == "猫"
|
||||
assert roles["negative"]["nodeId"] == "1@negative_prompt"
|
||||
|
||||
|
||||
def test_clip_encoder_wired_to_both_positive_and_negative_stays_positive():
|
||||
wf = _encoder_graph(
|
||||
{"class_type": "CLIPTextEncode", "inputs": {"text": "猫"},
|
||||
"_meta": {"title": "CLIP Text Encode"}},
|
||||
["1", 0],
|
||||
["1", 0],
|
||||
)
|
||||
roles = _by_role(parse_dynamic_inputs(wf))
|
||||
assert roles["prompt"]["nodeId"] == "1"
|
||||
assert "negative" not in roles
|
||||
|
||||
|
||||
def test_tagged_workflows_are_unchanged():
|
||||
for path in sorted(EXAMPLES.glob("*.json")):
|
||||
inputs = parse_dynamic_inputs(json.loads(path.read_text(encoding="utf-8")))
|
||||
assert inputs, path.name
|
||||
assert not any(i.get("auto") for i in inputs), path.name
|
||||
assert all("@" not in i["nodeId"] for i in inputs), path.name
|
||||
|
||||
|
||||
def test_patch_field_keys_write_named_widget_and_keep_links():
|
||||
wf = _load("qwen_image_t2i_API.json")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{"6": "新的提示词", "3@seed": 7, "3@steps": 12, "58@width": 1024, "3@model": "x"},
|
||||
)
|
||||
assert patched["6"]["inputs"]["text"] == "新的提示词"
|
||||
assert patched["3"]["inputs"]["seed"] == 7
|
||||
assert patched["3"]["inputs"]["steps"] == 12
|
||||
assert patched["58"]["inputs"]["width"] == 1024
|
||||
# linked input stays wired; the base workflow is untouched
|
||||
assert patched["3"]["inputs"]["model"] == ["66", 0]
|
||||
assert wf["3"]["inputs"]["seed"] == 1125488487853216
|
||||
|
||||
|
||||
def test_smart_fill_sort_accepts_subgraph_and_field_ids():
|
||||
slots = ref_image_slots(
|
||||
[
|
||||
{"nodeId": "171:12", "role": "image", "kind": "image"},
|
||||
{"nodeId": "9", "role": "image", "kind": "image"},
|
||||
{"nodeId": "3@image", "role": "image", "kind": "image"},
|
||||
]
|
||||
)
|
||||
assert [s["nodeId"] for s in slots] == ["3@image", "9", "171:12"]
|
||||
|
||||
|
||||
def test_list_backfills_schema_for_workflows_imported_before_detection(client):
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workflows"
|
||||
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
|
||||
" VALUES (?, 'image', ?, '[]', '[]', 1)",
|
||||
("旧导入·Qwen", json.dumps(_load("qwen_image_t2i_API.json"))),
|
||||
)
|
||||
conn.commit()
|
||||
wf_id = cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
wf = client.get(f"/api/workflows/{wf_id}").json()
|
||||
assert _by_role(wf["input_schema"])["prompt"]["nodeId"] == "6"
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert len(listed["input_schema"]) == len(wf["input_schema"])
|
||||
|
||||
|
||||
def test_native_workflow_full_chain(client, fake_comfy): # noqa: F811
|
||||
"""导入原生 Qwen 工作流 → 填参 → 生成 → ComfyUI 收到改写后的节点 → 历史可见."""
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(
|
||||
client, "原生·Qwen 文生图", "image", _load("qwen_image_t2i_API.json")
|
||||
)
|
||||
wf = client.get(f"/api/workflows/{wf_id}").json()
|
||||
roles = _by_role(wf["input_schema"])
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {
|
||||
roles["prompt"]["nodeId"]: "雨夜霓虹街道",
|
||||
roles["seed"]["nodeId"]: 99,
|
||||
roles["width"]["nodeId"]: 1024,
|
||||
roles["height"]["nodeId"]: 768,
|
||||
},
|
||||
"source": "studio-image",
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["6"]["inputs"]["text"] == "雨夜霓虹街道"
|
||||
assert queued["3"]["inputs"]["seed"] == 99
|
||||
assert queued["58"]["inputs"]["width"] == 1024
|
||||
assert queued["58"]["inputs"]["height"] == 768
|
||||
assert queued["3"]["inputs"]["positive"] == ["6", 0]
|
||||
history = client.get("/api/playground/jobs").json()
|
||||
assert any(j["id"] == job["id"] and j["status"] == "done" for j in history)
|
||||
|
||||
|
||||
def test_real_qwen21_t2i_full_chain(client, fake_comfy): # noqa: F811
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(
|
||||
client, "真实·Qwen2.1 文生图", "image", _load("wen_sheng_tu_API.json")
|
||||
)
|
||||
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {
|
||||
roles["prompt"]["nodeId"]: "雨夜霓虹街道",
|
||||
roles["negative"]["nodeId"]: "低清晰度",
|
||||
roles["seed"]["nodeId"]: 7,
|
||||
},
|
||||
"source": "studio-image",
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
|
||||
assert queued["459:452"]["inputs"]["negative_prompt"] == "低清晰度"
|
||||
assert queued["459:458"]["inputs"]["seed"] == 7
|
||||
assert queued["459:458"]["inputs"]["negative"] == ["459:452", 1]
|
||||
|
||||
|
||||
def test_comfy_rejection_is_reported_in_chinese(client, fake_comfy): # noqa: F811
|
||||
fake_comfy.fail_prompt = True
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(
|
||||
client, "原生·Qwen(拒绝)", "image", _load("qwen_image_t2i_API.json")
|
||||
)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"6": "x"}, "source": "studio-image"},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "ComfyUI 拒绝执行该工作流" in job["error"]
|
||||
assert "HTTP 500" in job["error"]
|
||||
|
||||
|
||||
def test_generate_unknown_workflow_has_chinese_detail(client):
|
||||
r = client.post("/api/playground/generate", json={"workflow_id": 987654, "input_values": {}})
|
||||
assert r.status_code == 404
|
||||
assert "工作流不存在" in r.json()["detail"]
|
||||
@@ -0,0 +1,308 @@
|
||||
"""HEL-636 A1–A6: the five-usage classifier judges from graph evidence only.
|
||||
|
||||
Every case feeds real (or minimally mutated) ComfyUI API exports — never a
|
||||
file name, model name or fixed node id — and asserts the usage, the media
|
||||
slots it implies, and the failure paths for ambiguous / unknown graphs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
detect_untagged_inputs,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _rename_everything(wf: dict, id_shift: int = 7) -> dict:
|
||||
"""Change every node id, title and widget value the classifier must not
|
||||
depend on (model file names / prompt texts)."""
|
||||
def new_id(nid: str) -> str:
|
||||
return f"x{int(nid) * id_shift + 3}" if nid.isdigit() else "x" + nid.replace(":", "_")
|
||||
|
||||
out: dict = {}
|
||||
for nid, node in wf.items():
|
||||
out[new_id(nid)] = json.loads(json.dumps(node))
|
||||
for node in out.values():
|
||||
for val in (node.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2 and str(val[0]).isdigit():
|
||||
val[0] = new_id(str(val[0]))
|
||||
if node.get("_meta", {}).get("title"):
|
||||
node["_meta"]["title"] = f"改·{node['_meta']['title']}"
|
||||
for key, val in list((node.get("inputs") or {}).items()):
|
||||
if isinstance(val, str) and val.endswith((".safetensors", ".gguf")):
|
||||
node["inputs"][key] = "另一个模型_" + val
|
||||
return out
|
||||
|
||||
|
||||
def _media(inputs: list[dict]) -> list[dict]:
|
||||
return [
|
||||
i
|
||||
for i in inputs
|
||||
if i["kind"] in ("image", "image_list", "video", "audio")
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A1 / A2 / A3: the three real shapes, plus id/title/model renames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a1_native_t2i_is_text2image_and_form_has_no_image_slot():
|
||||
wf = _load(FIXTURES / "qwen_image_t2i_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2image"
|
||||
assert c["ambiguous"] is False and c["needs_mapping"] is False
|
||||
assert _media(parse_dynamic_inputs(wf)) == []
|
||||
outs = parse_dynamic_outputs(wf)
|
||||
assert [(o["nodeId"], o["kind"]) for o in outs] == [("60", "image")]
|
||||
|
||||
|
||||
def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
|
||||
wf = _rename_everything(_load(FIXTURES / "qwen_image_edit_mask_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2image"
|
||||
inputs = parse_dynamic_inputs(wf)
|
||||
img = [i for i in _media(inputs) if i.get("role") == "image"]
|
||||
assert len(img) == 1 and img[0]["required"] is True
|
||||
mask = [i for i in _media(inputs) if i.get("role") == "mask"]
|
||||
assert len(mask) == 1 # mask never counts as an input image
|
||||
|
||||
|
||||
def test_a3_i2v_variant_renamed_still_image2video_first_frame_only():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2video"
|
||||
media = _media(parse_dynamic_inputs(wf))
|
||||
assert [i.get("role") for i in media] == ["first_frame"]
|
||||
# A3: no first/last pair → the pair validation must not fire
|
||||
assert blocking_problem(wf) is None
|
||||
|
||||
|
||||
def test_a4_ref2va_variant_with_renamed_class_and_prefix_still_groups():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_ref2va_05_API.json"))
|
||||
for node in wf.values():
|
||||
if node.get("class_type") == "MiniMaxH3ReferenceToVideo":
|
||||
node["class_type"] = "MyFancyRefToVideo"
|
||||
inputs = node["inputs"]
|
||||
for k in list(inputs):
|
||||
if k.startswith("ref_images.ref_image_"):
|
||||
inputs[k.replace("ref_images.ref_image_", "refs.pic_")] = inputs.pop(k)
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert len(groups) == 1
|
||||
g = groups[0]
|
||||
assert g["role"] == "reference_image" and (g["min"], g["max"]) == (1, 9)
|
||||
# patch rewires the renamed group in order and refuses a 10th image
|
||||
key = g["nodeId"]
|
||||
node_id = key.split("@")[0]
|
||||
patched = patch_workflow(wf, {key: [f"/u/{i}.png" for i in range(4)]})
|
||||
names = sorted(
|
||||
(k for k in patched[node_id]["inputs"] if k.startswith("refs.pic_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[-1]) if k.rsplit("_", 1)[-1].isdigit() else int(k[-1]),
|
||||
)
|
||||
assert names == [f"refs.pic_{i}" for i in range(4)]
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
|
||||
_c, errs = validate_input_values(
|
||||
detect_untagged_inputs(wf), {key: [f"/u/{i}.png" for i in range(10)]}
|
||||
)
|
||||
assert any("最多 9 张" in e for e in errs)
|
||||
|
||||
|
||||
def test_qwen_single_numbered_encoder_input_is_not_mistaken_for_a_group():
|
||||
"""TextEncodeQwenImage21's ``images.image_1`` stays an ordinary input slot."""
|
||||
inputs = parse_dynamic_inputs(_load(FIXTURES / "qwen21_edit_sage_API.json"))
|
||||
assert not any(i["kind"] == "image_list" for i in inputs)
|
||||
assert any(i.get("role") == "image" for i in inputs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Judgment table rows from the real example library
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_real_exports_classify():
|
||||
expect = {
|
||||
"qwen_image_t2i_API.json": "text2image",
|
||||
"qwen_image_edit_mask_API.json": "image2image",
|
||||
"qwen21_edit_sage_API.json": "image2image",
|
||||
"h3_i2v_04_API.json": "image2video",
|
||||
"h3_ref2va_05_API.json": "ref2video",
|
||||
"minimax_h3_t2v_untagged_API.json": "text2video",
|
||||
"wen_sheng_tu_API.json": "text2image",
|
||||
"wen_sheng_shipin_API.json": "text2video",
|
||||
}
|
||||
for name, usage in expect.items():
|
||||
assert classify_usage(_load(FIXTURES / name))["usage"] == usage, name
|
||||
examples = {
|
||||
"Krea2_t2i_20260818_API.json": "text2image",
|
||||
"krea2_T2I_CharSheetV1_API.json": "text2image",
|
||||
"minimax_h3-Turbo_Text2Video_20260813_API.json": "text2video",
|
||||
"minimax_h3-Turbo_Image2Video_20260813_API.json": "text2video",
|
||||
"MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_2ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_3ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_4ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_5ref_API.json": "ref2video",
|
||||
}
|
||||
for name, usage in examples.items():
|
||||
assert classify_usage(_load(EXAMPLES / name))["usage"] == usage, name
|
||||
|
||||
|
||||
def test_r2v_extend_reports_its_video_input_slot():
|
||||
wf = _load(EXAMPLES / "MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
assert c["evidence"]["video_slots"], "the reference-video slot must be reported"
|
||||
assert any("视频输入槽" in line for line in c["evidence"]["summary"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A5: ambiguity — single loose image into a video graph; multi-output graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image(nid: str) -> dict:
|
||||
return {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "x.png"},
|
||||
"_meta": {"title": f"图 {nid}"},
|
||||
}
|
||||
|
||||
|
||||
def test_single_loose_image_with_video_output_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "首帧还是参考图" in c["problem"]
|
||||
|
||||
|
||||
def test_first_frame_role_decides_image2video_without_ambiguity():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "first_frame": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
assert classify_usage(wf)["usage"] == "image2video"
|
||||
|
||||
|
||||
def test_same_graph_both_image_and_video_outputs_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
"10": {"class_type": "SaveImage", "inputs": {"images": ["7", 1]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "同时输出图片和视频" in c["problem"]
|
||||
|
||||
|
||||
def test_two_reference_images_into_a_custom_node_become_a_group_ref2video():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
"6": {"class_type": "LoadImage", "inputs": {"image": "b.png"}},
|
||||
"7": {
|
||||
"class_type": "GenericRefToVideo",
|
||||
"inputs": {
|
||||
"prompt": ["1", 0],
|
||||
"refs.pic_1": ["5", 0],
|
||||
"refs.pic_2": ["6", 0],
|
||||
},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["7@refs"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A6: failure paths — no output, unknown custom output nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_output_graph_reports_chinese_error_and_blocks():
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is False
|
||||
assert "未在图中找到输出节点" in c["problem"]
|
||||
assert blocking_problem(wf) and "未在图中找到输出节点" in blocking_problem(wf)
|
||||
|
||||
|
||||
def test_unknown_custom_save_node_needs_mapping_and_keeps_a_runnable_path():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "MyCloudExporter",
|
||||
"inputs": {"filename_prefix": "out", "render": ["8", 0]},
|
||||
},
|
||||
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["needs_mapping"] is True
|
||||
assert c["usage"] is None and "无法识别" in (c["problem"] or "")
|
||||
# unknown savers are a warning (user maps them at import), not a block —
|
||||
# once the user confirms the usage the job can still run and results are
|
||||
# pulled from ComfyUI history's images/gifs/videos keys.
|
||||
codes = {(w["level"], w["code"]) for w in workflow_warnings(wf)}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_utility_sink_tail_is_not_an_output_and_does_not_break_detection():
|
||||
"""VideoCombine feeding a cache-cleanup tail still counts as the output."""
|
||||
wf = _load(FIXTURES / "minimax_h3_t2v_untagged_API.json")
|
||||
wf["9999"] = {
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"inputs": {"anything": ["5978", 0]},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2video"
|
||||
assert c["needs_mapping"] is False
|
||||
assert workflow_warnings(wf) == []
|
||||
|
||||
|
||||
def test_evidence_lists_output_nodes_and_slots():
|
||||
wf = _load(FIXTURES / "h3_ref2va_05_API.json")
|
||||
c = classify_usage(wf)
|
||||
ev = c["evidence"]
|
||||
assert any(o["nodeId"] == "92" and o["kind"] == "video" for o in ev["outputs"])
|
||||
assert any(s["kind"] == "image_list" for s in ev["image_slots"])
|
||||
assert any("输出" in line for line in ev["summary"])
|
||||
@@ -26,6 +26,15 @@ VIDEO_WORKFLOW = {
|
||||
"inputs": {"file": ""},
|
||||
"_meta": {"title": "Previous Clip (Input:video)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
# Same but the video slot is missing entirely — must be rejected at enqueue.
|
||||
@@ -35,6 +44,15 @@ NO_VIDEO_WORKFLOW = {
|
||||
"inputs": {"text": ""},
|
||||
"_meta": {"title": "Main Prompt (Input:prompt)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -250,12 +268,14 @@ def _scene_ids(pid: int) -> list[int]:
|
||||
|
||||
|
||||
async def _fake_history(_client, _prompt_id, *_args):
|
||||
"""A successful ComfyUI history with one video output named out.mp4.
|
||||
Extra positional args (job_id) are accepted for the cancel-check wiring."""
|
||||
"""A successful ComfyUI history with one video output named out.mp4,
|
||||
reported under the workflow's real output node (30 = the tagged
|
||||
VHS_VideoCombine). Extra positional args (job_id) are accepted for the
|
||||
cancel-check wiring."""
|
||||
return {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {
|
||||
"99": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
"30": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
|
||||
prepare_media_inputs handed it; download_image writes out.mp4. Returns
|
||||
the list that collects uploaded local paths.
|
||||
"""
|
||||
queue_manager.paused = True
|
||||
# monkeypatch (not bare assignment): queue_manager is a process-level
|
||||
# singleton — a bare `paused = True` leaks into every later test and
|
||||
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
|
||||
# the previous value at teardown.
|
||||
monkeypatch.setattr(queue_manager, "paused", True)
|
||||
uploads: list[str] = []
|
||||
|
||||
async def fake_upload_video(self, path, subfolder="calliope"):
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""五段创作工作台 (HEL-614) — stage lifecycle, context handoff (confirmed
|
||||
drafts only), edit/confirm/restore, script import, in-flow generation via
|
||||
the shared queue, and honest failure states. LLM is faked by monkeypatching
|
||||
the workbench router's LLMClient; ComfyUI runs in dry_run placeholder mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.routers import workbench as wb
|
||||
|
||||
IMAGE_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "默认提示词"},
|
||||
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": "x"},
|
||||
"_meta": {"title": "(Output:image) 图片输出"},
|
||||
},
|
||||
}
|
||||
|
||||
SCRIPT_TEXT = (
|
||||
"《夜行便利店》\n第一场·内景·便利店·凌晨两点:店员小夏整理货架,风铃轻响,"
|
||||
"一位穿米色风衣的女孩推门而入,径直走到关东煮前。\n小夏:还是老样子?\n女孩点头。"
|
||||
)
|
||||
|
||||
STYLE_REPLY = json.dumps(
|
||||
{
|
||||
"directions": [
|
||||
{
|
||||
"id": "style1",
|
||||
"name": "霓虹赛博",
|
||||
"description": "青紫霓虹、湿润反光、浅景深",
|
||||
"prompt": "cyberpunk convenience store, neon rim light, wet floor reflections",
|
||||
"negative_prompt": "blurry, lowres",
|
||||
},
|
||||
{
|
||||
"id": "style2",
|
||||
"name": "暖调生活流",
|
||||
"description": "暖黄顶光、柔焦、生活质感",
|
||||
"prompt": "warm cozy interior, soft rim light, film grain",
|
||||
"negative_prompt": "",
|
||||
},
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
STORYBOARD_REPLY = json.dumps(
|
||||
{
|
||||
"shots": [
|
||||
{
|
||||
"id": "shot1",
|
||||
"scene": "1",
|
||||
"shot_size": "全景",
|
||||
"camera": "固定",
|
||||
"description": "凌晨便利店全貌,货架整齐,灯光惨白",
|
||||
"dialog": "",
|
||||
"duration_sec": 3,
|
||||
},
|
||||
{
|
||||
"id": "shot2",
|
||||
"scene": "1",
|
||||
"shot_size": "特写",
|
||||
"camera": "推",
|
||||
"description": "风铃轻响,女孩的手推开玻璃门",
|
||||
"dialog": "小夏:还是老样子?",
|
||||
"duration_sec": 2,
|
||||
},
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
ASSETS_REPLY = json.dumps(
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset1",
|
||||
"kind": "character",
|
||||
"name": "小夏",
|
||||
"description": "夜班店员,白色制服",
|
||||
"prompt": "convenience store clerk, white uniform, name tag, half body",
|
||||
},
|
||||
{
|
||||
"id": "asset2",
|
||||
"kind": "location",
|
||||
"name": "便利店",
|
||||
"description": "深夜便利店内部",
|
||||
"prompt": "japanese convenience store interior at night, bright shelves",
|
||||
},
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
SHOT_REPLY = json.dumps(
|
||||
{
|
||||
"shots": [
|
||||
{
|
||||
"id": "vshot1",
|
||||
"shot_no": "S1",
|
||||
"description": "开场全景",
|
||||
"prompt": "slow push in on convenience store shelves at night, cinematic",
|
||||
"duration_sec": 4,
|
||||
},
|
||||
{
|
||||
"id": "vshot2",
|
||||
"shot_no": "S2",
|
||||
"description": "风铃特写",
|
||||
"prompt": "close up wind chime, door opens, warm light spill",
|
||||
"duration_sec": 3,
|
||||
},
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
"""Scripted replies; records every chat() call for context assertions."""
|
||||
|
||||
replies: list[str] = []
|
||||
calls: list[list[dict]] = []
|
||||
|
||||
@classmethod
|
||||
def reset(cls, replies: list[str]) -> None:
|
||||
cls.replies = list(replies)
|
||||
cls.calls = []
|
||||
|
||||
@classmethod
|
||||
def for_role(cls, role: str, *, timeout: float = 120.0) -> "FakeLLM":
|
||||
return cls()
|
||||
|
||||
async def chat(self, messages, temperature=0.7, **kwargs) -> str:
|
||||
type(self).calls.append(messages)
|
||||
if type(self).replies:
|
||||
return type(self).replies.pop(0)
|
||||
return json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class RefusingLLM(FakeLLM):
|
||||
@classmethod
|
||||
def for_role(cls, role: str, *, timeout: float = 120.0) -> "RefusingLLM":
|
||||
return cls()
|
||||
|
||||
async def chat(self, messages, temperature=0.7, **kwargs) -> str:
|
||||
type(self).calls.append(messages)
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
|
||||
def _make_project(client, **fields) -> int:
|
||||
payload = {"title": fields.get("title", "夜行便利店"), "idea": "凌晨便利店的温暖相遇"}
|
||||
r = client.post("/api/projects", json=payload)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _overview(client, pid: int) -> dict:
|
||||
r = client.get(f"/api/workbench/projects/{pid}")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _stage(overview: dict, key: str) -> dict:
|
||||
return next(s for s in overview["stages"] if s["stage_key"] == key)
|
||||
|
||||
|
||||
def _wait_stage_status(client, pid: int, key: str, statuses: set[str], timeout: float = 10.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
stage = None
|
||||
while time.time() < deadline:
|
||||
stage = _stage(_overview(client, pid), key)
|
||||
if stage["status"] in statuses:
|
||||
return stage
|
||||
time.sleep(0.2)
|
||||
pytest.fail(f"stage {key} stuck in {stage and stage['status']!r}, want {statuses}")
|
||||
|
||||
|
||||
def _run(client, pid: int, key: str, instruction: str | None = None):
|
||||
body = {"instruction": instruction} if instruction else {}
|
||||
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/run", json=body)
|
||||
|
||||
|
||||
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_overview_ensures_five_todo_stages(client):
|
||||
pid = _make_project(client)
|
||||
overview = _overview(client, pid)
|
||||
assert [s["stage_key"] for s in overview["stages"]] == [
|
||||
"script",
|
||||
"style",
|
||||
"storyboard",
|
||||
"assets",
|
||||
"shot",
|
||||
]
|
||||
assert all(s["status"] == "todo" for s in overview["stages"])
|
||||
assert overview["project"]["title"] == "夜行便利店"
|
||||
|
||||
|
||||
def test_run_requires_previous_stage_confirmed(client, monkeypatch):
|
||||
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
|
||||
FakeLLM.reset([])
|
||||
pid = _make_project(client)
|
||||
r = _run(client, pid, "style")
|
||||
assert r.status_code == 409
|
||||
assert "剧本创作" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_five_stage_flow_confirmed_context_handoff(client, monkeypatch):
|
||||
"""从创意起步端到端:每段生成→确认;只有已确认内容进入下一段上下文。"""
|
||||
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
|
||||
FakeLLM.reset(
|
||||
[
|
||||
json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False),
|
||||
STYLE_REPLY,
|
||||
STORYBOARD_REPLY,
|
||||
ASSETS_REPLY,
|
||||
SHOT_REPLY,
|
||||
]
|
||||
)
|
||||
pid = _make_project(client)
|
||||
|
||||
# 1. 剧本创作
|
||||
assert _run(client, pid, "script").status_code == 200
|
||||
stage = _wait_stage_status(client, pid, "script", {"awaiting_confirm"})
|
||||
assert stage["content"]["text"] == SCRIPT_TEXT
|
||||
assert _confirm(client, pid, "script").status_code == 200
|
||||
|
||||
# 2. 视觉设计 — 上下文带剧本
|
||||
assert _run(client, pid, "style").status_code == 200
|
||||
stage = _wait_stage_status(client, pid, "style", {"awaiting_confirm"})
|
||||
assert len(stage["content"]["directions"]) == 2
|
||||
assert SCRIPT_TEXT[:10] in FakeLLM.calls[-1][1]["content"]
|
||||
r = _confirm(client, pid, "style", chosen_style_id="style2")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["chosen_style_id"] == "style2"
|
||||
|
||||
# 3. 分镜设计 — 上下文带剧本 + 已选风格(暖调生活流,非默认第一套)
|
||||
assert _run(client, pid, "storyboard").status_code == 200
|
||||
_wait_stage_status(client, pid, "storyboard", {"awaiting_confirm"})
|
||||
ctx = FakeLLM.calls[-1][1]["content"]
|
||||
assert "暖调生活流" in ctx
|
||||
assert SCRIPT_TEXT[:10] in ctx
|
||||
assert _confirm(client, pid, "storyboard").status_code == 200
|
||||
|
||||
# 4. 素材资产 — 上下文带分镜
|
||||
assert _run(client, pid, "assets").status_code == 200
|
||||
_wait_stage_status(client, pid, "assets", {"awaiting_confirm"})
|
||||
ctx = FakeLLM.calls[-1][1]["content"]
|
||||
assert "风铃轻响" in ctx
|
||||
assert _confirm(client, pid, "assets").status_code == 200
|
||||
|
||||
# 5. 镜头脚本 — 上下文带素材清单
|
||||
assert _run(client, pid, "shot").status_code == 200
|
||||
stage = _wait_stage_status(client, pid, "shot", {"awaiting_confirm"})
|
||||
ctx = FakeLLM.calls[-1][1]["content"]
|
||||
assert "小夏" in ctx
|
||||
assert stage["content"]["shots"][0]["prompt"].startswith("slow push")
|
||||
|
||||
# 进度反映到项目列表(真实阶段数据)
|
||||
r = client.get("/api/projects")
|
||||
stats = next(p for p in r.json() if p["id"] == pid)["stats"]
|
||||
assert stats["stage_confirmed"] == 4
|
||||
assert stats["stage_status"]["shot"] == "awaiting_confirm"
|
||||
|
||||
|
||||
def test_run_failure_keeps_confirmed_snapshot_and_reads_chinese(client, monkeypatch):
|
||||
monkeypatch.setattr(wb, "LLMClient", RefusingLLM)
|
||||
pid = _make_project(client)
|
||||
# 先用正常 LLM 确认剧本
|
||||
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
|
||||
FakeLLM.reset([json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)])
|
||||
_run(client, pid, "script")
|
||||
_wait_stage_status(client, pid, "script", {"awaiting_confirm"})
|
||||
_confirm(client, pid, "script")
|
||||
# 视觉设计生成失败(连接拒绝)
|
||||
monkeypatch.setattr(wb, "LLMClient", RefusingLLM)
|
||||
assert _run(client, pid, "style").status_code == 200
|
||||
stage = _wait_stage_status(client, pid, "style", {"todo"})
|
||||
assert "连接大模型服务失败" in (stage["last_run"]["error"] or "")
|
||||
# 剧本阶段不受影响
|
||||
assert _stage(_overview(client, pid), "script")["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_edit_after_confirm_and_restore(client, monkeypatch):
|
||||
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
|
||||
FakeLLM.reset([json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)])
|
||||
pid = _make_project(client)
|
||||
_run(client, pid, "script")
|
||||
_wait_stage_status(client, pid, "script", {"awaiting_confirm"})
|
||||
_confirm(client, pid, "script")
|
||||
|
||||
# 编辑已确认阶段 → 变回待确认草稿,已确认快照保留
|
||||
edited = {"text": SCRIPT_TEXT + "\n(结尾新增:女孩微笑离开。)"}
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/script", json={"content": edited})
|
||||
assert r.status_code == 200
|
||||
stage = r.json()["stage"]
|
||||
assert stage["status"] == "awaiting_confirm"
|
||||
assert stage["draft_edited"] is True
|
||||
assert stage["confirmed_content"]["text"] == SCRIPT_TEXT
|
||||
|
||||
# 恢复已确认版本
|
||||
r = client.post(f"/api/workbench/projects/{pid}/stages/script/restore")
|
||||
assert r.status_code == 200
|
||||
stage = r.json()["stage"]
|
||||
assert stage["status"] == "confirmed"
|
||||
assert stage["content"]["text"] == SCRIPT_TEXT
|
||||
|
||||
|
||||
def test_import_script_confirms_and_unlocks_style(client, monkeypatch):
|
||||
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
|
||||
FakeLLM.reset([STYLE_REPLY])
|
||||
pid = _make_project(client)
|
||||
r = client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["status"] == "confirmed"
|
||||
# 跳过剧本创作,直接从视觉设计继续
|
||||
assert _run(client, pid, "style").status_code == 200
|
||||
_wait_stage_status(client, pid, "style", {"awaiting_confirm"})
|
||||
# 太短的导入被拒绝
|
||||
r = client.post(
|
||||
f"/api/workbench/projects/{pid}/stages/script/import", json={"text": "太短"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "太短" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_generate_without_workflow_is_chinese_400(client):
|
||||
pid = _make_project(client)
|
||||
r = client.post(
|
||||
f"/api/workbench/projects/{pid}/generate",
|
||||
json={"kind": "image", "stage_key": "assets", "target_id": "asset1", "prompt": "a cat"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "没有可用的生图工作流" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_generate_image_job_returns_to_project(client):
|
||||
pid = _make_project(client)
|
||||
# 手动放入素材阶段草稿(带 asset1)
|
||||
r = client.patch(
|
||||
f"/api/workbench/projects/{pid}/stages/assets",
|
||||
json={"content": json.loads(ASSETS_REPLY)},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "假·文生图", "kind": "image", "workflow_json": IMAGE_WORKFLOW},
|
||||
)
|
||||
assert wf.status_code == 200, wf.text
|
||||
|
||||
r = client.post(
|
||||
f"/api/workbench/projects/{pid}/generate",
|
||||
json={
|
||||
"kind": "image",
|
||||
"stage_key": "assets",
|
||||
"target_id": "asset1",
|
||||
"prompt": "convenience store clerk portrait",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()["job"]
|
||||
assert job["payload"]["source"] == "workbench"
|
||||
assert job["payload"]["target_id"] == "asset1"
|
||||
assert job["payload"]["input_values"]["1"] == "convenience store clerk portrait"
|
||||
|
||||
# dry_run worker completes with a placeholder; overview enriches the asset
|
||||
deadline = time.time() + 40
|
||||
job_done = False
|
||||
mine = None
|
||||
while time.time() < deadline:
|
||||
jobs = client.get(f"/api/jobs?project_id={pid}").json()
|
||||
mine = next((j for j in jobs if j["id"] == job["id"]), None)
|
||||
if mine and mine["status"] in ("done", "failed"):
|
||||
job_done = mine["status"] == "done"
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job_done, f"workbench job did not finish: {mine}"
|
||||
stage = _stage(_overview(client, pid), "assets")
|
||||
asset = next(a for a in stage["content"]["assets"] if a["id"] == "asset1")
|
||||
assert asset["job"]["status"] == "done"
|
||||
assert asset["job"]["output"] and asset["job"]["output"].endswith(".png")
|
||||
|
||||
|
||||
def test_project_list_stats_include_active_jobs(client):
|
||||
pid = _make_project(client)
|
||||
wf = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "假·文生图2", "kind": "image", "workflow_json": IMAGE_WORKFLOW},
|
||||
)
|
||||
wf_id = wf.json()["id"]
|
||||
r = client.post(
|
||||
f"/api/workbench/projects/{pid}/generate",
|
||||
json={"kind": "image", "stage_key": "assets", "prompt": "x"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/projects")
|
||||
stats = next(p for p in r.json() if p["id"] == pid)["stats"]
|
||||
assert stats["active_jobs"] >= 1
|
||||
assert stats["stage_confirmed"] == 0
|
||||
# a project that never opened the workbench reports no stage rows at all
|
||||
pid2 = _make_project(client, title="未开工")
|
||||
stats2 = next(p for p in client.get("/api/projects").json() if p["id"] == pid2)["stats"]
|
||||
assert stats2["stage_status"] == {} and stats2["stage_confirmed"] == 0
|
||||
@@ -0,0 +1,235 @@
|
||||
"""HEL-636: the two-step import closes the loop — analyze → confirm → save,
|
||||
user corrections stick (reanalyze never overwrites them), pending workflows
|
||||
cannot generate, and legacy rows migrate losslessly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
AMBIGUOUS = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def test_analyze_returns_usage_evidence_and_problem(client):
|
||||
wf = _load("qwen_image_t2i_API.json")
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["usage"] == "text2image"
|
||||
assert body["usage_ambiguous"] is False and body["needs_mapping"] is False
|
||||
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
|
||||
assert body["usable_usage"] == [
|
||||
"text2image", "image2image", "text2video", "image2video", "ref2video",
|
||||
]
|
||||
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
|
||||
body = r.json()
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "首帧还是参考图" in body["usage_problem"]
|
||||
assert body["usage_evidence"]["image_slots"][0]["nodeId"] == "5"
|
||||
|
||||
bare = {"1": {"class_type": "Note", "inputs": {}}}
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": bare})
|
||||
body = r.json()
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
def test_create_stores_auto_usage_and_kind(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·文生图", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["usage_source"] == "auto"
|
||||
assert wf["kind"] == "image" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·参考生视频", "workflow_json": _load("h3_ref2va_05_API.json")},
|
||||
)
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["kind"] == "video"
|
||||
|
||||
|
||||
def test_ambiguous_import_stays_pending_and_cannot_generate(client):
|
||||
r = client.post("/api/workflows", json={"name": "歧义", "workflow_json": AMBIGUOUS})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] is None and wf["is_enabled"] is False
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "已停用" in gen.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
|
||||
|
||||
def test_confirm_page_correction_roundtrip(client):
|
||||
"""analyze → user picks usage + fixes the slot role → create carries both."""
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS}).json()
|
||||
assert analyzed["usage"] is None
|
||||
|
||||
slot = analyzed["usage_evidence"]["image_slots"][0]
|
||||
schema = [
|
||||
{**i, "role": "first_frame", "manual": True} if i["nodeId"] == slot["nodeId"] else i
|
||||
for i in analyzed["inputs"]
|
||||
]
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "确认·图生视频",
|
||||
"workflow_json": AMBIGUOUS,
|
||||
"usage": "image2video",
|
||||
"usage_source": "user",
|
||||
"input_schema": schema,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
# the manual role sticks on read (and through re-derivation)
|
||||
entry = next(i for i in wf["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame"
|
||||
|
||||
# reanalyze refreshes the auto schema but never the user's usage
|
||||
r = client.post(f"/api/workflows/{wf['id']}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
fresh = r.json()
|
||||
assert fresh["usage"] == "image2video" and fresh["usage_source"] == "user"
|
||||
entry = next(i for i in fresh["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame" and entry.get("manual") is True
|
||||
|
||||
|
||||
def test_patch_usage_confirms_pending_workflow(client):
|
||||
r = client.post("/api/workflows", json={"name": "待确认", "workflow_json": AMBIGUOUS})
|
||||
wid = r.json()["id"]
|
||||
assert r.json()["is_enabled"] is False
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "ref2video"})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wid)
|
||||
assert listed["usage"] == "ref2video"
|
||||
|
||||
|
||||
def test_reanalyze_upgrades_auto_usage_only(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "image2image", "usage_source": "user"})
|
||||
assert r.json()["usage"] == "image2image"
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.json()["usage"] == "image2image" # user decision survives
|
||||
assert r.json()["usage_source"] == "user"
|
||||
|
||||
|
||||
def test_legacy_kind_only_callers_still_work(client):
|
||||
"""Old clients post kind without usage — determinate graphs classify, the
|
||||
row is enabled and generation proceeds as before."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧调用", "kind": "image", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_migration_backfills_usage_and_keeps_history(client):
|
||||
"""A8: pre-HEL-636 rows (direct SQL, no usage) settle on re-migration;
|
||||
determinate ones gain usage, ambiguous ones are parked disabled, and
|
||||
existing jobs/history stay readable with their original kind."""
|
||||
pid = client.post("/api/projects", json={"title": "存量项目", "idea": "x"}).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'image', ?, '[]', '[]', 1)",
|
||||
("存量·文生图", json.dumps(_load("qwen_image_t2i_API.json"))),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'video', ?, '[]', '[]', 1)",
|
||||
("存量·歧义", json.dumps(AMBIGUOUS)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,"
|
||||
" output_paths_json) VALUES (?, 'image', NULL, 'done', '{}', ?)",
|
||||
(pid, json.dumps(["/old/result.png"])),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs["存量·文生图"]["is_enabled"] is True
|
||||
assert wfs["存量·歧义"]["usage"] is None
|
||||
assert wfs["存量·歧义"]["is_enabled"] is False
|
||||
assert wfs["存量·歧义"]["kind"] == "video" # old kind semantics untouched
|
||||
|
||||
jobs = client.get(f"/api/jobs?project_id={pid}").json()
|
||||
assert any(
|
||||
j["status"] == "done" and j["kind"] == "image" and j["output_paths"] == ["/old/result.png"]
|
||||
for j in jobs
|
||||
)
|
||||
|
||||
# a third run changes nothing (idempotent)
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
wfs2 = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs2["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs2["存量·歧义"]["usage"] is None
|
||||
|
||||
|
||||
def test_broken_qwen_import_stays_blocked_but_recorded(client):
|
||||
"""The damaged Qwen export classifies fine (image2image); its structural
|
||||
lint errors still refuse generation — a broken file case, not a usage case."""
|
||||
wf_json = _load("qwen21_edit_sage_API.json")
|
||||
r = client.post(
|
||||
"/api/workflows/analyze", json={"workflow_json": wf_json}
|
||||
)
|
||||
assert r.json()["usage"] == "image2image"
|
||||
r = client.post(
|
||||
"/api/workflows", json={"name": "损坏·Qwen", "kind": "image", "workflow_json": wf_json}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2image" and wf["is_enabled"] is True
|
||||
assert any(w["level"] == "error" for w in wf["warnings"])
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"4@prompt": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400 and "重新导出" in gen.json()["detail"]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
|
||||
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
|
||||
不针对任何固定 JSON 或模型名写死判定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.client import clear_object_info_cache
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import _url
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_object_info(workflow: dict) -> dict:
|
||||
"""A catalogue where every widget value the JSON carries is legal.
|
||||
|
||||
String widgets become combos whose candidates include the workflow's own
|
||||
value (merged across nodes of the same class); numbers/booleans are typed
|
||||
inputs; wired inputs are link types — none of those can mismatch.
|
||||
"""
|
||||
info: dict = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct:
|
||||
continue
|
||||
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
|
||||
required = spec["input"]["required"]
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if isinstance(val, str):
|
||||
# Merge: two nodes of one class with different values → both legal.
|
||||
if name in required and isinstance(required[name][0], list):
|
||||
if val not in required[name][0]:
|
||||
required[name][0] = [*required[name][0], val]
|
||||
continue
|
||||
required[name] = [[val], {}]
|
||||
elif name in required:
|
||||
continue
|
||||
elif isinstance(val, list):
|
||||
required[name] = ["*", {}]
|
||||
elif isinstance(val, bool):
|
||||
required[name] = ["BOOLEAN", {}]
|
||||
elif isinstance(val, (int, float)):
|
||||
required[name] = ["NUMBER", {}]
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
clear_object_info_cache()
|
||||
yield
|
||||
clear_object_info_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_at(client, fake_comfy): # noqa: F811
|
||||
"""Register a connection pointing at the fake server; yields its id."""
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield ep["id"]
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unit_missing_node_reported_with_node_context():
|
||||
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
|
||||
"_meta": {"title": "H3 SLA Attention"}}}
|
||||
findings = check_workflow_against({"OtherNode": {}}, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "missing_node" and f["level"] == "error"
|
||||
msg = f["message"]
|
||||
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
|
||||
assert "重新检查" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_combo_value_not_in_list():
|
||||
wf = {"127": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
|
||||
info = {"UNETLoader": {"input": {"required": {
|
||||
"unet_name": [["installed_model.safetensors"], {}],
|
||||
"weight_dtype": [["default", "fp8"], {"upload": False}],
|
||||
}}, "output": ["MODEL"]}}
|
||||
findings = check_workflow_against(info, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
|
||||
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
|
||||
assert "模型文件" in f["message"]
|
||||
assert "installed_model.safetensors" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_upload_backed_and_wired_inputs_skipped():
|
||||
wf = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
|
||||
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
|
||||
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
|
||||
}
|
||||
info = {
|
||||
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
|
||||
"output": ["IMAGE"]},
|
||||
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
|
||||
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
|
||||
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
|
||||
"output": ["IMAGE"]},
|
||||
}
|
||||
assert check_workflow_against(info, wf) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analyze with a target connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is True and check["ok"] is True
|
||||
assert check["findings"] == []
|
||||
|
||||
|
||||
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("H3SLAAttention")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
|
||||
assert missing and "H3SLAAttention" in missing[0]["message"]
|
||||
assert "900" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
# The server has a DIFFERENT unet installed than the one the JSON names.
|
||||
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
|
||||
assert bad and bad[0]["node_id"] == "127"
|
||||
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
|
||||
|
||||
|
||||
def test_unreachable_connection_degrades_to_unchecked(client):
|
||||
# A port that nothing listens on: the endpoint resolves, the server doesn't.
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
|
||||
)
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is False and check["ok"] is False
|
||||
assert "无法核查" in check["message"]
|
||||
assert check["findings"] == []
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved-workflow re-check + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "预检工作流",
|
||||
"workflow_json": workflow,
|
||||
"usage": "ref2video",
|
||||
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
|
||||
wf_id = _create_workflow(client, _load_05(), endpoint_at)
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("UNETLoader")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
|
||||
assert any(f["code"] == "missing_node" for f in body["findings"])
|
||||
|
||||
# User installs the node package, comes back and re-checks with refresh.
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["findings"] == []
|
||||
|
||||
|
||||
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.json()["connection_check"]["ok"] is True
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 1 # second call served from the 5-minute cache
|
||||
|
||||
wf_id = _create_workflow(client, _load_05())
|
||||
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 2 # manual refresh bypasses the TTL
|
||||
@@ -0,0 +1,197 @@
|
||||
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
|
||||
|
||||
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
|
||||
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.queue.workflow_test import workflow_test_dir
|
||||
from tests.test_studio_generation import (
|
||||
IMAGE_WORKFLOW,
|
||||
PNG_1PX,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
|
||||
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["reused"] is False
|
||||
job = _wait_terminal(client, body["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["origin"] == "workflow_test"
|
||||
assert job["payload"]["source"] == "workflow_test"
|
||||
assert job["phase"] == "finished"
|
||||
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
|
||||
assert len(job["output_paths"]) >= 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.is_file() and out.read_bytes() == PNG_1PX
|
||||
assert workflow_test_dir(wf_id) in out.parents
|
||||
# 提交给 fake ComfyUI 的是补丁后的真实提示词
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "测试提示词"
|
||||
|
||||
|
||||
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
listed = client.get("/api/jobs?limit=50").json()
|
||||
mine = [j for j in listed if j["id"] == job_id]
|
||||
assert mine and mine[0]["origin"] == "workflow_test"
|
||||
|
||||
pg = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
|
||||
|
||||
|
||||
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
|
||||
fake_comfy.fail_prompt = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "拒绝执行" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_no_output_does_not_pass(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
|
||||
fake_comfy.drop_outputs = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
settings.dry_run = True # conftest default; _real_run clears it — set again
|
||||
try:
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
assert r.status_code == 400
|
||||
assert "Dry-run" in r.json()["detail"]
|
||||
finally:
|
||||
settings.dry_run = False
|
||||
|
||||
|
||||
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
r1 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json()["reused"] is False
|
||||
r2 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["reused"] is True
|
||||
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
|
||||
_wait_terminal(client, r1.json()["job"]["id"])
|
||||
|
||||
|
||||
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
|
||||
for i in range(7):
|
||||
fake_comfy.out_name = f"t{i}.png"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={"input_values": {"1": f"第{i}次"}},
|
||||
)
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
|
||||
assert len(rows) <= 5
|
||||
kept_ids = {row["id"] for row in rows}
|
||||
# 被清掉的旧记录其产物文件也一并删除
|
||||
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
|
||||
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
|
||||
assert len(kept_ids) == 5
|
||||
|
||||
|
||||
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
assert workflow_test_dir(wf_id).is_dir()
|
||||
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 200 and r.json()["removed"] >= 1
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
|
||||
|
||||
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
job_id = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
|
||||
).json()["job"]["id"]
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{job_id}/cancel")
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
# 工作流列表带出「上次测试」实时状态
|
||||
listed = client.get("/api/workflows").json()
|
||||
mine = [w for w in listed if w["id"] == wf_id][0]
|
||||
assert mine["last_test"]["id"] == job_id
|
||||
|
||||
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
|
||||
|
||||
|
||||
def test_last_test_summary_live_on_list(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
|
||||
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
|
||||
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
|
||||
done = _wait_terminal(client, r.json()["job"]["id"])
|
||||
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
|
||||
assert listed[wf_a]["last_test"]["status"] == "done"
|
||||
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
|
||||
assert listed[wf_b]["last_test"] is None
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regression guard for the "crypto.randomUUID is not a function" crash.
|
||||
*
|
||||
* `crypto.randomUUID()` only exists in secure contexts (HTTPS, localhost,
|
||||
* 127.0.0.1). On a LAN HTTP page — e.g. http://192.168.200.15:8080 — the
|
||||
* browser exposes `crypto` WITHOUT `randomUUID`, so any direct call throws
|
||||
* and model saving dies. `crypto.getRandomValues()` has no secure-context
|
||||
* requirement, so the shared helper can always mint a proper v4 UUID.
|
||||
*
|
||||
* Two layers of protection, mirroring check-storage-ssr.mjs:
|
||||
* 1. behavioural — lib/ids.ts must produce unique, well-formed v4 UUIDs
|
||||
* under every crypto shape a browser can present, including the exact
|
||||
* insecure-context shape that produced the original crash.
|
||||
* 2. static — direct `crypto.randomUUID(` calls are banned outside
|
||||
* lib/ids.ts, so the crash cannot be reintroduced by copying the old
|
||||
* pattern into a new page.
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-random-uuid.mjs`).
|
||||
*/
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const HELPER = join(SRC, "lib", "ids.ts");
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function fail(message) {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${message}`);
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
console.log(`ok ${message}`);
|
||||
}
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
/** Capture the platform crypto before any test replaces it. */
|
||||
const REAL_CRYPTO = globalThis.crypto;
|
||||
|
||||
/** Replace the global under test. Node's own binding may be getter-only. */
|
||||
function setGlobalCrypto(value) {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { randomId } = await import(pathToFileURL(HELPER).href);
|
||||
|
||||
// ---- 1. behavioural ----------------------------------------------------
|
||||
|
||||
// The exact shape a LAN HTTP browser presents: crypto WITHOUT randomUUID.
|
||||
// This is the configuration that produced the original crash.
|
||||
const insecureContextCrypto = {
|
||||
getRandomValues: REAL_CRYPTO.getRandomValues.bind(REAL_CRYPTO),
|
||||
};
|
||||
setGlobalCrypto(insecureContextCrypto);
|
||||
try {
|
||||
const ids = new Set(Array.from({ length: 1000 }, randomId));
|
||||
if (ids.size !== 1000)
|
||||
fail(`insecure context: duplicate ids (${1000 - ids.size} collisions)`);
|
||||
else if (![...ids].every((id) => UUID_V4.test(id))) {
|
||||
fail("insecure context: ids are not all well-formed v4 UUIDs");
|
||||
} else {
|
||||
ok("insecure-context crypto (getRandomValues only) yields unique v4 UUIDs");
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`insecure-context crypto threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// crypto present but BOTH sources missing (exotic embed / ancient runtime).
|
||||
setGlobalCrypto({});
|
||||
try {
|
||||
const ids = new Set(Array.from({ length: 1000 }, randomId));
|
||||
if (ids.size !== 1000)
|
||||
fail(`method-less crypto: duplicate ids (${1000 - ids.size} collisions)`);
|
||||
else if (![...ids].every((id) => id.length >= 16)) {
|
||||
fail("method-less crypto: ids too short to be collision-resistant");
|
||||
} else {
|
||||
ok(
|
||||
"method-less crypto degrades to unique best-effort ids without throwing",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`method-less crypto threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// crypto entirely absent.
|
||||
setGlobalCrypto(undefined);
|
||||
try {
|
||||
const ids = new Set(Array.from({ length: 1000 }, randomId));
|
||||
if (ids.size !== 1000)
|
||||
fail(`absent crypto: duplicate ids (${1000 - ids.size} collisions)`);
|
||||
else ok("absent crypto degrades to unique best-effort ids without throwing");
|
||||
} catch (err) {
|
||||
fail(`absent crypto threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// A fully-featured crypto (secure context / Node default) must still be used
|
||||
// directly — guards against a helper that ignores randomUUID.
|
||||
setGlobalCrypto(REAL_CRYPTO);
|
||||
try {
|
||||
const probe = { used: false };
|
||||
setGlobalCrypto({
|
||||
randomUUID: () => {
|
||||
probe.used = true;
|
||||
return REAL_CRYPTO.randomUUID();
|
||||
},
|
||||
getRandomValues: REAL_CRYPTO.getRandomValues.bind(REAL_CRYPTO),
|
||||
});
|
||||
const id = randomId();
|
||||
if (!probe.used) fail("native randomUUID available but not preferred");
|
||||
else if (!UUID_V4.test(id)) fail(`secure-context id malformed: ${id}`);
|
||||
else ok("native randomUUID preferred when available");
|
||||
} catch (err) {
|
||||
fail(`secure-context crypto threw: ${err.message}`);
|
||||
} finally {
|
||||
setGlobalCrypto(REAL_CRYPTO);
|
||||
}
|
||||
|
||||
// ---- 2. static ---------------------------------------------------------
|
||||
|
||||
const UNSAFE = /crypto\.randomUUID\s*\(/;
|
||||
const EXTENSIONS = [".ts", ".js", ".svelte"];
|
||||
|
||||
function walk(dir) {
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
found.push(...walk(full));
|
||||
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const offenders = [];
|
||||
for (const file of walk(SRC)) {
|
||||
if (file === HELPER) continue; // the one place allowed to call the native API
|
||||
const lines = readFileSync(file, "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (UNSAFE.test(line))
|
||||
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (offenders.length) {
|
||||
fail(
|
||||
`direct \`crypto.randomUUID()\` call outside lib/ids.ts — use randomId() instead (insecure contexts crash on it):\n ${offenders.join("\n ")}`,
|
||||
);
|
||||
} else {
|
||||
ok("no direct `crypto.randomUUID()` calls in src/");
|
||||
}
|
||||
|
||||
// ---- result ------------------------------------------------------------
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} randomId/insecure-context check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nrandomId insecure-context checks passed.");
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regression guard for the HEL-613 rework: the skill editor must load the
|
||||
* SKILL.md BODY only — never the YAML frontmatter — so saving cannot wrap
|
||||
* the file in a second frontmatter layer (each edit used to nest one more
|
||||
* `---\nname: …` block into the content: the old strip regex anchored on
|
||||
* `\A`, a Python-ism JS treats as a literal, so it never matched).
|
||||
*
|
||||
* Layers:
|
||||
* 1. behavioural — stripSkillFrontmatter() must strip the exact block
|
||||
* shape the backend's render_skill_md writes, split at the right
|
||||
* boundary, be idempotent (the no-doubling invariant), and pass
|
||||
* plain bodies through unchanged.
|
||||
* 2. round-trip — strip → re-render (frontmatter + body) → strip again
|
||||
* yields the identical body: editing twice adds zero layers.
|
||||
* 3. static — the editor page must use the shared helper; the inline
|
||||
* `\A`-anchored regex cannot creep back in.
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-skill-frontmatter.mjs`).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
|
||||
let failed = false;
|
||||
function check(name, cond, detail = "") {
|
||||
console.log(
|
||||
`${cond ? "ok " : "FAIL "} ${name}${cond ? "" : ` :: ${detail}`}`,
|
||||
);
|
||||
if (!cond) failed = true;
|
||||
}
|
||||
|
||||
// Mirror of the backend's render_skill_md output shape
|
||||
// (calliope-backend/src/calliope/agent/skills_store.py).
|
||||
const rendered = [
|
||||
"---",
|
||||
"name: cyberpunk-style-kit",
|
||||
'description: "赛博朋克风格词库"',
|
||||
"version: 1.0.1",
|
||||
"metadata:",
|
||||
" author: user",
|
||||
" tags: [neon, rain]",
|
||||
" type: template",
|
||||
"---",
|
||||
"",
|
||||
"# 赛博朋克风格词库",
|
||||
"neon, rain, hologram",
|
||||
].join("\n");
|
||||
|
||||
const { stripSkillFrontmatter } = await import(
|
||||
pathToFileURL(join(ROOT, "src/lib/skillFrontmatter.ts"))
|
||||
);
|
||||
|
||||
// 1. Editor load: body only, no frontmatter.
|
||||
const body = stripSkillFrontmatter(rendered);
|
||||
check(
|
||||
"edit load strips the frontmatter block",
|
||||
!body.includes("name: cyberpunk-style-kit") &&
|
||||
!body.trimStart().startsWith("---"),
|
||||
JSON.stringify(body.slice(0, 40)),
|
||||
);
|
||||
check(
|
||||
"edit load keeps the body",
|
||||
body.includes("# 赛博朋克风格词库") && body.includes("neon, rain, hologram"),
|
||||
JSON.stringify(body),
|
||||
);
|
||||
|
||||
// 2. Strip splits at the exact boundary: frontmatter + body reassembles
|
||||
// the file byte-identically (proves no body text was eaten).
|
||||
const fm = rendered.slice(0, rendered.length - body.length);
|
||||
check(
|
||||
"strip boundary is exact",
|
||||
fm + body === rendered && fm.trimEnd().endsWith("---"),
|
||||
"frontmatter/body split drifted",
|
||||
);
|
||||
|
||||
// 3. No-doubling invariant: idempotent strip + round-trip stability.
|
||||
check(
|
||||
"strip is idempotent",
|
||||
stripSkillFrontmatter(body) === body,
|
||||
"second strip changed the body",
|
||||
);
|
||||
const savedAgain = fm + body; // render_skill_md re-wraps the same frontmatter
|
||||
check(
|
||||
"second edit-save-reload adds no layer",
|
||||
stripSkillFrontmatter(savedAgain) === body,
|
||||
"round-trip grew the content",
|
||||
);
|
||||
|
||||
// 4. Body without frontmatter passes through unchanged.
|
||||
const plain = "# Just a body\nwith text\n";
|
||||
check(
|
||||
"plain body unchanged",
|
||||
stripSkillFrontmatter(plain) === plain,
|
||||
JSON.stringify(stripSkillFrontmatter(plain)),
|
||||
);
|
||||
|
||||
// 5. Static: the page uses the shared helper; no `\A` regex remains.
|
||||
const page = readFileSync(
|
||||
join(ROOT, "src/routes/agents/+page.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
check(
|
||||
"page imports the shared strip helper",
|
||||
page.includes("stripSkillFrontmatter"),
|
||||
"helper import missing",
|
||||
);
|
||||
check(
|
||||
"no \\A-anchored strip regex left behind",
|
||||
!/\\A---/.test(page),
|
||||
"inline \\A regex present",
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
console.error("skill frontmatter checks FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("skill frontmatter checks passed.");
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Calliope Lab</title>
|
||||
<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&display=swap"
|
||||
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%
|
||||
|
||||
+1121
-579
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
|
||||
export const IMAGE_SLOT_ACCEPT =
|
||||
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
|
||||
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
|
||||
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
|
||||
export const VIDEO_SLOT_ACCEPT =
|
||||
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
|
||||
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
|
||||
|
||||
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
|
||||
export const AUDIO_SLOT_ACCEPT =
|
||||
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
|
||||
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
|
||||
|
||||
export type MediaSlotKind = "image" | "video" | "audio";
|
||||
|
||||
export function mediaSlotAccept(kind: MediaSlotKind): string {
|
||||
if (kind === "video") return VIDEO_SLOT_ACCEPT;
|
||||
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill a media slot of this kind (null = fine). */
|
||||
export function mediaFileProblem(
|
||||
kind: MediaSlotKind,
|
||||
file: { name: string; size: number },
|
||||
): string | null {
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
const max =
|
||||
kind === "video"
|
||||
? VIDEO_SLOT_MAX_BYTES
|
||||
: kind === "audio"
|
||||
? AUDIO_SLOT_MAX_BYTES
|
||||
: IMAGE_SLOT_MAX_BYTES;
|
||||
if (kind === "video" && !VIDEO_EXTS.includes(ext))
|
||||
return t("studio.errVideoFormat", { name: file.name });
|
||||
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
|
||||
return t("studio.errAudioFormat", { name: file.name });
|
||||
if (kind === "image" && !IMAGE_EXTS.includes(ext))
|
||||
return t("studio.errImageFormat", { name: file.name });
|
||||
if (file.size > max)
|
||||
return kind === "image"
|
||||
? t("studio.errImageSize", { name: file.name })
|
||||
: t("studio.errMediaSize", { name: file.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill an image slot (null = fine). */
|
||||
export function imageFileProblem(file: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string | null {
|
||||
return mediaFileProblem("image", file);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
|
||||
export function uploadBaseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const TEXT_AREA = new Set([
|
||||
'PrimitiveString',
|
||||
]);
|
||||
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
|
||||
const IMAGE = new Set(['LoadImage', 'ImageLoader', 'ETN_LoadImageBase64']);
|
||||
const IMAGE = new Set(['LoadImage', 'LoadImageMask', 'ImageLoader', 'ETN_LoadImageBase64']);
|
||||
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
|
||||
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
|
||||
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
|
||||
|
||||
@@ -44,6 +44,7 @@ export function compactInputValues(
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && !v.trim()) continue;
|
||||
if (Array.isArray(v) && !v.length) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* ComfyUI ResolutionSelector (comfy_extras/nodes_resolution.py) — same
|
||||
* ratios and rounding, so the size shown is the size ComfyUI renders.
|
||||
*/
|
||||
const RATIOS: Record<string, [number, number]> = {
|
||||
'1:1 (Square)': [1, 1],
|
||||
'2:3 (Portrait Photo)': [2, 3],
|
||||
'3:2 (Photo)': [3, 2],
|
||||
'3:4 (Portrait Standard)': [3, 4],
|
||||
'4:3 (Standard)': [4, 3],
|
||||
'9:16 (Portrait Widescreen)': [9, 16],
|
||||
'16:9 (Widescreen)': [16, 9],
|
||||
'21:9 (Ultrawide)': [21, 9],
|
||||
};
|
||||
|
||||
/** Python's round(): halves go to the even neighbour. */
|
||||
function roundHalfEven(x: number): number {
|
||||
const f = Math.floor(x);
|
||||
const diff = x - f;
|
||||
if (diff > 0.5) return f + 1;
|
||||
if (diff < 0.5) return f;
|
||||
return f % 2 === 0 ? f : f + 1;
|
||||
}
|
||||
|
||||
export function resolutionSelectorSize(
|
||||
aspectRatio: string,
|
||||
megapixels: number,
|
||||
multiple: number,
|
||||
): { width: number; height: number } | null {
|
||||
const ratio = RATIOS[aspectRatio];
|
||||
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
|
||||
const [w, h] = ratio;
|
||||
const m = Math.max(1, Math.floor(multiple) || 8);
|
||||
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
|
||||
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
|
||||
}
|
||||
|
||||
/** "16:9 (Widescreen)" → "16:9". */
|
||||
export function ratioShort(option: string): string {
|
||||
return option.split(' ')[0] ?? option;
|
||||
}
|
||||
@@ -1,6 +1,27 @@
|
||||
export type ComfyInputKind = 'text' | 'textarea' | 'number' | 'image' | 'image_url' | 'audio' | 'video';
|
||||
export type ComfyInputKind =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'image'
|
||||
| 'image_url'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| 'image_list'
|
||||
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
|
||||
| 'video_list'
|
||||
| 'audio_list';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
export interface WorkflowWarning {
|
||||
level: 'error' | 'warning';
|
||||
code: string;
|
||||
node_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicInput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
@@ -9,14 +30,118 @@ export interface ComfyDynamicInput {
|
||||
kind: ComfyInputKind;
|
||||
defaultValue?: string | number;
|
||||
required: boolean;
|
||||
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
|
||||
auto?: boolean;
|
||||
/** ComfyUI widget limits (number kind). */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
integer?: boolean;
|
||||
/** Allowed values (select kind). */
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
|
||||
media_hint?: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Output:image) → "image". */
|
||||
/** Role from title tag, e.g. (Output:image). */
|
||||
role?: string | null;
|
||||
kind: ComfyOutputKind;
|
||||
/** Null while an unknown custom node awaits the user's media-type pick. */
|
||||
kind: ComfyOutputKind | null;
|
||||
/** Untagged detection: node class + which rule typed it. */
|
||||
class_type?: string;
|
||||
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
|
||||
/** Form default for unknown candidates — never auto-applied. */
|
||||
suggested_kind?: 'image' | 'video' | null;
|
||||
}
|
||||
|
||||
/** One entry of a workflow's confirmed output mapping (HEL-638). */
|
||||
export interface WorkflowOutputMapEntry {
|
||||
nodeId: string;
|
||||
kind: 'image' | 'video';
|
||||
label?: string;
|
||||
class_type?: string;
|
||||
}
|
||||
|
||||
/** The five workflow usages the studio distinguishes (HEL-636). */
|
||||
export type WorkflowUsage =
|
||||
| 'text2image'
|
||||
| 'image2image'
|
||||
| 'text2video'
|
||||
| 'image2video'
|
||||
| 'ref2video';
|
||||
|
||||
export const WORKFLOW_USAGES: WorkflowUsage[] = [
|
||||
'text2image',
|
||||
'image2image',
|
||||
'text2video',
|
||||
'image2video',
|
||||
'ref2video',
|
||||
];
|
||||
|
||||
/** Which usages each generate page serves. */
|
||||
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
|
||||
image: ['text2image', 'image2image'],
|
||||
video: ['text2video', 'image2video', 'ref2video'],
|
||||
};
|
||||
|
||||
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
|
||||
export interface PreflightFinding {
|
||||
level: 'error' | 'warn' | 'info' | 'ok';
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string | null;
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
/** Result of checking a workflow against a target connection's /object_info. */
|
||||
export interface WorkflowConnectionCheck {
|
||||
endpoint_id: string | null;
|
||||
endpoint_name: string | null;
|
||||
base_url?: string | null;
|
||||
/** False when any error-level finding exists (or the server was unreachable). */
|
||||
ok: boolean;
|
||||
/** False → 无法核查 (server down): honest, non-blocking. */
|
||||
reachable: boolean;
|
||||
findings: PreflightFinding[];
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** Slim live record of a workflow's latest test run (HEL-643). */
|
||||
export interface WorkflowLastTest {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
output_count: number;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/analyze (import confirm step 1). */
|
||||
export interface WorkflowAnalyzeResult {
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
warnings?: WorkflowWarning[];
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
needs_mapping: boolean;
|
||||
usage_problem: string | null;
|
||||
usage_evidence: {
|
||||
outputs: ComfyDynamicOutput[];
|
||||
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
|
||||
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
|
||||
summary: string[];
|
||||
};
|
||||
usable_usage: WorkflowUsage[];
|
||||
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
|
||||
connection_check?: WorkflowConnectionCheck;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
@@ -29,12 +154,25 @@ export interface Workflow {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
/** Five-way usage (HEL-636); null = pending human confirmation. */
|
||||
usage?: WorkflowUsage | null;
|
||||
usage_source?: 'auto' | 'user' | null;
|
||||
workflow_json: Record<string, WorkflowNode>;
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_schema: ComfyDynamicOutput[];
|
||||
description: string | null;
|
||||
prompt_profile: string;
|
||||
is_enabled: boolean;
|
||||
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
|
||||
endpoint_id?: string | null;
|
||||
warnings?: WorkflowWarning[];
|
||||
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
|
||||
output_map?: WorkflowOutputMapEntry[] | null;
|
||||
output_map_source?: 'auto' | 'user' | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
/** Latest test-run record, queried live (HEL-643); null = never tested. */
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -53,6 +191,12 @@ export interface Job {
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
retry_count: number;
|
||||
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
active?: 'projects' | 'canvas' | 'build-scene' | 'library' | 'settings';
|
||||
active?: 'projects' | 'canvas' | 'build-scene' | 'library' | 'agents' | 'models' | 'settings';
|
||||
crumb?: string;
|
||||
/** Optional status content rendered between the crumb and the nav. */
|
||||
status?: import('svelte').Snippet;
|
||||
@@ -31,6 +31,8 @@ interface Props {
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
|
||||
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
|
||||
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
|
||||
<a class="nav-link" class:active={active === 'models'} href="/models">{t('nav.models')}</a>
|
||||
<a class="nav-link" class:active={active === 'settings'} href="/settings">{t('nav.settings')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -260,6 +260,18 @@
|
||||
oninput={(e) => setNumberValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
/>
|
||||
{:else if inp.kind === 'select'}
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
>
|
||||
{#each inp.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if allowUpload && isUploadKind(inp.kind)}
|
||||
{@const uploadingName = uploading[inp.nodeId]}
|
||||
{@const isUpload = !!current && !assetOptions.some((o) => o.path === current)}
|
||||
|
||||
@@ -346,6 +346,16 @@
|
||||
value={values[nodeId] ?? ctrl.input.defaultValue ?? ''}
|
||||
oninput={(e) => setValue(nodeId, Number(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
{:else if ctrl.input.kind === 'select'}
|
||||
<select
|
||||
class="adv-input"
|
||||
value={String(values[nodeId] ?? ctrl.input.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(nodeId, e.currentTarget.value)}
|
||||
>
|
||||
{#each ctrl.input.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="adv-input"
|
||||
|
||||
@@ -52,15 +52,16 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
@@ -69,23 +70,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float-bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
animation: toast-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -101,17 +89,17 @@
|
||||
background: currentColor;
|
||||
}
|
||||
.toast.success .badge {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.toast.error .badge {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
.toast.info .badge {
|
||||
color: var(--info);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
@@ -127,10 +115,12 @@
|
||||
padding: 0 2px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -139,7 +129,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,9 +141,11 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
playgroundApi,
|
||||
jobsApi,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type Job,
|
||||
type Workflow,
|
||||
} from '$lib/api';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3): fill the workflow's REAL input slots, submit
|
||||
* one REAL run to the chosen ComfyUI through the ordinary job queue, and
|
||||
* call it a pass only when at least one downloadable output of the right
|
||||
* media kind came back. Test artifacts are isolated from projects.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
workflow: Workflow | null;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), workflow, endpoints, onchanged }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let endpointId = $state('');
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
let listValues = $state<Record<string, string[]>>({});
|
||||
let sizes = $state<Record<string, number>>({});
|
||||
let uploadingName = $state<Record<string, string>>({});
|
||||
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
|
||||
let submitError = $state('');
|
||||
let confirmOpen = $state(false);
|
||||
let submitting = $state(false);
|
||||
let testJobId = $state<number | null>(null);
|
||||
|
||||
const wf = $derived(workflow);
|
||||
const schema = $derived(wf?.input_schema ?? []);
|
||||
const listEntries = $derived(
|
||||
schema.filter((i) => i.kind === 'image_list' || i.kind === 'video_list' || i.kind === 'audio_list'),
|
||||
);
|
||||
const formEntries = $derived(
|
||||
schema.filter((i) => i.kind !== 'image_list' && i.kind !== 'video_list' && i.kind !== 'audio_list'),
|
||||
);
|
||||
const hasH3Hint = $derived(
|
||||
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
);
|
||||
const outputKind = $derived<'image' | 'video'>(wf?.kind === 'video' ? 'video' : 'image');
|
||||
|
||||
const testsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflow-tests', wf?.id ?? 0],
|
||||
queryFn: () => workflows.tests(wf!.id),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 2000,
|
||||
})),
|
||||
);
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 5000,
|
||||
})),
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.status === 'pending' || j.status === 'running')
|
||||
.length,
|
||||
);
|
||||
|
||||
const history = $derived(($testsQuery.data ?? []) as Job[]);
|
||||
const activeJob = $derived.by(() => {
|
||||
if (testJobId != null) {
|
||||
const mine = history.find((j) => j.id === testJobId);
|
||||
if (mine && (mine.status === 'pending' || mine.status === 'running')) return mine;
|
||||
}
|
||||
return history.find((j) => j.status === 'pending' || j.status === 'running') ?? null;
|
||||
});
|
||||
const finishedJob = $derived.by(() => {
|
||||
if (testJobId != null) return history.find((j) => j.id === testJobId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const queueBusy = $derived(!!activeJob || submitting);
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !wf) return;
|
||||
endpointId = wf.endpoint_id ?? '';
|
||||
values = Object.fromEntries(
|
||||
schema
|
||||
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
|
||||
.map((i) => [i.nodeId, i.defaultValue as string | number]),
|
||||
);
|
||||
listValues = {};
|
||||
sizes = {};
|
||||
rejects = {};
|
||||
uploadingName = {};
|
||||
submitError = '';
|
||||
testJobId = wf.last_test && (wf.last_test.status === 'pending' || wf.last_test.status === 'running')
|
||||
? wf.last_test.id
|
||||
: null;
|
||||
});
|
||||
|
||||
function kindOf(entry: { kind: string }): MediaSlotKind {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function unitOf(kind: MediaSlotKind): string {
|
||||
return kind === 'image' ? '张' : '段';
|
||||
}
|
||||
|
||||
function setRejects(key: string, next: { name: string; message: string }[]) {
|
||||
rejects = { ...rejects, [key]: next };
|
||||
}
|
||||
|
||||
async function uploadOne(file: File): Promise<string | null> {
|
||||
const res = await playgroundApi.upload(file);
|
||||
sizes = { ...sizes, [res.path]: file.size };
|
||||
return res.path;
|
||||
}
|
||||
|
||||
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const max = entry.max ?? 9;
|
||||
const current = listValues[key] ?? [];
|
||||
const ok: string[] = [];
|
||||
const bad: { name: string; message: string }[] = [];
|
||||
for (const f of files) {
|
||||
const problem = mediaFileProblem(kind, f);
|
||||
if (problem) bad.push({ name: f.name, message: problem });
|
||||
else ok.push(f.name);
|
||||
}
|
||||
if (current.length + ok.length > max) {
|
||||
toast.error(
|
||||
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
|
||||
);
|
||||
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
for (const f of files) {
|
||||
if (!ok.includes(f.name)) continue;
|
||||
uploadingName = { ...uploadingName, [key]: f.name };
|
||||
try {
|
||||
const path = await uploadOne(f);
|
||||
if (path) listValues = { ...listValues, [key]: [...(listValues[key] ?? []), path] };
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaReplace(entry: (typeof listEntries)[number], index: number, file: File) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(kind, file);
|
||||
if (problem) {
|
||||
setRejects(key, [...(rejects[key] ?? []), { name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
uploadingName = { ...uploadingName, [key]: file.name };
|
||||
try {
|
||||
const path = await uploadOne(file);
|
||||
if (path) {
|
||||
const next = [...(listValues[key] ?? [])];
|
||||
next[index] = path;
|
||||
listValues = { ...listValues, [key]: next };
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
|
||||
const key = entry.nodeId;
|
||||
listValues = { ...listValues, [key]: (listValues[key] ?? []).filter((_, i) => i !== index) };
|
||||
}
|
||||
|
||||
/** Mirrors the backend's validate_input_values for immediate feedback. */
|
||||
function validate(): string[] {
|
||||
const errs: string[] = [];
|
||||
for (const e of schema) {
|
||||
const label = e.label;
|
||||
if (e.kind === 'image_list' || e.kind === 'video_list' || e.kind === 'audio_list') {
|
||||
const n = (listValues[e.nodeId] ?? []).length;
|
||||
const unit = unitOf(kindOf(e));
|
||||
if (e.required && n < (e.min ?? 1)) errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
|
||||
} else if (e.auto && e.required && e.role === 'prompt') {
|
||||
const v = values[e.nodeId];
|
||||
if (!(typeof v === 'string' && v.trim()) && !String(e.defaultValue ?? '').trim()) {
|
||||
errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
} else if (e.required && (e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') && e.role !== 'mask') {
|
||||
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!wf) return;
|
||||
const errs = validate();
|
||||
if (errs.length) {
|
||||
submitError = errs.join(';');
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function reallyRunTest() {
|
||||
if (!wf) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const inputValues: Record<string, unknown> = { ...values };
|
||||
for (const [k, v] of Object.entries(listValues)) {
|
||||
if (v.length) inputValues[k] = v;
|
||||
}
|
||||
const res = await workflows.test(wf.id, {
|
||||
...(endpointId ? { endpoint_id: endpointId } : {}),
|
||||
input_values: inputValues as Record<string, string | number | string[]>,
|
||||
});
|
||||
testJobId = res.job.id;
|
||||
if (res.reused) toast.info(t('wft.reusedToast'));
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
submitError = apiErrorMessage(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTest() {
|
||||
if (!activeJob) return;
|
||||
try {
|
||||
await jobsApi.cancel(activeJob.id);
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRecords() {
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.clearTests(wf.id);
|
||||
testJobId = null;
|
||||
await $testsQuery.refetch();
|
||||
onchanged?.();
|
||||
toast.success(t('wft.cleared'));
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function resultLine(job: Job): string {
|
||||
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
|
||||
return t('wft.passedShort', { n: job.output_paths.length });
|
||||
}
|
||||
if (job.error === 'cancelled') return t('wft.cancelledShort');
|
||||
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
|
||||
}
|
||||
|
||||
function timeOf(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const endpointLabel = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ?? t('conn.wfConnectionActive'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('wft.title')} size="lg">
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('wft.connection')}</span>
|
||||
<select class="f-input" bind:value={endpointId} disabled={queueBusy}>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="f-hint">{t('wft.costNote')}</span>
|
||||
</label>
|
||||
|
||||
{#if hasH3Hint}
|
||||
<div class="wft-note" role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeJob}
|
||||
{@const info = jobStatusInfo(activeJob)}
|
||||
<div class="wft-active" class:ok={activeJob.status === 'done'} class:bad={activeJob.status === 'failed'}>
|
||||
<div class="wft-active-head">
|
||||
<span class="status-chip">{t('wft.running')}</span>
|
||||
<span class="wft-active-detail">{info.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}>{t('wft.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
|
||||
<div class="wft-result ok">
|
||||
<p class="wft-verdict">{t('wft.passed', { n: finishedJob.output_paths.length })}</p>
|
||||
<div class="wft-outputs">
|
||||
{#each finishedJob.output_paths.slice(0, 4) as path (path)}
|
||||
<a
|
||||
class="wft-out"
|
||||
href={assetUrl(path)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('wft.download')}
|
||||
>
|
||||
<SafeMedia kind={outputKind} src={assetUrl(path)} controls={outputKind === 'video'} />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
|
||||
<div class="wft-result bad">
|
||||
<p class="wft-verdict">{t('wft.failed')}</p>
|
||||
<p class="wft-error">{finishedJob.error === 'cancelled' ? t('wft.cancelledShort') : finishedJob.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !queueBusy}
|
||||
<div class="wft-form">
|
||||
{#each listEntries as entry (entry.nodeId)}
|
||||
{@const kind = kindOf(entry)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={kind}
|
||||
paths={listValues[entry.nodeId] ?? []}
|
||||
max={entry.max ?? 9}
|
||||
sizes={sizes}
|
||||
uploadingName={uploadingName[entry.nodeId] || undefined}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
onAdd={(files) => mediaAdd(entry, files)}
|
||||
onReplace={(i, file) => mediaReplace(entry, i, file)}
|
||||
onRemove={(i) => mediaRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
/>
|
||||
{/each}
|
||||
{#if formEntries.length}
|
||||
<ComfyDynamicForm inputs={formEntries} bind:values allowUpload showErrors={!!submitError} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if submitError}<span class="f-error" role="alert">{submitError}</span>{/if}
|
||||
<div class="wft-actions">
|
||||
<Button variant="primary" loading={submitting} onclick={submitTest}>
|
||||
{t('wft.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wft-history">
|
||||
<div class="wft-history-head">
|
||||
<span class="f-label">{t('wft.history', { n: history.length })}</span>
|
||||
{#if history.length}
|
||||
<button type="button" class="link-danger" onclick={clearRecords}>
|
||||
{t('wft.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="wft-empty">{t('wft.never')}</p>
|
||||
{:else}
|
||||
<ul class="wft-rows">
|
||||
{#each history as job (job.id)}
|
||||
<li class:active={job.id === activeJob?.id}>
|
||||
<span class="mono wft-row-id">#{job.id}</span>
|
||||
<span class="wft-row-time">{timeOf(job.created_at)}</span>
|
||||
<span class="wft-row-result" class:ok={job.status === 'done' && (job.output_paths?.length ?? 0) > 0}>
|
||||
{resultLine(job)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmOpen}
|
||||
title={t('wft.confirmTitle')}
|
||||
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
|
||||
confirmLabel={t('wft.submit')}
|
||||
onconfirm={reallyRunTest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wft-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--info, #3b82f6);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--success);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-outputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.wft-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.wft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wft-rows {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wft-rows li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.wft-row-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wft-row-time {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -195,7 +195,6 @@
|
||||
{:else if messages.length === 0 && !running}
|
||||
<div class="empty">
|
||||
<div class="empty-mark"><Icon name="sparkle" size={22} /></div>
|
||||
<p class="empty-eyebrow">Calliope</p>
|
||||
<p class="empty-title">{t('agentChat.emptyTitle')}</p>
|
||||
<p class="empty-hint">{t('agentChat.emptyHint')}</p>
|
||||
{#if suggestions.length > 0}
|
||||
@@ -370,15 +369,6 @@
|
||||
box-shadow: 0 0 32px var(--accent-glow);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-eyebrow {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
|
||||
@@ -17,6 +17,13 @@ const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
'assets-agent': { label: 'Assets Agent', color: '#ec4899' }, // pink
|
||||
'video-agent': { label: 'Video Agent', color: '#fb923c' }, // orange
|
||||
'research-agent': { label: 'Research Agent', color: '#a78bfa' }, // violet
|
||||
// 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' },
|
||||
};
|
||||
|
||||
// Fallback palette for unknown names (stable hash → deterministic color).
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, projects, type Project } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A「片场暗房」project card for the home overview (a1, HEL-608).
|
||||
* All numbers come from the real project record — no sample progress.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
let { project }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const stats = $derived(
|
||||
project.stats ?? {
|
||||
scene_count: 0,
|
||||
character_count: 0,
|
||||
asset_ready_count: 0,
|
||||
asset_total_count: 0,
|
||||
stage_confirmed: 0,
|
||||
stage_status: {} as Record<string, string>,
|
||||
active_jobs: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Five-stage creation progress (HEL-614) — real stage rows only.
|
||||
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';
|
||||
if (state === 'running') return 'seg-running';
|
||||
if (state === 'awaiting_confirm') return 'seg-draft';
|
||||
return '';
|
||||
}
|
||||
|
||||
// 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)',
|
||||
];
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
|
||||
|
||||
const coverUrl = $derived(assetUrl(project.cover_path));
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
// Cover tag reflects the real five-stage state (a1: 「分镜设计 · 进行中」):
|
||||
// all confirmed → 已完成; otherwise the first unconfirmed stage + its state.
|
||||
const cover = $derived.by(() => {
|
||||
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 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')}` };
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
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-draft', label: t('home.statusDraft') };
|
||||
});
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
||||
if (mins < 1) return t('projectCard.timeJustNow');
|
||||
if (mins < 60) return t('projectCard.timeM', { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return t('projectCard.timeH', { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return t('projectCard.timeD', { count: days });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return t('projectCard.timeMo', { count: months });
|
||||
return t('projectCard.timeY', { count: Math.round(months / 12) });
|
||||
}
|
||||
|
||||
const updatedLabel = $derived(relativeTime(project.updated_at));
|
||||
|
||||
// ---- overflow menu (rename / delete), pattern from ProjectCard ----
|
||||
let menuOpen = $state(false);
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') menuOpen = false;
|
||||
};
|
||||
window.addEventListener('click', onDocClick);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('click', onDocClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
});
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let renameValue = $state('');
|
||||
|
||||
function openRename() {
|
||||
menuOpen = false;
|
||||
renameValue = project.title;
|
||||
renameOpen = true;
|
||||
}
|
||||
|
||||
const renameMutation = createMutation({
|
||||
mutationFn: (title: string) => projects.update(project.id, { title }),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.renamedTo', { title: renameValue }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function submitRename() {
|
||||
const title = renameValue.trim();
|
||||
renameOpen = false;
|
||||
if (!title || title === project.title) return;
|
||||
$renameMutation.mutate(title);
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: () => projects.delete(project.id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="a-card">
|
||||
<button
|
||||
type="button"
|
||||
class="card-main"
|
||||
aria-label={t('projectCard.open', { title: project.title })}
|
||||
onclick={() => goto(`/workbench/${project.id}`)}
|
||||
>
|
||||
<span class="cover">
|
||||
{#if coverUrl && !coverFailed}
|
||||
<img
|
||||
class="cover-img"
|
||||
src={coverUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (coverFailed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
|
||||
<Icon name="film" size={22} />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="status-tag {cover.cls}">{cover.label}</span>
|
||||
</span>
|
||||
<span class="body">
|
||||
<span class="title-row">
|
||||
<span class="title">{project.title}</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</span>
|
||||
<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>
|
||||
{/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>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="meta">
|
||||
<span class="meta-stats mono">
|
||||
{t('home.statScenes', { n: stats.scene_count })}
|
||||
· {t('home.statCharacters', { n: stats.character_count })}
|
||||
· {t('home.statAssets', {
|
||||
ready: stats.asset_ready_count,
|
||||
total: stats.asset_total_count,
|
||||
})}
|
||||
</span>
|
||||
<span class="meta-time">{updatedLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap" bind:this={menuEl}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
aria-label={t('projectCard.actions', { title: project.title })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item danger"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.title')}</span>
|
||||
<input class="field-input" bind:value={renameValue} required />
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
onclick={submitRename}
|
||||
>
|
||||
{t('common.rename')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('projectCard.deleteTitle')}
|
||||
message={t('projectCard.deleteMessage', { title: project.title })}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.a-card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.a-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.a-card:hover .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-main:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 132px;
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-ph {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.status-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--border-strong);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
/* a1: dark capsule over the cover so it stays legible on any image;
|
||||
the text carries the semantic color (§2.1). */
|
||||
.st-draft {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.st-progress {
|
||||
color: var(--run);
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px 15px;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.idea {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.2em;
|
||||
}
|
||||
|
||||
/* Five-stage progress segments (real stage rows, HEL-614). */
|
||||
.stage-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.seg {
|
||||
width: 20px;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg-running {
|
||||
background: var(--run);
|
||||
}
|
||||
.seg-draft {
|
||||
background: var(--warn);
|
||||
}
|
||||
.seg-confirmed {
|
||||
background: var(--success);
|
||||
}
|
||||
.seg-count {
|
||||
margin-left: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.jobs-pill {
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.meta-stats {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
.menu-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.a-card:hover .menu-btn,
|
||||
.menu-btn:focus-visible,
|
||||
.menu-btn[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 0;
|
||||
min-width: 132px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: var(--error-dim);
|
||||
} .a-card :global(.field-input) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -53,15 +53,24 @@
|
||||
const projectMemories = $derived(
|
||||
($memoriesQuery.data ?? []).filter((m) => m.scope === 'project'),
|
||||
);
|
||||
|
||||
const KIND_KEYS: Record<string, string> = {
|
||||
preference: 'memory.kindPreference',
|
||||
convention: 'memory.kindConvention',
|
||||
correction: 'memory.kindCorrection',
|
||||
};
|
||||
function kindLabel(kind: string): string {
|
||||
return KIND_KEYS[kind] ? t(KIND_KEYS[kind]) : kind;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>{t('memory.title')}</h1>
|
||||
<p class="lead">{t('memory.lead')}</p>
|
||||
<section class="a-panel">
|
||||
<h2 class="a-panel-title">{t('memory.title')}</h2>
|
||||
<p class="a-panel-lead lead">{t('memory.lead')}</p>
|
||||
|
||||
<div class="add-row">
|
||||
<input
|
||||
class="field-input add-input"
|
||||
class="a-input add-input"
|
||||
placeholder={t('memory.placeholder')}
|
||||
maxlength={500}
|
||||
bind:value={newContent}
|
||||
@@ -69,25 +78,25 @@
|
||||
if (e.key === 'Enter' && canAdd) $addMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
<select class="field-input scope-select" bind:value={newScope}>
|
||||
<select class="a-select scope-select" bind:value={newScope}>
|
||||
<option value="global">{t('memory.scopeGlobal')}</option>
|
||||
<option value="project">{t('memory.scopeProject')}</option>
|
||||
</select>
|
||||
{#if newScope === 'project'}
|
||||
<select class="field-input scope-select" bind:value={newProjectId}>
|
||||
<select class="a-select scope-select" bind:value={newProjectId}>
|
||||
<option value={null}>{t('memory.chooseProject')}</option>
|
||||
{#each ($projectsQuery.data ?? []) as p (p.id)}
|
||||
<option value={p.id}>{p.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select class="field-input scope-select" bind:value={newKind}>
|
||||
<select class="a-select scope-select" bind:value={newKind}>
|
||||
<option value="preference">{t('memory.kindPreference')}</option>
|
||||
<option value="convention">{t('memory.kindConvention')}</option>
|
||||
<option value="correction">{t('memory.kindCorrection')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canAdd || $addMutation.isPending}
|
||||
onclick={() => $addMutation.mutate()}
|
||||
@@ -102,11 +111,11 @@
|
||||
<p class="muted">{t('memory.empty')}</p>
|
||||
{:else}
|
||||
{#if globalMemories.length > 0}
|
||||
<h2 class="group-head">{t('memory.groupGlobal')}</h2>
|
||||
<h3 class="group-head">{t('memory.groupGlobal')}</h3>
|
||||
<div class="mem-list">
|
||||
{#each globalMemories as m (m.id)}
|
||||
<div class="mem-row">
|
||||
<span class="mem-kind">{m.kind}</span>
|
||||
<span class="mem-kind">{kindLabel(m.kind)}</span>
|
||||
<span class="mem-content">{m.content}</span>
|
||||
<span class="mem-meta">
|
||||
{m.source === 'user' ? t('memory.sourceYou') : t('memory.sourceAgent')} · {t('memory.usedCount', { count: m.use_count })}
|
||||
@@ -124,11 +133,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if projectMemories.length > 0}
|
||||
<h2 class="group-head">{t('memory.groupProject')}</h2>
|
||||
<h3 class="group-head">{t('memory.groupProject')}</h3>
|
||||
<div class="mem-list">
|
||||
{#each projectMemories as m (m.id)}
|
||||
<div class="mem-row">
|
||||
<span class="mem-kind">{m.kind}</span>
|
||||
<span class="mem-kind">{kindLabel(m.kind)}</span>
|
||||
<span class="mem-project">{m.project_title ?? `#${m.project_id}`}</span>
|
||||
<span class="mem-content">{m.content}</span>
|
||||
<span class="mem-meta">
|
||||
@@ -150,21 +159,8 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
.panel h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 var(--space-lg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
@@ -177,19 +173,6 @@
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
.field-input {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.scope-select {
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
@@ -198,9 +181,7 @@
|
||||
margin: var(--space-md) 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mem-list {
|
||||
display: flex;
|
||||
@@ -213,17 +194,15 @@
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.mem-kind {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
font-size: 10.5px;
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
@@ -257,7 +236,7 @@
|
||||
}
|
||||
.mem-del:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
const items = [
|
||||
{ id: 'llm', href: '/settings?tab=llm', labelKey: 'nav.llm', blurbKey: 'nav.llmBlurb' },
|
||||
{ id: 'comfy', href: '/settings?tab=comfy', labelKey: 'nav.comfy', blurbKey: 'nav.comfyBlurb' },
|
||||
{ id: 'workflows', href: '/settings?tab=workflows', labelKey: 'nav.workflows', blurbKey: 'nav.workflowsBlurb' },
|
||||
{ id: 'agent', href: '/settings?tab=agent', labelKey: 'nav.agent', blurbKey: 'nav.agentBlurb' },
|
||||
{ id: 'skills', href: '/settings?tab=skills', labelKey: 'nav.skills', blurbKey: 'nav.skillsBlurb' },
|
||||
{ id: 'queue', href: '/settings?tab=queue', labelKey: 'nav.queue', blurbKey: 'nav.queueBlurb' },
|
||||
{ id: 'storage', href: '/settings?tab=storage', labelKey: 'nav.storage', blurbKey: 'nav.storageBlurb' },
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
/** Section ids with unsaved edits — shown with a small warning dot. */
|
||||
dirty?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
let { dirty }: Props = $props();
|
||||
|
||||
let active = $derived(page.url.searchParams.get('tab') || 'llm');
|
||||
</script>
|
||||
|
||||
<aside class="side">
|
||||
<p class="eyebrow">{t('nav.eyebrow')}</p>
|
||||
{#each items as item (item.id)}
|
||||
<a
|
||||
class="item"
|
||||
class:active={active === item.id}
|
||||
href={item.href}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
>
|
||||
<span class="label">
|
||||
{t(item.labelKey)}
|
||||
{#if dirty?.has(item.id)}
|
||||
<span class="dirty-dot" title={t('nav.unsaved')} aria-label={t('nav.unsaved')}></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="blurb">{t(item.blurbKey)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.side {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 8px 12px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.item.active {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.dirty-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.blurb {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.item.active .blurb {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,485 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { agentApi } from '$lib/api';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
tags: string[];
|
||||
dir: string;
|
||||
}
|
||||
|
||||
const skillsQuery = createQuery({
|
||||
queryKey: ['agent-skills'],
|
||||
queryFn: agentApi.listSkills,
|
||||
});
|
||||
const pathQuery = createQuery({
|
||||
queryKey: ['agent-skills-path'],
|
||||
queryFn: agentApi.skillsPath,
|
||||
});
|
||||
|
||||
// Expanded skill (file list open) + the file whose body is being previewed.
|
||||
let expanded = $state<string | null>(null);
|
||||
let filesBySkill = $state<Record<string, string[]>>({});
|
||||
let preview = $state<{
|
||||
skill: string;
|
||||
path: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
} | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
|
||||
async function toggleFiles(name: string) {
|
||||
if (expanded === name) {
|
||||
expanded = null;
|
||||
return;
|
||||
}
|
||||
expanded = name;
|
||||
preview = null;
|
||||
if (!filesBySkill[name]) {
|
||||
try {
|
||||
const res = await agentApi.skillFiles(name);
|
||||
filesBySkill = { ...filesBySkill, [name]: res.files };
|
||||
} catch {
|
||||
filesBySkill = { ...filesBySkill, [name]: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(skill: string, file: string) {
|
||||
previewLoading = true;
|
||||
preview = null;
|
||||
try {
|
||||
const res = await agentApi.readSkillFile(skill, file);
|
||||
preview = { skill, path: file, content: res.content, truncated: res.truncated };
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('skills.couldNotRead'));
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, quiet = false) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (!quiet) toast.success(t('skills.copied'));
|
||||
} catch {
|
||||
/* clipboard unavailable — text stays selectable */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>{t('skills.title')}</h1>
|
||||
<p class="lead">{t('skills.lead')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
$skillsQuery.refetch();
|
||||
toast.info(t('skills.refreshed'));
|
||||
}}
|
||||
>
|
||||
<Icon name="retry" size={14} />
|
||||
{t('skills.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if $pathQuery.data?.path}
|
||||
<div class="path-banner">
|
||||
<Icon name="folder" size={14} />
|
||||
<span class="mono path-text">{$pathQuery.data.path}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mini-btn"
|
||||
onclick={() => copyText($pathQuery.data?.path ?? '')}
|
||||
title={t('skills.copyPath')}
|
||||
>
|
||||
{t('skills.copyPathBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="callout info">
|
||||
<Icon name="edit" size={16} />
|
||||
<div class="callout-body">
|
||||
<p class="howto-title"><strong>{t('skills.howtoTitle')}</strong></p>
|
||||
<ol class="howto">
|
||||
<li>{@html t('skills.howto1')}</li>
|
||||
<li>{@html t('skills.howto2')}</li>
|
||||
<li>{@html t('skills.howto3')}</li>
|
||||
<li>{@html t('skills.howto4')}</li>
|
||||
</ol>
|
||||
<p class="note">{@html t('skills.note')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $skillsQuery.isLoading}
|
||||
<div class="stack">
|
||||
<Skeleton height="72px" />
|
||||
<Skeleton height="72px" />
|
||||
</div>
|
||||
{:else if ($skillsQuery.data ?? []).length === 0}
|
||||
<p class="empty">{t('skills.noSkills')}</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each $skillsQuery.data ?? [] as skill (skill.dir)}
|
||||
<article class="skill-card">
|
||||
<header class="card-head">
|
||||
<button
|
||||
type="button"
|
||||
class="expand"
|
||||
aria-expanded={expanded === skill.dir}
|
||||
onclick={() => toggleFiles(skill.dir)}
|
||||
title={expanded === skill.dir ? t('skills.hideFiles') : t('skills.showFiles')}
|
||||
>
|
||||
<Icon
|
||||
name={expanded === skill.dir ? 'chevron-down' : 'chevron-right'}
|
||||
size={14}
|
||||
/>
|
||||
</button>
|
||||
<div class="card-text">
|
||||
<div class="name-row">
|
||||
<span class="name">/{skill.name}</span>
|
||||
{#if skill.version}
|
||||
<span class="version">v{skill.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if skill.description}
|
||||
<p class="desc">{skill.description}</p>
|
||||
{/if}
|
||||
{#if Array.isArray(skill.tags) && skill.tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each skill.tags as tag (tag)}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if expanded === skill.dir}
|
||||
<div class="files">
|
||||
<p class="files-label">{t('skills.filesIn')} <span class="mono">{skill.dir}/</span></p>
|
||||
{#each filesBySkill[skill.dir] ?? [] as file (file)}
|
||||
<button
|
||||
type="button"
|
||||
class="file-row"
|
||||
class:active={preview?.skill === skill.dir && preview?.path === file}
|
||||
onclick={() => openFile(skill.dir, file)}
|
||||
>
|
||||
<Icon name="edit" size={12} />
|
||||
<span class="mono">{file}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted">{t('skills.noFiles')}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if previewLoading}
|
||||
<p class="muted">{t('skills.loadingFile')}</p>
|
||||
{:else if preview}
|
||||
<div class="preview-panel">
|
||||
<header class="preview-head">
|
||||
<span class="mono preview-path">{preview.skill}/{preview.path}</span>
|
||||
{#if preview.truncated}
|
||||
<span class="truncated-chip">{t('skills.truncated')}</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="mini-btn"
|
||||
onclick={() => copyText(preview?.content ?? '')}
|
||||
>
|
||||
{t('skills.copy')}
|
||||
</button>
|
||||
<button type="button" class="mini-btn" onclick={() => (preview = null)}>{t('skills.close')}</button>
|
||||
</header>
|
||||
<pre class="mono preview-body">{preview.content}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.panel-head h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.panel-head .lead {
|
||||
margin: 0;
|
||||
}
|
||||
.lead {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.path-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
|
||||
}
|
||||
.path-banner :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.path-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.mini-btn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.callout.info :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.callout-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.callout-body p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.howto-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.howto {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.howto li {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.snippet {
|
||||
margin: 8px 0 4px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
.snippet-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.note {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.skill-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.expand {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.expand:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.card-text {
|
||||
min-width: 0;
|
||||
}
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.version {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.files {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.files-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.file-row:hover,
|
||||
.file-row.active {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.file-row.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.preview-panel {
|
||||
margin-top: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.preview-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.truncated-chip {
|
||||
font-size: 10.5px;
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.preview-body {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,700 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, type Workflow, type ComfyDynamicInput, type ComfyDynamicOutput } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
const client = useQueryClient();
|
||||
const workflowsQuery = createQuery({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: workflows.list,
|
||||
});
|
||||
|
||||
let jsonText = $state('');
|
||||
let uploadedFileName = $state<string | null>(null);
|
||||
let dragging = $state(false);
|
||||
let parseError = $state('');
|
||||
let analyzedInputs = $state<ComfyDynamicInput[]>([]);
|
||||
let analyzedOutputs = $state<ComfyDynamicOutput[]>([]);
|
||||
let analyzed = $state(false);
|
||||
let pendingJson = $state<Record<string, unknown> | null>(null);
|
||||
|
||||
let wfName = $state('');
|
||||
let wfKind = $state<'image' | 'video'>('image');
|
||||
let wfProfile = $state('prose');
|
||||
let wfDescription = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state<number | null>(null);
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editProfile = $state('prose');
|
||||
let detailsId = $state<number | null>(null);
|
||||
|
||||
async function analyzeRaw(text: string) {
|
||||
parseError = '';
|
||||
analyzed = false;
|
||||
analyzedInputs = [];
|
||||
analyzedOutputs = [];
|
||||
pendingJson = null;
|
||||
try {
|
||||
const json = JSON.parse(text) as Record<string, unknown>;
|
||||
pendingJson = json;
|
||||
const result = await workflows.analyze(json);
|
||||
analyzedInputs = result.inputs;
|
||||
analyzedOutputs = result.outputs;
|
||||
analyzed = true;
|
||||
wfProfile = result.suggested_profile ?? 'prose';
|
||||
if (!wfName.trim() && uploadedFileName) {
|
||||
wfName = uploadedFileName.replace(/\.json$/i, '');
|
||||
}
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : t('wf.invalidJson');
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(file: File) {
|
||||
uploadedFileName = file.name;
|
||||
const text = await file.text();
|
||||
jsonText = text;
|
||||
await analyzeRaw(text);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) void onFile(file);
|
||||
}
|
||||
|
||||
async function saveToLibrary() {
|
||||
if (!pendingJson || !wfName.trim() || !analyzed) return;
|
||||
saving = true;
|
||||
try {
|
||||
await workflows.create({
|
||||
name: wfName.trim(),
|
||||
kind: wfKind,
|
||||
workflow_json: pendingJson,
|
||||
description: wfDescription.trim() || undefined,
|
||||
prompt_profile: wfProfile,
|
||||
});
|
||||
jsonText = '';
|
||||
uploadedFileName = null;
|
||||
analyzed = false;
|
||||
analyzedInputs = [];
|
||||
analyzedOutputs = [];
|
||||
pendingJson = null;
|
||||
const name = wfName.trim();
|
||||
wfName = '';
|
||||
wfDescription = '';
|
||||
wfKind = 'image';
|
||||
wfProfile = 'prose';
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('wf.savedToLibrary', { name }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(wf: Workflow) {
|
||||
editingId = wf.id;
|
||||
editName = wf.name;
|
||||
editDescription = wf.description ?? '';
|
||||
editProfile = wf.prompt_profile ?? 'prose';
|
||||
}
|
||||
|
||||
async function saveEdit(id: number) {
|
||||
try {
|
||||
await workflows.update(id, {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim(),
|
||||
prompt_profile: editProfile,
|
||||
});
|
||||
editingId = null;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('wf.updated'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.updateFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(wf: Workflow) {
|
||||
try {
|
||||
await workflows.update(wf.id, { is_enabled: !wf.is_enabled });
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(
|
||||
t('wf.enabledState', {
|
||||
name: wf.name,
|
||||
state: wf.is_enabled ? t('wf.stateDisabled') : t('wf.stateEnabled'),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.updateFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
let deleteTarget = $state<Workflow | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
function askDelete(wf: Workflow) {
|
||||
deleteTarget = wf;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const wf = deleteTarget;
|
||||
deleteTarget = null;
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.delete(wf.id);
|
||||
if (detailsId === wf.id) detailsId = null;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('wf.deleted', { name: wf.name }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
let list = $derived($workflowsQuery.data ?? []);
|
||||
let imageCount = $derived(list.filter((w) => w.kind === 'image').length);
|
||||
let videoCount = $derived(list.filter((w) => w.kind === 'video').length);
|
||||
</script>
|
||||
|
||||
<section class="block">
|
||||
<header class="block-head">
|
||||
<div>
|
||||
<h2>{t('wf.title')}</h2>
|
||||
<p>{@html t('wf.lead')}</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">{t('wf.countSaved', { count: list.length })}</span>
|
||||
<span class="stat image">{t('wf.countImage', { count: imageCount })}</span>
|
||||
<span class="stat video">{t('wf.countVideo', { count: videoCount })}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="register panel">
|
||||
<h3>{t('wf.analyzeRegister')}</h3>
|
||||
<div
|
||||
class="drop"
|
||||
class:dragging
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
onclick={() => document.getElementById('wf-file')?.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && document.getElementById('wf-file')?.click()}
|
||||
>
|
||||
<input
|
||||
id="wf-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (f) void onFile(f);
|
||||
}}
|
||||
/>
|
||||
<div class="drop-icon">JSON</div>
|
||||
<div>
|
||||
<strong>{t('wf.dropHint')}</strong>
|
||||
<p class="muted">{t('wf.dropSub')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if uploadedFileName}
|
||||
<p class="file-name mono">{uploadedFileName}</p>
|
||||
{/if}
|
||||
|
||||
{#if jsonText}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.jsonPreview')}</span>
|
||||
<textarea class="field-textarea mono preview" rows="8" readonly value={jsonText}></textarea>
|
||||
</label>
|
||||
<Button variant="secondary" size="sm" onclick={() => analyzeRaw(jsonText)}>{t('wf.reanalyze')}</Button>
|
||||
{/if}
|
||||
|
||||
{#if parseError}
|
||||
<div class="error">{parseError}</div>
|
||||
{/if}
|
||||
|
||||
{#if analyzed}
|
||||
<div class="io-panel">
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.detectedInputs', { count: analyzedInputs.length })}</h4>
|
||||
{#if analyzedInputs.length === 0}
|
||||
<p class="muted">{@html t('wf.noInputs')}</p>
|
||||
{:else}
|
||||
{#each analyzedInputs as inp}
|
||||
<div class="pill input">
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{#if inp.role}<span class="kind">{inp.role}</span>{/if}
|
||||
<span>{inp.label}</span>
|
||||
<span class="node mono">#{inp.nodeId}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.detectedOutputs', { count: analyzedOutputs.length })}</h4>
|
||||
{#if analyzedOutputs.length === 0}
|
||||
<p class="muted">{@html t('wf.noOutputs')}</p>
|
||||
{:else}
|
||||
{#each analyzedOutputs as out}
|
||||
<div class="pill output">
|
||||
<span class="kind">{out.kind}</span>
|
||||
{#if out.role}<span class="kind">{out.role}</span>{/if}
|
||||
<span>{out.label}</span>
|
||||
<span class="node mono">#{out.nodeId}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.name')}</span>
|
||||
<input class="field-input" bind:value={wfName} placeholder={t('wf.namePlaceholder')} required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.kind')}</span>
|
||||
<select class="field-select" bind:value={wfKind}>
|
||||
<option value="image">{t('wf.kindImage')}</option>
|
||||
<option value="video">{t('wf.kindVideo')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.promptFormat')}</span>
|
||||
<select class="field-select" bind:value={wfProfile}>
|
||||
<option value="prose">{t('wf.profileProse')}</option>
|
||||
<option value="minimax_h3_ref">{t('wf.profileH3')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.description')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={wfDescription}
|
||||
rows="2"
|
||||
placeholder={t('wf.descPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<Button variant="primary" loading={saving} disabled={!wfName.trim()} onclick={saveToLibrary}>
|
||||
{t('wf.saveToLibrary')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="library">
|
||||
<div class="library-head">
|
||||
<h3>{t('wf.savedWorkflows', { count: list.length })}</h3>
|
||||
</div>
|
||||
|
||||
{#if $workflowsQuery.isLoading}
|
||||
<div class="cards" aria-busy="true" aria-label={t('wf.loadingWorkflows')}>
|
||||
{#each [1, 2, 3] as n (n)}
|
||||
<div class="card skel-card">
|
||||
<Skeleton circle height="40px" />
|
||||
<div class="skel-lines">
|
||||
<Skeleton width="45%" height="15px" />
|
||||
<Skeleton width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if list.length === 0}
|
||||
<div class="empty">
|
||||
<strong>{t('wf.noWorkflows')}</strong>
|
||||
<p class="muted">{t('wf.noWorkflowsHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cards">
|
||||
{#each list as wf (wf.id)}
|
||||
<article class="card" class:disabled={!wf.is_enabled}>
|
||||
{#if editingId === wf.id}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.name')}</span>
|
||||
<input class="field-input" bind:value={editName} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.description')}</span>
|
||||
<textarea class="field-textarea" rows="2" bind:value={editDescription}></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.promptFormat')}</span>
|
||||
<select class="field-select" bind:value={editProfile}>
|
||||
<option value="prose">{t('wf.profileProse')}</option>
|
||||
<option value="minimax_h3_ref">{t('wf.profileH3')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="field-hint">{t('wf.jsonLocked')}</p>
|
||||
<div class="row">
|
||||
<Button size="sm" onclick={() => saveEdit(wf.id)}>{t('common.save')}</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => (editingId = null)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-top">
|
||||
<div class="tile" class:video={wf.kind === 'video'}>
|
||||
{wf.kind === 'video' ? 'VID' : 'IMG'}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<strong>{wf.name}</strong>
|
||||
<div class="tags">
|
||||
<span class="kind-badge" class:video={wf.kind === 'video'}>{wf.kind}</span>
|
||||
{#if wf.prompt_profile === 'minimax_h3_ref'}
|
||||
<span class="kind-badge h3">H3-ref</span>
|
||||
{/if}
|
||||
<span class="count">{t('wf.inputsCount', { count: wf.input_schema?.length ?? 0 })}</span>
|
||||
<span class="count">{t('wf.outputsCount', { count: wf.output_schema?.length ?? 0 })}</span>
|
||||
{#if !wf.is_enabled}<StatusChip status="disabled" label={t('wf.disabled')} />{/if}
|
||||
</div>
|
||||
{#if wf.description}
|
||||
<p class="desc">{wf.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<Button variant="ghost" size="sm" onclick={() => startEdit(wf)}>{t('common.edit')}</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (detailsId = detailsId === wf.id ? null : wf.id)}
|
||||
>
|
||||
{detailsId === wf.id ? t('wf.hideIo') : t('wf.viewIo')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => toggleEnabled(wf)}>
|
||||
{wf.is_enabled ? t('wf.disable') : t('wf.enable')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onclick={() => askDelete(wf)}>{t('common.delete')}</Button>
|
||||
</div>
|
||||
{#if detailsId === wf.id}
|
||||
<div class="io-panel nested">
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.inputs')}</h4>
|
||||
{#each wf.input_schema ?? [] as inp}
|
||||
<div class="pill input">
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{#if inp.role}<span class="kind">{inp.role}</span>{/if}
|
||||
<span>{inp.label}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t('wf.noInputsCached')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.outputs')}</h4>
|
||||
{#each wf.output_schema ?? [] as out}
|
||||
<div class="pill output">
|
||||
<span class="kind">{out.kind}</span>
|
||||
<span>{out.label}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t('wf.noOutputsCached')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('wf.deleteTitle')}
|
||||
message={deleteTarget ? t('wf.deleteMessage', { name: deleteTarget.name }) : ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={confirmDelete}
|
||||
oncancel={() => (deleteTarget = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.block-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.block-head h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.block-head p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
max-width: 52ch;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.block-head code {
|
||||
background: var(--bg-elevated);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.stat.image {
|
||||
color: var(--accent);
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
.stat.video {
|
||||
color: var(--info);
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.panel,
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.register {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
.register h3,
|
||||
.library-head h3 {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-md);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.drop:hover,
|
||||
.drop.dragging {
|
||||
border-color: var(--accent);
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
.drop:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.drop-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
.preview {
|
||||
font-size: 11px;
|
||||
max-height: 180px;
|
||||
}
|
||||
.error {
|
||||
margin: var(--space-md) 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
}
|
||||
.io-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-md) 0;
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.io-panel.nested {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.io-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.io-col h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.pill .kind {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.pill.input .kind {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.pill.output .kind {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
.pill .node {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.library-head {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.card.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.tile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tile.video {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
.card-meta strong {
|
||||
font-size: 15px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.kind-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kind-badge.video {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--info);
|
||||
}
|
||||
.kind-badge.h3 {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.empty {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
.skel-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.skel-lines {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,596 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { settings, jobsApi, comfy } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A「片场暗房」application shell — spec-v2 §3 (HEL-608).
|
||||
*
|
||||
* 216px left navigation with the confirmed item order, the 影工坊 brand,
|
||||
* a ComfyUI address row pinned to the nav bottom, and a per-page top bar
|
||||
* (title 20/600 + gray blurb + page actions). Unbuilt pages stay in the
|
||||
* nav with an honest 「开发中」 pill instead of pretending to work.
|
||||
*/
|
||||
|
||||
type NavId =
|
||||
| 'projects'
|
||||
| 'workbench'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'tasks'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'settings';
|
||||
|
||||
interface NavItem {
|
||||
id: NavId;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href?: string;
|
||||
wip?: boolean;
|
||||
}
|
||||
|
||||
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 /
|
||||
// 生成任务 / 智能体 / ComfyUI 连接 / 模型设置 / 设置.
|
||||
const NAV: 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: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
|
||||
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
|
||||
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
active: NavId;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Page-level actions rendered right of the top bar (chips / buttons). */
|
||||
actions?: import('svelte').Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { active, title, subtitle, actions, children }: Props = $props();
|
||||
|
||||
// Real ComfyUI connections from settings — shown honestly without a
|
||||
// fake "connected" claim; tested state lives on /connections (a6).
|
||||
const settingsQuery = createQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settings.get,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// 生成任务 badge: how many jobs are queued or running right now
|
||||
// (spec §3 — 进行数徽章). Polled lightly; SSE-driven pages keep
|
||||
// their own faster cadence.
|
||||
const activeJobsQuery = createQuery({
|
||||
queryKey: ['jobs', 'active-count'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
refetchInterval: 8_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
const activeJobs = $derived(
|
||||
($activeJobsQuery.data ?? []).filter((j) => j.status === 'pending' || j.status === 'running')
|
||||
.length,
|
||||
);
|
||||
|
||||
// The ACTIVE ComfyUI connection (HEL-611). Its status dot comes from a
|
||||
// real server-side probe (GET /system_stats from the workbench server,
|
||||
// §3) — green only when that probe passes.
|
||||
const activeEndpoint = $derived.by(() => {
|
||||
const s = $settingsQuery.data;
|
||||
if (!s) return null;
|
||||
return s.comfyui_endpoints?.find((ep) => ep.id === s.comfyui_active_id) ?? null;
|
||||
});
|
||||
|
||||
const comfyUrl = $derived.by(() => {
|
||||
const raw = (activeEndpoint?.base_url ?? $settingsQuery.data?.comfyui_base_url ?? '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const u = new URL(raw.includes('://') ? raw : `http://${raw}`);
|
||||
return u.host;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
});
|
||||
|
||||
const activeId = $derived(activeEndpoint?.id ?? null);
|
||||
const probeQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['comfy-probe', activeId],
|
||||
queryFn: () => comfy.testEndpoint({ endpoint_id: activeId ?? undefined, timeout_sec: 5 }),
|
||||
enabled: activeId != null,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
})),
|
||||
);
|
||||
const probeState = $derived.by<'unset' | 'testing' | 'ok' | 'fail'>(() => {
|
||||
if (!activeEndpoint) return 'unset';
|
||||
if ($probeQuery.isPending) return 'testing';
|
||||
if ($probeQuery.isError) return 'fail';
|
||||
return $probeQuery.data?.ok ? 'ok' : 'fail';
|
||||
});
|
||||
const endpointName = $derived(activeEndpoint?.name?.trim() || 'ComfyUI');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title} · {t('shell.brand')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="a-shell a-darkroom">
|
||||
<!-- Mobile guard: this is a desktop-first tool (spec §4.5). -->
|
||||
<div class="desktop-only-notice" data-testid="mobile-notice">
|
||||
<Icon name="film" size={22} />
|
||||
<p>{t('shell.mobileNotice')}</p>
|
||||
</div>
|
||||
|
||||
<div class="shell-frame">
|
||||
<aside class="side">
|
||||
<a class="brand" href="/" title={t('shell.brandHome')}>
|
||||
<span class="brand-mark" aria-hidden="true">{t('shell.brandMark')}</span>
|
||||
<span class="brand-copy">
|
||||
<span class="brand-name">{t('shell.brand')}</span>
|
||||
<span class="brand-sub">AIGC STUDIO</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav" aria-label={t('shell.navAria')}>
|
||||
{#each NAV as item (item.id)}
|
||||
{#if item.href}
|
||||
<a
|
||||
class="nav-item"
|
||||
class:active={active === item.id}
|
||||
href={item.href}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
>
|
||||
<span class="nav-icon"><Icon name={item.icon} size={17} /></span>
|
||||
<span class="nav-label">{t(item.labelKey)}</span>
|
||||
{#if item.id === 'tasks' && activeJobs > 0}
|
||||
<span class="nav-badge">{activeJobs}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="nav-item disabled" title={t('shell.wipHint')} aria-disabled="true">
|
||||
<span class="nav-icon"><Icon name={item.icon} size={17} /></span>
|
||||
<span class="nav-label">{t(item.labelKey)}</span>
|
||||
<span class="wip-pill">{t('shell.wip')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="side-foot">
|
||||
<a
|
||||
class="comfy-row"
|
||||
href="/connections"
|
||||
title={probeState === 'fail'
|
||||
? ($probeQuery.data?.message ?? t('shell.comfyManage'))
|
||||
: t('shell.comfyManage')}
|
||||
>
|
||||
<span class="dot {probeState}" aria-hidden="true"></span>
|
||||
<span class="comfy-text">
|
||||
{#if probeState === 'unset'}
|
||||
{t('shell.comfyUnset')}
|
||||
{:else}
|
||||
<span class="comfy-state" class:fail={probeState === 'fail'}>
|
||||
{t(`shell.comfy_${probeState}`, { name: endpointName })}
|
||||
</span>
|
||||
{#if comfyUrl}<span class="mono comfy-host">{comfyUrl}</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
<div class="lang-row">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-copy">
|
||||
<h1>{title}</h1>
|
||||
{#if subtitle}<p class="sub">{subtitle}</p>{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
<div class="topbar-actions">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.a-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.desktop-only-notice {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell-frame {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---------------- side navigation (§3, 216px) ---------------- */
|
||||
.side {
|
||||
width: 216px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 12px 12px;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 2px 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: var(--avatar-1);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 150ms;
|
||||
}
|
||||
|
||||
.nav-item .nav-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
a.nav-item:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.disabled {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.wip-pill {
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 进行数徽章:进行中计数用信号青 */
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.comfy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 7px;
|
||||
text-decoration: none;
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 150ms;
|
||||
}
|
||||
|
||||
.comfy-row:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 连接状态点: cyan = online, cyan breathing = testing,
|
||||
red = failed, gray = no connection configured. */
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.dot.ok {
|
||||
background: var(--run);
|
||||
}
|
||||
.dot.fail {
|
||||
background: var(--error);
|
||||
}
|
||||
.dot.testing {
|
||||
background: var(--run);
|
||||
animation: shell-breathe 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
@keyframes shell-breathe {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.comfy-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comfy-state,
|
||||
.comfy-host {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.comfy-state.fail {
|
||||
color: var(--error);
|
||||
}
|
||||
.comfy-host {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.lang-row :global(.lang-switcher) {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
border-color: var(--border-strong);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------------- main column ---------------- */
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topbar-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---------------- responsive (§4.5) ---------------- */
|
||||
@media (max-width: 1023px) {
|
||||
.side {
|
||||
width: 64px;
|
||||
padding: 14px 8px 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.brand {
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.brand-copy {
|
||||
display: none;
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
}
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
.wip-pill {
|
||||
display: none;
|
||||
}
|
||||
/* icon-only rail: the tasks badge collapses to a small amber dot */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 5px;
|
||||
min-width: 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
/* icon-only rail: a small dot marks unbuilt entries */
|
||||
.nav-item.disabled .nav-icon {
|
||||
position: relative;
|
||||
}
|
||||
.nav-item.disabled .nav-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
}
|
||||
.comfy-text,
|
||||
.lang-row {
|
||||
display: none;
|
||||
}
|
||||
.comfy-row {
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 12px 18px;
|
||||
}
|
||||
.sub {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.shell-frame {
|
||||
display: none;
|
||||
}
|
||||
.desktop-only-notice {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.desktop-only-notice p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.desktop-only-notice :global(.icon) {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,389 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
|
||||
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
|
||||
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
|
||||
* error = red border + red reason.
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
/** Current value: a server-side path ('' = empty). */
|
||||
value: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
|
||||
aspect?: 'square' | 'wide';
|
||||
/** Empty-state main line (defaults to 点击或拖入). */
|
||||
emptyText?: string;
|
||||
/** Empty-state mono line, e.g. accepted formats and size limit. */
|
||||
emptySub?: string;
|
||||
/** Byte size of the current file when known. */
|
||||
size?: number;
|
||||
uploading?: boolean;
|
||||
uploadName?: string;
|
||||
error?: string;
|
||||
/** Show the "pick from image results" link (video-page image slots). */
|
||||
allowFromResults?: boolean;
|
||||
onFile: (file: File) => void;
|
||||
onFromResults?: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
required = false,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
emptyText,
|
||||
emptySub,
|
||||
size,
|
||||
uploading = false,
|
||||
uploadName,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
onFile,
|
||||
onFromResults,
|
||||
onClear,
|
||||
}: Props = $props();
|
||||
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let broken = $state(false);
|
||||
let dims = $state('');
|
||||
$effect(() => {
|
||||
void value;
|
||||
broken = false;
|
||||
dims = '';
|
||||
});
|
||||
|
||||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
if (kind === 'audio') return 'audio/*';
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
function noteDims(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
|
||||
function pick() {
|
||||
if (uploading || !fileInput) return;
|
||||
fileInput.value = '';
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) onFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="media-slot" class:wide={aspect === 'wide'}>
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
|
||||
{#if hint}<span class="slot-hint">{hint}</span>{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept()}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f) onFile(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if value && !broken}
|
||||
<div class="slot-filled" class:invalid={!!error}>
|
||||
<div class="slot-thumb">
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
{:else if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted></video>
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
|
||||
{:else}
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
|
||||
{uploading ? uploadName : uploadBaseName(value)}
|
||||
</span>
|
||||
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
|
||||
{t('studio.refReplace')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
disabled={uploading}
|
||||
onclick={onClear}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-empty"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
class:wide={aspect === 'wide'}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pick}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
|
||||
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
|
||||
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.media-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.slot-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 86px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.media-slot.wide .slot-empty {
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.slot-empty:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.slot-empty.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.slot-empty.invalid {
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.slot-empty:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pick-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pick-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.slot-filled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.slot-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dims {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-clear:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,524 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import {
|
||||
IMAGE_SLOT_ACCEPT,
|
||||
formatBytes,
|
||||
mediaSlotAccept,
|
||||
uploadBaseName,
|
||||
type MediaSlotKind,
|
||||
} from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-media grid (ref-upload-spec §2 `.refgrid`, generalized to
|
||||
* image/video/audio in HEL-643): numbered cells 参考图/参考视频/参考音频
|
||||
* 1…N in the order they reach the workflow, each with replace and ✕; a
|
||||
* dashed add cell while below `max`; rejected files show as error cells
|
||||
* that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
interface Props {
|
||||
label: string;
|
||||
paths: string[];
|
||||
max: number;
|
||||
/** Media family of the slots — drives thumbnails, accept and copy. */
|
||||
mediaKind?: MediaSlotKind;
|
||||
/** Byte sizes of files uploaded in this session, by server path. */
|
||||
sizes?: Record<string, number>;
|
||||
/** Name of the file being uploaded (add or replace), if any. */
|
||||
uploadingName?: string;
|
||||
/** Index being replaced while uploading (-1 = adding). */
|
||||
uploadingIndex?: number;
|
||||
rejects?: Reject[];
|
||||
error?: string;
|
||||
allowFromResults?: boolean;
|
||||
onAdd: (files: File[]) => void;
|
||||
onReplace: (index: number, file: File) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onDismissReject: (index: number) => void;
|
||||
onFromResults?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
paths,
|
||||
max,
|
||||
mediaKind = 'image',
|
||||
sizes = {},
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
rejects = [],
|
||||
error,
|
||||
allowFromResults = false,
|
||||
onAdd,
|
||||
onReplace,
|
||||
onRemove,
|
||||
onDismissReject,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let addInput = $state<HTMLInputElement | null>(null);
|
||||
let replaceInput = $state<HTMLInputElement | null>(null);
|
||||
let replaceIndex = $state(-1);
|
||||
let dragDepth = $state(0);
|
||||
let dims = $state<Record<string, string>>({});
|
||||
let durations = $state<Record<string, number>>({});
|
||||
|
||||
const uploading = $derived(!!uploadingName);
|
||||
const remaining = $derived(Math.max(0, max - paths.length));
|
||||
const accept = $derived(mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind));
|
||||
const isMedia = $derived(mediaKind !== 'image');
|
||||
const hintLabel = $derived(
|
||||
isMedia
|
||||
? t('studio.refHintMedia', { n: paths.length, max })
|
||||
: t('studio.refHint', { n: paths.length, max }),
|
||||
);
|
||||
function durationOf(path: string): string {
|
||||
const d = durations[path];
|
||||
if (d == null || !Number.isFinite(d)) return '';
|
||||
return `${d.toFixed(1)}s`;
|
||||
}
|
||||
function noteDuration(path: string, e: Event): void {
|
||||
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
|
||||
if (el && Number.isFinite(el.duration)) {
|
||||
durations = { ...durations, [path]: el.duration };
|
||||
}
|
||||
}
|
||||
|
||||
function pickAdd() {
|
||||
if (uploading || !addInput) return;
|
||||
addInput.value = '';
|
||||
addInput.click();
|
||||
}
|
||||
|
||||
function pickReplace(i: number) {
|
||||
if (uploading || !replaceInput) return;
|
||||
replaceIndex = i;
|
||||
replaceInput.value = '';
|
||||
replaceInput.click();
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], durationOf(path), sizes[path] != null ? formatBytes(sizes[path]) : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="refgrid-field">
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}<em>*</em></span>
|
||||
<span class="slot-hint">{hintLabel}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={addInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
bind:this={replaceInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid" class:audio={mediaKind === 'audio'}>
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-thumb"
|
||||
title={t('studio.refReplace')}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
{#if mediaKind === 'image'}
|
||||
<img
|
||||
src={assetUrl(path)}
|
||||
alt={t('studio.slotRefN', { n: i + 1 })}
|
||||
onload={(e) => noteDims(path, e)}
|
||||
/>
|
||||
{:else if mediaKind === 'video'}
|
||||
<video
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></video>
|
||||
{:else}
|
||||
<span class="audio-glyph"><Icon name="music" size={18} /></span>
|
||||
<audio
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></audio>
|
||||
{/if}
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={isMedia ? t('studio.refRemoveMedia', { kind: label, n: i + 1 }) : t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
|
||||
<span class="ref-meta mono">
|
||||
{metaOf(path)}
|
||||
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<div class="ref-cell rejected" role="alert">
|
||||
<div class="ref-thumb error">
|
||||
<Icon name="close" size={16} />
|
||||
<span class="mono">{t('studio.refRejectedShort')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
onclick={() => onDismissReject(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name err" title={rej.name}>{rej.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remaining > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="ref-add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pickAdd}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading && uploadingIndex < 0}
|
||||
<Spinner size="sm" />
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{isMedia ? t('studio.refAddSubMedia', { n: remaining, max }) : t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
|
||||
{/if}
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<span class="slot-error">{rej.message}</span>
|
||||
{/each}
|
||||
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.refgrid-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.refgrid.audio {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.refgrid.audio .ref-thumb {
|
||||
aspect-ratio: auto;
|
||||
min-height: 64px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.refgrid.audio audio {
|
||||
width: 90%;
|
||||
height: 28px;
|
||||
}
|
||||
.refgrid video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.audio-glyph {
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.refgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ref-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ref-cell.rejected {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ref-thumb.error {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ref-no {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ref-x {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-x:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-name {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-name.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
padding: 8px 4px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.ref-add:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.ref-add.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-add:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.add-main {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.add-sub {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.full-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.refgrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
/** `danger` = outlined (list rows); `danger-solid` = confirm dialogs only. */
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Stretch to the container width (e.g. a panel's main action). */
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
@@ -16,6 +19,7 @@
|
||||
let {
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
block = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = 'button',
|
||||
@@ -27,7 +31,10 @@
|
||||
|
||||
<button
|
||||
{type}
|
||||
class="btn btn-{variant} btn-{size}"
|
||||
class="a-btn btn {variant}"
|
||||
class:sm={size === 'sm'}
|
||||
class:lg={size === 'lg'}
|
||||
class:block
|
||||
class:loading
|
||||
disabled={disabled || loading}
|
||||
{title}
|
||||
@@ -44,86 +51,13 @@
|
||||
</button>
|
||||
|
||||
<style>
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
|
||||
.btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 28px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-md {
|
||||
min-height: 34px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #23232b;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: var(--error);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -139,9 +73,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-primary .btn-spinner,
|
||||
.btn-danger .btn-spinner {
|
||||
--spinner-track: rgba(255, 255, 255, 0.35);
|
||||
--spinner-arc: #fff;
|
||||
.btn.primary .btn-spinner {
|
||||
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
|
||||
--spinner-arc: var(--accent-ink);
|
||||
}
|
||||
.btn.danger .btn-spinner {
|
||||
--spinner-arc: var(--err);
|
||||
}
|
||||
.btn.danger-solid .btn-spinner {
|
||||
--spinner-track: color-mix(in srgb, var(--on-media) 30%, transparent);
|
||||
--spinner-arc: var(--on-media);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,16 +46,18 @@
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.card.hoverable {
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
border-color var(--d-state) ease,
|
||||
transform var(--d-state) var(--ease-out);
|
||||
}
|
||||
.card.hoverable:hover {
|
||||
border-color: #3f3f46;
|
||||
transform: translateY(-1px);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card.hoverable:hover {
|
||||
border-color: var(--line-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>{cancelLabel}</Button
|
||||
>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onclick={handleConfirm}
|
||||
<Button variant={danger ? 'danger-solid' : 'primary'} onclick={handleConfirm}
|
||||
>{confirmLabel}</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -144,22 +144,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(5, 5, 8, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: modal-fade 150ms ease-out;
|
||||
background: var(--overlay);
|
||||
animation: modal-fade var(--d-state) ease-out;
|
||||
}
|
||||
.modal-panel {
|
||||
width: min(520px, 100%);
|
||||
max-height: calc(100vh - 96px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: modal-rise 180ms ease-out;
|
||||
animation: modal-rise var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.modal-panel.size-lg {
|
||||
width: min(920px, 100%);
|
||||
@@ -177,7 +174,7 @@
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -194,12 +191,14 @@
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.modal-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
indeterminate?: boolean;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
/** 进度条: running = 信号青, done = green, failed = red (static). */
|
||||
tone?: 'run' | 'success' | 'error';
|
||||
/** Track only — no label / percentage row. */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -11,13 +15,15 @@
|
||||
indeterminate = false,
|
||||
label,
|
||||
size = 'md',
|
||||
tone = 'run',
|
||||
bare = false,
|
||||
}: Props = $props();
|
||||
|
||||
const pct = $derived(Math.round(Math.min(100, Math.max(0, value))));
|
||||
const showMeta = $derived(Boolean(label) || !indeterminate);
|
||||
const showMeta = $derived(!bare && (Boolean(label) || !indeterminate));
|
||||
</script>
|
||||
|
||||
<div class="progress progress-{size}">
|
||||
<div class="progress progress-{size} tone-{tone}">
|
||||
{#if showMeta}
|
||||
<div class="progress-meta">
|
||||
{#if label}
|
||||
@@ -39,7 +45,7 @@
|
||||
{#if indeterminate}
|
||||
<div class="fill indeterminate"></div>
|
||||
{:else}
|
||||
<div class="fill" style:width={pct + '%'}></div>
|
||||
<div class="fill" style:transform="scaleX({pct / 100})"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,8 +75,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.progress-sm .track {
|
||||
height: 4px;
|
||||
@@ -80,9 +86,18 @@
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 250ms ease;
|
||||
background: var(--run-grad);
|
||||
box-shadow: 0 0 8px var(--run-soft);
|
||||
transform-origin: left;
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
.tone-success .fill {
|
||||
background: var(--ok);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tone-error .fill {
|
||||
background: var(--err);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fill.indeterminate {
|
||||
position: absolute;
|
||||
@@ -92,10 +107,11 @@
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--accent) 40%,
|
||||
var(--accent) 60%,
|
||||
var(--run) 40%,
|
||||
var(--run) 60%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
animation: progress-slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Svelte 5 runes mode, TypeScript strict, scoped styles driven by the CSS vars in
|
||||
|
||||
Conventions shared by all primitives:
|
||||
|
||||
- Styling uses the global tokens (`--bg-surface`, `--border`, `--accent`, `--radius-*`, `--space-*`, fonts). Never hardcode hex colors outside this directory.
|
||||
- Styling uses the global A「片场暗房」tokens in `src/routes/app.css` (`--surface`, `--border`, `--accent`, …; legacy names such as `--bg-surface` are aliases). Button renders the shared `.a-btn` classes. Never hardcode hex colors.
|
||||
- All interactive primitives expose a `:focus-visible` accent ring. `app.css` also provides a global `:focus-visible` fallback for native elements.
|
||||
- Motion respects `prefers-reduced-motion`: `app.css` kills animation/transition durations globally; components that need a styled static fallback (Skeleton, ProgressBar indeterminate, StatusChip pulse) handle it in their own scoped styles.
|
||||
|
||||
@@ -19,7 +19,8 @@ Conventions shared by all primitives:
|
||||
| Prop | Type | Default | Notes |
|
||||
| ---------- | ------------------------------------------------- | ------------- | -------------------------------------------------- |
|
||||
| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger'` | `'secondary'` | primary = `--accent` bg; danger = `--error` tinted |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 28px, md = 34px min-height |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 30px, md = 34px min-height (shared `.a-btn`) |
|
||||
| `block` | `boolean` | `false` | full container width (`.a-btn.block`) |
|
||||
| `disabled` | `boolean` | `false` | reduced opacity, no pointer events |
|
||||
| `loading` | `boolean` | `false` | inline spinner, disables, width stays stable |
|
||||
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | |
|
||||
@@ -97,6 +98,8 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
|
||||
| `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} />
|
||||
|
||||
@@ -19,36 +19,26 @@
|
||||
></div>
|
||||
|
||||
<style>
|
||||
/* §四.9 骨架屏:--bg-3 圆角块,1.4s 呼吸透明度;减少动态时静态。 */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-3);
|
||||
border-radius: var(--r-sm);
|
||||
animation: skeleton-breath 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
@keyframes skeleton-breath {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton::after {
|
||||
display: none;
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .skeleton {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
|
||||
let { status, label }: Props = $props();
|
||||
|
||||
type Tone = 'success' | 'info' | 'muted' | 'error' | 'warning' | 'accent';
|
||||
type Tone = 'ok' | 'run' | 'muted' | 'err' | 'warn';
|
||||
|
||||
const toneByStatus: Record<string, Tone> = {
|
||||
ready: 'success',
|
||||
done: 'success',
|
||||
completed: 'success',
|
||||
running: 'info',
|
||||
generating: 'info',
|
||||
queued: 'info',
|
||||
ready: 'ok',
|
||||
done: 'ok',
|
||||
completed: 'ok',
|
||||
running: 'run',
|
||||
generating: 'run',
|
||||
in_progress: 'run',
|
||||
queued: 'muted',
|
||||
pending: 'muted',
|
||||
idle: 'muted',
|
||||
draft: 'muted',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
paused: 'warning',
|
||||
in_progress: 'accent',
|
||||
failed: 'err',
|
||||
error: 'err',
|
||||
paused: 'warn',
|
||||
};
|
||||
|
||||
const key = $derived(status.trim().toLowerCase());
|
||||
const tone: Tone = $derived(toneByStatus[key] ?? 'muted');
|
||||
const pulsing = $derived(key === 'running');
|
||||
const pulsing = $derived(tone === 'run');
|
||||
</script>
|
||||
|
||||
<span class="chip chip-{tone}">
|
||||
@@ -35,59 +35,48 @@
|
||||
</span>
|
||||
|
||||
<style>
|
||||
/* §四.2 状态标签:20px 高、--r-sm、12px;语义色文字 + 13% 软底。 */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
gap: var(--sp-2);
|
||||
height: 20px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-success {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
.chip-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.chip-info {
|
||||
color: var(--info);
|
||||
background: color-mix(in srgb, var(--info) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--info) 35%, transparent);
|
||||
.chip-run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-error {
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--error) 35%, transparent);
|
||||
.chip-err {
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.chip-warning {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--warning) 35%, transparent);
|
||||
}
|
||||
.chip-accent {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
.chip-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.chip-muted {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--border);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.dot.pulsing {
|
||||
animation: chip-pulse 1.2s ease-in-out infinite;
|
||||
animation: chip-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
0%,
|
||||
@@ -103,4 +92,7 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .dot.pulsing {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (next: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked, label, disabled = false, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- §3.10 开关: 34×19 capsule; on = --accent-lo track + ink knob, off = --bg-4. -->
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
class:on={checked}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
onclick={() => onchange?.(!checked)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color var(--d-state) ease,
|
||||
border-color var(--d-state) ease;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--text-3);
|
||||
transition:
|
||||
transform var(--d-state) var(--ease-out),
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent-lo);
|
||||
border-color: var(--accent-lo);
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(15px);
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,451 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchAsset, WorkbenchStage } from '$lib/api';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
kind: 'image' | 'video';
|
||||
targetId: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
}) => void;
|
||||
onRetryJob?: (jobId: number) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<WorkbenchAsset[]>([]);
|
||||
|
||||
const assets = $derived(
|
||||
Array.isArray((stage.content as { assets?: unknown } | null)?.assets)
|
||||
? ((stage.content as unknown as { assets: WorkbenchAsset[] }).assets ?? [])
|
||||
: [],
|
||||
);
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
character: 'workbench.asset.kindCharacter',
|
||||
location: 'workbench.asset.kindLocation',
|
||||
prop: 'workbench.asset.kindProp',
|
||||
};
|
||||
|
||||
function startEdit() {
|
||||
draft = assets.map((a) => ({ ...a }));
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const clean = draft.filter((a) => a.name.trim() && a.prompt.trim());
|
||||
if (!clean.length) return;
|
||||
onSave?.({ assets: clean });
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function addAsset() {
|
||||
draft = [
|
||||
...draft,
|
||||
{
|
||||
id: `asset-new-${draft.length + 1}-${Date.now() % 1000}`,
|
||||
kind: 'prop',
|
||||
name: '',
|
||||
description: '',
|
||||
prompt: '',
|
||||
negative_prompt: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function removeAsset(id: string) {
|
||||
draft = draft.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
function jobActive(job: WorkbenchAsset['job']): boolean {
|
||||
return !!job && (job.status === 'pending' || job.status === 'running');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
<div class="editor">
|
||||
<div class="edit-list">
|
||||
{#each draft as asset (asset.id)}
|
||||
<div class="asset-edit">
|
||||
<div class="asset-edit-head">
|
||||
<select class="wb-input w-kind" bind:value={asset.kind}>
|
||||
<option value="character">{t('workbench.asset.kindCharacter')}</option>
|
||||
<option value="location">{t('workbench.asset.kindLocation')}</option>
|
||||
<option value="prop">{t('workbench.asset.kindProp')}</option>
|
||||
</select>
|
||||
<input class="wb-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
title={t('workbench.asset.remove')}
|
||||
onclick={() => removeAsset(asset.id)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<input class="wb-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
rows="2"
|
||||
bind:value={asset.prompt}
|
||||
placeholder={t('workbench.asset.promptPh')}
|
||||
></textarea>
|
||||
<input
|
||||
class="wb-input mono"
|
||||
bind:value={asset.negative_prompt}
|
||||
placeholder={t('workbench.style.negativePh')}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addAsset}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.asset.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
disabled={!draft.some((a) => a.name.trim() && a.prompt.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
{t('workbench.saveDraft')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if assets.length === 0}
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.asset.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.asset.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.asset.generate')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="asset-grid" data-testid="asset-grid">
|
||||
{#each assets as asset (asset.id)}
|
||||
<div class="asset-card">
|
||||
<div class="asset-head">
|
||||
<span class="kind-tag kind-{asset.kind}">{t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp')}</span>
|
||||
<span class="asset-name">{asset.name}</span>
|
||||
{#if asset.job}
|
||||
<JobChip job={asset.job} />
|
||||
{#if asset.job.status === 'failed'}
|
||||
<button
|
||||
type="button"
|
||||
class="retry-link"
|
||||
onclick={() => onRetryJob?.(asset.job!.id)}
|
||||
>
|
||||
{t('workbench.retryJob')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if asset.description}<p class="asset-desc">{asset.description}</p>{/if}
|
||||
{#if asset.job?.status === 'done' && asset.job.output}
|
||||
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener">
|
||||
<img src={assetUrl(asset.job.output) ?? ''} alt={asset.name} loading="lazy" />
|
||||
</a>
|
||||
{/if}
|
||||
<PromptBlock label={t('workbench.asset.promptLabel')} text={asset.prompt} />
|
||||
{#if asset.negative_prompt}
|
||||
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
|
||||
{/if}
|
||||
<div class="asset-foot">
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
disabled={busy || jobActive(asset.job)}
|
||||
title={t('workbench.asset.generateHint')}
|
||||
onclick={() =>
|
||||
onGenerate?.({
|
||||
kind: 'image',
|
||||
targetId: asset.id,
|
||||
prompt: asset.prompt,
|
||||
negativePrompt: asset.negative_prompt || undefined,
|
||||
})}
|
||||
>
|
||||
<Icon name="image" size={13} />
|
||||
{jobActive(asset.job) ? t('workbench.generating') : t('workbench.asset.generateImage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="view-foot">
|
||||
<span class="hint">{t('workbench.asset.summary', { n: assets.length })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.todo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 22px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.todo-title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.todo-body {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.asset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.asset-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kind-tag {
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kind-character {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.kind-location {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.kind-prop {
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
}
|
||||
.asset-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.asset-desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.asset-shot {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.asset-shot img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.asset-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--error);
|
||||
font-size: 11px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.edit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.asset-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.asset-edit-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.view-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.foot-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-input.w-kind {
|
||||
width: 92px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--error-dim);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchJobBrief } from '$lib/api';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Inline workbench job state chip (spec §5 任务条目四态):
|
||||
* 排队灰 / 进行中琥珀百分比 / 完成绿 / 失败红(附原因).
|
||||
*/
|
||||
interface Props {
|
||||
job: WorkbenchJobBrief;
|
||||
}
|
||||
|
||||
let { job }: Props = $props();
|
||||
|
||||
const live = $derived(progressFor(job.id));
|
||||
const pct = $derived(live?.progress ?? null);
|
||||
|
||||
const state = $derived(
|
||||
job.status === 'done'
|
||||
? 'done'
|
||||
: job.status === 'failed'
|
||||
? 'failed'
|
||||
: job.status === 'running'
|
||||
? 'running'
|
||||
: 'pending',
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="job-chip st-{state}" data-testid="wb-job-chip">
|
||||
{#if state === 'running'}
|
||||
<span class="breath" aria-hidden="true"></span>
|
||||
{#if pct != null}{t('workbench.jobRunningPct', { n: Math.round(pct) })}{:else}{t('workbench.jobRunning')}{/if}
|
||||
{:else if state === 'pending'}
|
||||
{t('workbench.jobQueued')}
|
||||
{:else if state === 'done'}
|
||||
{t('workbench.jobDone')}
|
||||
{:else}
|
||||
{t('workbench.jobFailed')}
|
||||
{/if}
|
||||
</span>
|
||||
{#if state === 'failed' && job.error}
|
||||
<span class="job-error">{job.error}</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.job-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--text-2);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.st-failed {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.job-error {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.breath {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
@keyframes wb-chip-breath {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.breath {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user