Compare commits

...
Author SHA1 Message Date
30e8e85766 feat(studio): 三种图片输入工作流与最多九张参考图 (HEL-633)
- 解析:H3 参考生视频 ref_images 自增输入收拢为 1–9 张有序参考图组;
  图生视频首/尾帧、Qwen 编辑输入图按真实连线识别,未连线不出槽位;
  Qwen 编辑模式暴露 resolution
- 打补丁:按张数克隆/复用/删除 LoadImage 并按序接线 ref_image_0..N-1
- 校验:参考图上限 9、至少 1 张、格式/存在性;必填图片槽位
- 工作流体检:缺 class_type / 断链 / 采样器无模型 → 导入时中文告警,
  生成接口与 worker 拒绝执行并提示重新导出
- 前端:RefImageGrid(逐张添加/预览/替换/删除/序号/超限整批拒绝)、
  MediaSlot 横排已选态、分辨率字段、损坏工作流提示、连接页告警标签
- 测试:三份真实 JSON + 1/4/9 张参考图全链路

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 09:16:17 +08:00
60c043db7c fix(HEL-627): 补齐真实工作流参数并修正虚假完成
- 解析 ResolutionSelector 比例/总像素、KSampler/BasicScheduler 降噪,附带 ComfyUI 取值范围
- 已导入的自动识别工作流读取时按最新规则重新解析,无需重导
- 提交前后端校验参数(中文原因),非法值 400 拒绝不入队
- 任务新增 phase / comfy_prompt_id:连接中→已接收(前方排队)→执行中→保存中→完成
- 空产出、队列与历史都丢失、执行中断均判失败;Dry-run 结果标为占位,不算完成
- ComfyUI 缺模型报错写明缺哪个文件
- 生图/生视频/任务页统一用 jobStatusInfo 展示真实状态,结果区支持复用参数

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 23:05:34 +08:00
0031a897d7 fix(HEL-623): split dual-prompt text encoders; positive wiring wins; detect duration via expression
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 18:57:00 +08:00
shigongyuan-agentandmultica-agent 063a3c7063 fix: untagged ComfyUI workflows expose inputs; studio generate always gives feedback (HEL-623)
Root cause: parse_dynamic_inputs only recognised (Input:role) title tags.
A workflow exported straight from ComfyUI has default titles, so its
input_schema was empty and the studio form rendered no prompt box; the
generate click then enqueued (or was blocked) with no toast at all.

- parser: untagged fallback detects prompt/negative (via wiring to
  sampler positive/negative), seed/steps/cfg, latent width/height/batch,
  numeric primitives, LoadImage/LoadImageMask/video/audio slots; only the
  prompt is required; entries carry auto=true
- patcher: "<nodeId>@<widget>" keys target one widget on multi-widget
  nodes, never overwrite linked inputs; LoadImageMask uploads like images
- workflows API re-derives an empty stored schema (old imports fixed
  without re-import); smart_fill sort accepts subgraph/field ids
- studio: blocked submit toasts the reason and scrolls to the field;
  success toasts "已提交"; submit errors show the API detail; job.failed
  for this window's jobs toasts the reason; no-param workflows show a
  note + disabled prompt box; auto-detected params show a note
- connections import: chip shows node count, toast reports real params
- Chinese messages for ComfyUI rejection/upload/timeout/execution errors
  and playground workflow-not-found/disabled
- tests: native Qwen t2i / Qwen edit+mask / untagged H3 fixtures, patch
  keys, backfill, full fake-ComfyUI chain, Chinese rejection

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 18:25:41 +08:00
施工员andmultica-agent 1a7a682cd0 HEL-615: 全部页面统一为 A「片场暗房」界面
- 设置页重写进 AppShell:生成与队列 / 智能体运行 / 存储路径三段,其余设置入口链到模型设置、ComfyUI 连接、智能体;旧 ?tab= 链接跳转到对应新页
- 项目卡状态标签改为按五段真实进度显示(已完成 / 某阶段生成中·待确认 / 进行中 / 待开始)
- 首页生成任务行对齐 a1(来源标签、项目名、进度条、本地时间)
- 连接页:右栏默认新增表单 + 任务使用目标面板,进入页面自动实测各连接;模型页默认新增表单、自动 1-token 实测,去掉双加号
- 生图/生视频:生成参数面板标题、通栏生成按钮、成片预览、按类型的历史标题与 16:9 视频历史、顶栏连接状态胶囊,连接状态改用服务器实测
- 智能体页:五个 Agent / Skill 库 / 已装 Skill 面板标题,内置 Skill 中文名,去掉对话空态 Calliope 字样
- 侧栏底部 ComfyUI 状态修正为真实实测(原查询不响应导致一直测试中)
- 统一样式来源:.a-btn/.a-panel/.a-select/.status-chip 等只在 app.css 定义一次,删除各页重复定义;新增 Switch、Button block、ProgressBar tone
- 旧 Calliope 路由(/projects、/project/:id、/canvas、/library、/build-scene、/playground)重定向到 A 页面;页面标题改为影工坊
- 轮询间隔单位改为秒(7 种语言)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 16:25:55 +08:00
总工 d2a8ebf7dd merge: HEL-614 five-stage creation workbench into test branch (06b15dc) 2026-09-28 14:59:42 +08:00
总工andmultica-agent 06b15dc3b6 feat: five-stage creation workbench (HEL-614)
- backend: project_stages/stage_runs tables; /api/workbench router
  (overview, run agent with confirmed-upstream context only, edit draft,
  confirm, restore, script import, in-flow image/video generation via the
  shared queue with source=workbench); project stats gain real
  stage_confirmed/stage_status/active_jobs
- frontend: /workbench picker + /workbench/[id] five-stage page (stepper,
  per-stage editors, style direction chooser, storyboard table, asset
  prompt cards with in-flow generation, shot video prompts with player,
  agent + task side rail); nav 创作工作台 now opens it; home entries go
  through the workbench (导入剧本 skips 剧本创作); project cards show
  real five-stage progress; i18n keys in all seven dictionaries
- tests: 9 backend workbench tests (flow handoff, edit/confirm/restore,
  import, generation, Chinese failure states)

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 13:30:19 +08:00
总工 eeafe739f7 merge: HEL-613 five named agents chat + custom skills into test branch (rework 88660cb) 2026-09-28 12:13:42 +08:00
shigongyuan-agentandmultica-agent 88660cb3b4 fix: strip SKILL.md frontmatter in the skill editor (HEL-613 rework)
The editor's strip regex anchored on \\A — a Python-ism JS treats as a
literal — so it never matched: the editor showed the raw frontmatter and
every save re-wrapped the file in one more frontmatter layer. Extract the
strip into lib/skillFrontmatter.ts with a ^ anchor, import it from the
page, and guard it with scripts/check-skill-frontmatter.mjs (wired into
npm test): strips the render_skill_md block shape, exact split boundary,
idempotent, save-reload round-trip adds no layer, and the inline \\A
pattern is banned statically.

Also per review notes: skill toggles are now the spec-v2 §5 34x19 capsule
(amber track + dark knob when on), and version-history timestamps render
localized instead of the raw SQLite UTC string.

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 12:03:20 +08:00
shigongyuan-agentandmultica-agent cdd6eba8ba feat: five named agents independent chat + custom skill management (HEL-613)
Backend:
- agents_registry: five AIGC roles (剧本创作/视觉设计/分镜设计/素材资产/镜头脚本)
  with zh personas, agent-chat mode text, and llm_role mapping onto the
  models-page five-role binding surface (story/style/script/assets/video)
- agent_sessions.agent_key column + migration; sessions filter/exclusion;
  named replies stamped with the owner agent's display name
- prompt assembly: per-agent persona + AGENT CHAT mode section replace the
  generic sandbox text; mentions section suppressed for agent chats
- main loop routes the LLM through the agent's bound role
- skill admin surface: skill_overrides/skill_bindings/skill_versions tables,
  PUT/PATCH/DELETE /api/agent/skills/{name} + bindings + versions/restore;
  effective rule = global enable ∩ enabled per-agent binding; builtin skills
  seeded to all five agents at upgrade; read_skill/list_skills gated for
  named-agent sessions

Frontend:
- /agents is now the three-column A-darkroom page (agent list + skill library
  / chat with reused AgentChat / skill manager with editor, assignment
  capsules, version history + rollback); per-agent model select synced with
  the models page; narrow breakpoint turns the manager into a drawer
- legacy ?project=&task= deep links still route into the canvas flow
- i18n keys added to all 7 locales

Tests: tests/test_agent_roles.py + tests/test_skill_admin.py (27 cases)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 11:28:26 +08:00
shigongyuan-agent 7be478dafd merge: HEL-612 standalone image/video studio + tasks page into test branch (HEL-620) 2026-09-28 10:17:45 +08:00
shigongyuan-agentandmultica-agent d49797fe5d fix: home tasks note no longer claims the jobs page is under development (HEL-620 review)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 10:17:40 +08:00
shigongyuan-agentandmultica-agent df84c22143 feat: standalone image/video studio windows + generation tasks page (HEL-612)
- /image and /video pages (a3/a4): three-column layout — params (connection,
  workflow, upload slots driven by declared inputs, prompt/negative/seed/
  size/duration fields, dynamic params), current-task card with live SSE
  progress + cancel, result grid with zoom/send-to-img2img/download, video
  player with custom amber controls + first/last-frame strip, right history
  with retry/delete and failure reasons
- /tasks page: both task classes summarized with source labels
  (独立生图/独立生视频/Playground/创作流程), filters, retry/cancel/delete
- nav: 生图/生视频/生成任务 real pages with active-jobs badge (进行数徽章)
- playground generate accepts source (studio-image/studio-video) for origin
  labeling; api.ts types follow
- parseDbTs helper: UTC-naive backend timestamps parse as UTC so elapsed/
  ETA/duration math is offset-correct
- backend tests: full-chain fake-ComfyUI server — image chain, video chain
  with frame uploads, retry-after-failure, delete-removes-outputs, tasks
  listing; i18n zh/en/es/fr/de/ja/ko complete

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 09:47:48 +08:00
shigongyuan-agentandmultica-agent 1cf9188955 feat: multi-ComfyUI connections + workflow foundation (HEL-611)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 00:03:05 +08:00
shigongyuan-agentandmultica-agent 25d1e05a30 fix: survive missing crypto.randomUUID on insecure LAN HTTP (HEL-610)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 22:37:42 +08:00
shigongyuan-agentandmultica-agent 4945d29e52 feat: A darkroom shell + projects overview home, models page into the shell (HEL-608)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 21:42:06 +08:00
multica-builderandmultica-agent 5d909aca3b feat: models settings page per approved A darkroom spec (HEL-605)
- New /models page: LLM cards (type tag, agent-default badge, test state
  with latency/consecutive failures), add/edit form (OpenAI-compatible
  segment, mono base URL/model, masked key with sk-****tail, timeout
  dropdown, save gated on a passing test), agent default-model panel for
  the five AIGC agents, dashed server-connectivity notice, disabled
  image/video tabs as future placeholders; responsive 1440/1024/768
  breakpoints per spec-v2 §9.
- Scoped A darkroom design tokens in app.css (.a-darkroom) + Noto Sans SC.
- Top nav: add Agents and Models entries before Settings.
- Backend: add 'style' agent role, llm-test timeout_sec (5-60, default 10),
  api_key_tail (last 4 chars) in public profiles; 4 new tests.
- i18n: 84 models.* keys + nav.agents/nav.models in all 7 dictionaries.

Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 20:49:59 +08:00
multica-builder 786b349752 feat: default UI language to zh and add LLM connection test
- First-open default language is now Simplified Chinese (i18n.svelte.ts);
  stored language choice still wins.
- New POST /api/settings/llm-test endpoint probes an OpenAI-compatible
  endpoint with a 1-token chat completion; falls back to a saved profile's
  secret so keys never travel back to the browser.
- Settings LLM cards gain a connection-test button with readable
  success/failure feedback (auth / timeout / connect / HTTP detail),
  localized in all seven dictionaries.
- Backend tests cover success, saved-secret reuse, 401, connection
  refused, and input validation against a local fake OpenAI server.
2026-09-27 20:04:46 +08:00
124 changed files with 31269 additions and 2944 deletions
@@ -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:
+64 -15
View File
@@ -41,22 +41,35 @@ 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()
@@ -74,7 +87,7 @@ class ComfyUIClient:
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 +107,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 +120,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 +214,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 +229,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 +269,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)
@@ -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,94 @@
"""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, node)}没有接到任何节点,"
"它的宽高不会生效,已不在表单中显示",
}
)
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}。"
+546 -2
View File
@@ -1,13 +1,16 @@
"""Parse ComfyUI API-format workflows for (Input[:role]) / (Output[:role]) titled nodes."""
from __future__ import annotations
import math
from typing import Any
from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
from calliope.comfyui.registry import (
ComfyOutputKind,
IMAGE_CLASSES,
AUDIO_CLASSES,
IMAGE_CLASSES,
VIDEO_CLASSES,
ComfyOutputKind,
class_to_input_kind,
class_to_output_kind,
)
@@ -36,6 +39,12 @@ def extract_default_value(node: dict[str, Any]) -> str | int | float | None:
def parse_dynamic_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
"""Tagged ``(Input:role)`` nodes; untagged workflows fall back to detection.
A workflow exported straight from ComfyUI carries default node titles
("CLIP Text Encode (Positive Prompt)", "KSampler", …) and no tags at all —
without the fallback it would expose nothing to fill in.
"""
results: list[dict[str, Any]] = []
for node_id, node in workflow.items():
if not isinstance(node, dict):
@@ -54,9 +63,544 @@ def parse_dynamic_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
"required": True,
}
)
if not results:
results = detect_untagged_inputs(workflow)
return results
def effective_input_schema(
stored: list[dict[str, Any]] | None, workflow: dict[str, Any]
) -> list[dict[str, Any]]:
"""The schema a stored workflow exposes now.
Auto-detected (and empty) schemas are re-derived from the workflow JSON
on every read, so parser upgrades reach workflows imported earlier
without a re-import. Tagged ``(Input:…)`` schemas are kept as stored.
"""
if not stored or any(isinstance(e, dict) and e.get("auto") for e in stored):
return parse_dynamic_inputs(workflow)
return stored
# ---------------------------------------------------------------------------
# Untagged detection
# ---------------------------------------------------------------------------
_MASK_IMAGE_CLASSES = frozenset({"LoadImageMask"})
_STRING_PRIMITIVES = frozenset({"PrimitiveString", "PrimitiveStringMultiline"})
_NUMBER_PRIMITIVES = frozenset({"PrimitiveInt", "PrimitiveFloat", "INT", "FLOAT"})
_SAMPLER_CLASSES = frozenset({"KSampler", "KSamplerAdvanced"})
_NEGATIVE_WORDS = ("negative", "负面", "反向", "反面")
_SYSTEM_WORDS = ("system", "系统")
_PROMPT_WORDS = ("prompt", "positive", "提示", "正向")
_NEGATIVE_TEXT_FIELDS = ("negative_prompt", "negative_text", "negative")
# Title keyword → (role, Chinese label) for untagged numeric primitives.
_NUMBER_TITLE_ROLES: tuple[tuple[tuple[str, ...], str, str], ...] = (
(("duration", "seconds", "second", "时长", "秒"), "duration", "时长(秒)"),
(("fps", "frame rate", "framerate", "帧率"), "fps", "帧率"),
(("seed", "种子"), "seed", "随机种子"),
(("width", "宽度"), "width", "宽度"),
(("height", "高度"), "height", "高度"),
(("steps", "步数"), "steps", "采样步数"),
(("cfg",), "cfg", "CFG"),
)
def _is_scalar(value: Any) -> bool:
return isinstance(value, (str, int, float)) and not isinstance(value, bool)
def _title_of(node: dict[str, Any]) -> str:
return str((node.get("_meta") or {}).get("title") or "").strip()
def _node_sort_key(node_id: str) -> tuple:
return tuple(
(0, int(p)) if p.isdigit() else (1, p)
for p in str(node_id).replace(FIELD_KEY_SEP, ":").split(":")
)
def _consumers(workflow: dict[str, Any]) -> dict[str, list[tuple[str, str]]]:
"""source nodeId → [(consumer nodeId, consumer input name), …]."""
out: dict[str, list[tuple[str, str]]] = {}
for nid, node in workflow.items():
if not isinstance(node, dict):
continue
for name, val in (node.get("inputs") or {}).items():
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
out.setdefault(str(val[0]), []).append((str(nid), str(name)))
return out
def _feeds_negative(
node_id: str, consumers: dict[str, list[tuple[str, str]]], depth: int = 4
) -> bool:
"""True when this node's output reaches a sampler/guider ``negative`` input
and never a ``positive`` one.
A text encoder wired into both (one conditioning reused for positive and
negative) is treated as the positive prompt; only a negative-sounding
title makes it negative (see ``_is_negative``).
"""
frontier = [node_id]
seen = {node_id}
negative = False
for _ in range(depth):
nxt: list[str] = []
for nid in frontier:
for cid, name in consumers.get(nid, []):
if name == "positive":
return False
if name == "negative":
negative = True
continue
if cid not in seen:
seen.add(cid)
nxt.append(cid)
frontier = nxt
return negative
_FRAME_INPUTS = frozenset({"length", "num_frames", "frames", "video_length", "frame_count"})
_SECONDS_INPUTS = frozenset({"duration", "seconds", "duration_seconds"})
_EXPRESSION_HINTS = ("expression", "math")
def _duration_hint(
node_id: str,
workflow: dict[str, Any],
consumers: dict[str, list[tuple[str, str]]],
depth: int = 3,
) -> str | None:
"""How an untitled number feeds the video length downstream.
``"seconds"`` — it reaches a seconds input, or a frame-count input only
after passing through a math/expression node (seconds × fps → frames);
``"frames"`` — it is wired straight into a frame-count input.
"""
frontier = [(node_id, False)]
seen = {node_id}
for _ in range(depth):
nxt: list[tuple[str, bool]] = []
for nid, via_expr in frontier:
for cid, name in consumers.get(nid, []):
if name in _SECONDS_INPUTS:
return "seconds"
if name in _FRAME_INPUTS:
return "seconds" if via_expr else "frames"
if cid in seen:
continue
seen.add(cid)
ct = str((workflow.get(cid) or {}).get("class_type") or "").lower()
nxt.append((cid, via_expr or any(h in ct for h in _EXPRESSION_HINTS)))
frontier = nxt
return None
def _entry(
key: str,
label: str,
role: str | None,
kind: str,
default: Any,
required: bool,
**extra: Any,
) -> dict[str, Any]:
entry = {
"nodeId": key,
"label": label,
"role": role,
"kind": kind,
"defaultValue": default if _is_scalar(default) else None,
"required": required,
"auto": True,
}
entry.update({k: v for k, v in extra.items() if v is not None})
return entry
# ComfyUI's own widget limits (nodes.py / comfy_extras) for the widgets we
# expose; the studio validates against them before a job is queued. Seeds
# stop at 2**53-1 (ComfyUI allows 2**64-1): larger values lose precision in
# the browser's JSON numbers.
_NUMBER_LIMITS: dict[str, dict[str, Any]] = {
"seed": {"min": 0, "max": 2**53 - 1, "step": 1, "integer": True},
"steps": {"min": 1, "max": 10000, "step": 1, "integer": True},
"cfg": {"min": 0.0, "max": 100.0, "step": 0.1},
"denoise": {"min": 0.0, "max": 1.0, "step": 0.01},
"width": {"min": 16, "max": 16384, "step": 8, "integer": True},
"height": {"min": 16, "max": 16384, "step": 8, "integer": True},
"batch": {"min": 1, "max": 4096, "step": 1, "integer": True},
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
}
# Text encoders whose ``resolution`` widget sizes the generated image when an
# input image is wired (edit mode); in text-to-image it only sizes references.
_RESOLUTION_ENCODERS: dict[str, dict[str, Any]] = {
"TextEncodeQwenImage21": {"min": 0, "max": 4096, "step": 32, "integer": True},
}
# comfy_extras/nodes_resolution.py — option strings must match exactly.
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
"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),
}
def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int) -> tuple[int, int]:
"""Width/height exactly as ComfyUI's ResolutionSelector computes them."""
w_ratio, h_ratio = RESOLUTION_ASPECT_RATIOS[aspect_ratio]
scale = math.sqrt(megapixels * 1024 * 1024 / (w_ratio * h_ratio))
multiple = max(1, int(multiple))
return (
round(w_ratio * scale / multiple) * multiple,
round(h_ratio * scale / multiple) * multiple,
)
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list
# (values read from /object_info). The workflow wires ``<group>.<prefix>N``;
# extra images are cloned LoadImage nodes added at patch time (patcher.py).
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
"MiniMaxH3ReferenceToVideo": {
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
},
}
# Consumer input name → (role, label) of the LoadImage wired into it.
_FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
"first_frame": ("first_frame", "首帧"),
"start_image": ("first_frame", "首帧"),
"last_frame": ("last_frame", "尾帧"),
"end_image": ("last_frame", "尾帧"),
}
def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
return IMAGE_GROUPS.get(class_type, {}).get(group)
def image_group_slots(
node: dict[str, Any], group: str, prefix: str
) -> list[tuple[int, str, Any]]:
"""``[(N, input name, link), …]`` wired into ``<group>.<prefix>N``, by N."""
head = f"{group}.{prefix}"
out: list[tuple[int, str, Any]] = []
for name, val in (node.get("inputs") or {}).items():
if name.startswith(head) and name[len(head):].isdigit():
out.append((int(name[len(head):]), name, val))
return sorted(out)
def _image_groups(
workflow: dict[str, Any],
) -> tuple[list[dict[str, Any]], set[str]]:
"""Group entries for Autogrow reference inputs + the LoadImage ids they absorb."""
entries: list[dict[str, Any]] = []
absorbed: set[str] = set()
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
for group, spec in IMAGE_GROUPS.get(str(node.get("class_type") or ""), {}).items():
sources: list[str] = []
for _n, _name, link in image_group_slots(node, group, spec["prefix"]):
if not (isinstance(link, list) and len(link) == 2):
continue
src = workflow.get(str(link[0]))
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
sources.append(str(link[0]))
if not sources:
continue
absorbed.update(sources)
entries.append(
_entry(
f"{nid}{FIELD_KEY_SEP}{group}",
spec["label"],
"reference_image",
"image_list",
None,
True,
min=1,
max=spec["max"],
)
)
return entries, absorbed
def _image_slot(
nid: str, workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
) -> tuple[str, str] | None:
"""(role, label) of a LoadImage from what consumes it; None when unwired."""
uses = consumers.get(nid, [])
if not uses:
return None
for _cid, name in uses:
if name in _FRAME_SLOT_ROLES:
return _FRAME_SLOT_ROLES[name]
for cid, name in uses:
ct = str((workflow.get(cid) or {}).get("class_type") or "")
if "TextEncode" in ct and name.startswith("image"):
return "image", "输入图"
return "image", ""
def _key(node_id: str, node: dict[str, Any], field: str) -> str:
"""Plain nodeId when that already patches ``field``; else ``nodeId@field``."""
return (
str(node_id) if resolve_patch_field(node) == field else f"{node_id}{FIELD_KEY_SEP}{field}"
)
def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
"""Infer fillable inputs from node classes and wiring of an untagged workflow.
Only scalar widgets are exposed (linked inputs are wiring, not values).
The positive prompt is the only required field; everything else keeps the
workflow's own value when left blank.
"""
if not isinstance(workflow, dict):
return []
consumers = _consumers(workflow)
nodes = sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
)
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
groups, grouped_images = _image_groups(workflow)
media: list[dict[str, Any]] = []
numbers: list[dict[str, Any]] = []
taken_roles: set[str] = set()
def add_number(nid: str, node: dict[str, Any], field: str, role: str, label: str) -> None:
if role in taken_roles:
return
value = (node.get("inputs") or {}).get(field)
if not _is_scalar(value) or isinstance(value, str):
return
taken_roles.add(role)
numbers.append(
_entry(
_key(nid, node, field), label, role, "number", value, False,
**_NUMBER_LIMITS.get(role, {}),
)
)
for nid, node in nodes:
ct = str(node.get("class_type") or "")
inputs = node.get("inputs") or {}
title = _title_of(node)
lowered = title.lower()
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
if nid in grouped_images:
continue
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
slot = _image_slot(nid, workflow, consumers)
if is_mask:
label = title if title and title != ct else "遮罩"
if "mask" not in label.lower() and "遮罩" not in label:
label = f"遮罩 · {label}"
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
media.append(
_entry(_key(nid, node, "image"), label, "mask", "image", None, not has_default)
)
continue
if slot is None:
# Nothing consumes it — ComfyUI never runs it, so no upload slot.
continue
role, label = slot
media.append(
_entry(
_key(nid, node, "image"),
label or (title if title and title != ct else "参考图"),
role,
"image",
None,
True,
)
)
continue
if ct in VIDEO_CLASSES or ct in AUDIO_CLASSES:
kind = "video" if ct in VIDEO_CLASSES else "audio"
field = resolve_patch_field(node)
has_default = isinstance(inputs.get(field), str) and bool(inputs.get(field))
media.append(_entry(str(nid), title or ct, kind, kind, None, not has_default))
continue
if ct in _STRING_PRIMITIVES and isinstance(inputs.get("value"), str):
texts.append((nid, node, "value", _is_negative(nid, lowered, consumers)))
continue
if ct == "CLIPTextEncode" or "TextEncode" in ct:
field = next((f for f in ("text", "prompt") if isinstance(inputs.get(f), str)), None)
neg_field = next(
(f for f in _NEGATIVE_TEXT_FIELDS if isinstance(inputs.get(f), str)), None
)
if field and neg_field:
# One encoder carrying both prompts (TextEncodeQwenImage21…):
# its outputs feed positive *and* negative, so wiring says nothing.
texts.append((nid, node, field, False))
texts.append((nid, node, neg_field, True))
elif field:
texts.append((nid, node, field, _is_negative(nid, lowered, consumers)))
edits_image = any(
k.startswith("images.") and isinstance(v, list) for k, v in inputs.items()
)
if ct in _RESOLUTION_ENCODERS and edits_image and "resolution" not in taken_roles:
value = inputs.get("resolution")
if _is_scalar(value) and not isinstance(value, str):
taken_roles.add("resolution")
numbers.append(
_entry(
_key(nid, node, "resolution"),
"输出分辨率",
"resolution",
"number",
value,
False,
**_RESOLUTION_ENCODERS[ct],
)
)
continue
if ct in _SAMPLER_CLASSES:
add_number(nid, node, "seed" if "seed" in inputs else "noise_seed", "seed", "随机种子")
add_number(nid, node, "steps", "steps", "采样步数")
add_number(nid, node, "cfg", "cfg", "CFG")
add_number(nid, node, "denoise", "denoise", "降噪强度")
continue
if ct == "RandomNoise":
add_number(nid, node, "noise_seed", "seed", "随机种子")
continue
if ct == "BasicScheduler":
add_number(nid, node, "steps", "steps", "采样步数")
add_number(nid, node, "denoise", "denoise", "降噪强度")
continue
if ct == "ResolutionSelector" and nid in consumers:
ratio = inputs.get("aspect_ratio")
if (
"aspect_ratio" not in taken_roles
and isinstance(ratio, str)
and ratio in RESOLUTION_ASPECT_RATIOS
):
taken_roles.add("aspect_ratio")
multiple = inputs.get("multiple")
numbers.append(
_entry(
_key(nid, node, "aspect_ratio"),
"画面比例",
"aspect_ratio",
"select",
ratio,
False,
options=list(RESOLUTION_ASPECT_RATIOS),
resolution={
"megapixels": _key(nid, node, "megapixels"),
"multiple": multiple if isinstance(multiple, int) else 8,
},
)
)
add_number(nid, node, "megapixels", "megapixels", "总像素(百万)")
continue
if ct == "CFGGuider":
add_number(nid, node, "cfg", "cfg", "CFG")
continue
if ct.startswith("Empty") and "Latent" in ct:
if nid not in consumers:
# Orphan latent (Qwen edit sizes from its encoder): editing it changes nothing.
continue
add_number(nid, node, "width", "width", "宽度")
add_number(nid, node, "height", "height", "高度")
add_number(nid, node, "batch_size", "batch", "批次数量")
continue
if ct in _NUMBER_PRIMITIVES and _is_scalar(inputs.get("value")):
role, label = None, title if title and title != ct else f"{ct} #{nid}"
for words, r, zh in _NUMBER_TITLE_ROLES:
if any(w in lowered for w in words):
role, label = r, (title if title and title != ct else zh)
break
if role is None and (not title or title == ct):
hint = _duration_hint(nid, workflow, consumers)
if hint == "seconds":
role, label = "duration", "时长(秒)"
elif hint == "frames":
label = "视频帧数"
if role and role in taken_roles:
role = None
if role:
taken_roles.add(role)
limits = dict(_NUMBER_LIMITS.get(role or "", {}))
if ct in ("PrimitiveInt", "INT"):
limits["integer"] = True
if role == "duration":
limits.setdefault("min", 0.1)
numbers.append(
_entry(
_key(nid, node, "value"), label, role, "number", inputs["value"], False,
**limits,
)
)
results: list[dict[str, Any]] = []
positives = [t for t in texts if not t[3]]
negatives = [t for t in texts if t[3]]
def rank(t: tuple[str, dict[str, Any], str, bool]) -> tuple[int, int]:
title = _title_of(t[1]).lower()
system = any(w in title for w in _SYSTEM_WORDS)
named = any(w in title for w in _PROMPT_WORDS)
return (1 if system else 0, 0 if named else 1)
positives.sort(key=rank)
for i, (nid, node, field, _neg) in enumerate(positives):
title = _title_of(node)
if i == 0:
results.append(
_entry(
_key(nid, node, field),
"正向提示词",
"prompt",
"textarea",
node["inputs"][field],
True,
)
)
else:
label = title if title and title != node.get("class_type") else f"文本 #{nid}"
results.append(
_entry(
_key(nid, node, field), label, None, "textarea", node["inputs"][field], False
)
)
for i, (nid, node, field, _neg) in enumerate(negatives):
role = "negative" if i == 0 else None
label = "反向提示词" if i == 0 else (_title_of(node) or f"文本 #{nid}")
results.append(
_entry(_key(nid, node, field), label, role, "textarea", node["inputs"][field], False)
)
results.extend(numbers)
results.extend(media)
results.extend(groups)
return results
def _is_negative(
node_id: str, lowered_title: str, consumers: dict[str, list[tuple[str, str]]]
) -> bool:
if any(w in lowered_title for w in _NEGATIVE_WORDS):
return True
return _feeds_negative(node_id, consumers)
def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
for node_id, node in workflow.items():
@@ -1,10 +1,15 @@
"""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 IMAGE_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 = "@"
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
@@ -26,6 +31,77 @@ 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_image_group(
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
) -> None:
"""Rewire an Autogrow image group to exactly ``paths``, in order.
``<group>.<prefix>0 … N-1`` each get their own LoadImage: 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 image reaches ComfyUI.
"""
from calliope.comfyui.parser import image_group_slots, image_group_spec
node = workflow.get(node_id)
if not isinstance(node, dict):
return
spec = image_group_spec(str(node.get("class_type") or ""), group)
if spec is None:
return
prefix = spec["prefix"]
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 src.get("class_type") in IMAGE_CLASSES:
loaders.append(str(link[0]))
if not loaders:
return
template = copy.deepcopy(workflow[loaders[0]])
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 {})
loader_inputs["image"] = path
loader["inputs"] = loader_inputs
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{spec['label']} {i + 1}"}
workflow[nid] = loader
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
node["inputs"] = inputs
def patch_workflow(
base: dict[str, Any],
values_by_node_id: dict[str, Any],
@@ -35,11 +111,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_image_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
@@ -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`.
@@ -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,127 @@
"""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 == "image_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()]
hi = entry.get("max")
if hi is not None and len(paths) > hi:
errors.append(f"{label}最多 {hi} 张(当前 {len(paths)} 张),请删除多余的图片")
continue
bad = [
f"{label} {i}:{msg}"
for i, p in enumerate(paths, 1)
if (msg := _image_file_problem(p))
]
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") == "image_list":
lo = entry.get("min") or 1
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
errors.append(f"需要至少 {lo} 张{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"})
def _image_file_problem(path: str) -> str | None:
"""Why ``path`` cannot be a studio reference image (None = fine)."""
from calliope import config
target = Path(path)
if target.suffix.lower() not in _IMAGE_EXTS:
return "格式不支持,请使用 PNG / JPG / WebP"
try:
target.resolve().relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return "只能使用在本页上传或从生成结果选入的图片"
if not target.is_file():
return "文件已不存在,请重新上传"
return None
+188 -1
View File
@@ -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,
+93 -1
View File
@@ -138,7 +138,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 +149,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 +285,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);
"""
@@ -319,6 +378,11 @@ 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")
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 +422,13 @@ 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")
# 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 +438,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")
+4
View File
@@ -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):
@@ -200,6 +204,8 @@ class WorkflowCreate(BaseModel):
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
class WorkflowUpdate(BaseModel):
@@ -208,6 +214,7 @@ 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
class WorkflowAnalyze(BaseModel):
+48 -3
View File
@@ -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
@@ -38,12 +39,13 @@ class QueueManager:
) -> 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', ?)
""",
(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)),
)
conn.commit()
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
@@ -51,6 +53,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 +106,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 +157,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,),
)
+140 -11
View File
@@ -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
@@ -22,6 +25,28 @@ from calliope.queue.manager import queue_manager
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 +104,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 +116,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)",
},
)
@@ -151,22 +180,30 @@ 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:
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")
raise RuntimeError(
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
)
broken = blocking_problem(workflow)
if broken:
raise RuntimeError(broken)
input_values = payload.get("input_values") or {}
if payload.get("continue_source") and kind == "video":
@@ -176,19 +213,42 @@ 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)
saved = [m for m in outputs_meta if m.get("type") == "output"]
outputs_meta = saved or outputs_meta
if not outputs_meta:
raise RuntimeError(
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
"不算生成完成。请确认工作流带有保存/预览节点,并到 ComfyUI 历史中核对该任务。"
)
queue_manager.set_phase(job["id"], "saving")
dest_dir = config.settings.assets_dir / str(project_id) / kind
dest_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
@@ -204,10 +264,42 @@ class QueueWorker:
paths.append(str(dest))
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 +404,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 +417,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:
+198 -10
View File
@@ -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,360 @@
"""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 地址不能为空")
if "://" not in url:
url = f"http://{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)
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)
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 {}),
}
+12 -2
View File
@@ -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,9 @@ 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.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 +26,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):
@@ -323,27 +335,47 @@ 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 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"])
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)
@@ -1,7 +1,9 @@
from __future__ import annotations
import time
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
@@ -11,6 +13,9 @@ 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):
id: str | None = None
@@ -20,6 +25,18 @@ class LlmProfileIn(BaseModel):
api_key: str | None = None
class LlmTestIn(BaseModel):
profile_id: str | None = None
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):
llm_base_url: str | None = None
llm_model: str | None = None
@@ -28,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
@@ -55,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"}:
@@ -104,5 +124,92 @@ 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()
@router.post("/llm-test")
async def test_llm_connection(payload: LlmTestIn) -> dict[str, Any]:
settings.ensure_llm_profiles()
base_url = (payload.base_url or "").strip()
model = (payload.model or "").strip()
api_key = (payload.api_key or "").strip() or None
if payload.profile_id:
profile = next(
(p for p in settings.llm_profiles if p.get("id") == payload.profile_id), None
)
if profile is None:
raise HTTPException(status_code=404, detail="Unknown LLM profile")
if not base_url:
base_url = str(profile.get("base_url") or "").strip()
if not model:
model = str(profile.get("model") or "").strip()
if api_key is None:
key = profile.get("api_key")
if isinstance(key, str) and key.strip():
api_key = key
if not base_url or not model:
raise HTTPException(status_code=400, detail="base_url and model are required")
url = f"{base_url.rstrip('/')}/chat/completions"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1,
"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=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 {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__}
elapsed_ms = int((time.perf_counter() - started) * 1000)
if resp.status_code in (401, 403):
return {"ok": False, "error": "auth", "detail": f"HTTP {resp.status_code}"}
if resp.status_code == 404:
return {
"ok": False,
"error": "not_found",
"detail": "HTTP 404 — check base URL (OpenAI-compatible servers usually end with /v1)",
}
if resp.status_code >= 400:
text = resp.text[:200].strip().replace("\n", " ")
return {"ok": False, "error": "http", "detail": f"HTTP {resp.status_code}: {text}"}
try:
data = resp.json()
except ValueError:
return {"ok": False, "error": "http", "detail": "response is not JSON"}
if not (isinstance(data, dict) and data.get("choices")):
return {"ok": False, "error": "http", "detail": "response has no choices"}
return {"ok": True, "elapsed_ms": elapsed_ms}
@@ -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,7 +5,12 @@ from typing import Any
from fastapi import APIRouter, HTTPException
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.parser import (
effective_input_schema,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.profiles import detect_prompt_profile
from calliope.config import settings
from calliope.db import get_db, row_to_dict
@@ -17,9 +22,11 @@ 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 []
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
data["is_enabled"] = bool(data.get("is_enabled"))
data["warnings"] = workflow_warnings(data["workflow_json"])
return data
@@ -31,6 +38,7 @@ async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
"inputs": inputs,
"outputs": outputs,
"suggested_profile": detect_prompt_profile(payload.workflow_json),
"warnings": workflow_warnings(payload.workflow_json),
}
@@ -129,8 +137,9 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
""",
(
payload.name,
@@ -140,6 +149,7 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
json.dumps(outputs),
payload.description,
profile,
payload.endpoint_id,
),
)
conn.commit()
@@ -171,7 +181,9 @@ 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}
# endpoint_id: explicit null clears the binding; other Nones are skips.
explicit_null = {"endpoint_id"} & {k for k, v in data.items() if v is None}
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
if data:
fields = ", ".join(f"{k} = :{k}" for k in data)
data["id"] = workflow_id
@@ -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"
}
}
}
@@ -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采样器"
}
}
}
+202
View File
@@ -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,562 @@
"""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
# ----------------------------------------------------------------------
# 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]},
},
}
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_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 == []
@@ -0,0 +1,260 @@
"""LLM connection test endpoint: probe an OpenAI-compatible endpoint without paid keys."""
from __future__ import annotations
import json
import socket
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from calliope.config import settings
class _FakeOpenAIHandler(BaseHTTPRequestHandler):
server_version = "FakeOpenAI/1.0"
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(length) or b"{}")
auth = self.headers.get("Authorization") or ""
seen = {"path": self.path, "auth": auth, "body": body}
self.server.requests.append(seen)
if self.path != "/v1/chat/completions":
self._send(404, {"error": "not found"})
return
if self.server.require_auth and auth != f"Bearer {self.server.api_key}":
self._send(401, {"error": {"message": "Incorrect API key"}})
return
self._send(
200,
{
"id": "chatcmpl-test",
"object": "chat.completion",
"model": body.get("model", "fake"),
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "pong"}, "finish_reason": "stop"}
],
},
)
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 log_message(self, format, *args):
pass
@pytest.fixture
def fake_openai():
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeOpenAIHandler)
server.requests = []
server.require_auth = False
server.api_key = ""
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]}/v1"
def _snapshot() -> dict:
return {
"llm_profiles": [dict(p) for p in (settings.llm_profiles or [])],
"llm_active_id": settings.llm_active_id,
"llm_base_url": settings.llm_base_url,
"llm_model": settings.llm_model,
"llm_api_key": settings.llm_api_key,
}
def _restore(prev: dict) -> None:
for key, value in prev.items():
setattr(settings, key, value)
def test_llm_test_success(client, fake_openai):
r = client.post(
"/api/settings/llm-test",
json={"base_url": _url(fake_openai), "model": "fake-model", "api_key": "sk-test"},
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert isinstance(body["elapsed_ms"], int)
assert fake_openai.requests[0]["path"] == "/v1/chat/completions"
assert fake_openai.requests[0]["auth"] == "Bearer sk-test"
assert fake_openai.requests[0]["body"]["model"] == "fake-model"
def test_llm_test_uses_saved_profile_secret(client, fake_openai):
prev = _snapshot()
fake_openai.require_auth = True
fake_openai.api_key = "sk-saved-secret"
try:
pid = "cccccccc-cccc-cccc-cccc-cccccccccccc"
r = client.post(
"/api/settings",
json={
"llm_profiles": [
{
"id": pid,
"name": "Fake",
"base_url": _url(fake_openai),
"model": "fake-model",
"api_key": "sk-saved-secret",
}
],
"llm_active_id": pid,
},
)
assert r.status_code == 200
assert r.json()["llm_profiles"][0]["api_key"] is True
r = client.post("/api/settings/llm-test", json={"profile_id": pid})
assert r.status_code == 200
assert r.json()["ok"] is True
assert fake_openai.requests[-1]["auth"] == "Bearer sk-saved-secret"
finally:
_restore(prev)
def test_llm_test_auth_rejected(client, fake_openai):
fake_openai.require_auth = True
fake_openai.api_key = "sk-right-one"
r = client.post(
"/api/settings/llm-test",
json={"base_url": _url(fake_openai), "model": "fake-model", "api_key": "sk-wrong-one"},
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert body["error"] == "auth"
assert "401" in body["detail"]
def test_llm_test_connection_refused(client):
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
r = client.post(
"/api/settings/llm-test",
json={"base_url": f"http://127.0.0.1:{port}/v1", "model": "fake-model"},
)
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert body["error"] == "connect"
assert body["detail"]
def test_llm_test_validation(client):
r = client.post("/api/settings/llm-test", json={"model": "only-model"})
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
+2 -1
View File
@@ -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
+4
View File
@@ -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
+220
View File
@@ -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,363 @@
"""独立生图/生视频 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."""
server_version = "FakeComfy/1.0"
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.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"}]
}
self.server.history[prompt_id] = {
"status": {"status_str": "success", "completed": True},
"outputs": {"9": out_entry},
"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 _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"
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,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"]
+421
View File
@@ -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
+1 -1
View File
@@ -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 ."
},
+172
View File
@@ -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.");
+2 -2
View File
@@ -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%
+1068 -574
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
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'];
/** Why this file cannot fill an image slot (null = fine). */
export function imageFileProblem(file: { name: string; size: number }): string | null {
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
if (!IMAGE_EXTS.includes(ext)) return t('studio.errImageFormat', { name: file.name });
if (file.size > IMAGE_SLOT_MAX_BYTES) return t('studio.errImageSize', { name: file.name });
return null;
}
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;
}
+1 -1
View File
@@ -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;
+41
View File
@@ -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;
}
+37 -1
View File
@@ -1,6 +1,24 @@
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';
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,6 +27,17 @@ 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 };
}
export interface ComfyDynamicOutput {
@@ -35,6 +64,9 @@ export interface Workflow {
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
}
export interface Job {
@@ -53,6 +85,10 @@ 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;
}
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"
@@ -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,525 @@
<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' || 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-progress', 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: rgba(22, 19, 10, 0.82);
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: rgba(13, 14, 17, 0.78);
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(--accent);
}
.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-draft {
background: var(--accent);
}
.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(--accent-dim);
color: var(--accent);
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: rgba(13, 14, 17, 0.6);
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;
}
/* 进行数徽章 (spec §3): accent-dim capsule with amber count */
.nav-badge {
margin-left: auto;
min-width: 20px;
padding: 1px 7px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
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);
}
/* §5 连接状态点: green = probe passed, amber 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(--success);
}
.dot.fail {
background: var(--error);
}
.dot.testing {
background: var(--accent);
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: rgba(229, 72, 77, 0.65);
}
.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,446 @@
<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';
/**
* Reference-image grid (ref-upload-spec §2 `.refgrid`): 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;
/** 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,
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>>({});
const uploading = $derived(!!uploadingName);
const remaining = $derived(Math.max(0, max - paths.length));
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], 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">{t('studio.refHint', { n: paths.length, max })}</span>
</div>
<input
bind:this={addInput}
type="file"
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={IMAGE_SLOT_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={IMAGE_SLOT_ACCEPT}
onchange={(e) => {
const f = e.currentTarget.files?.[0];
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
}}
/>
<div class="refgrid">
{#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)}
>
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
<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={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">{t('studio.refAdd')}</span>
<span class="add-sub mono">{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">{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;
}
.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
@@ -5,6 +5,8 @@
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
/** Stretch to the container width (e.g. a panel's main action). */
block?: boolean;
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
@@ -16,6 +18,7 @@
let {
variant = 'secondary',
size = 'md',
block = false,
disabled = false,
loading = false,
type = 'button',
@@ -27,7 +30,9 @@
<button
{type}
class="btn btn-{variant} btn-{size}"
class="a-btn btn {variant}"
class:sm={size === 'sm'}
class:block
class:loading
disabled={disabled || loading}
{title}
@@ -44,86 +49,13 @@
</button>
<style>
/* Look comes from the shared `.a-btn` rules in app.css (§5). */
.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 +71,11 @@
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: rgba(22, 19, 10, 0.3);
--spinner-arc: var(--accent-contrast);
}
.btn.danger .btn-spinner {
--spinner-arc: var(--error);
}
</style>
@@ -4,6 +4,10 @@
indeterminate?: boolean;
label?: string;
size?: 'sm' | 'md';
/** §5 进度条: running = amber, done = green, failed = red (static). */
tone?: 'accent' | 'success' | 'error';
/** Track only — no label / percentage row. */
bare?: boolean;
}
let {
@@ -11,13 +15,15 @@
indeterminate = false,
label,
size = 'md',
tone = 'accent',
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}
@@ -69,7 +75,7 @@
position: relative;
width: 100%;
overflow: hidden;
background: var(--bg-elevated);
background: var(--surface-3);
border-radius: 999px;
}
.progress-sm .track {
@@ -82,7 +88,13 @@
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 250ms ease;
transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.tone-success .fill {
background: var(--success);
}
.tone-error .fill {
background: var(--error);
}
.fill.indeterminate {
position: absolute;
+5 -2
View File
@@ -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} />
@@ -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>
<!-- §5 开关: 34×19 capsule; on = amber track + dark knob, off = --surface-3. -->
<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: 999px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
position: relative;
cursor: pointer;
padding: 0;
transition:
background-color 150ms,
border-color 150ms;
}
.switch:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.knob {
position: absolute;
top: 2px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 999px;
background: var(--text-3);
transition:
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 150ms;
}
.switch.on {
background: var(--accent);
border-color: var(--accent);
}
.switch.on .knob {
transform: translateX(15px);
background: var(--accent-contrast);
}
</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: rgba(232, 163, 61, 0.6);
}
.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: rgba(255, 255, 255, 0.24);
}
.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(--accent);
background: var(--accent-dim);
}
.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>
@@ -0,0 +1,102 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/**
* Mono prompt card (spec §4.3 提示词卡): dark bg, strong border, header
* label + copy action, horizontally scrollable mono body.
*/
interface Props {
label: string;
text: string;
copyKey?: string;
}
let { label, text, copyKey = 'workbench.copied' }: Props = $props();
async function copy() {
try {
await navigator.clipboard.writeText(text);
toast.success(t(copyKey));
} catch {
toast.error(t('workbench.copyFailed'));
}
}
</script>
<div class="prompt-card">
<div class="prompt-head">
<span class="prompt-label">{label}</span>
<button type="button" class="copy-btn" onclick={copy}>
<Icon name="copy" size={12} />
{t('workbench.copy')}
</button>
</div>
<pre class="prompt-body">{text}</pre>
</div>
<style>
.prompt-card {
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
overflow: hidden;
min-width: 0;
}
.prompt-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.prompt-label {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
color: var(--accent);
font-size: 10.5px;
font-family: inherit;
cursor: pointer;
flex-shrink: 0;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms;
}
.copy-btn:hover {
border-color: var(--accent);
}
.prompt-body {
margin: 0;
padding: 9px 10px;
font-family: var(--font-a-mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
font-variant-numeric: tabular-nums;
max-height: 220px;
overflow: auto;
}
</style>
@@ -0,0 +1,307 @@
<script lang="ts">
import type { WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import PromptBlock from './PromptBlock.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
projectIdea?: string | null;
onSave?: (content: Record<string, unknown>) => void;
onImport?: (text: string) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, projectIdea = null, onSave, onImport, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state('');
let importText = $state('');
let importOpen = $state(false);
const text = $derived(
typeof (stage.content as { text?: unknown } | null)?.text === 'string'
? (stage.content as unknown as { text: string }).text
: '',
);
const charCount = $derived(text.replace(/\s/g, '').length);
function startEdit() {
draft = text;
editing = true;
}
function save() {
const value = draft.trim();
if (value.length < 30) return;
onSave?.({ text: value });
editing = false;
}
function doImport() {
const value = importText.trim();
if (value.length < 30) return;
onImport?.(value);
importOpen = false;
importText = '';
}
</script>
{#if stage.status === 'todo' && !text}
<div class="todo-card">
<p class="todo-title">{t('workbench.script.todoTitle')}</p>
{#if projectIdea}
<p class="todo-idea">{t('workbench.script.todoIdea')}</p>
<blockquote class="idea-quote">{projectIdea}</blockquote>
{/if}
<div class="todo-actions">
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.script.generate')}
</button>
<button type="button" class="btn ghost" onclick={() => (importOpen = !importOpen)}>
<Icon name="upload" size={14} />
{t('workbench.script.importBtn')}
</button>
<button type="button" class="btn ghost" onclick={startEdit}>
<Icon name="edit" size={14} />
{t('workbench.script.writeManually')}
</button>
</div>
{#if importOpen}
<div class="import-box">
<textarea
class="wb-input"
rows="8"
bind:value={importText}
placeholder={t('workbench.script.importPlaceholder')}
></textarea>
<div class="import-foot">
<span class="hint">{t('workbench.script.importHint')}</span>
<button
type="button"
class="btn primary sm"
disabled={importText.trim().length < 30}
onclick={doImport}
>
{t('workbench.script.importConfirm')}
</button>
</div>
</div>
{/if}
</div>
{:else if editing}
<div class="editor">
<textarea
class="wb-input mono-area"
rows="18"
bind:value={draft}
data-testid="script-editor"
></textarea>
<div class="editor-foot">
<span class="hint">{t('workbench.script.editorHint')}</span>
<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.trim().length < 30}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else}
<article class="script-text" data-testid="script-view">{text}</article>
<div class="view-foot">
<span class="char-count mono">{t('workbench.charCount', { n: charCount })}</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?.()}
title={t('workbench.regenerateHint')}
>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
<PromptBlock label={t('workbench.script.copyLabel')} {text} />
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
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-idea {
margin: 0;
font-size: 12px;
color: var(--text-2);
}
.idea-quote {
margin: 0;
padding: 10px 12px;
border-left: 2px solid var(--accent);
background: var(--surface-2);
border-radius: 0 8px 8px 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
white-space: pre-wrap;
max-height: 180px;
overflow: auto;
}
.todo-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.import-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.script-text {
padding: 18px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 13.5px;
line-height: 1.8;
color: var(--text);
white-space: pre-wrap;
max-height: 460px;
overflow: auto;
}
.editor {
display: flex;
flex-direction: column;
gap: 10px;
}
.mono-area {
min-height: 320px;
resize: vertical;
line-height: 1.8;
}
.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;
}
.char-count {
font-size: 11px;
color: var(--text-3);
}
.foot-btns {
display: flex;
gap: 8px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 13px;
line-height: 1.7;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.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: rgba(255, 255, 255, 0.24);
}
.btn.sm {
padding: 6px 12px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,425 @@
<script lang="ts">
import type { VideoShot, 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;
durationSec?: number;
}) => void;
onRetryJob?: (jobId: number) => void;
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<VideoShot[]>([]);
const shots = $derived(
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
? ((stage.content as unknown as { shots: VideoShot[] }).shots ?? [])
: [],
);
function startEdit() {
draft = shots.map((s) => ({ ...s }));
editing = true;
}
function save() {
const clean = draft.filter((s) => s.prompt.trim());
if (!clean.length) return;
onSave?.({ shots: clean });
editing = false;
}
function addShot() {
draft = [
...draft,
{
id: `vshot-new-${draft.length + 1}-${Date.now() % 1000}`,
shot_no: `S${draft.length + 1}`,
description: '',
prompt: '',
duration_sec: 5,
},
];
}
function removeShot(id: string) {
draft = draft.filter((s) => s.id !== id);
}
function jobActive(job: VideoShot['job']): boolean {
return !!job && (job.status === 'pending' || job.status === 'running');
}
</script>
{#if editing}
<div class="editor">
<div class="edit-list">
{#each draft as shot (shot.id)}
<div class="vshot-edit">
<div class="vshot-edit-head">
<input class="wb-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
<input
class="wb-input w-dur mono"
type="number"
min="1"
step="1"
bind:value={shot.duration_sec}
placeholder={t('workbench.board.duration')}
/>
<button
type="button"
class="icon-btn danger"
title={t('workbench.vshot.remove')}
onclick={() => removeShot(shot.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
<textarea
class="wb-input mono"
rows="3"
bind:value={shot.prompt}
placeholder={t('workbench.vshot.promptPh')}
></textarea>
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.vshot.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((s) => s.prompt.trim())}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if shots.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.vshot.todoTitle')}</p>
<p class="todo-body">{t('workbench.vshot.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.vshot.generate')}
</button>
</div>
{:else}
<div class="vshot-list" data-testid="vshot-list">
{#each shots as shot (shot.id)}
<div class="vshot-card">
<div class="vshot-head">
<span class="vshot-no mono">{shot.shot_no}</span>
<span class="vshot-dur mono">{shot.duration_sec}s</span>
{#if shot.job}
<JobChip job={shot.job} />
{#if shot.job.status === 'failed'}
<button
type="button"
class="retry-link"
onclick={() => onRetryJob?.(shot.job!.id)}
>
{t('workbench.retryJob')}
</button>
{/if}
{/if}
</div>
{#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
{#if shot.job?.status === 'done' && shot.job.output}
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
controls
preload="metadata"
></video>
{/if}
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
<div class="vshot-foot">
<button
type="button"
class="btn ghost gen"
disabled={busy || jobActive(shot.job)}
title={t('workbench.vshot.generateHint')}
onclick={() =>
onGenerate?.({
kind: 'video',
targetId: shot.id,
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
>
<Icon name="film" size={13} />
{jobActive(shot.job) ? t('workbench.generating') : t('workbench.vshot.generateVideo')}
</button>
</div>
</div>
{/each}
</div>
<div class="view-foot">
<span class="hint">{t('workbench.vshot.summary', { n: shots.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);
}
.vshot-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.vshot-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.vshot-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.vshot-no {
font-size: 13px;
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.vshot-dur {
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.vshot-desc {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.vshot-video {
width: 100%;
max-height: 300px;
border-radius: 8px;
border: 1px solid var(--border);
background: #000;
}
.vshot-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;
}
.vshot-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.vshot-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: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.w-no {
width: 90px;
flex-shrink: 0;
}
.wb-input.w-dur {
width: 110px;
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;
margin-left: auto;
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: rgba(255, 255, 255, 0.24);
}
.btn.gen {
color: var(--accent);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,231 @@
<script lang="ts">
import type { WorkbenchStage } from '$lib/api';
import { t } from '$lib/i18n.svelte';
/**
* Five-stage stepper (a2): one step per agent with the spec §2.1 semantic
* colors — gray=待开始, amber=进行中/待确认 (+breath while running),
* green=已确认, blue=用户改过草稿. A step unlocks when itself or any
* earlier stage has content; later steps stay clickable only after the
* previous one is confirmed.
*/
interface Props {
stages: WorkbenchStage[];
activeKey: string;
onSelect: (key: string) => void;
}
let { stages, activeKey, onSelect }: Props = $props();
const ORDER = ['script', 'style', 'storyboard', 'assets', 'shot'];
function label(key: string): string {
return t(`workbench.stage.${key}`);
}
function charOf(key: string): string {
const map: Record<string, string> = {
script: '剧',
style: '视',
storyboard: '分',
assets: '素',
shot: '镜',
};
return map[key] ?? key;
}
function avatarVar(key: string): string {
const idx = ORDER.indexOf(key) + 1;
return `var(--avatar-${idx})`;
}
function stateOf(stage: WorkbenchStage): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
if (stage.status === 'running') return 'running';
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
if (stage.status === 'awaiting_confirm') return stage.draft_edited ? 'edited' : 'draft';
return 'todo';
}
function stateLabel(stage: WorkbenchStage): string {
const s = stateOf(stage);
if (s === 'running') return t('workbench.state.running');
if (s === 'confirmed') return t('workbench.state.confirmed');
if (s === 'edited') return t('workbench.state.edited');
if (s === 'draft') return t('workbench.state.draft');
return t('workbench.state.todo');
}
function unlocked(stages: WorkbenchStage[], key: string): boolean {
const idx = ORDER.indexOf(key);
if (idx <= 0) return true;
const prev = stages.find((s) => s.stage_key === ORDER[idx - 1]);
return prev?.status === 'confirmed';
}
</script>
<ol class="stepper" data-testid="workbench-stepper">
{#each ORDER as key, i}
{@const stage = stages.find((s) => s.stage_key === key)}
{@const state = stage ? stateOf(stage) : 'todo'}
{@const open = stage ? unlocked(stages, key) : i === 0}
<li class="step">
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}></span>{/if}
<button
type="button"
class="step-btn"
class:active={activeKey === key}
class:locked={!open}
data-stage={key}
data-state={state}
onclick={() => onSelect(key)}
aria-current={activeKey === key ? 'step' : undefined}
title={open ? label(key) : t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
>
<span class="avatar" style:background={avatarVar(key)}>{charOf(key)}</span>
<span class="step-copy">
<span class="step-name">{label(key)}</span>
<span class="step-state st-{state}">
{#if state === 'running'}<span class="breath" aria-hidden="true"></span>{/if}
{stateLabel(stage!)}
</span>
</span>
</button>
</li>
{/each}
</ol>
<style>
.stepper {
display: flex;
align-items: stretch;
gap: 0;
list-style: none;
margin: 0;
padding: 12px 0 4px;
overflow-x: auto;
scrollbar-width: thin;
}
.step {
display: flex;
align-items: center;
flex-shrink: 0;
}
.bar {
width: 26px;
height: 2px;
background: var(--surface-3);
border-radius: 1px;
}
.bar-done {
background: var(--success);
}
.step-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
cursor: pointer;
font-family: inherit;
text-align: left;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms;
}
.step-btn:hover:not(.locked) {
background: var(--surface-3);
}
.step-btn.active {
background: var(--surface-2);
border-color: var(--border-strong);
}
.step-btn.locked {
opacity: 0.55;
cursor: not-allowed;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #edeef0;
flex-shrink: 0;
}
.step-copy {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 58px;
}
.step-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.step-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
white-space: nowrap;
}
.st-todo {
color: var(--text-3);
}
.st-running,
.st-draft {
color: var(--accent);
}
.st-edited {
color: var(--info);
}
.st-confirmed {
color: var(--success);
}
.step-btn.active[data-state='running'] .avatar,
.step-btn[data-state='running'] .avatar {
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
}
.breath {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes wb-breath {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.breath {
animation: none;
opacity: 0.8;
}
}
</style>
@@ -0,0 +1,395 @@
<script lang="ts">
import type { StoryboardShot, WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, onSave, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<StoryboardShot[]>([]);
const shots = $derived(
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
? ((stage.content as unknown as { shots: StoryboardShot[] }).shots ?? [])
: [],
);
const totalDuration = $derived(shots.reduce((acc, s) => acc + (s.duration_sec || 0), 0));
function startEdit() {
draft = shots.map((s) => ({ ...s }));
editing = true;
}
function save() {
const clean = draft.filter((s) => s.description.trim());
if (!clean.length) return;
onSave?.({ shots: clean });
editing = false;
}
function addShot() {
draft = [
...draft,
{
id: `shot-new-${draft.length + 1}-${Date.now() % 1000}`,
scene: `${draft.length + 1}`,
shot_size: '中景',
camera: '固定',
description: '',
dialog: '',
duration_sec: 3,
},
];
}
function removeShot(id: string) {
draft = draft.filter((s) => s.id !== id);
}
</script>
{#if editing}
<div class="editor">
<div class="edit-list">
{#each draft as shot, i (shot.id)}
<div class="shot-edit">
<div class="shot-edit-head">
<span class="shot-index mono">{String(i + 1).padStart(2, '0')}</span>
<button
type="button"
class="icon-btn danger"
title={t('workbench.board.remove')}
onclick={() => removeShot(shot.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<div class="shot-fields">
<input class="wb-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
<input class="wb-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
<input class="wb-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
<input
class="wb-input w-xs mono"
type="number"
min="0.5"
step="0.5"
bind:value={shot.duration_sec}
placeholder={t('workbench.board.duration')}
/>
</div>
<textarea
class="wb-input"
rows="2"
bind:value={shot.description}
placeholder={t('workbench.board.descPh')}
></textarea>
<input class="wb-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.board.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((s) => s.description.trim())}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if shots.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
<p class="todo-body">{t('workbench.board.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.board.generate')}
</button>
</div>
{:else}
<div class="board-table" data-testid="storyboard-table">
<div class="bt-head">
<span class="col-no">#</span>
<span class="col-scene">{t('workbench.board.scene')}</span>
<span class="col-size">{t('workbench.board.size')}</span>
<span class="col-camera">{t('workbench.board.camera')}</span>
<span class="col-desc">{t('workbench.board.desc')}</span>
<span class="col-dialog">{t('workbench.board.dialog')}</span>
<span class="col-dur mono">{t('workbench.board.duration')}</span>
</div>
{#each shots as shot, i (shot.id)}
<div class="bt-row">
<span class="col-no mono">{String(i + 1).padStart(2, '0')}</span>
<span class="col-scene">{shot.scene}</span>
<span class="col-size"><span class="tag">{shot.shot_size}</span></span>
<span class="col-camera">{shot.camera}</span>
<span class="col-desc">{shot.description}</span>
<span class="col-dialog">{shot.dialog || '—'}</span>
<span class="col-dur mono">{shot.duration_sec}s</span>
</div>
{/each}
</div>
<div class="view-foot">
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</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);
}
.board-table {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
overflow-x: auto;
}
.bt-head,
.bt-row {
display: grid;
grid-template-columns: 34px 64px 76px 76px minmax(220px, 1fr) minmax(140px, 0.8fr) 56px;
gap: 10px;
align-items: start;
padding: 10px 14px;
min-width: 680px;
}
.bt-head {
font-size: 11px;
font-weight: 600;
color: var(--text-3);
border-bottom: 1px solid var(--border);
background: var(--surface-2);
position: sticky;
top: 0;
}
.bt-row {
border-bottom: 1px solid var(--border);
font-size: 12.5px;
line-height: 1.6;
color: var(--text);
}
.bt-row:last-child {
border-bottom: none;
}
.col-no {
color: var(--text-3);
}
.col-scene,
.col-camera {
color: var(--text-2);
white-space: nowrap;
}
.col-desc {
color: var(--text);
}
.col-dialog {
color: var(--text-2);
}
.col-dur {
text-align: right;
color: var(--text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tag {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
font-size: 10.5px;
white-space: nowrap;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shot-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.shot-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.shot-index {
color: var(--text-3);
font-size: 11px;
}
.shot-fields {
display: flex;
flex-wrap: wrap;
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: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.w-xs {
width: 110px;
flex: 1;
min-width: 96px;
}
.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);
font-variant-numeric: tabular-nums;
}
.foot-btns {
display: flex;
gap: 8px;
}
.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;
}
.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: rgba(255, 255, 255, 0.24);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,384 @@
<script lang="ts">
import type { StyleDirection, WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import PromptBlock from './PromptBlock.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
chosenStyleId?: string | null;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, chosenStyleId = $bindable(null), onSave, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<StyleDirection[]>([]);
const directions = $derived(
Array.isArray((stage.content as { directions?: unknown } | null)?.directions)
? ((stage.content as unknown as { directions: StyleDirection[] }).directions ?? [])
: [],
);
// Default selection: kept choice → first direction.
const effectiveChosen = $derived(
chosenStyleId && directions.some((d) => d.id === chosenStyleId)
? chosenStyleId
: (directions[0]?.id ?? null),
);
function startEdit() {
draft = directions.map((d) => ({ ...d }));
editing = true;
}
function save() {
const clean = draft.filter((d) => d.name.trim() && d.prompt.trim());
if (clean.length < 2) return;
onSave?.({ directions: clean });
editing = false;
}
function removeDirection(id: string) {
draft = draft.filter((d) => d.id !== id);
}
function addDirection() {
const n = draft.length + 1;
draft = [
...draft,
{
id: `style-new-${n}-${Date.now() % 1000}`,
name: '',
description: '',
prompt: '',
negative_prompt: '',
},
];
}
</script>
{#if editing}
<div class="editor">
{#each draft as dir, i (dir.id)}
<div class="dir-edit">
<div class="dir-edit-head">
<span class="dir-index mono">{i + 1}</span>
<input class="wb-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
<button
type="button"
class="icon-btn danger"
title={t('workbench.style.remove')}
onclick={() => removeDirection(dir.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<input
class="wb-input"
bind:value={dir.description}
placeholder={t('workbench.style.descPh')}
/>
<textarea
class="wb-input mono"
rows="2"
bind:value={dir.prompt}
placeholder={t('workbench.style.promptPh')}
></textarea>
<input
class="wb-input mono"
bind:value={dir.negative_prompt}
placeholder={t('workbench.style.negativePh')}
/>
</div>
{/each}
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addDirection}>
<Icon name="plus" size={13} />
{t('workbench.style.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.filter((d) => d.name.trim() && d.prompt.trim()).length < 2}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if directions.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.style.todoTitle')}</p>
<p class="todo-body">{t('workbench.style.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.style.generate')}
</button>
</div>
{:else}
<div class="dir-grid" role="radiogroup" aria-label={t('workbench.stage.style')} data-testid="style-directions">
{#each directions as dir (dir.id)}
<label class="dir-card" class:chosen={dir.id === effectiveChosen}>
<input
type="radio"
name="wb-style"
value={dir.id}
checked={dir.id === effectiveChosen}
onchange={() => (chosenStyleId = dir.id)}
/>
<div class="dir-head">
<span class="dir-name">{dir.name}</span>
{#if dir.id === effectiveChosen}
<span class="chosen-tag">{t('workbench.style.chosen')}</span>
{/if}
</div>
{#if dir.description}<p class="dir-desc">{dir.description}</p>{/if}
<PromptBlock label={t('workbench.style.promptLabel')} text={dir.prompt} />
{#if dir.negative_prompt}
<PromptBlock label={t('workbench.style.negativeLabel')} text={dir.negative_prompt} />
{/if}
</label>
{/each}
</div>
<div class="view-foot">
<span class="hint">{t('workbench.style.chooseHint')}</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);
}
.dir-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.dir-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition:
border-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 150ms;
}
.dir-card:hover {
border-color: var(--border-strong);
}
.dir-card.chosen {
border-color: var(--accent);
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
}
.dir-card input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.dir-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.dir-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.chosen-tag {
padding: 1px 9px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
font-size: 10.5px;
font-weight: 600;
flex-shrink: 0;
}
.dir-desc {
margin: 0;
font-size: 12px;
line-height: 1.7;
color: var(--text-2);
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.dir-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.dir-edit-head {
display: flex;
align-items: center;
gap: 8px;
}
.dir-index {
color: var(--text-3);
font-size: 11px;
}
.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;
margin-top: 4px;
}
.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: rgba(232, 163, 61, 0.6);
}
.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;
}
.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: rgba(255, 255, 255, 0.24);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
/**
* Backend timestamps are SQLite UTC-naive strings ("2026-09-28 01:44:28").
* `Date.parse` treats those as LOCAL time, shifting every elapsed/duration
* computation by the local UTC offset. Parse them as UTC instead; strings
* that already carry a timezone marker pass through unchanged.
*/
export function parseDbTs(raw: string | null | undefined): number {
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith('Z') ? s : `${s.replace(' ', 'T')}Z`);
}
+33
View File
@@ -0,0 +1,33 @@
import { t } from '$lib/i18n.svelte';
export type EndpointKind = 'local' | 'lan' | 'cloud';
/** 本机 / 局域网 / 云端 by host — drives the blue (local, LAN) vs purple (cloud) tags (§2.1). */
export function classifyEndpoint(rawUrl: string): EndpointKind {
try {
const u = new URL(rawUrl.trim());
const h = u.hostname.toLowerCase();
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return 'local';
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return 'lan';
if (a === 169 && b === 254) return 'lan';
return 'cloud';
}
if (!h.includes('.') || h.endsWith('.local') || h.endsWith('.lan') || h.endsWith('.internal'))
return 'lan';
return 'cloud';
} catch {
return 'cloud';
}
}
export function endpointTypeLabel(kind: EndpointKind): string {
return kind === 'local'
? t('models.typeLocal')
: kind === 'lan'
? t('models.typeLan')
: t('models.typeCloud');
}
+1 -1
View File
@@ -25,7 +25,7 @@ function initialLanguage(): Language {
// language this build no longer ships must not blank the UI. This runs on
// the server too, where storage is absent and we render the default.
const stored = storageGet(STORAGE_KEY);
return isLanguage(stored) ? stored : 'en';
return isLanguage(stored) ? stored : 'zh';
}
export const language = $state<{ current: Language }>({ current: initialLanguage() });
+699 -1
View File
@@ -34,6 +34,8 @@ export const de: Dict = {
'nav.projects': 'Projekte',
'nav.buildScene': 'Szene bauen',
'nav.settings': 'Einstellungen',
'nav.agents': 'Agenten',
'nav.models': 'Modelle',
'nav.home': 'Start',
'nav.primary': 'Primär',
'nav.projectStages': 'Projektphasen',
@@ -162,6 +164,8 @@ export const de: Dict = {
// --- API errors ---
'api.unknownError': 'Etwas ist schiefgelaufen',
'api.networkError': 'Backend nicht erreichbar – prüfe, ob es läuft, und versuche es erneut',
'api.httpError': 'Anfrage fehlgeschlagen (HTTP {status}): {detail}',
'playground.openFailed': 'Playground konnte nicht geöffnet werden',
'playground.opening': 'Playground wird geöffnet…',
'agents.opening': 'Agent wird geöffnet…',
@@ -962,6 +966,27 @@ export const de: Dict = {
'library.emptyFilterBody': 'Für diesen Filter gibt es noch nichts – probiere „Alle“ oder erzeuge etwas Neues.',
// --- Settings ---
'settings.pageSub': 'Warteschlange, Agenten-Laufzeit und Speicherpfade · LLMs, ComfyUI-Verbindungen und Skills haben eigene Seiten',
'settings.secQueue': 'Warteschlange',
'settings.secQueueBlurb': 'Parallelität, Wiederholungen, Abfrage und Testlauf',
'settings.secAgent': 'Agenten-Laufzeit',
'settings.secAgentBlurb': 'Schritte, Kontext, Prompt-Härtung und Shell',
'settings.secStorage': 'Speicherpfade',
'settings.secStorageBlurb': 'Daten-, Asset- und Arbeitsordner',
'settings.elsewhere': 'Weitere Einstellungen',
'settings.linkModelsBlurb': 'LLMs und Standardmodell je Agent',
'settings.linkConnectionsBlurb': 'Adressen, Verbindungstests und Workflow-Bibliothek',
'settings.linkAgentsBlurb': 'Skills und Einzelchats der fünf Agenten',
'settings.dryRunTitle': 'Testlauf-Modus',
'settings.dryRunHint': 'Aktiv: ComfyUI wird nicht aufgerufen, Aufträge liefern Platzhalter-Bilder/-Videos zum Durchspielen; für echte Generierung ausschalten.',
'settings.agentRunSection': 'Agenten-Laufzeit',
'settings.agentRunLead': 'Begrenzt Werkzeugschritte pro Lauf und mitgeführten Verlauf.',
'settings.rangeHint': 'Bereich {min}–{max}',
'settings.loadFailed': 'Einstellungen konnten nicht geladen werden – läuft der Server?',
'skillName.character-consistency': 'Figurenkonsistenz',
'skillName.h3-video-prompt-enhancer': 'H3-Videoprompt-Verstärker',
'skillName.scene-to-video': 'Szene zu Video',
'skillName.shot-composer-blockout': 'Einstellungs-Blocking',
'settings.loading': 'Einstellungen werden geladen',
'settings.allSaved': 'Alle Änderungen gespeichert',
'settings.unsaved': 'Ungespeicherte Änderungen',
@@ -995,6 +1020,12 @@ export const de: Dict = {
'settings.removeLlmTitle': 'LLM entfernen',
'settings.name': 'Name',
'settings.active': 'Aktiv',
'settings.testLlm': 'Verbindung testen',
'settings.llmTestOk': 'Verbindung erfolgreich ({ms} ms)',
'settings.llmTestFail': 'Verbindung fehlgeschlagen: {detail}',
'settings.llmTestAuth': 'Schlüssel abgelehnt ({detail})',
'settings.llmTestTimeout': 'Keine Antwort innerhalb von 15 s',
'settings.llmTestConnect': 'Server nicht erreichbar ({detail})',
'settings.agentModelsSection': 'Agentenmodell',
'settings.agentModelsLead': 'Modell für Story-Entwürfe und Agenten-Aufgaben.',
'settings.agentMaxStepsField': 'Max. Schritte',
@@ -1002,7 +1033,7 @@ export const de: Dict = {
'settings.queueSection': 'Job-Warteschlange',
'settings.queueLead': 'Parallelität und Zeitlimits für Generierungsjobs und Agent-Tool-Aufrufe.',
'settings.concurrencyField': 'Gleichzeitige Jobs',
'settings.pollIntervalField': 'Abfrageintervall (ms)',
'settings.pollIntervalField': 'Abfrageintervall (s)',
'settings.pollTimeoutField': 'Job-Timeout (s)',
'settings.pollTimeoutHint': 'Wie lange ein ComfyUI-Job oder ein Agent-Tool laufen darf. 0 wartet bis zum Ende.',
'settings.maxRetriesField': 'Max. Wiederholungen',
@@ -1128,4 +1159,671 @@ export const de: Dict = {
'shot.errBlob': 'Der aufgenommene Frame konnte nicht gelesen werden',
'shot.errRecorder': 'Recorder konnte nicht gestartet werden',
'shot.errNoData': 'Keine Szenendaten zum Exportieren',
// Models settings page (spec-v2 §9)
'models.title': 'Modelle',
'models.subtitle': 'Eigene LLMs für Agenten anlegen · Phase 1 nur LLMs; Bild-/Videomodelle folgen später',
'models.statusCount': '{n} Modelle · {m} verfügbar',
'models.add': 'Modell hinzufügen',
'models.tabLlm': 'Sprachmodelle',
'models.tabImage': 'Bildmodelle',
'models.tabVideo': 'Videomodelle',
'models.comingSoon': 'Später',
'models.empty': 'Noch keine Modelle — oben rechts auf „Modell hinzufügen“ klicken, um das erste LLM anzubinden',
'models.typeLocal': 'Lokal',
'models.typeLan': 'LAN',
'models.typeCloud': 'Cloud',
'models.defaultBadge': 'Agenten-Standard',
'models.setDefault': 'Als Standard',
'models.defaultSetToast': 'Als Agenten-Standardmodell gesetzt',
'models.test': 'Verbindung testen',
'models.testing': 'Teste…',
'models.edit': 'Bearbeiten',
'models.delete': 'Löschen',
'models.statusUntested': 'Nicht getestet',
'models.statusConnected': 'Verbunden',
'models.statusFailed': 'Verbindung fehlgeschlagen',
'models.latency': '{ms}ms Latenz',
'models.agentsUsing': 'von {n} Agenten genutzt',
'models.lastTestAt': 'letzter Test {time}',
'models.consecutiveFails': '{n} Fehlversuche in Folge',
'models.cloudPaused': 'Cloud-Aufrufe pausiert',
'models.formTitleAdd': 'Modell hinzufügen',
'models.formTitleEdit': 'Modell bearbeiten',
'models.formClose': 'Schließen',
'models.fName': 'Name',
'models.fNamePh': 'Modell benennen, z. B. DeepSeek offiziell',
'models.fApiType': 'API-Typ',
'models.apiOpenai': 'OpenAI-kompatibel',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': 'Andere',
'models.apiTypeHelp': 'Phase 1 unterstützt nur OpenAI-kompatible APIs; weitere Protokolle kommen mit Bild-/Videomodellen',
'models.fBaseUrl': 'Basis-URL',
'models.baseUrlHelp': 'Mit Protokoll und Port, z. B. http://192.168.200.18:11434/v1',
'models.fModel': 'Modellname',
'models.modelHelp': 'Modell-ID des Anbieters verwenden',
'models.fApiKey': 'API-Schlüssel',
'models.keyNoEcho': 'wird nie angezeigt',
'models.keyHelp': 'Wird nur auf diesem Server gespeichert; der vollständige Schlüssel erscheint nie in der Oberfläche',
'models.keyKeepHint': 'Schlüssel gespeichert — leer lassen zum Behalten',
'models.fTimeout': 'Zeitüberschreitung',
'models.timeoutSec': '{n}s',
'models.testOk': '✓ Test bestanden · {ms}ms · {model} verfügbar',
'models.testFail': '✕ Test fehlgeschlagen · {reason}',
'models.needTest': 'Speichern erst nach bestandenem Test',
'models.testBtn': 'Verbindung testen',
'models.save': 'Modell speichern',
'models.errName': 'Bitte benennen Sie das Modell',
'models.errUrl': 'Ungültige URL — Protokoll und Pfad nötig, z. B. https://api.deepseek.com/v1',
'models.errModel': 'Bitte den Modellnamen des Anbieters eingeben',
'models.errKey': 'Bitte den API-Schlüssel eingeben',
'models.failTimeout': 'Zeitüberschreitung ({sec}s, keine Antwort): der Server erreicht diese Adresse nicht — Adresse, Port und Firewall prüfen',
'models.failAuth': 'Authentifizierung fehlgeschlagen (401): API-Schlüssel prüfen; bei Aggregatoren die Modellfreischaltung bestätigen',
'models.failNotFound': 'Erreichbar, Modell aber nicht gefunden (404): Modellnamen prüfen',
'models.failNotOpenai': 'Erreichbar, aber keine OpenAI-kompatible API — Pfad muss auf /v1 enden',
'models.failConnect': 'Der Server kann diese Adresse nicht erreichen — Adresse und Port prüfen',
'models.failHttp': 'Test failed (HTTP {status})',
'models.delTitle': 'Modell löschen',
'models.delConfirmUsed': '{n} Agenten nutzen dieses Modell; nach dem Löschen nutzen sie den Agenten-Standard. Löschen?',
'models.delConfirmFree': 'Dieses Modell löschen? Kann nicht widerrufen werden.',
'models.delDefaultHint': 'Dies ist der Agenten-Standard — zuerst einen anderen Standard wählen',
'models.agentsTitle': 'Agenten-Standardmodelle',
'models.agentsHint': 'Gilt für neue Sitzungen · individuelle Auswahl auf der Agenten-Seite',
'models.agentDefault': 'Agenten-Standard',
'models.agentStory': 'Drehbuch',
'models.agentStyle': 'Visuelles Design',
'models.agentScript': 'Storyboards',
'models.agentAssets': 'Asset-Bibliothek',
'models.agentVideo': 'Shotlisten',
'models.unavailable': '(nicht verfügbar)',
'models.noticeStrong': 'Erreichbarkeit gilt vom Workbench-Server aus:',
'models.noticeBody': 'eine im Browser öffnungsfähige Adresse ist nicht automatisch vom Server erreichbar; Verbindungs- und Dialoganfragen stellt der Server. API-Schlüssel liegen nur auf dem Server und werden nie angezeigt.',
'models.saved': 'Modell gespeichert',
'models.deleted': 'Modell gelöscht',
'models.assignSaved': 'Agentenmodelle aktualisiert',
'models.saveFailed': 'Speichern fehlgeschlagen: {detail}',
'models.loading': 'Lädt…',
'models.loadFailed': 'Einstellungen konnten nicht geladen werden',
'models.idleFormHint': '„Bearbeiten“ auf einer Karte wählen oder oben „Modell hinzufügen“ klicken',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Film-Atelier',
'shell.brandMark': 'F',
'shell.brandHome': 'Zur Projektübersicht',
'shell.navAria': 'Hauptnavigation',
'shell.navProjects': 'Projekte',
'shell.navWorkbench': 'Werkstatt',
'shell.navImage': 'Bilderzeugen',
'shell.navVideo': 'Videoerzeugen',
'shell.navTasks': 'Aufträge',
'shell.navAgents': 'Agenten',
'shell.navConnections': 'ComfyUI-Verbindungen',
'shell.navModels': 'Modelle',
'shell.navSettings': 'Einstellungen',
'shell.wip': 'In Entwicklung',
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
'shell.comfy_ok': '{name} verbunden',
'shell.comfy_fail': '{name} nicht erreichbar',
'shell.comfy_testing': '{name} wird getestet…',
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
'home.title': 'Projektübersicht',
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
'home.projectCount': '{n} Projekte · {m} in Arbeit',
'home.startIdea': 'Mit einer Idee starten',
'home.importScript': 'Drehbuch importieren',
'home.entryIdeaTitle': 'Mit einer Idee starten',
'home.entryIdeaBody': 'Beschreibe eine Idee; die Agenten führen sie Schritt für Schritt durch Story, Storyboard und Clips.',
'home.entryScriptTitle': 'Drehbuch importieren',
'home.entryScriptBody': 'Schon ein Drehbuch? Einfügen, Projekt anlegen und der Drehbuch-Agent zerlegt es in geordnete Szenen.',
'home.ideaPlaceholder': 'Ein regnerischer kleiner Hafen, in dem der Leuchtfeuerwärter …',
'home.scriptField': 'Vollständiges Drehbuch',
'home.scriptPlaceholder': 'Das vollständige Drehbuch hier einfügen …',
'home.scriptHint': 'Nach dem Anlegen öffnet sich die Agenten-Seite; der Drehbuch-Agent macht aus dem Text geordnete Szenen.',
'home.errTitle': 'Bitte gib dem Projekt einen Titel',
'home.errScript': 'Bitte füge das vollständige Drehbuch ein',
'home.myProjects': 'Meine Projekte',
'home.myProjectsHint': 'Projekt anklicken, um weiterzuarbeiten',
'home.emptyProjects': 'Noch keine Projekte. Lege oben über „Mit einer Idee starten“ oder „Drehbuch importieren“ das erste an.',
'home.noIdea': 'Noch keine Ideenbeschreibung',
'home.statScenes': '{n} Szenen',
'home.statCharacters': '{n} Figuren',
'home.statAssets': 'Assets {ready}/{total}',
'home.statusDraft': 'Entwurf',
'home.statusProgress': 'In Arbeit',
'home.statusDone': 'Abgeschlossen',
'home.tasksTitle': 'Aufträge',
'home.activeJobs': '{n} aktiv',
'home.emptyJobs': 'Noch keine Generierungsaufträge',
'home.emptyJobsSub': 'Aufträge aus Projekten oder der Werkstatt erscheinen hier.',
'home.tasksNote': 'Vollständige Verlaufshistorie und Filter finden sich auf der Auftragsseite (Navigation → 生成任务).',
'home.kindImage': 'Bild',
'home.kindVideo': 'Video',
'home.kindExport': 'Film',
'home.jobDone': 'fertig',
'home.jobRunning': 'läuft',
'home.jobQueued': 'wartet',
'home.jobFailed': 'fehlgeschlagen',
'home.jobRequeued': 'Auftrag erneut eingereiht',
'home.jobRetryFailed': 'Erneutes Einreihen fehlgeschlagen',
'home.jobProject': 'Projekt #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'settings.comfyMultiHint': 'ComfyUI-Adressen unterstützen jetzt mehrere Verbindungen (lokal / LAN / Cloud), je Workflow bindbar.',
'settings.comfyGoto': 'In „ComfyUI-Verbindungen“ verwalten →',
'conn.title': 'ComfyUI-Verbindungen',
'conn.subtitle': 'Lokale, LAN- oder Cloud-ComfyUI-API-Adressen registrieren · Erreichbarkeit wird vom Server dieses Workbenches geprüft',
'conn.statusCount': '{n} Verbindungen · {m} bestanden',
'conn.add': 'Verbindung hinzufügen',
'conn.loading': 'Verbindungen werden geladen…',
'conn.loadFailed': 'Verbindungen konnten nicht geladen werden',
'conn.empty': 'Noch keine ComfyUI-Verbindung — die erste Adresse über „Verbindung hinzufügen“ eintragen.',
'conn.targetsTitle': 'Ausführungsziel',
'conn.targetImage': 'Einzelbild',
'conn.targetVideo': 'Einzelvideo',
'conn.targetFlow': 'Aufträge im Arbeitsablauf',
'conn.targetChange': 'Ändern',
'conn.targetMixed': '{n} Verbindungen',
'conn.targetNote': 'Aufträge folgen der Workflow-Bindung; ungebundene Workflows nutzen die Standardverbindung. Bindungen in der Workflow-Bibliothek ändern – Bild-/Videoseite können vor dem Generieren umschalten.',
'conn.defaultBadge': 'Standard',
'conn.test': 'Verbindung testen',
'conn.testing': 'Teste…',
'conn.edit': 'Bearbeiten',
'conn.delete': 'Löschen',
'conn.setDefault': 'Als Standard festlegen',
'conn.delDefaultHint': 'Die Standardverbindung kann nicht gelöscht werden; erst anderen Standard wählen',
'conn.delTitle': 'Verbindung löschen',
'conn.delConfirmUsed': 'An diese Verbindung gebundene Workflows fallen auf die Standardverbindung zurück. Bestehende Job-Snapshots bleiben. „{name}“ löschen?',
'conn.delConfirmFree': 'Verbindung „{name}“ löschen?',
'conn.statusUntested': 'Ungetestet',
'conn.statusConnected': 'Test bestanden',
'conn.statusFailed': 'Test fehlgeschlagen',
'conn.latency': '{ms} ms Latenz',
'conn.version': 'Version {v}',
'conn.lastTestAt': 'zuletzt getestet {time}',
'conn.boundWorkflows': '{n} Workflows gebunden',
'conn.tokenSaved': 'Token gespeichert — leer lassen zum Behalten',
'conn.saved': 'Verbindung gespeichert',
'conn.deleted': 'Verbindung gelöscht',
'conn.saveFailed': 'Speichern fehlgeschlagen: {detail}',
'conn.defaultSetToast': 'Als Standardverbindung festgelegt',
'conn.formTitleAdd': 'ComfyUI-Verbindung hinzufügen',
'conn.formTitleEdit': 'ComfyUI-Verbindung bearbeiten',
'conn.formClose': 'Schließen',
'conn.fName': 'Name',
'conn.fNamePh': 'z. B. Lokales ComfyUI / Renderbox',
'conn.fBaseUrl': 'API-Adresse',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'HTTP-API-Adresse von ComfyUI (Standardport 8188); gleiches Format für lokal, LAN und Cloud.',
'conn.fAuth': 'Authentifizierung',
'conn.authNone': 'Ohne',
'conn.authBearer': 'Bearer-Token',
'conn.authHeader': 'Eigener Header',
'conn.authHelp': 'Nur nötig, wenn der Endpunkt hinter Auth liegt (Reverse-Proxy-Key usw.).',
'conn.fToken': 'Zugriffstoken',
'conn.fHeaderName': 'Header-Name',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': 'nur serverseitig gespeichert, nie zurückgespiegelt',
'conn.keyHelp': 'Der Token liegt in der Serverkonfiguration des Workbenches und erreicht nie den Browser.',
'conn.fNotes': 'Notizen',
'conn.fNotesPh': 'optional: was die Maschine läuft, VRAM…',
'conn.errName': 'Name ist erforderlich',
'conn.errUrl': 'Adresse muss eine gültige http://- oder https://-URL sein',
'conn.errHeaderName': 'Eigener Header braucht einen Headernamen',
'conn.testOk': '✓ Test bestanden · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': 'Vor dem Speichern muss der Test bestehen',
'conn.testBtn': 'Testen',
'conn.save': 'Speichern',
'conn.noticeStrong': 'Der Server entscheidet über Erreichbarkeit: ',
'conn.noticeBody': 'Eine im Browser öffnende Adresse heißt nicht, dass Generierung funktioniert; Tests wie Generierungsanfragen stellt der Server dieses Workbenches. Tokens bleiben serverseitig.',
'conn.wfTitle': 'Workflow-Bibliothek',
'conn.wfHint': 'ComfyUI-JSON im API-Format importieren, als Bild/Video klassifizieren, mit einer Verbindung verknüpfen',
'conn.wfEmpty': 'Noch keine Workflows importiert. In ComfyUI den Entwicklermodus aktivieren, als „API-Format“ exportieren und hier einfügen.',
'conn.wfCount': '{n} Workflows · {m} Bild · {k} Video',
'conn.wfKindImage': 'Bild',
'conn.wfKindVideo': 'Video',
'conn.wfConnection': 'Verknüpfte Verbindung',
'conn.wfConnectionActive': 'Folgt der Standardverbindung',
'conn.wfParams': '{n} ausfüllbare Parameter',
'conn.wfImport': 'Workflow importieren',
'conn.wfImporting': 'Importiere…',
'conn.wfNodes': '{n} Knoten',
'conn.wfImportedN': 'Workflow importiert – {n} Parameter erkannt',
'conn.wfImportedNoParams': 'Workflow importiert, aber keine ausfüllbaren Parameter erkannt: er läuft mit seinen eingebauten Einstellungen',
'conn.wfImportFailed': 'Import fehlgeschlagen: {detail}',
'conn.wfImportTitle': 'ComfyUI-Workflow importieren (API-Format-JSON)',
'conn.wfImportHint': 'Einfügen oder Datei wählen; Name ist Pflicht, die Art entscheidet über Bild- oder Video-Fenster.',
'conn.wfJsonInvalid': 'Kein gültiges API-Format-JSON (erwartet: Objekt aus Knoten-ID → {{…class_type…}})',
'conn.wfJsonPh': 'ComfyUI-API-JSON hier einfügen oder rechts eine Datei wählen',
'conn.wfDeleted': 'Workflow gelöscht',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
'conn.missingMaterial': 'Ein dediziertes Qwen Image 2.1-Workflow-JSON fehlt noch im Repo: aus ComfyUI exportieren und hier importieren — der generische Importweg steht bereit.',
// --- Eigenständige Bild-/Videogenerierung (a3/a4, HEL-612) ---
'studio.image.title': 'Bild',
'studio.image.subtitle': 'Bilder mit importierten Workflows erzeugen · Verbindungen & Warteschlange gemeinsam mit ComfyUI-Verbindungen',
'studio.video.title': 'Video',
'studio.video.subtitle': 'Videos mit importierten Workflows erzeugen · Verbindungen & Warteschlange gemeinsam mit ComfyUI-Verbindungen',
'studio.genParams': 'Parameter',
'studio.modeTextToImage': 'Text zu Bild',
'studio.modeTextToVideo': 'Text zu Video',
'studio.chipIdle': '{name} · Warteschlange frei',
'studio.chipBusy': '{name} · {n} in Warteschlange',
'studio.video.resultsTitle': 'Vorschau',
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
'studio.historyCount': '{n} gesamt',
'studio.lastDone': 'Zuletzt fertig · {time}',
'studio.kindImageText': 'Bild',
'studio.kindVideoText': 'Video',
'studio.loading': 'Lädt…',
'studio.connLabel': 'Verbindung',
'studio.connQueue': 'Warteschlange {n}',
'studio.connUntested': 'Prüfe…',
'studio.connOk': 'Verbunden · {ms} ms',
'studio.connFail': 'Nicht erreichbar',
'studio.connManage': 'Verbindungen verwalten',
'studio.wfLabel': 'Workflow',
'studio.wfParams': '{n} ausfüllbare Parameter',
'studio.wfBindTo': 'Gebunden an {name}',
'studio.wfFollowActive': 'Folgt der Standardverbindung',
'studio.promptLabel': 'Positiver Prompt',
'studio.promptPh': 'Bild beschreiben: Motiv, Umgebung, Licht, Stil…',
'studio.negLabel': 'Negativer Prompt',
'studio.seedLabel': 'Seed',
'studio.sizeLabel': 'Größe',
'studio.batchLabel': 'Stapel',
'studio.durationLabel': 'Dauer (s)',
'studio.fpsLabel': 'Bildrate',
'studio.stepsLabel': 'Schritte',
'studio.cfgLabel': 'CFG',
'studio.paramsTitle': 'Workflow-Parameter',
'studio.chars': '{n} Zeichen',
'studio.insertStyle': 'Stilwörter einfügen',
'studio.styleSoon': 'Die Skill-Bibliothek kommt mit dem Agenten-Feature',
'studio.generateN': 'Generieren · {n} Bilder',
'studio.generate': 'Generieren',
'studio.submitting': 'Übermitteln…',
'studio.etaHint': 'Die tatsächliche Zeit hängt von Zielverbindung und Workflow ab',
'studio.errNoConn': 'Zuerst eine Generierungsverbindung wählen',
'studio.errConnDown': 'Diese Verbindung ist nicht verfügbar (letzter Test fehlgeschlagen um {time}); wechseln oder unter ComfyUI-Verbindungen reparieren',
'studio.errPrompt': 'Bitte den Prompt ausfüllen',
'studio.errPromptLong': 'Prompt überschreitet 2000 Zeichen — bitte kürzen',
'studio.errRefImage': 'Bild-zu-Bild braucht ein Referenzbild',
'studio.errFirstLast': 'Erster/letzter Frame braucht beide Frames',
'studio.errUpload': 'Bitte {label} hochladen',
'studio.errFill': 'Bitte {label} ausfüllen',
'studio.slotRef': 'Referenzbild',
'studio.slotRefN': 'Referenz {n}',
'studio.slotMask': 'Maske',
'studio.slotFirst': 'Erster Frame',
'studio.slotLast': 'Letzter Frame',
'studio.slotRefVideo': 'Referenzvideo',
'studio.slotAudio': 'Referenzaudio',
'studio.slotPick': 'Klicken oder Datei ziehen',
'studio.slotFromResults': 'Aus Bildergebnissen wählen',
'studio.taskCurrent': 'Aktuelle Aufgabe',
'studio.taskQueued': 'In Warteschlange',
'studio.taskRunning': 'Generiert',
'studio.taskCancel': 'Aufgabe abbrechen',
'studio.queuedBehind': '{n} Aufgabe(n) davor',
'studio.stepsShort': 'Schritte {v}',
'studio.usedShort': 'Verstrichen {v}',
'studio.etaShort': 'Restzeit {v}',
'studio.targetShort': 'Ziel {name}',
'studio.renderFrames': 'Frame {done}/{total}',
'studio.noActiveTask': 'Keine aktive Aufgabe · links Parameter setzen und Generieren drücken',
'studio.resultsTitle': 'Neueste Ergebnisse',
'studio.selectHint': 'Ausgewählt',
'studio.sendToImg2Img': 'Zu img2img senden',
'studio.zoom': 'Vergrößern',
'studio.download': 'Herunterladen',
'studio.downloadMp4': 'MP4 herunterladen',
'studio.copyPrompt': 'Prompt kopieren',
'studio.copiedToast': 'Kopiert — in Bild-/Videogenerierung einfügen',
'studio.sentToImg2ImgToast': 'Als Referenzbild eingetragen — Parameter prüfen und generieren',
'studio.noImg2ImgWf': 'Noch kein Bild-Workflow mit Referenzbild-Eingabe — zuerst einen img2img-Workflow in ComfyUI-Verbindungen importieren',
'studio.videoRefStrip': 'Referenz erster / letzter Frame',
'studio.historyEmptyImage': 'Noch keine Bildgenerierungen — links Parameter setzen und Generieren drücken',
'studio.historyEmptyVideo': 'Noch keine Videogenerierungen — links Parameter setzen und Generieren drücken',
'studio.jobName': 'Aufgabe #{id}',
'studio.retryLink': 'Erneut versuchen',
'studio.retryQueuedToast': 'Aufgabe wieder eingereiht',
'studio.retryFailedToast': 'Erneut Einreihen fehlgeschlagen: {detail}',
'studio.errNoWorkflow': 'Bitte zuerst einen Workflow wählen',
'studio.errBlocked': 'Senden nicht möglich: {reason}',
'studio.queuedToast': 'An „{name}“ gesendet, in der Warteschlange',
'studio.submitFailedToast': 'Senden fehlgeschlagen: {detail}',
'studio.jobFailedToast': 'Generierung fehlgeschlagen: {detail}',
'studio.wfNoParamsNote': 'Dieser Workflow hat keine ausfüllbaren Parameter und läuft mit eingebautem Prompt und Einstellungen. Um sie hier auszufüllen, Knotentitel in ComfyUI mit (Input:prompt) usw. markieren und neu importieren.',
'studio.wfAutoParamsNote': 'Keine (Input:…)-Titelmarken – {n} Parameter anhand der Knotentypen erkannt; leere Felder behalten die Workflow-Werte.',
'studio.promptNotDeclared': 'Dieser Workflow hat keinen Prompt-Knoten',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Abbruch angefordert',
'studio.deleteBtn': 'Löschen',
'studio.delTitle': 'Aufgabeneintrag löschen',
'studio.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
'studio.missingQwen': 'Dedizierte Qwen-Image-2.1-JSONs (t2i / img2img / Maske) fehlen noch — der generische Importweg steht bereit; aus ComfyUI exportieren und importieren.',
'studio.missingFirstLast': 'Das dedizierte MiniMax-H3-JSON für ersten/letzten Frame fehlt noch — die mitgelieferten t2v-/r2v-Beispiele können inzwischen importiert werden.',
'studio.pickerTitle': 'Bildergebnis wählen',
'studio.pickerEmpty': 'Noch keine Bildergebnisse verfügbar',
'studio.pickerPick': 'Verwenden',
'studio.uploadFailedToast': 'Upload fehlgeschlagen: {detail}',
'studio.playAria': 'Wiedergabe',
'studio.pauseAria': 'Pause',
'studio.fullscreenAria': 'Vollbild',
// --- Generierungsaufgaben-Seite (HEL-612) ---
'tasks.title': 'Generierungsaufgaben',
'tasks.subtitle': 'Alle Bild-/Videoaufgaben — eigenständige Fenster und Kreativfluss',
'tasks.filterAll': 'Alle',
'tasks.filterImage': 'Bilder',
'tasks.filterVideo': 'Videos',
'tasks.count': '{n} Aufgaben · {m} aktiv',
'tasks.empty': 'Noch keine Aufgaben. Reiche die erste in Bild oder Video ein.',
'tasks.goGenerate': 'Bild öffnen',
'tasks.sourceStudioImage': 'Eigenständiges Bild',
'tasks.sourceStudioVideo': 'Eigenständiges Video',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': 'Kreativfluss',
'tasks.sourceOther': 'Generierung',
'tasks.queued': 'Warteschlange',
'tasks.running': 'Läuft',
'tasks.done': 'Fertig',
'tasks.failed': 'Fehlgeschlagen',
'tasks.retry': 'Erneut',
'tasks.cancel': 'Abbrechen',
'tasks.delete': 'Löschen',
'tasks.delTitle': 'Aufgabe löschen',
'tasks.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
'tasks.outputs': '{n} Ergebnisse',
'agentsPage.title': 'Agenten',
'agentsPage.subtitle': 'Chatte unabhängig mit den fünf Kreativ-Agenten; Skills definieren und zuweisen',
'agentsPage.statusChip': '{n} Skills · {m} aktiv',
'agentsPage.agentsTitle': 'Fünf Agenten',
'agentsPage.skillLib': 'Skill-Bibliothek',
'agentsPage.skillLibCount': '{n}',
'agentsPage.installedTitle': '{name} · installierte Skills',
'agentsPage.installedCount': '{n} · {m} aktiv',
'agentsPage.newSkill': 'Neuer Skill',
'agentsPage.skillLibEmpty': 'Noch keine Skills — lege einen über „Neuer Skill“ an.',
'agentsPage.skillsCount': '{n} Skills',
'agentsPage.replying': 'Antwortet…',
'agentsPage.idle': 'Inaktiv',
'agentsPage.assigned': 'zugewiesen {n}',
'agentsPage.edit': 'Bearbeiten',
'agentsPage.toggleSkill': 'Skill ein- oder ausschalten',
'agentsPage.independentNote': 'Eigenständiger Chat · außerhalb des Kreativ-Workflows',
'agentsPage.model': 'Modell',
'agentsPage.modelDefault': 'Agent-Standard',
'agentsPage.newSessionLabel': 'Neuer Chat',
'agentsPage.newSession': 'Neuer Chat',
'agentsPage.clear': 'Leeren',
'agentsPage.export': 'Exportieren',
'agentsPage.skillManage': 'Skill-Verwaltung',
'agentsPage.composerPlaceholder': 'Weiterschreiben oder SkriptFragment einfügen… (Enter senden, Shift+Enter Zeilenumbruch)',
'agentsPage.send': 'Senden',
'agentsPage.clearTitle': 'Chat leeren',
'agentsPage.clearConfirm': 'Der Verlauf ist danach unwiederbringlich weg. Chat mit Agent {name} leeren?',
'agentsPage.skillErrName': 'Gib dem Skill einen Namen',
'agentsPage.skillErrAssign': 'Mindestens einem Agent zuweisen, sonst wirkt er nie',
'agentsPage.skillLoadFailed': 'Skill-Inhalt konnte nicht gelesen werden',
'agentsPage.skillSaved': 'Skill gespeichert',
'agentsPage.skillSaveFailed': 'Speichern fehlgeschlagen: {detail}',
'agentsPage.skillEnabled': 'Skill aktiviert',
'agentsPage.skillDisabled': 'Skill deaktiviert — neue Antworten nutzen ihn nicht mehr',
'agentsPage.skillDeleted': 'Skill gelöscht',
'agentsPage.skillRestored': 'Auf frühere Version zurückgesetzt',
'agentsPage.skillTypeTemplate': 'Prompt-Vorlage',
'agentsPage.skillTypeKnowledge': 'Referenzwissen',
'agentsPage.skillTypeRules': 'Prozessregeln',
'agentsPage.versions': 'Versionsverlauf',
'agentsPage.versionsEmpty': 'Noch keine Versionen — jedes Speichern archiviert die alte.',
'agentsPage.restore': 'Zurücksetzen',
'agentsPage.close': 'Schließen',
'agentsPage.editSkill': 'Skill bearbeiten',
'agentsPage.skillName': 'Name',
'agentsPage.skillNamePh': 'z. B. cyberpunk-style-kit',
'agentsPage.skillType': 'Typ',
'agentsPage.skillDesc': 'Beschreibung',
'agentsPage.skillDescPh': 'Wann der Agent ihn nutzen soll (erscheint in seiner Skill-Liste)',
'agentsPage.skillContent': 'Inhalt',
'agentsPage.skillContentPh': 'Markdown möglich: Vorlagen, Referenzen oder Regeln…',
'agentsPage.skillContentHint': 'Markdown möglich; beim Speichern wird die Version erhöht und die alte archiviert.',
'agentsPage.assignTo': 'Zuweisen an',
'agentsPage.saveNext': 'v{v}+1 speichern',
'agentsPage.save': 'Speichern',
'agentsPage.disable': 'Deaktivieren',
'agentsPage.enable': 'Aktivieren',
'agentsPage.delete': 'Löschen',
'agentsPage.delSkillTitle': 'Skill löschen',
'agentsPage.delSkillConfirm': 'Unwiderruflich; die {n} zugewiesenen Agenten verlieren ihn ebenfalls. Löschen?',
'agentsPage.skillIdleHint': 'Wähle „Bearbeiten“ in der Bibliothek oder klicke „Neuer Skill“',
'agentsPage.sugScript1': 'Idee zu einem Drei-Akt-Plan',
'agentsPage.sugScript1P': 'Idee: Ein Ladenmitarbeiter bemerkt jede Nacht zur selben Minute denselben Kunden. Schreibe einen Drei-Akt-Plan.',
'agentsPage.sugScript2': 'Bestehendes Skript überarbeiten',
'agentsPage.sugScript2P': 'Ich füge ein Skript ein — lies es zuerst und nenne drei Verbesserungen zu Dialog und Rhythmus.',
'agentsPage.sugStyle1': 'Stilrichtungen vorschlagen',
'agentsPage.sugStyle1P': 'Urbaner Liebes-Kurzfilm im Neonregen: drei unterschiedliche Stilrichtungen mit englischen Prompt-Schlüsselwörtern.',
'agentsPage.sugStyle2': 'Realistische Film-Lookkeywords',
'agentsPage.sugStyle2P': 'Erstelle nutzbare Prompts für realistischen Filmlook (positiv + negativ), Englisch mit chinesischen Notizen.',
'agentsPage.sugBoard1': 'Skript in Storyboard zerlegen',
'agentsPage.sugBoard1P': 'Zerlege dieses Skript in Einstellungen: Nummer, Größe, Bewegung, Inhalt, Dialog, Dauer.',
'agentsPage.sugBoard2': 'Höhepunkt-Storyboard',
'agentsPage.sugBoard2P': '30-sekündige Verfolgungs-Szene: eine Version mit klarem Rhythmus.',
'agentsPage.sugAssets1': 'Charakterbogen-Prompts',
'agentsPage.sugAssets1P': 'Die Protagonistin ist eine 17-jährige Nacht-Detektivin: konsistente Prompts (front / Seite / Ganzkörper).',
'agentsPage.sugAssets2': 'Asset-Liste',
'agentsPage.sugAssets2P': 'Erstelle für den Convenience-Store im Regen Prompt-Listen für Sets und Requisiten.',
'agentsPage.sugShot1': 'Ein Video-Prompt',
'agentsPage.sugShot1P': 'Heranfahrende Einstellung vom Neonschild zur Glastür: ein sofort nutzbarer Video-Prompt.',
'agentsPage.sugShot2': 'Erst-/Letztbild-Einstellungen',
'agentsPage.sugShot2P': 'Zeige, wie man einen Erst-/Letztbild-Prompt schreibt: Beschreibung, Bewegung und Dauer.',
'workbench.title': 'Kreativ-Werkbank',
'workbench.subtitle': 'Drehbuch → Stil → Storyboard → Assets → Shotliste, fünf Agenten im Staffellauf',
'workbench.pickerSubtitle': 'Projekt wählen und den Fünf-Phasen-Fluss fortsetzen',
'workbench.pickerEmpty': 'Noch keine Projekte',
'workbench.pickerEmptyBody': 'Zur Übersicht zurückgehen und mit einer Idee oder einem importierten Drehbuch starten.',
'workbench.pickerGoHome': 'Zur Übersicht',
'workbench.pickerFootHint': 'Die Werkbank ist auch über die Übersicht (Idee / Drehbuch importieren) erreichbar.',
'workbench.stageProgress': 'Fünf-Phasen-Fortschritt',
'workbench.activeJobs': '{n} laufende Aufgaben',
'workbench.stage.script': 'Drehbuch',
'workbench.stage.style': 'Bildstil',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Shotliste',
'workbench.lockedStep': 'Wird freigeschaltet, wenn „{stage}“ bestätigt ist',
'workbench.state.todo': 'Nicht begonnen',
'workbench.state.running': 'Generiert',
'workbench.state.draft': 'Zu bestätigen',
'workbench.state.confirmed': 'Bestätigt',
'workbench.state.edited': 'Bearbeitet',
'workbench.copied': 'Kopiert — direkt in die Bild-/Video-Generierung einfügen',
'workbench.copyFailed': 'Kopieren fehlgeschlagen, bitte manuell kopieren',
'workbench.copy': 'Kopieren',
'workbench.charCount': '{n} Zeichen',
'workbench.runStage': 'Mit {agent} generieren',
'workbench.runDone': '{agent} hat den Entwurf fertig — bitte prüfen',
'workbench.runFailed': 'Generierung fehlgeschlagen, erneut versuchen',
'workbench.runningHint': '{agent} schreibt diese Phase…',
'workbench.confirmNext': 'Bestätigen & weiter',
'workbench.confirmLast': 'Phase bestätigen',
'workbench.confirmed': 'Phase „{agent}“ bestätigt',
'workbench.confirmFailed': 'Bestätigen fehlgeschlagen, erneut versuchen',
'workbench.restore': 'Bestätigte Version wiederherstellen',
'workbench.restored': 'Zuletzt bestätigte Version wiederhergestellt',
'workbench.restoreFailed': 'Wiederherstellen fehlgeschlagen, erneut versuchen',
'workbench.draftNotice': 'Unbestätigter Entwurf: Der nächste Agent sieht ihn erst nach der Bestätigung.',
'workbench.draftSaved': 'Entwurf gespeichert — Bestätigung ausstehend',
'workbench.saveFailed': 'Speichern fehlgeschlagen, erneut versuchen',
'workbench.saveDraft': 'Entwurf speichern',
'workbench.cancelEdit': 'Abbrechen',
'workbench.editDraft': 'Bearbeiten',
'workbench.regenerate': 'Neu generieren',
'workbench.regenerateHint': 'Phase neu generieren (die bestätigte Version bleibt wiederherstellbar)',
'workbench.instructionPh': 'Zusätzliche Vorgaben (optional): filmischer, natürlichere Dialoge…',
'workbench.jobSubmitted': 'Generierungsauftrag eingereicht — Ergebnisse kehren zum Projekt zurück',
'workbench.jobSubmitFailed': 'Auftrag konnte nicht eingereicht werden',
'workbench.jobRequeued': 'Auftrag erneut eingereiht',
'workbench.retryJob': 'Erneut versuchen',
'workbench.cancelJob': 'Auftrag abbrechen',
'workbench.openOutput': 'Ergebnis ansehen',
'workbench.jobRunningPct': '{n} %',
'workbench.jobRunning': 'Läuft',
'workbench.jobQueued': 'Warteschlange',
'workbench.jobDone': 'Fertig',
'workbench.jobFailed': 'Fehlgeschlagen',
'workbench.generating': 'Generiert…',
'workbench.progressChip': 'Phase {n}/5',
'workbench.backHome': 'Übersicht',
'workbench.loadFailed': 'Werkbank konnte nicht geladen werden.',
'workbench.retryLoad': 'Erneut versuchen',
'workbench.agentSkills': '{n} Skills',
'workbench.chatWithAgent': 'Direkt chatten',
'workbench.tasksTitle': 'Phasen-Aufträge',
'workbench.tasksAll': 'Alle Aufträge',
'workbench.tasksEmpty': 'Noch keine Aufträge. Sende Prompts aus der Asset- oder Shotliste-Phase.',
'workbench.flowTitle': 'Fünf-Phasen-Fortschritt',
'workbench.script.todoTitle': 'Mit dem Drehbuch beginnen: Idee an den Drehbuch-Agenten übergeben',
'workbench.script.todoIdea': 'Deine Idee:',
'workbench.script.generate': 'Drehbuch-Entwurf erstellen',
'workbench.script.importBtn': 'Bestehendes Drehbuch importieren',
'workbench.script.writeManually': 'Selbst schreiben',
'workbench.script.importPlaceholder': 'Bestehendes Drehbuch hier einfügen… Beim Import wird die Entwurfsphase übersprungen und mit dem Bildstil fortgefahren.',
'workbench.script.importHint': 'Das importierte Drehbuch wird zur bestätigten Baseline dieser Phase.',
'workbench.script.importConfirm': 'Importieren & überspringen',
'workbench.script.importDone': 'Drehbuch importiert — mit Bildstil fortfahren',
'workbench.script.importFailed': 'Import fehlgeschlagen, Text prüfen und erneut versuchen',
'workbench.script.editorHint': 'Text direkt bearbeiten; nach dem Speichern ist die Bestätigung nötig.',
'workbench.script.copyLabel': 'Vollständiges Drehbuch',
'workbench.style.todoTitle': 'Bildstil für das Drehbuch festlegen',
'workbench.style.todoBody': 'Der Stil-Agent schlägt mehrere Richtungen vor — jeweils mit kopierfertigen Bild-Prompts.',
'workbench.style.generate': 'Stilrichtungen vorschlagen',
'workbench.style.namePh': 'Name, z. B. Neon-Cyber',
'workbench.style.descPh': 'Beschreibung (Palette / Licht / Material)',
'workbench.style.promptPh': 'Englische Bild-Schlüsselwörter, kommagetrennt',
'workbench.style.negativePh': 'Negativ-Prompt (optional, Englisch)',
'workbench.style.negativeLabel': 'Negativ-Prompt',
'workbench.style.remove': 'Richtung entfernen',
'workbench.style.add': 'Richtung hinzufügen',
'workbench.style.chosen': 'Ausgewählt',
'workbench.style.chooseHint': 'Beim Bestätigen wird der gewählte Stil an die Folgephasen übergeben.',
'workbench.style.promptLabel': 'Stil-Prompt',
'workbench.board.scene': 'Szene',
'workbench.board.size': 'Einstellung',
'workbench.board.camera': 'Kamera',
'workbench.board.duration': 'Sek',
'workbench.board.desc': 'Beschreibung',
'workbench.board.dialog': 'Dialog/SFX',
'workbench.board.descPh': 'Was dieser Shot zeigt',
'workbench.board.dialogPh': 'Dialog oder Toneinsatz (optional)',
'workbench.board.remove': 'Shot entfernen',
'workbench.board.add': 'Shot hinzufügen',
'workbench.board.todoTitle': 'Drehbuch Shot für Shot zerlegen',
'workbench.board.todoBody': 'Der Storyboard-Agent liefert Nummern, Einstellungen, Kamerafahrten und Dauern.',
'workbench.board.generate': 'Storyboard erstellen',
'workbench.board.summary': '{n} Shots · ~{sec} s',
'workbench.asset.kindCharacter': 'Figur',
'workbench.asset.kindLocation': 'Location',
'workbench.asset.kindProp': 'Requisite',
'workbench.asset.namePh': 'Asset-Name',
'workbench.asset.descPh': 'Beschreibung (Aussehen, Verwendung)',
'workbench.asset.promptPh': 'Vollständiger englischer Bild-Prompt',
'workbench.asset.promptLabel': 'Bild-Prompt',
'workbench.asset.remove': 'Asset entfernen',
'workbench.asset.add': 'Asset hinzufügen',
'workbench.asset.todoTitle': 'Asset-Liste mit Bild-Prompts aufbauen',
'workbench.asset.todoBody': 'Der Asset-Agent kombiniert Drehbuch, Stil und Storyboard zu kopierfertigen Prompts.',
'workbench.asset.generate': 'Liste erstellen',
'workbench.asset.generateImage': 'Bild generieren',
'workbench.asset.generateHint': 'Generiert über den verbundenen ComfyUI-Workflow; Ergebnisse kehren zum Projekt zurück',
'workbench.asset.summary': '{n} Assets',
'workbench.vshot.noPh': 'Shot-Nr., z. B. S1',
'workbench.vshot.descPh': 'Beschreibung des Shots',
'workbench.vshot.promptPh': 'Vollständiger Video-Prompt (Englisch)',
'workbench.vshot.promptLabel': 'Video-Prompt',
'workbench.vshot.remove': 'Shot entfernen',
'workbench.vshot.add': 'Shot hinzufügen',
'workbench.vshot.todoTitle': 'Shot-weise Videoskripte erstellen',
'workbench.vshot.todoBody': 'Der Shotlisten-Agent wandelt das Storyboard in videomodell-taugliche Prompts.',
'workbench.vshot.generate': 'Shotliste erstellen',
'workbench.vshot.generateVideo': 'Shot-Video generieren',
'workbench.vshot.generateHint': 'Generiert über den verbundenen ComfyUI-Video-Workflow; Ergebnisse kehren zum Projekt zurück',
'workbench.vshot.summary': '{n} Shots',
'home.errScriptShort': 'Drehbuch zu kurz — prüfe den eingefügten Text (mind. 30 Zeichen)',
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
'tasks.sourceWorkbench': 'Kreativ-Fluss',
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
'jobst.done': 'Fertig',
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
'jobst.dryRun': 'Platzhalter',
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
'jobst.noOutput': 'Keine Ausgabe',
'jobst.noOutputDetail': 'Der Job endete ohne Ergebnisdatei – keine fertige Generierung',
'jobst.queued': 'Wartet',
'jobst.queuedDetail': 'Wartet in der Workbench-Warteschlange auf ComfyUI',
'jobst.connecting': 'Verbinde',
'jobst.connectingDetail': 'Verbinde mit ComfyUI und lade Eingaben hoch',
'jobst.comfyQueued': 'Angenommen',
'jobst.comfyQueuedDetail': 'ComfyUI hat den Job angenommen und eingereiht',
'jobst.comfyQueuedAhead': 'ComfyUI hat den Job angenommen – {n} davor in der Warteschlange',
'jobst.comfyRunning': 'Läuft',
'jobst.comfyRunningDetail': 'ComfyUI führt den Job aus',
'jobst.saving': 'Speichert',
'jobst.savingDetail': 'ComfyUI hat Ergebnisse geliefert, lade herunter',
'jobst.failed': 'Fehlgeschlagen',
'jobst.cancelled': 'Abgebrochen',
'studio.ratioLabel': 'Seitenverhältnis',
'studio.megapixelsLabel': 'Megapixel',
'studio.outputSize': 'Ausgabe {w} × {h}',
'studio.denoiseLabel': 'Entrauschen',
'studio.errNumber': '„{label}“ muss eine Zahl sein',
'studio.errInteger': '„{label}“ muss eine ganze Zahl sein',
'studio.errMin': '„{label}“ muss mindestens {v} sein',
'studio.errMax': '„{label}“ darf höchstens {v} sein',
'studio.errOption': '„{label}“ ist keine Option des Workflows',
'studio.reuseParams': 'Einstellungen übernehmen',
'studio.reusedToast': 'Einstellungen dieses Jobs übernommen – anpassen und neu generieren',
'studio.reuseMissingWf': 'Der Workflow dieses Jobs existiert nicht mehr',
'studio.doneToast': 'Fertig: {n} Datei(en) (ComfyUI-Prompt-ID {id})',
'studio.dryRunToast': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
'studio.dryRunNote': 'Dry-run ist aktiv: Generieren schreibt nur Platzhalter und ruft ComfyUI nie auf. In den Einstellungen abschalten.',
};
+698 -1
View File
@@ -32,6 +32,8 @@ export const en = {
'nav.projects': 'Projects',
'nav.buildScene': 'Build Scene',
'nav.settings': 'Settings',
'nav.agents': 'Agents',
'nav.models': 'Models',
'nav.home': 'Home',
'nav.primary': 'Primary',
'nav.projectStages': 'Project stages',
@@ -162,6 +164,8 @@ export const en = {
// --- API errors ---
'api.unknownError': 'Something went wrong',
'api.networkError': 'Cannot reach the workbench backend — check that it is running and retry',
'api.httpError': 'Request failed (HTTP {status}): {detail}',
'playground.openFailed': 'Failed to open playground',
'playground.opening': 'Opening playground…',
'agents.opening': 'Opening agent…',
@@ -962,6 +966,27 @@ export const en = {
'library.emptyFilterBody': 'Nothing matches this filter yet — try All or generate something new.',
// --- Settings ---
'settings.pageSub': 'Generation queue, agent runtime and storage paths · LLMs, ComfyUI connections and Skills live on their own pages',
'settings.secQueue': 'Queue & generation',
'settings.secQueueBlurb': 'Concurrency, retries, polling and dry run',
'settings.secAgent': 'Agent runtime',
'settings.secAgentBlurb': 'Steps, context, prompt hardening and shell',
'settings.secStorage': 'Storage paths',
'settings.secStorageBlurb': 'Data, assets and agent workspace folders',
'settings.elsewhere': 'Managed elsewhere',
'settings.linkModelsBlurb': 'LLMs and default model per agent',
'settings.linkConnectionsBlurb': 'Addresses, connection tests and workflow library',
'settings.linkAgentsBlurb': 'Skills and standalone chats for the five agents',
'settings.dryRunTitle': 'Dry-run mode',
'settings.dryRunHint': 'When on, ComfyUI is not called and jobs return placeholder images / videos so you can walk the flow; turn off for real generation.',
'settings.agentRunSection': 'Agent runtime',
'settings.agentRunLead': 'Caps tool-call steps per run and how much history is carried along.',
'settings.rangeHint': 'Range {min}–{max}',
'settings.loadFailed': 'Could not load settings — check that the workbench server is running',
'skillName.character-consistency': 'Character consistency',
'skillName.h3-video-prompt-enhancer': 'H3 video prompt enhancer',
'skillName.scene-to-video': 'Scene to video',
'skillName.shot-composer-blockout': 'Shot blockout',
'settings.loading': 'Loading settings',
'settings.allSaved': 'All changes saved',
'settings.unsaved': 'Unsaved changes',
@@ -995,6 +1020,12 @@ export const en = {
'settings.removeLlmTitle': 'Remove LLM',
'settings.name': 'Name',
'settings.active': 'Active',
'settings.testLlm': 'Test connection',
'settings.llmTestOk': 'Connected ({ms} ms)',
'settings.llmTestFail': 'Connection failed: {detail}',
'settings.llmTestAuth': 'API key rejected ({detail})',
'settings.llmTestTimeout': 'No response within 15 s',
'settings.llmTestConnect': 'Could not reach the server ({detail})',
'settings.agentModelsSection': 'Agent model',
'settings.agentModelsLead': 'Model used for story drafting and agent tasks.',
'settings.agentMaxStepsField': 'Max steps',
@@ -1002,7 +1033,7 @@ export const en = {
'settings.queueSection': 'Job queue',
'settings.queueLead': 'Concurrency and timeouts for generation jobs and agent tool calls.',
'settings.concurrencyField': 'Concurrent jobs',
'settings.pollIntervalField': 'Poll interval (ms)',
'settings.pollIntervalField': 'Poll interval (sec)',
'settings.pollTimeoutField': 'Job timeout (s)',
'settings.pollTimeoutHint': 'How long a ComfyUI job or one agent tool call may run. 0 waits until it finishes.',
'settings.maxRetriesField': 'Max retries',
@@ -1012,6 +1043,8 @@ export const en = {
'settings.comfySection': 'ComfyUI',
'settings.comfyLead': 'Where ComfyUI runs.',
'settings.comfyHint': 'Must be reachable from this machine.',
'settings.comfyMultiHint': 'ComfyUI addresses now support multiple connections (local / LAN / cloud), bindable per imported workflow.',
'settings.comfyGoto': 'Manage in “ComfyUI Connections” →',
'settings.storageSection': 'System Paths',
'settings.storageLead': 'Where files live on disk, and where the agent may work.',
'settings.storageCalloutStrong': 'Changing data directory',
@@ -1128,6 +1161,670 @@ export const en = {
'shot.errBlob': 'Could not read the captured frame',
'shot.errRecorder': 'Could not start recorder',
'shot.errNoData': 'No scene data to export',
// Models settings page (spec-v2 §9)
'models.title': 'Models',
'models.subtitle': 'Add custom LLMs for your agents · phase 1 covers LLMs only; image / video models come later',
'models.statusCount': '{n} models · {m} available',
'models.add': 'Add model',
'models.tabLlm': 'Language models',
'models.tabImage': 'Image models',
'models.tabVideo': 'Video models',
'models.comingSoon': 'Coming later',
'models.empty': 'No models yet — click "Add model" in the top right to connect your first LLM',
'models.typeLocal': 'Local',
'models.typeLan': 'LAN',
'models.typeCloud': 'Cloud',
'models.defaultBadge': 'Agent default',
'models.setDefault': 'Set as default',
'models.defaultSetToast': 'Set as agent default model',
'models.test': 'Test connection',
'models.testing': 'Testing…',
'models.edit': 'Edit',
'models.delete': 'Delete',
'models.statusUntested': 'Not tested',
'models.statusConnected': 'Connected',
'models.statusFailed': 'Connection failed',
'models.latency': '{ms}ms latency',
'models.agentsUsing': 'used by {n} agents',
'models.lastTestAt': 'last test {time}',
'models.consecutiveFails': '{n} consecutive failures',
'models.cloudPaused': 'Cloud calls paused',
'models.formTitleAdd': 'Add model',
'models.formTitleEdit': 'Edit model',
'models.formClose': 'Close',
'models.fName': 'Name',
'models.fNamePh': 'Name this model, e.g. DeepSeek official',
'models.fApiType': 'API type',
'models.apiOpenai': 'OpenAI-compatible',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': 'Other',
'models.apiTypeHelp': 'Phase 1 supports OpenAI-compatible APIs only; other protocols arrive with image / video models',
'models.fBaseUrl': 'Base URL',
'models.baseUrlHelp': 'Include protocol and port, e.g. http://192.168.200.18:11434/v1',
'models.fModel': 'Model name',
'models.modelHelp': 'Use the model ID from your provider',
'models.fApiKey': 'API key',
'models.keyNoEcho': 'never echoed',
'models.keyHelp': 'Stored on this server only; the full key is never shown in the UI',
'models.keyKeepHint': 'Key saved — leave empty to keep it',
'models.fTimeout': 'Timeout',
'models.timeoutSec': '{n}s',
'models.testOk': '✓ Test passed · {ms}ms · {model} available',
'models.testFail': '✕ Test failed · {reason}',
'models.needTest': 'Save unlocks after a passing test',
'models.testBtn': 'Test connection',
'models.save': 'Save model',
'models.errName': 'Please name this model',
'models.errUrl': 'Invalid URL — it needs protocol and path, e.g. https://api.deepseek.com/v1',
'models.errModel': 'Please enter the model name from your provider',
'models.errKey': 'Please enter the API key',
'models.failTimeout': 'Timed out ({sec}s, no response): the server cannot reach this address — check the address, port and firewall',
'models.failAuth': 'Authentication failed (401): check that the API key is correct and not expired; with aggregators, confirm the model is enabled for your account',
'models.failNotFound': 'Reachable but model not found (404): check the model name spelling',
'models.failNotOpenai': 'Reachable but not recognized as an OpenAI-compatible API — make sure the path ends with /v1',
'models.failConnect': 'The server cannot connect to this address — check the address and port',
'models.failHttp': 'Test failed (HTTP {status})',
'models.delTitle': 'Delete model',
'models.delConfirmUsed': '{n} agents use this model; after deletion they fall back to the agent default. Delete?',
'models.delConfirmFree': 'Delete this model? This cannot be undone.',
'models.delDefaultHint': 'This is the agent default — pick another default first',
'models.agentsTitle': 'Agent default models',
'models.agentsHint': 'Changes apply to new sessions · per-agent overrides live on the Agents page',
'models.agentDefault': 'Agent default',
'models.agentStory': 'Screenplay',
'models.agentStyle': 'Visual design',
'models.agentScript': 'Storyboards',
'models.agentAssets': 'Asset library',
'models.agentVideo': 'Shot lists',
'models.unavailable': '(unavailable)',
'models.noticeStrong': 'Connectivity is measured from the workbench server:',
'models.noticeBody': 'a URL opening in your browser does not mean the server running this workbench can reach it; connection tests and chat requests are made by the server. API keys are stored on the server only and never echoed in the UI.',
'models.saved': 'Model saved',
'models.deleted': 'Model deleted',
'models.assignSaved': 'Agent models updated',
'models.saveFailed': 'Save failed: {detail}',
'models.loading': 'Loading…',
'models.loadFailed': 'Failed to load settings',
'models.idleFormHint': 'Pick "Edit" on a card, or click "Add model" above',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Film Atelier',
'shell.brandMark': 'F',
'shell.brandHome': 'Back to project overview',
'shell.navAria': 'Primary',
'shell.navProjects': 'Projects',
'shell.navWorkbench': 'Workbench',
'shell.navImage': 'Image Gen',
'shell.navVideo': 'Video Gen',
'shell.navTasks': 'Jobs',
'shell.navAgents': 'Agents',
'shell.navConnections': 'ComfyUI Connections',
'shell.navModels': 'Models',
'shell.navSettings': 'Settings',
'shell.wip': 'In development',
'shell.wipHint': 'This page is under construction and will open later',
'shell.comfy_ok': '{name} connected',
'shell.comfy_fail': '{name} unreachable',
'shell.comfy_testing': 'Testing {name}…',
'shell.comfyUnset': 'ComfyUI · not configured',
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
'home.title': 'Projects overview',
'home.subtitle': 'Manage your projects — start from an idea or a ready-made script.',
'home.projectCount': '{n} projects · {m} in progress',
'home.startIdea': 'Start from an idea',
'home.importScript': 'Import a script',
'home.entryIdeaTitle': 'Start from an idea',
'home.entryIdeaBody': 'Describe an idea once; the agents take it through story, boards and clips step by step.',
'home.entryScriptTitle': 'Import a script',
'home.entryScriptBody': 'Already have a script? Paste it in, create the project, and let the script agent break it into ordered scenes.',
'home.ideaPlaceholder': 'A rainy little harbor where the lighthouse keeper finds…',
'home.scriptField': 'Full script',
'home.scriptPlaceholder': 'Paste the complete script here…',
'home.scriptHint': 'After creating, the Agents page opens and the script agent turns the pasted script into ordered scenes.',
'home.errTitle': 'Please give the project a title',
'home.errScript': 'Please paste the full script',
'home.myProjects': 'My projects',
'home.myProjectsHint': 'Click a project to continue working',
'home.emptyProjects': 'No projects yet. Use “Start from an idea” or “Import a script” above to create the first one.',
'home.noIdea': 'No idea description yet',
'home.statScenes': '{n} scenes',
'home.statCharacters': '{n} characters',
'home.statAssets': 'assets {ready}/{total}',
'home.statusDraft': 'Draft',
'home.statusProgress': 'In progress',
'home.statusDone': 'Completed',
'home.tasksTitle': 'Jobs',
'home.activeJobs': '{n} active',
'home.emptyJobs': 'No generation jobs yet',
'home.emptyJobsSub': 'Jobs started from a project or the workbench will show up here.',
'home.tasksNote': 'Full generation history and filters live in the Jobs page (nav → 生成任务).',
'home.kindImage': 'image',
'home.kindVideo': 'video',
'home.kindExport': 'film',
'home.jobDone': 'done',
'home.jobRunning': 'running',
'home.jobQueued': 'queued',
'home.jobFailed': 'failed',
'home.jobRequeued': 'Job requeued',
'home.jobRetryFailed': 'Could not requeue the job',
'home.jobProject': 'Project #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'conn.title': 'ComfyUI Connections',
'conn.subtitle': 'Register local, LAN or cloud ComfyUI API addresses · connectivity is probed by the server running this workbench',
'conn.statusCount': '{n} connections · {m} passing',
'conn.add': 'Add connection',
'conn.loading': 'Loading connections…',
'conn.loadFailed': 'Failed to load connections',
'conn.empty': 'No ComfyUI connections yet — add your first address with “Add connection”.',
'conn.targetsTitle': 'Where jobs run',
'conn.targetImage': 'Standalone image',
'conn.targetVideo': 'Standalone video',
'conn.targetFlow': 'Workbench jobs',
'conn.targetChange': 'Change',
'conn.targetMixed': '{n} connections',
'conn.targetNote': 'Jobs follow each workflow’s binding; unbound workflows use the default connection. Change bindings in the workflow library — the image / video pages can also switch before generating.',
'conn.defaultBadge': 'Default',
'conn.test': 'Test connection',
'conn.testing': 'Testing…',
'conn.edit': 'Edit',
'conn.delete': 'Delete',
'conn.setDefault': 'Set as default',
'conn.delDefaultHint': 'The default connection cannot be deleted; set another default first',
'conn.delTitle': 'Delete connection',
'conn.delConfirmUsed': 'Workflows bound to this connection will fall back to the default. Existing job snapshots are kept. Delete “{name}”?',
'conn.delConfirmFree': 'Delete connection “{name}”?',
'conn.statusUntested': 'Untested',
'conn.statusConnected': 'Test passed',
'conn.statusFailed': 'Test failed',
'conn.latency': '{ms} ms latency',
'conn.version': 'version {v}',
'conn.lastTestAt': 'last tested {time}',
'conn.boundWorkflows': '{n} workflows bound',
'conn.tokenSaved': 'Token saved — leave blank to keep it',
'conn.saved': 'Connection saved',
'conn.deleted': 'Connection deleted',
'conn.saveFailed': 'Save failed: {detail}',
'conn.defaultSetToast': 'Set as default connection',
'conn.formTitleAdd': 'Add ComfyUI connection',
'conn.formTitleEdit': 'Edit ComfyUI connection',
'conn.formClose': 'Close',
'conn.fName': 'Name',
'conn.fNamePh': 'e.g. Local ComfyUI / render box',
'conn.fBaseUrl': 'API address',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'HTTP API address of ComfyUI (default port 8188); same format for local, LAN and cloud.',
'conn.fAuth': 'Authentication',
'conn.authNone': 'None',
'conn.authBearer': 'Bearer token',
'conn.authHeader': 'Custom header',
'conn.authHelp': 'Only needed when the endpoint sits behind auth (reverse proxy key, etc.).',
'conn.fToken': 'Access token',
'conn.fHeaderName': 'Header name',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': 'stored server-side only, never echoed',
'conn.keyHelp': 'The token lives in the workbench server config file and never reaches the browser.',
'conn.fNotes': 'Notes',
'conn.fNotesPh': 'optional: what this box runs, VRAM…',
'conn.errName': 'Name is required',
'conn.errUrl': 'Address must be a valid http:// or https:// URL',
'conn.errHeaderName': 'Custom header needs a header name',
'conn.testOk': '✓ Test passed · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': 'Pass the test before saving',
'conn.testBtn': 'Test',
'conn.save': 'Save',
'conn.noticeStrong': 'The server decides connectivity: ',
'conn.noticeBody': 'an address opening in your browser does not mean generation works; both tests and generation requests are made by the server running this workbench. Tokens stay server-side.',
'conn.wfTitle': 'Workflow library',
'conn.wfHint': 'Import ComfyUI API-format JSON, classify as image / video, bind to a connection',
'conn.wfEmpty': 'No workflows imported yet. In ComfyUI enable developer mode, export as “API format” JSON, then paste it here.',
'conn.wfCount': '{n} workflows · {m} image · {k} video',
'conn.wfKindImage': 'Image',
'conn.wfKindVideo': 'Video',
'conn.wfConnection': 'Bound connection',
'conn.wfConnectionActive': 'Follows the default connection',
'conn.wfParams': '{n} fillable parameters',
'conn.wfImport': 'Import workflow',
'conn.wfImporting': 'Importing…',
'conn.wfNodes': '{n} nodes',
'conn.wfImportedN': 'Workflow imported — {n} fillable parameters detected',
'conn.wfImportedNoParams': 'Workflow imported, but no fillable parameters were detected: it will run with its built-in settings',
'conn.wfImportFailed': 'Import failed: {detail}',
'conn.wfImportTitle': 'Import ComfyUI workflow (API-format JSON)',
'conn.wfImportHint': 'Paste or pick a file; name is required, the kind decides whether it shows in the image or video window.',
'conn.wfJsonInvalid': 'Not valid API-format JSON (expected an object of node id to class_type)',
'conn.wfJsonPh': 'Paste the ComfyUI API-format JSON here, or pick a file',
'conn.wfDeleted': 'Workflow deleted',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
'conn.missingMaterial': 'A dedicated Qwen Image 2.1 workflow JSON is not in the repo yet: export it from ComfyUI and import it here — the generic import path is ready.',
// --- Standalone image / video generation (a3/a4, HEL-612) ---
'studio.image.title': 'Image',
'studio.image.subtitle': 'Generate images with imported workflows · shares connections & queue with ComfyUI Connections',
'studio.video.title': 'Video',
'studio.video.subtitle': 'Generate videos with imported workflows · shares connections & queue with ComfyUI Connections',
'studio.genParams': 'Parameters',
'studio.modeTextToImage': 'Text to image',
'studio.modeTextToVideo': 'Text to video',
'studio.chipIdle': '{name} · queue idle',
'studio.chipBusy': '{name} · {n} in queue',
'studio.video.resultsTitle': 'Preview',
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
'studio.historyCount': '{n} total',
'studio.lastDone': 'Last done · {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'video',
'studio.loading': 'Loading…',
'studio.connLabel': 'Connection',
'studio.connQueue': 'Queue {n}',
'studio.connUntested': 'Checking…',
'studio.connOk': 'Connected · {ms} ms',
'studio.connFail': 'Unreachable',
'studio.connManage': 'Manage connections',
'studio.wfLabel': 'Workflow',
'studio.wfParams': '{n} fillable params',
'studio.wfBindTo': 'Bound to {name}',
'studio.wfFollowActive': 'Follows the default connection',
'studio.promptLabel': 'Positive prompt',
'studio.promptPh': 'Describe the shot: subject, setting, light, style…',
'studio.negLabel': 'Negative prompt',
'studio.seedLabel': 'Seed',
'studio.sizeLabel': 'Size',
'studio.batchLabel': 'Batch',
'studio.durationLabel': 'Duration (s)',
'studio.fpsLabel': 'Frame rate',
'studio.stepsLabel': 'Steps',
'studio.cfgLabel': 'CFG',
'studio.paramsTitle': 'Workflow params',
'studio.chars': '{n} chars',
'studio.insertStyle': 'Insert style words',
'studio.styleSoon': 'Skill word library arrives with the Agents feature',
'studio.generateN': 'Generate · {n} images',
'studio.generate': 'Generate',
'studio.submitting': 'Submitting…',
'studio.etaHint': 'Actual time depends on the target connection and workflow',
'studio.errNoConn': 'Select a generation connection first',
'studio.errConnDown': "This connection is unavailable (last test failed at {time}) — switch to another or fix it in ComfyUI Connections",
'studio.errPrompt': 'Please fill in the prompt',
'studio.errPromptLong': 'Prompt exceeds 2000 characters — please trim it',
'studio.errRefImage': 'Image-to-image needs a reference image',
'studio.errFirstLast': 'First/last-frame video needs both the first and the last frame',
'studio.errUpload': 'Please upload {label}',
'studio.errFill': 'Please fill in {label}',
'studio.slotRef': 'Reference image',
'studio.slotRefN': 'Reference {n}',
'studio.slotMask': 'Mask',
'studio.slotFirst': 'First frame',
'studio.slotLast': 'Last frame',
'studio.slotRefVideo': 'Reference video',
'studio.slotAudio': 'Reference audio',
'studio.slotPick': 'Click or drop a file',
'studio.slotFromResults': 'Pick from image results',
'studio.taskCurrent': 'Current task',
'studio.taskQueued': 'Queued',
'studio.taskRunning': 'Generating',
'studio.taskCancel': 'Cancel task',
'studio.queuedBehind': '{n} task(s) ahead',
'studio.stepsShort': 'Steps {v}',
'studio.usedShort': 'Elapsed {v}',
'studio.etaShort': 'ETA {v}',
'studio.targetShort': 'Target {name}',
'studio.renderFrames': 'Frame {done}/{total}',
'studio.noActiveTask': 'No active task · set the parameters on the left and press Generate',
'studio.resultsTitle': 'Recent results',
'studio.selectHint': 'Selected',
'studio.sendToImg2Img': 'Send to img2img',
'studio.zoom': 'Zoom',
'studio.download': 'Download',
'studio.downloadMp4': 'Download MP4',
'studio.copyPrompt': 'Copy prompt',
'studio.copiedToast': 'Copied — paste it into image/video generation',
'studio.sentToImg2ImgToast': 'Filled in as the reference image — check the params and generate',
'studio.noImg2ImgWf': 'No image workflow declares a reference-image input yet — import an img2img workflow in ComfyUI Connections first',
'studio.videoRefStrip': 'First / last frame reference',
'studio.historyEmptyImage': 'No image generations yet — set the parameters on the left and press Generate',
'studio.historyEmptyVideo': 'No video generations yet — set the parameters on the left and press Generate',
'studio.jobName': 'Task #{id}',
'studio.retryLink': 'Retry',
'studio.retryQueuedToast': 'Task re-queued',
'studio.retryFailedToast': 'Re-queue failed: {detail}',
'studio.errNoWorkflow': 'Choose a workflow first',
'studio.errBlocked': 'Cannot submit: {reason}',
'studio.queuedToast': 'Submitted to “{name}”, queued',
'studio.submitFailedToast': 'Submit failed: {detail}',
'studio.jobFailedToast': 'Generation failed: {detail}',
'studio.wfNoParamsNote': 'This workflow has no fillable parameters and will run with its built-in prompt and settings. To fill them here, tag node titles with (Input:prompt) etc. in ComfyUI and re-import.',
'studio.wfAutoParamsNote': 'No (Input:…) title tags found — {n} parameters were detected from node types; blank fields keep the workflow values.',
'studio.promptNotDeclared': 'This workflow has no prompt node — nothing to fill',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Cancel requested',
'studio.deleteBtn': 'Delete',
'studio.delTitle': 'Delete task record',
'studio.delConfirm': 'The result files will be removed together with the record. Delete it?',
'studio.wfEmptyTitle': 'No {kind} workflows yet',
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
'studio.missingQwen': 'Dedicated Qwen Image 2.1 (t2i / img2img / mask) JSONs are still missing — the generic import path is ready; export from ComfyUI and import.',
'studio.missingFirstLast': 'The dedicated MiniMax H3 first/last-frame JSON is still missing — the bundled t2v / reference-to-video examples can be imported meanwhile.',
'studio.pickerTitle': 'Pick an image result',
'studio.pickerEmpty': 'No image results available yet',
'studio.pickerPick': 'Use',
'studio.uploadFailedToast': 'Upload failed: {detail}',
'studio.playAria': 'Play',
'studio.pauseAria': 'Pause',
'studio.fullscreenAria': 'Fullscreen',
// --- Generation tasks page (HEL-612) ---
'tasks.title': 'Generation tasks',
'tasks.subtitle': 'All image / video tasks — standalone windows and the creation flow',
'tasks.filterAll': 'All',
'tasks.filterImage': 'Images',
'tasks.filterVideo': 'Videos',
'tasks.count': '{n} tasks · {m} active',
'tasks.empty': 'No generation tasks yet. Submit the first one in Image or Video.',
'tasks.goGenerate': 'Open Image',
'tasks.sourceStudioImage': 'Standalone image',
'tasks.sourceStudioVideo': 'Standalone video',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': 'Creation flow',
'tasks.sourceOther': 'Generation',
'tasks.queued': 'Queued',
'tasks.running': 'Running',
'tasks.done': 'Done',
'tasks.failed': 'Failed',
'tasks.retry': 'Retry',
'tasks.cancel': 'Cancel',
'tasks.delete': 'Delete',
'tasks.delTitle': 'Delete task',
'tasks.delConfirm': 'The result files will be removed together with the task. Delete it?',
'tasks.outputs': '{n} outputs',
'agentsPage.title': 'Agents',
'agentsPage.subtitle': 'Chat independently with the five creation agents; define and assign Skills',
'agentsPage.statusChip': '{n} Skills · {m} enabled',
'agentsPage.agentsTitle': 'Five agents',
'agentsPage.skillLib': 'Skill library',
'agentsPage.skillLibCount': '{n}',
'agentsPage.installedTitle': '{name} · installed Skills',
'agentsPage.installedCount': '{n} · {m} on',
'agentsPage.newSkill': 'New Skill',
'agentsPage.skillLibEmpty': 'No Skills yet — create one with “New Skill” above.',
'agentsPage.skillsCount': '{n} Skills',
'agentsPage.replying': 'Replying…',
'agentsPage.idle': 'Idle',
'agentsPage.assigned': 'assigned {n}',
'agentsPage.edit': 'Edit',
'agentsPage.toggleSkill': 'Enable or disable Skill',
'agentsPage.independentNote': 'Independent chat · not part of the creation flow',
'agentsPage.model': 'Model',
'agentsPage.modelDefault': 'Agent default',
'agentsPage.newSessionLabel': 'New chat',
'agentsPage.newSession': 'New chat',
'agentsPage.clear': 'Clear',
'agentsPage.export': 'Export',
'agentsPage.skillManage': 'Skill manager',
'agentsPage.composerPlaceholder': 'Keep chatting, or paste a script fragment… (Enter to send, Shift+Enter for newline)',
'agentsPage.send': 'Send',
'agentsPage.clearTitle': 'Clear chat',
'agentsPage.clearConfirm': 'History cannot be recovered after clearing. Clear the chat with the {name} agent?',
'agentsPage.skillErrName': 'Please give the Skill a name',
'agentsPage.skillErrAssign': 'Assign it to at least one Agent, otherwise it never takes effect',
'agentsPage.skillLoadFailed': 'Failed to read Skill content',
'agentsPage.skillSaved': 'Skill saved',
'agentsPage.skillSaveFailed': 'Save failed: {detail}',
'agentsPage.skillEnabled': 'Skill enabled',
'agentsPage.skillDisabled': 'Skill disabled — new replies will not use it',
'agentsPage.skillDeleted': 'Skill deleted',
'agentsPage.skillRestored': 'Rolled back to a previous version',
'agentsPage.skillTypeTemplate': 'Prompt template',
'agentsPage.skillTypeKnowledge': 'Reference knowledge',
'agentsPage.skillTypeRules': 'Process rules',
'agentsPage.versions': 'Version history',
'agentsPage.versionsEmpty': 'No versions yet — every save snapshots the old one.',
'agentsPage.restore': 'Restore',
'agentsPage.close': 'Close',
'agentsPage.editSkill': 'Edit Skill',
'agentsPage.skillName': 'Name',
'agentsPage.skillNamePh': 'e.g. cyberpunk-style-kit',
'agentsPage.skillType': 'Type',
'agentsPage.skillDesc': 'Description',
'agentsPage.skillDescPh': 'When should the agent use this Skill (shown in its skill list)',
'agentsPage.skillContent': 'Content',
'agentsPage.skillContentPh': 'Markdown supported: prompt templates, references, or process rules…',
'agentsPage.skillContentHint': 'Markdown supported; saving auto-bumps the version and archives the old one.',
'agentsPage.assignTo': 'Assign to',
'agentsPage.saveNext': 'Save v{v}+1',
'agentsPage.save': 'Save',
'agentsPage.disable': 'Disable',
'agentsPage.enable': 'Enable',
'agentsPage.delete': 'Delete',
'agentsPage.delSkillTitle': 'Delete Skill',
'agentsPage.delSkillConfirm': 'Deletion is irreversible; the {n} assigned agents lose it too. Delete it?',
'agentsPage.skillIdleHint': 'Pick “Edit” from the Skill library, or click “New Skill”',
'agentsPage.sugScript1': 'Turn an idea into a three-act outline',
'agentsPage.sugScript1P': 'I have an idea: a convenience-store clerk notices the same customer at the same minute every night. Please write a three-act short-film outline.',
'agentsPage.sugScript2': 'Polish an existing script',
'agentsPage.sugScript2P': 'I will paste an existing script — read it first, then point out three improvements to dialogue and pacing.',
'agentsPage.sugStyle1': 'Propose style directions',
'agentsPage.sugStyle1P': 'A neon-lit rainy urban romance short — give me three distinct visual style directions, each with English image-prompt keyword sets.',
'agentsPage.sugStyle2': 'Cinematic-realism keywords',
'agentsPage.sugStyle2P': 'Compile a ready-to-use set of cinematic-realism style prompts (positive + negative), English with Chinese notes.',
'agentsPage.sugBoard1': 'Break the script into a storyboard',
'agentsPage.sugBoard1P': 'Break this script into a shot list: shot number, size, camera move, frame content, dialogue cue, suggested duration.',
'agentsPage.sugBoard2': 'Storyboard a climax',
'agentsPage.sugBoard2P': 'A thirty-second chase climax — give one storyboard pass with clear rhythm changes.',
'agentsPage.sugAssets1': 'Character sheet prompts',
'agentsPage.sugAssets1P': 'The protagonist is a 17-year-old night-owl girl detective — write a consistent character-sheet prompt set (front / side / full body).',
'agentsPage.sugAssets2': 'Asset checklist',
'agentsPage.sugAssets2P': 'Based on the rainy convenience-store scene, list scene and prop asset prompts.',
'agentsPage.sugShot1': 'One video prompt',
'agentsPage.sugShot1P': 'A push-in shot at the store entrance, neon sign to glass door — write one directly usable video-model prompt.',
'agentsPage.sugShot2': 'First/last-frame shots',
'agentsPage.sugShot2P': 'Show how to write a first/last-frame video prompt: how to describe first frame, last frame, camera move, and duration.',
'workbench.title': 'Creation Workbench',
'workbench.subtitle': 'Script → style → storyboard → assets → shot list, five agents in relay',
'workbench.pickerSubtitle': 'Pick a project to continue its five-stage flow',
'workbench.pickerEmpty': 'No projects yet',
'workbench.pickerEmptyBody': 'Go back to the overview and start from an idea or import a script.',
'workbench.pickerGoHome': 'Open overview',
'workbench.pickerFootHint': 'You can also enter the workbench from the overview\'s 从创意开始 / 导入剧本 entries.',
'workbench.stageProgress': 'Five-stage progress',
'workbench.activeJobs': '{n} jobs running',
'workbench.stage.script': 'Script',
'workbench.stage.style': 'Visual Style',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Shot List',
'workbench.lockedStep': 'Unlocks after 「{stage}」 is confirmed',
'workbench.state.todo': 'Not started',
'workbench.state.running': 'Generating',
'workbench.state.draft': 'Awaiting confirm',
'workbench.state.confirmed': 'Confirmed',
'workbench.state.edited': 'Edited',
'workbench.copied': 'Copied — paste it into image/video generation',
'workbench.copyFailed': 'Copy failed, please copy manually',
'workbench.copy': 'Copy',
'workbench.charCount': '{n} chars',
'workbench.runStage': 'Generate with {agent}',
'workbench.runDone': '{agent} finished the draft — please review and confirm',
'workbench.runFailed': 'Generation failed, please retry',
'workbench.runningHint': '{agent} is writing this stage…',
'workbench.confirmNext': 'Confirm & continue',
'workbench.confirmLast': 'Confirm stage',
'workbench.confirmed': '「{agent}」 stage confirmed',
'workbench.confirmFailed': 'Confirm failed, please retry',
'workbench.restore': 'Restore confirmed',
'workbench.restored': 'Restored to the last confirmed version',
'workbench.restoreFailed': 'Restore failed, please retry',
'workbench.draftNotice': 'This is an unconfirmed draft — the next agent only sees it after you confirm.',
'workbench.draftSaved': 'Draft saved — still needs confirmation',
'workbench.saveFailed': 'Save failed, please retry',
'workbench.saveDraft': 'Save draft',
'workbench.cancelEdit': 'Cancel',
'workbench.editDraft': 'Edit',
'workbench.regenerate': 'Regenerate',
'workbench.regenerateHint': 'Ask the agent to redo this stage (the confirmed version is kept and restorable)',
'workbench.instructionPh': 'Extra instructions (optional), e.g. more cinematic, casual dialogue…',
'workbench.jobSubmitted': 'Generation queued — results return to this project',
'workbench.jobSubmitFailed': 'Failed to submit the job',
'workbench.jobRequeued': 'Job re-queued',
'workbench.retryJob': 'Retry',
'workbench.cancelJob': 'Cancel job',
'workbench.openOutput': 'Open result',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': 'Running',
'workbench.jobQueued': 'Queued',
'workbench.jobDone': 'Done',
'workbench.jobFailed': 'Failed',
'workbench.generating': 'Generating…',
'workbench.progressChip': 'Stage {n}/5',
'workbench.backHome': 'Overview',
'workbench.loadFailed': 'Failed to load the workbench.',
'workbench.retryLoad': 'Retry',
'workbench.agentSkills': '{n} skills',
'workbench.chatWithAgent': 'Chat solo',
'workbench.tasksTitle': 'Stage jobs',
'workbench.tasksAll': 'All jobs',
'workbench.tasksEmpty': 'No generation jobs yet. Send prompts from the assets or shot-list stage.',
'workbench.flowTitle': 'Five-stage progress',
'workbench.script.todoTitle': 'Start from the script: hand your idea to the Script agent',
'workbench.script.todoIdea': 'Your idea:',
'workbench.script.generate': 'Draft the script',
'workbench.script.importBtn': 'Import existing script',
'workbench.script.writeManually': 'Write manually',
'workbench.script.importPlaceholder': 'Paste an existing script here… Importing skips script drafting and continues from visual style.',
'workbench.script.importHint': 'The imported script becomes the confirmed baseline of this stage.',
'workbench.script.importConfirm': 'Import & skip drafting',
'workbench.script.importDone': 'Script imported — continue from visual style',
'workbench.script.importFailed': 'Import failed, please check the text and retry',
'workbench.script.editorHint': 'Edit the script text directly; confirmation is still required after saving.',
'workbench.script.copyLabel': 'Full script',
'workbench.style.todoTitle': 'Define the visual style for the script',
'workbench.style.todoBody': 'The Visual Style agent proposes several distinct directions, each with copy-ready image prompts.',
'workbench.style.generate': 'Propose style directions',
'workbench.style.namePh': 'Direction name, e.g. Neon Cyber',
'workbench.style.descPh': 'Description (palette / lighting / texture)',
'workbench.style.promptPh': 'English image keywords, comma separated',
'workbench.style.negativePh': 'Negative prompt (optional, English)',
'workbench.style.negativeLabel': 'Negative prompt',
'workbench.style.remove': 'Remove direction',
'workbench.style.add': 'Add direction',
'workbench.style.chosen': 'Selected',
'workbench.style.chooseHint': 'When confirming this stage, your selected style is handed to later stages.',
'workbench.style.promptLabel': 'Style prompt',
'workbench.board.scene': 'Scene',
'workbench.board.size': 'Shot size',
'workbench.board.camera': 'Camera',
'workbench.board.duration': 'Sec',
'workbench.board.desc': 'Description',
'workbench.board.dialog': 'Dialog/SFX',
'workbench.board.descPh': 'What this shot shows',
'workbench.board.dialogPh': 'Dialog or SFX cue (optional)',
'workbench.board.remove': 'Remove shot',
'workbench.board.add': 'Add shot',
'workbench.board.todoTitle': 'Break the script into a shot-by-shot storyboard',
'workbench.board.todoBody': 'The Storyboard agent produces shot numbers, sizes, camera moves, framing and durations.',
'workbench.board.generate': 'Draft the storyboard',
'workbench.board.summary': '{n} shots · ~{sec}s',
'workbench.asset.kindCharacter': 'Character',
'workbench.asset.kindLocation': 'Location',
'workbench.asset.kindProp': 'Prop',
'workbench.asset.namePh': 'Asset name',
'workbench.asset.descPh': 'Description (appearance, usage)',
'workbench.asset.promptPh': 'Full English image prompt',
'workbench.asset.promptLabel': 'Image prompt',
'workbench.asset.remove': 'Remove asset',
'workbench.asset.add': 'Add asset',
'workbench.asset.todoTitle': 'Build the asset list with image prompts',
'workbench.asset.todoBody': 'The Assets agent combines script, style and storyboard into copy-ready prompts for characters/locations/props.',
'workbench.asset.generate': 'Draft the asset list',
'workbench.asset.generateImage': 'Generate image',
'workbench.asset.generateHint': 'Generate with the connected ComfyUI workflow; results return to this project',
'workbench.asset.summary': '{n} assets',
'workbench.vshot.noPh': 'Shot no., e.g. S1',
'workbench.vshot.descPh': 'Description of the shot',
'workbench.vshot.promptPh': 'Full video-model prompt (English)',
'workbench.vshot.promptLabel': 'Video prompt',
'workbench.vshot.remove': 'Remove shot',
'workbench.vshot.add': 'Add shot',
'workbench.vshot.todoTitle': 'Produce the shot-by-shot video script',
'workbench.vshot.todoBody': 'The Shot List agent turns the storyboard into prompts ready for video models.',
'workbench.vshot.generate': 'Draft the shot list',
'workbench.vshot.generateVideo': 'Generate shot video',
'workbench.vshot.generateHint': 'Generate with the connected ComfyUI video workflow; results return to this project',
'workbench.vshot.summary': '{n} shots',
'home.errScriptShort': 'The script looks too short — check the paste is complete (min 30 characters)',
'home.stageProgress': 'Five-stage progress',
'tasks.sourceWorkbench': 'Creation flow',
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
'jobst.done': 'Done',
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
'jobst.dryRun': 'Placeholder',
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
'jobst.noOutput': 'No output',
'jobst.noOutputDetail': 'The job ended without any result file — not a finished generation',
'jobst.queued': 'Queued',
'jobst.queuedDetail': 'Waiting in the workbench queue to be sent to ComfyUI',
'jobst.connecting': 'Connecting',
'jobst.connectingDetail': 'Connecting to ComfyUI and uploading inputs',
'jobst.comfyQueued': 'Accepted',
'jobst.comfyQueuedDetail': 'ComfyUI accepted the job and queued it',
'jobst.comfyQueuedAhead': 'ComfyUI accepted the job — {n} ahead in its queue',
'jobst.comfyRunning': 'Running',
'jobst.comfyRunningDetail': 'ComfyUI is running the job',
'jobst.saving': 'Saving',
'jobst.savingDetail': 'ComfyUI returned results, downloading them',
'jobst.failed': 'Failed',
'jobst.cancelled': 'Cancelled',
'studio.ratioLabel': 'Aspect ratio',
'studio.megapixelsLabel': 'Megapixels',
'studio.outputSize': 'Output {w} × {h}',
'studio.denoiseLabel': 'Denoise',
'studio.errNumber': '“{label}” must be a number',
'studio.errInteger': '“{label}” must be a whole number',
'studio.errMin': '“{label}” must be at least {v}',
'studio.errMax': '“{label}” must be at most {v}',
'studio.errOption': '“{label}” is not one of the workflow\'s options',
'studio.reuseParams': 'Reuse settings',
'studio.reusedToast': 'Settings from this job are filled in — adjust and generate again',
'studio.reuseMissingWf': 'The workflow this job used no longer exists',
'studio.doneToast': 'Done: {n} file(s) (ComfyUI prompt ID {id})',
'studio.dryRunToast': 'Placeholder only: Dry-run is on, ComfyUI was not called',
'studio.dryRunNote': 'Dry-run is on: Generate only writes placeholder files and never calls ComfyUI. Turn it off in Settings.',
};
export type Dict = typeof en;
+699 -1
View File
@@ -34,6 +34,8 @@ export const es: Dict = {
'nav.projects': 'Proyectos',
'nav.buildScene': 'Componer escena',
'nav.settings': 'Ajustes',
'nav.agents': 'Agentes',
'nav.models': 'Modelos',
'nav.home': 'Inicio',
'nav.primary': 'Principal',
'nav.projectStages': 'Etapas del proyecto',
@@ -162,6 +164,8 @@ export const es: Dict = {
// --- API errors ---
'api.unknownError': 'Algo salió mal',
'api.networkError': 'No se puede conectar con el backend; comprueba que esté en marcha y reintenta',
'api.httpError': 'La solicitud falló (HTTP {status}): {detail}',
'playground.openFailed': 'No se pudo abrir el playground',
'playground.opening': 'Abriendo el playground…',
'agents.opening': 'Abriendo el agente…',
@@ -962,6 +966,27 @@ export const es: Dict = {
'library.emptyFilterBody': 'Nada coincide con este filtro todavía – prueba «Todo» o genera algo nuevo.',
// --- Settings ---
'settings.pageSub': 'Cola de generación, ejecución de agentes y rutas · LLM, conexiones ComfyUI y Skills tienen su propia página',
'settings.secQueue': 'Cola y generación',
'settings.secQueueBlurb': 'Concurrencia, reintentos, sondeo y prueba',
'settings.secAgent': 'Ejecución de agentes',
'settings.secAgentBlurb': 'Pasos, contexto, refuerzo de prompts y shell',
'settings.secStorage': 'Rutas de almacenamiento',
'settings.secStorageBlurb': 'Carpetas de datos, recursos y espacio de trabajo',
'settings.elsewhere': 'Gestionado en otras páginas',
'settings.linkModelsBlurb': 'LLM y modelo por defecto de cada agente',
'settings.linkConnectionsBlurb': 'Direcciones, pruebas de conexión y biblioteca de flujos',
'settings.linkAgentsBlurb': 'Skills y chats independientes de los cinco agentes',
'settings.dryRunTitle': 'Modo de prueba',
'settings.dryRunHint': 'Activado: no se llama a ComfyUI y las tareas devuelven imágenes / vídeos de muestra; desactívalo para generar de verdad.',
'settings.agentRunSection': 'Ejecución de agentes',
'settings.agentRunLead': 'Limita los pasos de herramientas por ejecución y el historial incluido.',
'settings.rangeHint': 'Rango {min}–{max}',
'settings.loadFailed': 'No se pudieron cargar los ajustes: comprueba que el servidor esté activo',
'skillName.character-consistency': 'Coherencia de personajes',
'skillName.h3-video-prompt-enhancer': 'Mejora de prompts de vídeo H3',
'skillName.scene-to-video': 'Escena a vídeo',
'skillName.shot-composer-blockout': 'Encuadre previo del plano',
'settings.loading': 'Cargando ajustes',
'settings.allSaved': 'Todos los cambios guardados',
'settings.unsaved': 'Cambios sin guardar',
@@ -995,6 +1020,12 @@ export const es: Dict = {
'settings.removeLlmTitle': 'Quitar LLM',
'settings.name': 'Nombre',
'settings.active': 'Activo',
'settings.testLlm': 'Probar conexión',
'settings.llmTestOk': 'Conexión correcta ({ms} ms)',
'settings.llmTestFail': 'Error de conexión: {detail}',
'settings.llmTestAuth': 'Clave rechazada ({detail})',
'settings.llmTestTimeout': 'Sin respuesta en 15 s',
'settings.llmTestConnect': 'No se pudo conectar al servidor ({detail})',
'settings.agentModelsSection': 'Modelo del agente',
'settings.agentModelsLead': 'Modelo usado para redactar la historia y para las tareas del agente.',
'settings.agentMaxStepsField': 'Pasos máximos',
@@ -1002,7 +1033,7 @@ export const es: Dict = {
'settings.queueSection': 'Cola de tareas',
'settings.queueLead': 'Concurrencia y tiempos de espera de las tareas de generación y las llamadas de herramientas del agente.',
'settings.concurrencyField': 'Tareas simultáneas',
'settings.pollIntervalField': 'Intervalo de sondeo (ms)',
'settings.pollIntervalField': 'Intervalo de sondeo (s)',
'settings.pollTimeoutField': 'Tiempo límite de tarea (s)',
'settings.pollTimeoutHint': 'Cuánto puede durar un trabajo de ComfyUI o una llamada de herramienta del agente. 0 espera hasta que termine.',
'settings.maxRetriesField': 'Reintentos máximos',
@@ -1128,4 +1159,671 @@ export const es: Dict = {
'shot.errBlob': 'No se pudo leer el fotograma capturado',
'shot.errRecorder': 'No se pudo iniciar el grabador',
'shot.errNoData': 'No hay datos de escena para exportar',
// Models settings page (spec-v2 §9)
'models.title': 'Modelos',
'models.subtitle': 'Añade LLM personalizados para tus agentes · la fase 1 solo incluye LLM; los modelos de imagen / vídeo llegarán después',
'models.statusCount': '{n} modelos · {m} disponibles',
'models.add': 'Añadir modelo',
'models.tabLlm': 'Modelos de lenguaje',
'models.tabImage': 'Modelos de imagen',
'models.tabVideo': 'Modelos de vídeo',
'models.comingSoon': 'Próximamente',
'models.empty': 'Aún no hay modelos: pulsa «Añadir modelo» arriba a la derecha para conectar tu primer LLM',
'models.typeLocal': 'Local',
'models.typeLan': 'LAN',
'models.typeCloud': 'Nube',
'models.defaultBadge': 'Predeterminado',
'models.setDefault': 'Predeterminar',
'models.defaultSetToast': 'Modelo predeterminado de agentes actualizado',
'models.test': 'Probar conexión',
'models.testing': 'Probando…',
'models.edit': 'Editar',
'models.delete': 'Eliminar',
'models.statusUntested': 'Sin probar',
'models.statusConnected': 'Conectado',
'models.statusFailed': 'Conexión fallida',
'models.latency': '{ms}ms de latencia',
'models.agentsUsing': 'usado por {n} agentes',
'models.lastTestAt': 'última prueba {time}',
'models.consecutiveFails': '{n} fallos consecutivos',
'models.cloudPaused': 'Llamadas en la nube en pausa',
'models.formTitleAdd': 'Añadir modelo',
'models.formTitleEdit': 'Editar modelo',
'models.formClose': 'Cerrar',
'models.fName': 'Nombre',
'models.fNamePh': 'Nombra el modelo, p. ej. DeepSeek oficial',
'models.fApiType': 'Tipo de API',
'models.apiOpenai': 'Compatible con OpenAI',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': 'Otro',
'models.apiTypeHelp': 'La fase 1 solo admite APIs compatibles con OpenAI; otros protocolos llegarán con los modelos de imagen / vídeo',
'models.fBaseUrl': 'URL base',
'models.baseUrlHelp': 'Incluye protocolo y puerto, p. ej. http://192.168.200.18:11434/v1',
'models.fModel': 'Nombre del modelo',
'models.modelHelp': 'Usa el ID de modelo que indica tu proveedor',
'models.fApiKey': 'Clave API',
'models.keyNoEcho': 'nunca se muestra',
'models.keyHelp': 'Se guarda solo en este servidor; la clave completa nunca aparece en la interfaz',
'models.keyKeepHint': 'Clave guardada: déjalo vacío para conservarla',
'models.fTimeout': 'Tiempo de espera',
'models.timeoutSec': '{n}s',
'models.testOk': '✓ Prueba superada · {ms}ms · {model} disponible',
'models.testFail': '✕ Prueba fallida · {reason}',
'models.needTest': 'Guardar se habilita tras superar la prueba',
'models.testBtn': 'Test connection',
'models.save': 'Guardar modelo',
'models.errName': 'Ponle nombre al modelo',
'models.errUrl': 'URL no válida: debe incluir protocolo y ruta, p. ej. https://api.deepseek.com/v1',
'models.errModel': 'Introduce el nombre del modelo de tu proveedor',
'models.errKey': 'Introduce la clave API',
'models.failTimeout': 'Tiempo de espera agotado ({sec}s sin respuesta): el servidor no llega a esta dirección; revisa dirección, puerto y cortafuegos',
'models.failAuth': 'Autenticación fallida (401): comprueba que la clave API sea correcta y no haya caducado; con agregadores, confirma que el modelo esté activado',
'models.failNotFound': 'Accesible pero modelo no encontrado (404): revisa cómo se escribe el nombre',
'models.failNotOpenai': 'Accesible pero no parece una API compatible con OpenAI: comprueba que la ruta termine en /v1',
'models.failConnect': 'El servidor no puede conectarse a esta dirección: revisa dirección y puerto',
'models.failHttp': 'Prueba fallida (HTTP {status})',
'models.delTitle': 'Eliminar modelo',
'models.delConfirmUsed': '{n} agentes usan este modelo; tras eliminarlo usarán el predeterminado. ¿Eliminar?',
'models.delConfirmFree': '¿Eliminar este modelo? No se puede deshacer.',
'models.delDefaultHint': 'Es el modelo predeterminado: elige otro predeterminado primero',
'models.agentsTitle': 'Modelos predeterminados de agentes',
'models.agentsHint': 'Los cambios aplican a sesiones nuevas · ajustes por agente en la página Agentes',
'models.agentDefault': 'Predeterminado',
'models.agentStory': 'Guion',
'models.agentStyle': 'Diseño visual',
'models.agentScript': 'Storyboards',
'models.agentAssets': 'Biblioteca de assets',
'models.agentVideo': 'Listas de planos',
'models.unavailable': '(no disponible)',
'models.noticeStrong': 'La conectividad se mide desde el servidor del workbench:',
'models.noticeBody': 'que una URL abra en tu navegador no significa que el servidor pueda acceder a ella; las pruebas de conexión y las peticiones las hace el servidor. Las claves API se guardan solo en el servidor y nunca se muestran.',
'models.saved': 'Modelo guardado',
'models.deleted': 'Modelo eliminado',
'models.assignSaved': 'Modelos de agentes actualizados',
'models.saveFailed': 'Error al guardar: {detail}',
'models.loading': 'Cargando…',
'models.loadFailed': 'No se pudieron cargar los ajustes',
'models.idleFormHint': 'Pulsa «Editar» en una tarjeta o «Añadir modelo» arriba',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Atelier de Cine',
'shell.brandMark': 'A',
'shell.brandHome': 'Volver al resumen de proyectos',
'shell.navAria': 'Navegación principal',
'shell.navProjects': 'Proyectos',
'shell.navWorkbench': 'Taller',
'shell.navImage': 'Generar imagen',
'shell.navVideo': 'Generar vídeo',
'shell.navTasks': 'Tareas',
'shell.navAgents': 'Agentes',
'shell.navConnections': 'Conexiones ComfyUI',
'shell.navModels': 'Modelos',
'shell.navSettings': 'Ajustes',
'shell.wip': 'En desarrollo',
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
'shell.comfy_ok': '{name} conectado',
'shell.comfy_fail': '{name} sin conexión',
'shell.comfy_testing': 'Probando {name}…',
'shell.comfyUnset': 'ComfyUI · sin configurar',
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
'home.title': 'Resumen de proyectos',
'home.subtitle': 'Gestiona tus proyectos: empieza con una idea o un guion terminado.',
'home.projectCount': '{n} proyectos · {m} en curso',
'home.startIdea': 'Empezar con una idea',
'home.importScript': 'Importar guion',
'home.entryIdeaTitle': 'Empezar con una idea',
'home.entryIdeaBody': 'Describe una idea; los agentes la llevan por historia, storyboard y clips paso a paso.',
'home.entryScriptTitle': 'Importar guion',
'home.entryScriptBody': '¿Ya tienes guion? Pégalo, crea el proyecto y el agente de guion lo dividirá en escenas ordenadas.',
'home.ideaPlaceholder': 'Un pequeño puerto lluvioso donde el farero descubre…',
'home.scriptField': 'Guion completo',
'home.scriptPlaceholder': 'Pega aquí el guion completo…',
'home.scriptHint': 'Al crearlo se abre la página de Agentes; el agente de guion convierte el texto en escenas ordenadas.',
'home.errTitle': 'Ponle un título al proyecto',
'home.errScript': 'Pega el guion completo',
'home.myProjects': 'Mis proyectos',
'home.myProjectsHint': 'Haz clic en un proyecto para continuar',
'home.emptyProjects': 'Aún no hay proyectos. Crea el primero con «Empezar con una idea» o «Importar guion».',
'home.noIdea': 'Sin descripción de la idea',
'home.statScenes': '{n} escenas',
'home.statCharacters': '{n} personajes',
'home.statAssets': 'recursos {ready}/{total}',
'home.statusDraft': 'Borrador',
'home.statusProgress': 'En curso',
'home.statusDone': 'Terminado',
'home.tasksTitle': 'Tareas',
'home.activeJobs': '{n} activas',
'home.emptyJobs': 'Aún no hay tareas de generación',
'home.emptyJobsSub': 'Las tareas iniciadas en un proyecto o en el taller aparecerán aquí.',
'home.tasksNote': 'El historial completo de generación y los filtros están en la página de tareas (navegación → 生成任务).',
'home.kindImage': 'imagen',
'home.kindVideo': 'vídeo',
'home.kindExport': 'película',
'home.jobDone': 'lista',
'home.jobRunning': 'en curso',
'home.jobQueued': 'en cola',
'home.jobFailed': 'fallida',
'home.jobRequeued': 'Tarea reencolada',
'home.jobRetryFailed': 'No se pudo reencolar la tarea',
'home.jobProject': 'Proyecto #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'settings.comfyMultiHint': 'Las direcciones ComfyUI ahora admiten varias conexiones (local / LAN / nube), vinculables por flujo de trabajo.',
'settings.comfyGoto': 'Gestionar en «Conexiones ComfyUI» →',
'conn.title': 'Conexiones ComfyUI',
'conn.subtitle': 'Registra direcciones API de ComfyUI locales, de LAN o de nube · la conectividad la prueba el servidor',
'conn.statusCount': '{n} conexiones · {m} correctas',
'conn.add': 'Añadir conexión',
'conn.loading': 'Cargando conexiones…',
'conn.loadFailed': 'Error al cargar las conexiones',
'conn.empty': 'Aún no hay conexiones ComfyUI; añade la primera dirección con «Añadir conexión».',
'conn.targetsTitle': 'Destino de las tareas',
'conn.targetImage': 'Imagen independiente',
'conn.targetVideo': 'Vídeo independiente',
'conn.targetFlow': 'Tareas del flujo creativo',
'conn.targetChange': 'Cambiar',
'conn.targetMixed': '{n} conexiones',
'conn.targetNote': 'Las tareas siguen la conexión de cada flujo; los no vinculados usan la predeterminada. Cambia vínculos en la biblioteca de flujos; las páginas de imagen / vídeo también permiten cambiar antes de generar.',
'conn.defaultBadge': 'Predeterminada',
'conn.test': 'Probar conexión',
'conn.testing': 'Probando…',
'conn.edit': 'Editar',
'conn.delete': 'Eliminar',
'conn.setDefault': 'Establecer como predeterminada',
'conn.delDefaultHint': 'La conexión predeterminada no se puede eliminar; elige otra primero',
'conn.delTitle': 'Eliminar conexión',
'conn.delConfirmUsed': 'Los flujos vinculados a esta conexión volverán a la predeterminada. Las instantáneas existentes se conservan. ¿Eliminar «{name}»?',
'conn.delConfirmFree': '¿Eliminar la conexión «{name}»?',
'conn.statusUntested': 'Sin probar',
'conn.statusConnected': 'Prueba superada',
'conn.statusFailed': 'Prueba fallida',
'conn.latency': '{ms} ms de latencia',
'conn.version': 'versión {v}',
'conn.lastTestAt': 'última prueba {time}',
'conn.boundWorkflows': '{n} flujos vinculados',
'conn.tokenSaved': 'Token guardado; deja vacío para conservarlo',
'conn.saved': 'Conexión guardada',
'conn.deleted': 'Conexión eliminada',
'conn.saveFailed': 'Error al guardar: {detail}',
'conn.defaultSetToast': 'Establecida como predeterminada',
'conn.formTitleAdd': 'Añadir conexión ComfyUI',
'conn.formTitleEdit': 'Editar conexión ComfyUI',
'conn.formClose': 'Cerrar',
'conn.fName': 'Nombre',
'conn.fNamePh': 'p. ej. ComfyUI local / equipo de render',
'conn.fBaseUrl': 'Dirección API',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'Dirección HTTP de la API de ComfyUI (puerto 8188 por defecto); mismo formato para local, LAN y nube.',
'conn.fAuth': 'Autenticación',
'conn.authNone': 'Ninguna',
'conn.authBearer': 'Token Bearer',
'conn.authHeader': 'Cabecera personalizada',
'conn.authHelp': 'Solo necesario si el extremo está tras autenticación (clave de proxy inverso, etc.).',
'conn.fToken': 'Token de acceso',
'conn.fHeaderName': 'Nombre de cabecera',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': 'se guarda solo en el servidor, nunca se muestra',
'conn.keyHelp': 'El token vive en el archivo de configuración del servidor y nunca llega al navegador.',
'conn.fNotes': 'Notas',
'conn.fNotesPh': 'opcional: qué ejecuta, VRAM…',
'conn.errName': 'El nombre es obligatorio',
'conn.errUrl': 'La dirección debe ser una URL http:// o https:// válida',
'conn.errHeaderName': 'La cabecera personalizada necesita un nombre',
'conn.testOk': '✓ Prueba superada · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': 'Supera la prueba antes de guardar',
'conn.testBtn': 'Probar',
'conn.save': 'Guardar',
'conn.noticeStrong': 'El servidor decide la conectividad: ',
'conn.noticeBody': 'Que una dirección abra en el navegador no significa que la generación funcione; las pruebas y las peticiones las hace el servidor. Los tokens permanecen en el servidor.',
'conn.wfTitle': 'Biblioteca de flujos',
'conn.wfHint': 'Importa JSON en formato API de ComfyUI, clasifícalo como imagen/vídeo y vincúlalo a una conexión',
'conn.wfEmpty': 'Aún no hay flujos importados. Activa el modo desarrollador en ComfyUI, exporta en «formato API» y pégalo aquí.',
'conn.wfCount': '{n} flujos · {m} imagen · {k} vídeo',
'conn.wfKindImage': 'Imagen',
'conn.wfKindVideo': 'Vídeo',
'conn.wfConnection': 'Conexión vinculada',
'conn.wfConnectionActive': 'Sigue la conexión predeterminada',
'conn.wfParams': '{n} parámetros rellenables',
'conn.wfImport': 'Importar flujo',
'conn.wfImporting': 'Importando…',
'conn.wfNodes': '{n} nodos',
'conn.wfImportedN': 'Flujo importado: {n} parámetros detectados',
'conn.wfImportedNoParams': 'Flujo importado, pero sin parámetros rellenables: se ejecutará con su configuración interna',
'conn.wfImportFailed': 'Error al importar: {detail}',
'conn.wfImportTitle': 'Importar flujo de ComfyUI (JSON en formato API)',
'conn.wfImportHint': 'Pega o elige un archivo; el nombre es obligatorio y el tipo decide si aparece en la ventana de imagen o vídeo.',
'conn.wfJsonInvalid': 'JSON no válido en formato API (se esperaba un objeto id de nodo → {{…class_type…}})',
'conn.wfJsonPh': 'Pega aquí el JSON en formato API de ComfyUI o elige un archivo',
'conn.wfDeleted': 'Flujo eliminado',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
'conn.missingMaterial': 'Aún falta en el repo un JSON dedicado de Qwen Image 2.1: expórtalo desde ComfyUI e impórtalo aquí; la vía genérica ya está lista.',
// --- Generación independiente de imagen / vídeo (a3/a4, HEL-612) ---
'studio.image.title': 'Imagen',
'studio.image.subtitle': 'Genera imágenes con flujos importados · conexiones y cola compartidas con Conexiones ComfyUI',
'studio.video.title': 'Vídeo',
'studio.video.subtitle': 'Genera vídeos con flujos importados · conexiones y cola compartidas con Conexiones ComfyUI',
'studio.genParams': 'Parámetros',
'studio.modeTextToImage': 'Texto a imagen',
'studio.modeTextToVideo': 'Texto a vídeo',
'studio.chipIdle': '{name} · cola libre',
'studio.chipBusy': '{name} · {n} en cola',
'studio.video.resultsTitle': 'Vista previa',
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
'studio.historyCount': '{n} en total',
'studio.lastDone': 'Último · {time}',
'studio.kindImageText': 'imagen',
'studio.kindVideoText': 'vídeo',
'studio.loading': 'Cargando…',
'studio.connLabel': 'Conexión',
'studio.connQueue': 'Cola {n}',
'studio.connUntested': 'Comprobando…',
'studio.connOk': 'Conectado · {ms} ms',
'studio.connFail': 'Sin conexión',
'studio.connManage': 'Gestionar conexiones',
'studio.wfLabel': 'Flujo',
'studio.wfParams': '{n} parámetros rellenables',
'studio.wfBindTo': 'Vinculado a {name}',
'studio.wfFollowActive': 'Sigue la conexión por defecto',
'studio.promptLabel': 'Prompt positivo',
'studio.promptPh': 'Describe la imagen: sujeto, entorno, luz, estilo…',
'studio.negLabel': 'Prompt negativo',
'studio.seedLabel': 'Semilla',
'studio.sizeLabel': 'Tamaño',
'studio.batchLabel': 'Lote',
'studio.durationLabel': 'Duración (s)',
'studio.fpsLabel': 'Fotogramas/s',
'studio.stepsLabel': 'Pasos',
'studio.cfgLabel': 'CFG',
'studio.paramsTitle': 'Parámetros del flujo',
'studio.chars': '{n} car.',
'studio.insertStyle': 'Insertar palabras de estilo',
'studio.styleSoon': 'La biblioteca de Skills llegará con la función Agentes',
'studio.generateN': 'Generar · {n} imágenes',
'studio.generate': 'Generar',
'studio.submitting': 'Enviando…',
'studio.etaHint': 'El tiempo real depende de la conexión y del flujo',
'studio.errNoConn': 'Selecciona primero una conexión de generación',
'studio.errConnDown': 'Esta conexión no está disponible (última prueba fallida a las {time}); cambia de conexión o arréglala en Conexiones ComfyUI',
'studio.errPrompt': 'Rellena el prompt',
'studio.errPromptLong': 'El prompt supera los 2000 caracteres; recórtalo',
'studio.errRefImage': 'Imagen-a-imagen necesita una imagen de referencia',
'studio.errFirstLast': 'El vídeo primer/último fotograma necesita ambos fotogramas',
'studio.errUpload': 'Sube {label}',
'studio.errFill': 'Rellena {label}',
'studio.slotRef': 'Imagen de referencia',
'studio.slotRefN': 'Referencia {n}',
'studio.slotMask': 'Máscara',
'studio.slotFirst': 'Primer fotograma',
'studio.slotLast': 'Último fotograma',
'studio.slotRefVideo': 'Vídeo de referencia',
'studio.slotAudio': 'Audio de referencia',
'studio.slotPick': 'Haz clic o arrastra',
'studio.slotFromResults': 'Elegir de resultados de imagen',
'studio.taskCurrent': 'Tarea actual',
'studio.taskQueued': 'En cola',
'studio.taskRunning': 'Generando',
'studio.taskCancel': 'Cancelar tarea',
'studio.queuedBehind': '{n} tarea(s) por delante',
'studio.stepsShort': 'Pasos {v}',
'studio.usedShort': 'Transcurrido {v}',
'studio.etaShort': 'Restante {v}',
'studio.targetShort': 'Destino {name}',
'studio.renderFrames': 'Fotograma {done}/{total}',
'studio.noActiveTask': 'Sin tarea activa · ajusta los parámetros y pulsa Generar',
'studio.resultsTitle': 'Resultados recientes',
'studio.selectHint': 'Elegida',
'studio.sendToImg2Img': 'Enviar a img2img',
'studio.zoom': 'Ampliar',
'studio.download': 'Descargar',
'studio.downloadMp4': 'Descargar MP4',
'studio.copyPrompt': 'Copiar prompt',
'studio.copiedToast': 'Copiado; pégalo en generación de imagen/vídeo',
'studio.sentToImg2ImgToast': 'Añadido como referencia; revisa los parámetros y genera',
'studio.noImg2ImgWf': 'Ningún flujo de imagen declara una entrada de referencia todavía; importa antes un flujo img2img en Conexiones ComfyUI',
'studio.videoRefStrip': 'Referencia primer / último fotograma',
'studio.historyEmptyImage': 'Aún no hay generaciones de imagen; ajusta los parámetros y pulsa Generar',
'studio.historyEmptyVideo': 'Aún no hay generaciones de vídeo; ajusta los parámetros y pulsa Generar',
'studio.jobName': 'Tarea #{id}',
'studio.retryLink': 'Reintentar',
'studio.retryQueuedToast': 'Tarea vuelta a la cola',
'studio.retryFailedToast': 'Error al reencolar: {detail}',
'studio.errNoWorkflow': 'Elige primero un flujo',
'studio.errBlocked': 'No se puede enviar: {reason}',
'studio.queuedToast': 'Enviado a «{name}», en cola',
'studio.submitFailedToast': 'Error al enviar: {detail}',
'studio.jobFailedToast': 'La generación falló: {detail}',
'studio.wfNoParamsNote': 'Este flujo no tiene parámetros rellenables y se ejecutará con su prompt y ajustes internos. Para rellenarlos aquí, etiqueta los títulos de nodo con (Input:prompt) etc. en ComfyUI y vuelve a importar.',
'studio.wfAutoParamsNote': 'Sin etiquetas (Input:…): se detectaron {n} parámetros por tipo de nodo; los campos vacíos conservan los valores del flujo.',
'studio.promptNotDeclared': 'Este flujo no tiene nodo de prompt',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Cancelación solicitada',
'studio.deleteBtn': 'Eliminar',
'studio.delTitle': 'Eliminar registro de tarea',
'studio.delConfirm': 'Los archivos de resultados se eliminarán junto con el registro. ¿Eliminar?',
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
'studio.missingQwen': 'Faltan los JSON dedicados de Qwen Image 2.1 (t2i / img2img / máscara): la vía de importación genérica está lista; expórtalos desde ComfyUI.',
'studio.missingFirstLast': 'Falta el JSON dedicado de MiniMax H3 primer/último fotograma; los ejemplos incluidos de t2v / r2v pueden usarse mientras tanto.',
'studio.pickerTitle': 'Elige un resultado de imagen',
'studio.pickerEmpty': 'Aún no hay resultados de imagen',
'studio.pickerPick': 'Usar',
'studio.uploadFailedToast': 'Subida fallida: {detail}',
'studio.playAria': 'Reproducir',
'studio.pauseAria': 'Pausar',
'studio.fullscreenAria': 'Pantalla completa',
// --- Página de tareas de generación (HEL-612) ---
'tasks.title': 'Tareas de generación',
'tasks.subtitle': 'Todas las tareas de imagen / vídeo: ventanas independientes y flujo de creación',
'tasks.filterAll': 'Todas',
'tasks.filterImage': 'Imágenes',
'tasks.filterVideo': 'Vídeos',
'tasks.count': '{n} tareas · {m} activas',
'tasks.empty': 'Aún no hay tareas. Envía la primera en Imagen o Vídeo.',
'tasks.goGenerate': 'Abrir Imagen',
'tasks.sourceStudioImage': 'Imagen independiente',
'tasks.sourceStudioVideo': 'Vídeo independiente',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': 'Flujo de creación',
'tasks.sourceOther': 'Generación',
'tasks.queued': 'En cola',
'tasks.running': 'Ejecutando',
'tasks.done': 'Hecho',
'tasks.failed': 'Fallido',
'tasks.retry': 'Reintentar',
'tasks.cancel': 'Cancelar',
'tasks.delete': 'Eliminar',
'tasks.delTitle': 'Eliminar tarea',
'tasks.delConfirm': 'Los archivos de resultados se eliminarán junto con la tarea. ¿Eliminar?',
'tasks.outputs': '{n} resultados',
'agentsPage.title': 'Agentes',
'agentsPage.subtitle': 'Habla de forma independiente con los cinco agentes de creación; define y asigna Skills',
'agentsPage.statusChip': '{n} Skills · {m} activos',
'agentsPage.agentsTitle': 'Cinco agentes',
'agentsPage.skillLib': 'Biblioteca de Skills',
'agentsPage.skillLibCount': '{n}',
'agentsPage.installedTitle': '{name} · Skills instalados',
'agentsPage.installedCount': '{n} · {m} activos',
'agentsPage.newSkill': 'Nuevo Skill',
'agentsPage.skillLibEmpty': 'Aún no hay Skills; crea uno con «Nuevo Skill».',
'agentsPage.skillsCount': '{n} Skills',
'agentsPage.replying': 'Respondiendo…',
'agentsPage.idle': 'Inactivo',
'agentsPage.assigned': 'asignado {n}',
'agentsPage.edit': 'Editar',
'agentsPage.toggleSkill': 'Activar o desactivar Skill',
'agentsPage.independentNote': 'Chat independiente · fuera del flujo de creación',
'agentsPage.model': 'Modelo',
'agentsPage.modelDefault': 'Predeterminado del agente',
'agentsPage.newSessionLabel': 'Nueva conversación',
'agentsPage.newSession': 'Nueva conversación',
'agentsPage.clear': 'Vaciar',
'agentsPage.export': 'Exportar',
'agentsPage.skillManage': 'Gestión de Skills',
'agentsPage.composerPlaceholder': 'Sigue charlando o pega un fragmento de guion… (Enter envía, Shift+Enter salto de línea)',
'agentsPage.send': 'Enviar',
'agentsPage.clearTitle': 'Vaciar conversación',
'agentsPage.clearConfirm': 'El historial no se puede recuperar. ¿Vaciar la conversación con el agente {name}?',
'agentsPage.skillErrName': 'Ponle un nombre al Skill',
'agentsPage.skillErrAssign': 'Asígnalo al menos a un Agent, o no surtirá efecto',
'agentsPage.skillLoadFailed': 'No se pudo leer el contenido del Skill',
'agentsPage.skillSaved': 'Skill guardado',
'agentsPage.skillSaveFailed': 'Error al guardar: {detail}',
'agentsPage.skillEnabled': 'Skill activado',
'agentsPage.skillDisabled': 'Skill desactivado; las nuevas respuestas no lo usarán',
'agentsPage.skillDeleted': 'Skill eliminado',
'agentsPage.skillRestored': 'Se restauró una versión anterior',
'agentsPage.skillTypeTemplate': 'Plantilla de prompts',
'agentsPage.skillTypeKnowledge': 'Conocimiento de referencia',
'agentsPage.skillTypeRules': 'Reglas de proceso',
'agentsPage.versions': 'Historial de versiones',
'agentsPage.versionsEmpty': 'Aún no hay versiones; cada guardado archiva la anterior.',
'agentsPage.restore': 'Restaurar',
'agentsPage.close': 'Cerrar',
'agentsPage.editSkill': 'Editar Skill',
'agentsPage.skillName': 'Nombre',
'agentsPage.skillNamePh': 'p. ej. cyberpunk-style-kit',
'agentsPage.skillType': 'Tipo',
'agentsPage.skillDesc': 'Descripción',
'agentsPage.skillDescPh': 'Cuándo debe usarlo el agente (aparece en su lista de Skills)',
'agentsPage.skillContent': 'Contenido',
'agentsPage.skillContentPh': 'Admite Markdown: plantillas, referencias o reglas…',
'agentsPage.skillContentHint': 'Admite Markdown; al guardar se incrementa la versión y se archiva la anterior.',
'agentsPage.assignTo': 'Asignar a',
'agentsPage.saveNext': 'Guardar v{v}+1',
'agentsPage.save': 'Guardar',
'agentsPage.disable': 'Desactivar',
'agentsPage.enable': 'Activar',
'agentsPage.delete': 'Eliminar',
'agentsPage.delSkillTitle': 'Eliminar Skill',
'agentsPage.delSkillConfirm': 'No se puede deshacer; los {n} agentes asignados lo perderán. ¿Eliminar?',
'agentsPage.skillIdleHint': 'Elige «Editar» en la biblioteca o pulsa «Nuevo Skill»',
'agentsPage.sugScript1': 'Convierte una idea en un esquema de tres actos',
'agentsPage.sugScript1P': 'Tengo una idea: un dependiente nota al mismo cliente al mismo minuto cada noche. Escribe un esquema de tres actos.',
'agentsPage.sugScript2': 'Pulir un guion existente',
'agentsPage.sugScript2P': 'Pegaré un guion; léelo primero y señala tres mejoras de diálogo y ritmo.',
'agentsPage.sugStyle1': 'Proponer direcciones de estilo',
'agentsPage.sugStyle1P': 'Corto romántico urbano bajo lluvia y neón: dame tres direcciones visuales distintas con palabras clave en inglés.',
'agentsPage.sugStyle2': 'Palabras clave cinematográficas',
'agentsPage.sugStyle2P': 'Compila prompts de estilo cinematográfico realista (positivo + negativo), inglés con notas en chino.',
'agentsPage.sugBoard1': 'Desglosar el guion en un storyboard',
'agentsPage.sugBoard1P': 'Desglosa este guion por planos: número, tamaño, movimiento, contenido, diálogo y duración.',
'agentsPage.sugBoard2': 'Storyboard del clímax',
'agentsPage.sugBoard2P': 'Clímax de persecución de 30 segundos: da una versión con ritmo marcado.',
'agentsPage.sugAssets1': 'Prompts de hoja de personaje',
'agentsPage.sugAssets1P': 'La protagonista es una detective de 17 años: escribe prompts coherentes (frente / perfil / cuerpo entero).',
'agentsPage.sugAssets2': 'Lista de assets',
'agentsPage.sugAssets2P': 'Basándote en la escena de la tienda nocturna, lista prompts de escenarios y atrezzo.',
'agentsPage.sugShot1': 'Un prompt de vídeo',
'agentsPage.sugShot1P': 'Travelling de entrada al neón hacia la puerta de cristal: escribe un prompt de vídeo listo para usar.',
'agentsPage.sugShot2': 'Planos primer/último fotograma',
'agentsPage.sugShot2P': 'Muestra cómo escribir un prompt con primer y último fotograma: descripción, movimiento y duración.',
'workbench.title': 'Taller de creación',
'workbench.subtitle': 'Guion → estilo → storyboard → assets → lista de planos, cinco agentes en relevo',
'workbench.pickerSubtitle': 'Elige un proyecto para continuar su flujo de cinco etapas',
'workbench.pickerEmpty': 'Aún no hay proyectos',
'workbench.pickerEmptyBody': 'Vuelve al resumen general y empieza desde una idea o importa un guion.',
'workbench.pickerGoHome': 'Ir al resumen',
'workbench.pickerFootHint': 'También puedes entrar al taller desde el resumen (idea / importar guion).',
'workbench.stageProgress': 'Progreso de cinco etapas',
'workbench.activeJobs': '{n} tareas en curso',
'workbench.stage.script': 'Guion',
'workbench.stage.style': 'Estilo visual',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Lista de planos',
'workbench.lockedStep': 'Se desbloquea al confirmar «{stage}»',
'workbench.state.todo': 'Sin empezar',
'workbench.state.running': 'Generando',
'workbench.state.draft': 'Por confirmar',
'workbench.state.confirmed': 'Confirmado',
'workbench.state.edited': 'Editado',
'workbench.copied': 'Copiado — pégalo en la generación de imagen/vídeo',
'workbench.copyFailed': 'Error al copiar, cópialo manualmente',
'workbench.copy': 'Copiar',
'workbench.charCount': '{n} caracteres',
'workbench.runStage': 'Generar con {agent}',
'workbench.runDone': '{agent} terminó el borrador — revísalo y confirma',
'workbench.runFailed': 'Falló la generación, reintenta',
'workbench.runningHint': '{agent} está escribiendo esta etapa…',
'workbench.confirmNext': 'Confirmar y continuar',
'workbench.confirmLast': 'Confirmar etapa',
'workbench.confirmed': 'Etapa «{agent}» confirmada',
'workbench.confirmFailed': 'Error al confirmar, reintenta',
'workbench.restore': 'Restaurar versión confirmada',
'workbench.restored': 'Se restauró la última versión confirmada',
'workbench.restoreFailed': 'Error al restaurar, reintenta',
'workbench.draftNotice': 'Borrador sin confirmar: el siguiente agente solo lo verá tras confirmar.',
'workbench.draftSaved': 'Borrador guardado — falta confirmarlo',
'workbench.saveFailed': 'Error al guardar, reintenta',
'workbench.saveDraft': 'Guardar borrador',
'workbench.cancelEdit': 'Cancelar',
'workbench.editDraft': 'Editar',
'workbench.regenerate': 'Regenerar',
'workbench.regenerateHint': 'Vuelve a generar esta etapa (la versión confirmada se conserva)',
'workbench.instructionPh': 'Indicaciones extra (opcional): más cinematográfico, diálogos naturales…',
'workbench.jobSubmitted': 'Tarea en cola — los resultados vuelven al proyecto',
'workbench.jobSubmitFailed': 'Error al enviar la tarea',
'workbench.jobRequeued': 'Tarea reencolada',
'workbench.retryJob': 'Reintentar',
'workbench.cancelJob': 'Cancelar tarea',
'workbench.openOutput': 'Ver resultado',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': 'En curso',
'workbench.jobQueued': 'En cola',
'workbench.jobDone': 'Listo',
'workbench.jobFailed': 'Fallido',
'workbench.generating': 'Generando…',
'workbench.progressChip': 'Etapa {n}/5',
'workbench.backHome': 'Resumen',
'workbench.loadFailed': 'Error al cargar el taller.',
'workbench.retryLoad': 'Reintentar',
'workbench.agentSkills': '{n} skills',
'workbench.chatWithAgent': 'Chat individual',
'workbench.tasksTitle': 'Tareas de etapa',
'workbench.tasksAll': 'Todas las tareas',
'workbench.tasksEmpty': 'Aún no hay tareas. Envía prompts desde la etapa de assets o planos.',
'workbench.flowTitle': 'Progreso de cinco etapas',
'workbench.script.todoTitle': 'Empieza por el guion: entrega tu idea al agente de Guion',
'workbench.script.todoIdea': 'Tu idea:',
'workbench.script.generate': 'Redactar el borrador',
'workbench.script.importBtn': 'Importar guion existente',
'workbench.script.writeManually': 'Escribir a mano',
'workbench.script.importPlaceholder': 'Pega aquí un guion existente… Al importar se salta la redacción y sigue en estilo visual.',
'workbench.script.importHint': 'El guion importado pasa a ser la base confirmada de esta etapa.',
'workbench.script.importConfirm': 'Importar y saltar',
'workbench.script.importDone': 'Guion importado — sigue desde estilo visual',
'workbench.script.importFailed': 'Error al importar, revisa el texto y reintenta',
'workbench.script.editorHint': 'Edita el texto directamente; aún necesitarás confirmar.',
'workbench.script.copyLabel': 'Guion completo',
'workbench.style.todoTitle': 'Define el estilo visual del guion',
'workbench.style.todoBody': 'El agente de Estilo propone varias direcciones con prompts de imagen listos para copiar.',
'workbench.style.generate': 'Proponer direcciones',
'workbench.style.namePh': 'Nombre, p. ej. Neón Cyber',
'workbench.style.descPh': 'Descripción (paleta / luz / materia)',
'workbench.style.promptPh': 'Palabras clave en inglés, separadas por comas',
'workbench.style.negativePh': 'Prompt negativo (opcional, inglés)',
'workbench.style.negativeLabel': 'Prompt negativo',
'workbench.style.remove': 'Eliminar dirección',
'workbench.style.add': 'Añadir dirección',
'workbench.style.chosen': 'Elegido',
'workbench.style.chooseHint': 'Al confirmar, el estilo elegido pasa a las siguientes etapas.',
'workbench.style.promptLabel': 'Prompt de estilo',
'workbench.board.scene': 'Escena',
'workbench.board.size': 'Plano',
'workbench.board.camera': 'Cámara',
'workbench.board.duration': 'Seg',
'workbench.board.desc': 'Descripción',
'workbench.board.dialog': 'Diálogo/SFX',
'workbench.board.descPh': 'Qué muestra este plano',
'workbench.board.dialogPh': 'Diálogo o efecto (opcional)',
'workbench.board.remove': 'Eliminar plano',
'workbench.board.add': 'Añadir plano',
'workbench.board.todoTitle': 'Divide el guion plano a plano',
'workbench.board.todoBody': 'El agente de Storyboard produce números, escalas, movimientos y duraciones.',
'workbench.board.generate': 'Redactar el storyboard',
'workbench.board.summary': '{n} planos · ~{sec} s',
'workbench.asset.kindCharacter': 'Personaje',
'workbench.asset.kindLocation': 'Localización',
'workbench.asset.kindProp': 'Atrezo',
'workbench.asset.namePh': 'Nombre del asset',
'workbench.asset.descPh': 'Descripción (apariencia, uso)',
'workbench.asset.promptPh': 'Prompt de imagen completo en inglés',
'workbench.asset.promptLabel': 'Prompt de imagen',
'workbench.asset.remove': 'Eliminar asset',
'workbench.asset.add': 'Añadir asset',
'workbench.asset.todoTitle': 'Construye la lista de assets con prompts',
'workbench.asset.todoBody': 'El agente de Assets combina guion, estilo y storyboard en prompts listos para copiar.',
'workbench.asset.generate': 'Redactar la lista',
'workbench.asset.generateImage': 'Generar imagen',
'workbench.asset.generateHint': 'Genera con el workflow ComfyUI conectado; los resultados vuelven al proyecto',
'workbench.asset.summary': '{n} assets',
'workbench.vshot.noPh': 'N.º de plano, p. ej. S1',
'workbench.vshot.descPh': 'Descripción del plano',
'workbench.vshot.promptPh': 'Prompt completo de vídeo (inglés)',
'workbench.vshot.promptLabel': 'Prompt de vídeo',
'workbench.vshot.remove': 'Eliminar plano',
'workbench.vshot.add': 'Añadir plano',
'workbench.vshot.todoTitle': 'Produce el guion de vídeo plano a plano',
'workbench.vshot.todoBody': 'El agente de Planos convierte el storyboard en prompts para modelos de vídeo.',
'workbench.vshot.generate': 'Redactar los planos',
'workbench.vshot.generateVideo': 'Generar vídeo del plano',
'workbench.vshot.generateHint': 'Genera con el workflow de vídeo ComfyUI; los resultados vuelven al proyecto',
'workbench.vshot.summary': '{n} planos',
'home.errScriptShort': 'El guion es demasiado corto — revisa el pegado (mín. 30 caracteres)',
'home.stageProgress': 'Progreso de cinco etapas',
'tasks.sourceWorkbench': 'Flujo de creación',
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
'jobst.done': 'Listo',
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
'jobst.dryRun': 'Marcador',
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
'jobst.noOutput': 'Sin salida',
'jobst.noOutputDetail': 'El trabajo terminó sin archivos de resultado: no cuenta como generado',
'jobst.queued': 'En cola',
'jobst.queuedDetail': 'Esperando en la cola del taller para enviarse a ComfyUI',
'jobst.connecting': 'Conectando',
'jobst.connectingDetail': 'Conectando con ComfyUI y subiendo entradas',
'jobst.comfyQueued': 'Aceptado',
'jobst.comfyQueuedDetail': 'ComfyUI aceptó el trabajo y lo puso en cola',
'jobst.comfyQueuedAhead': 'ComfyUI aceptó el trabajo: {n} por delante en su cola',
'jobst.comfyRunning': 'Ejecutando',
'jobst.comfyRunningDetail': 'ComfyUI está ejecutando el trabajo',
'jobst.saving': 'Guardando',
'jobst.savingDetail': 'ComfyUI devolvió resultados, descargándolos',
'jobst.failed': 'Fallido',
'jobst.cancelled': 'Cancelado',
'studio.ratioLabel': 'Relación de aspecto',
'studio.megapixelsLabel': 'Megapíxeles',
'studio.outputSize': 'Salida {w} × {h}',
'studio.denoiseLabel': 'Eliminación de ruido',
'studio.errNumber': '«{label}» debe ser un número',
'studio.errInteger': '«{label}» debe ser un número entero',
'studio.errMin': '«{label}» debe ser al menos {v}',
'studio.errMax': '«{label}» debe ser como máximo {v}',
'studio.errOption': '«{label}» no es una opción del flujo',
'studio.reuseParams': 'Reutilizar ajustes',
'studio.reusedToast': 'Ajustes de este trabajo aplicados: modifícalos y genera de nuevo',
'studio.reuseMissingWf': 'El flujo que usó este trabajo ya no existe',
'studio.doneToast': 'Listo: {n} archivo(s) (ID de prompt de ComfyUI {id})',
'studio.dryRunToast': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
'studio.dryRunNote': 'Dry-run activo: Generar solo escribe marcadores y nunca llama a ComfyUI. Desactívalo en Ajustes.',
};
+699 -1
View File
@@ -34,6 +34,8 @@ export const fr: Dict = {
'nav.projects': 'Projets',
'nav.buildScene': 'Composer la scène',
'nav.settings': 'Paramètres',
'nav.agents': 'Agents',
'nav.models': 'Modèles',
'nav.home': 'Accueil',
'nav.primary': 'Principal',
'nav.projectStages': 'Étapes du projet',
@@ -162,6 +164,8 @@ export const fr: Dict = {
// --- API errors ---
'api.unknownError': "Une erreur s'est produite",
'api.networkError': 'Impossible de joindre le backend : vérifiez qu\'il tourne puis réessayez',
'api.httpError': 'Échec de la requête (HTTP {status}) : {detail}',
'playground.openFailed': "Échec de l'ouverture du bac à sable",
'playground.opening': 'Ouverture du bac à sable…',
'agents.opening': "Ouverture de l'agent…",
@@ -962,6 +966,27 @@ export const fr: Dict = {
'library.emptyFilterBody': 'Rien ne correspond à ce filtre pour l’instant – essayez « Tout » ou générez quelque chose de nouveau.',
// --- Settings ---
'settings.pageSub': 'File de génération, exécution des agents et chemins · LLM, connexions ComfyUI et Skills ont leur propre page',
'settings.secQueue': 'File et génération',
'settings.secQueueBlurb': 'Parallélisme, relances, interrogation et essai',
'settings.secAgent': 'Exécution des agents',
'settings.secAgentBlurb': 'Étapes, contexte, durcissement des prompts et shell',
'settings.secStorage': 'Chemins de stockage',
'settings.secStorageBlurb': 'Dossiers de données, ressources et espace de travail',
'settings.elsewhere': 'Géré ailleurs',
'settings.linkModelsBlurb': 'LLM et modèle par défaut de chaque agent',
'settings.linkConnectionsBlurb': 'Adresses, tests de connexion et bibliothèque de workflows',
'settings.linkAgentsBlurb': 'Skills et discussions dédiées des cinq agents',
'settings.dryRunTitle': 'Mode essai',
'settings.dryRunHint': 'Activé : ComfyUI n’est pas appelé et les tâches renvoient des images / vidéos factices ; désactivez pour générer réellement.',
'settings.agentRunSection': 'Exécution des agents',
'settings.agentRunLead': 'Limite les étapes d’outils par exécution et l’historique transmis.',
'settings.rangeHint': 'Plage {min}–{max}',
'settings.loadFailed': 'Impossible de charger les réglages : vérifiez que le serveur tourne',
'skillName.character-consistency': 'Cohérence des personnages',
'skillName.h3-video-prompt-enhancer': 'Amélioration de prompts vidéo H3',
'skillName.scene-to-video': 'Scène en vidéo',
'skillName.shot-composer-blockout': 'Pré-cadrage du plan',
'settings.loading': 'Chargement des paramètres',
'settings.allSaved': 'Toutes les modifications sont enregistrées',
'settings.unsaved': 'Modifications non enregistrées',
@@ -995,6 +1020,12 @@ export const fr: Dict = {
'settings.removeLlmTitle': 'Retirer le LLM',
'settings.name': 'Nom',
'settings.active': 'Actif',
'settings.testLlm': 'Tester la connexion',
'settings.llmTestOk': 'Connexion réussie ({ms} ms)',
'settings.llmTestFail': 'Échec de connexion : {detail}',
'settings.llmTestAuth': 'Clé refusée ({detail})',
'settings.llmTestTimeout': 'Aucune réponse sous 15 s',
'settings.llmTestConnect': 'Serveur injoignable ({detail})',
'settings.agentModelsSection': 'Modèle agent',
'settings.agentModelsLead': "Modèle utilisé pour la rédaction de l'histoire et les tâches de l'agent.",
'settings.agentMaxStepsField': 'Étapes max.',
@@ -1002,7 +1033,7 @@ export const fr: Dict = {
'settings.queueSection': "File d'attente",
'settings.queueLead': 'Parallélisme et délais des tâches de génération et des appels d’outils de l’agent.',
'settings.concurrencyField': 'Tâches simultanées',
'settings.pollIntervalField': 'Intervalle de vérification (ms)',
'settings.pollIntervalField': 'Intervalle de vérification (s)',
'settings.pollTimeoutField': 'Délai de la tâche (s)',
'settings.pollTimeoutHint': 'Durée maximale d’un job ComfyUI ou d’un appel d’outil de l’agent. 0 attend la fin.',
'settings.maxRetriesField': 'Tentatives max.',
@@ -1128,4 +1159,671 @@ export const fr: Dict = {
'shot.errBlob': "Impossible de lire l'image capturée",
'shot.errRecorder': "Impossible de démarrer l'enregistreur",
'shot.errNoData': 'Aucune donnée de scène à exporter',
// Models settings page (spec-v2 §9)
'models.title': 'Modèles',
'models.subtitle': 'Ajoutez des LLM personnalisés pour vos agents · la phase 1 ne couvre que les LLM ; les modèles image / vidéo arriveront ensuite',
'models.statusCount': '{n} modèles · {m} disponibles',
'models.add': 'Ajouter un modèle',
'models.tabLlm': 'Modèles de langage',
'models.tabImage': "Modèles d'image",
'models.tabVideo': 'Modèles vidéo',
'models.comingSoon': 'À venir',
'models.empty': 'Aucun modèle pour l’instant — cliquez sur « Ajouter un modèle » en haut à droite pour connecter votre premier LLM',
'models.typeLocal': 'Local',
'models.typeLan': 'LAN',
'models.typeCloud': 'Cloud',
'models.defaultBadge': 'Par défaut',
'models.setDefault': 'Définir par défaut',
'models.defaultSetToast': 'Modèle par défaut des agents défini',
'models.test': 'Tester la connexion',
'models.testing': 'Test en cours…',
'models.edit': 'Modifier',
'models.delete': 'Supprimer',
'models.statusUntested': 'Non testé',
'models.statusConnected': 'Connecté',
'models.statusFailed': 'Échec de connexion',
'models.latency': 'latence {ms}ms',
'models.agentsUsing': 'utilisé par {n} agents',
'models.lastTestAt': 'dernier test {time}',
'models.consecutiveFails': '{n} échecs consécutifs',
'models.cloudPaused': 'Appels cloud suspendus',
'models.formTitleAdd': 'Ajouter un modèle',
'models.formTitleEdit': 'Modifier le modèle',
'models.formClose': 'Fermer',
'models.fName': 'Nom',
'models.fNamePh': 'Nommez le modèle, ex. DeepSeek officiel',
'models.fApiType': 'Type d’API',
'models.apiOpenai': 'Compatible OpenAI',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': 'Autre',
'models.apiTypeHelp': 'La phase 1 ne prend en charge que les API compatibles OpenAI ; les autres protocoles arriveront avec les modèles image / vidéo',
'models.fBaseUrl': 'URL de base',
'models.baseUrlHelp': 'Avec protocole et port, ex. http://192.168.200.18:11434/v1',
'models.fModel': 'Nom du modèle',
'models.modelHelp': 'Utilisez l’identifiant de modèle fourni par votre prestataire',
'models.fApiKey': 'Clé API',
'models.keyNoEcho': 'jamais affichée',
'models.keyHelp': 'Stockée uniquement sur ce serveur ; la clé complète n’apparaît jamais dans l’interface',
'models.keyKeepHint': 'Clé enregistrée — laissez vide pour la conserver',
'models.fTimeout': 'Délai d’attente',
'models.timeoutSec': '{n}s',
'models.testOk': '✓ Test réussi · {ms}ms · {model} disponible',
'models.testFail': '✕ Échec du test · {reason}',
'models.needTest': 'Enregistrement possible après un test réussi',
'models.testBtn': 'Test connection',
'models.save': 'Enregistrer le modèle',
'models.errName': 'Veuillez nommer le modèle',
'models.errUrl': 'URL invalide — protocole et chemin requis, ex. https://api.deepseek.com/v1',
'models.errModel': 'Saisissez le nom du modèle fourni par votre prestataire',
'models.errKey': 'Saisissez la clé API',
'models.failTimeout': 'Délai dépassé ({sec}s sans réponse) : le serveur n’atteint pas cette adresse — vérifiez adresse, port et pare-feu',
'models.failAuth': 'Échec d’authentification (401) : vérifiez que la clé API est correcte et non expirée ; avec les agrégateurs, confirmez que le modèle est activé',
'models.failNotFound': 'Joignable mais modèle introuvable (404) : vérifiez l’orthographe du nom',
'models.failNotOpenai': 'Joignable mais non reconnue comme API compatible OpenAI — le chemin doit se terminer par /v1',
'models.failConnect': 'Le serveur ne peut pas se connecter à cette adresse — vérifiez adresse et port',
'models.failHttp': 'Test failed (HTTP {status})',
'models.delTitle': 'Supprimer le modèle',
'models.delConfirmUsed': '{n} agents utilisent ce modèle ; après suppression ils reviendront au modèle par défaut. Supprimer ?',
'models.delConfirmFree': 'Supprimer ce modèle ? Action irréversible.',
'models.delDefaultHint': 'Ce modèle est celui par défaut — choisissez d’abord un autre défaut',
'models.agentsTitle': 'Modèles par défaut des agents',
'models.agentsHint': 'Appliqué aux nouvelles sessions · réglage par agent sur la page Agents',
'models.agentDefault': 'Par défaut',
'models.agentStory': 'Scénario',
'models.agentStyle': 'Direction visuelle',
'models.agentScript': 'Storyboards',
'models.agentAssets': 'Bibliothèque d’assets',
'models.agentVideo': 'Plans de tournage',
'models.unavailable': '(indisponible)',
'models.noticeStrong': 'La connectivité est mesurée depuis le serveur du workbench :',
'models.noticeBody': 'une URL qui s’ouvre dans votre navigateur n’est pas forcément accessible au serveur ; les tests de connexion et les requêtes sont faits par le serveur. Les clés API restent sur le serveur et ne sont jamais affichées.',
'models.saved': 'Modèle enregistré',
'models.deleted': 'Modèle supprimé',
'models.assignSaved': 'Modèles des agents mis à jour',
'models.saveFailed': 'Échec de l’enregistrement : {detail}',
'models.loading': 'Chargement…',
'models.loadFailed': 'Impossible de charger les paramètres',
'models.idleFormHint': 'Cliquez « Modifier » sur une carte ou « Ajouter un modèle » ci-dessus',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Atelier Cinéma',
'shell.brandMark': 'A',
'shell.brandHome': 'Retour à l\'aperçu des projets',
'shell.navAria': 'Navigation principale',
'shell.navProjects': 'Projets',
'shell.navWorkbench': 'Atelier',
'shell.navImage': 'Génération d\'image',
'shell.navVideo': 'Génération vidéo',
'shell.navTasks': 'Tâches',
'shell.navAgents': 'Agents',
'shell.navConnections': 'Connexions ComfyUI',
'shell.navModels': 'Modèles',
'shell.navSettings': 'Réglages',
'shell.wip': 'En développement',
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
'shell.comfy_ok': '{name} connecté',
'shell.comfy_fail': '{name} injoignable',
'shell.comfy_testing': 'Test de {name}…',
'shell.comfyUnset': 'ComfyUI · non configuré',
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
'home.title': 'Aperçu des projets',
'home.subtitle': 'Gérez vos projets — commencez avec une idée ou un scénario prêt.',
'home.projectCount': '{n} projets · {m} en cours',
'home.startIdea': 'Commencer avec une idée',
'home.importScript': 'Importer un scénario',
'home.entryIdeaTitle': 'Commencer avec une idée',
'home.entryIdeaBody': 'Décrivez une idée ; les agents la mènent étape par étape à travers l\'histoire, le storyboard et les plans.',
'home.entryScriptTitle': 'Importer un scénario',
'home.entryScriptBody': 'Vous avez déjà un scénario ? Collez-le, créez le projet et l\'agent scénario le découpe en scènes ordonnées.',
'home.ideaPlaceholder': 'Un petit port pluvieux où le gardien de phare découvre…',
'home.scriptField': 'Scénario complet',
'home.scriptPlaceholder': 'Collez ici le scénario complet…',
'home.scriptHint': 'Après création, la page Agents s\'ouvre et l\'agent scénario transforme le texte en scènes ordonnées.',
'home.errTitle': 'Donnez un titre au projet',
'home.errScript': 'Collez le scénario complet',
'home.myProjects': 'Mes projets',
'home.myProjectsHint': 'Cliquez sur un projet pour continuer',
'home.emptyProjects': 'Aucun projet pour le moment. Créez le premier avec « Commencer avec une idée » ou « Importer un scénario ».',
'home.noIdea': 'Pas encore de description',
'home.statScenes': '{n} scènes',
'home.statCharacters': '{n} personnages',
'home.statAssets': 'ressources {ready}/{total}',
'home.statusDraft': 'Brouillon',
'home.statusProgress': 'En cours',
'home.statusDone': 'Terminé',
'home.tasksTitle': 'Tâches',
'home.activeJobs': '{n} actives',
'home.emptyJobs': 'Aucune tâche de génération',
'home.emptyJobsSub': 'Les tâches lancées depuis un projet ou l\'atelier s\'afficheront ici.',
'home.tasksNote': 'L\'historique complet et les filtres se trouvent sur la page Tâches (navigation → 生成任务).',
'home.kindImage': 'image',
'home.kindVideo': 'vidéo',
'home.kindExport': 'film',
'home.jobDone': 'terminée',
'home.jobRunning': 'en cours',
'home.jobQueued': 'en attente',
'home.jobFailed': 'échouée',
'home.jobRequeued': 'Tâche remise en file',
'home.jobRetryFailed': 'Impossible de remettre la tâche en file',
'home.jobProject': 'Projet #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'settings.comfyMultiHint': 'Les adresses ComfyUI prennent désormais en charge plusieurs connexions (local / LAN / cloud), associables par workflow.',
'settings.comfyGoto': 'Gérer dans « Connexions ComfyUI » →',
'conn.title': 'Connexions ComfyUI',
'conn.subtitle': 'Enregistrez des adresses API ComfyUI locales, LAN ou cloud · la connectivité est testée par le serveur',
'conn.statusCount': '{n} connexions · {m} réussies',
'conn.add': 'Ajouter une connexion',
'conn.loading': 'Chargement des connexions…',
'conn.loadFailed': 'Échec du chargement des connexions',
'conn.empty': 'Aucune connexion ComfyUI pour l\'instant — ajoutez la première adresse via « Ajouter une connexion ».',
'conn.targetsTitle': 'Cible des tâches',
'conn.targetImage': 'Image autonome',
'conn.targetVideo': 'Vidéo autonome',
'conn.targetFlow': 'Tâches du flux créatif',
'conn.targetChange': 'Modifier',
'conn.targetMixed': '{n} connexions',
'conn.targetNote': 'Les tâches suivent la liaison de chaque workflow ; sans liaison, la connexion par défaut est utilisée. Modifiez les liaisons dans la bibliothèque — les pages image / vidéo peuvent aussi changer avant génération.',
'conn.defaultBadge': 'Par défaut',
'conn.test': 'Tester la connexion',
'conn.testing': 'Test…',
'conn.edit': 'Modifier',
'conn.delete': 'Supprimer',
'conn.setDefault': 'Définir par défaut',
'conn.delDefaultHint': 'La connexion par défaut ne peut pas être supprimée ; choisissez-en une autre d\'abord',
'conn.delTitle': 'Supprimer la connexion',
'conn.delConfirmUsed': 'Les workflows liés à cette connexion reviendront à la connexion par défaut. Les instantanés existants sont conservés. Supprimer « {name} » ?',
'conn.delConfirmFree': 'Supprimer la connexion « {name} » ?',
'conn.statusUntested': 'Non testée',
'conn.statusConnected': 'Test réussi',
'conn.statusFailed': 'Échec du test',
'conn.latency': '{ms} ms de latence',
'conn.version': 'version {v}',
'conn.lastTestAt': 'dernier test {time}',
'conn.boundWorkflows': '{n} workflows liés',
'conn.tokenSaved': 'Jeton enregistré — laissez vide pour le conserver',
'conn.saved': 'Connexion enregistrée',
'conn.deleted': 'Connexion supprimée',
'conn.saveFailed': 'Échec de l\'enregistrement : {detail}',
'conn.defaultSetToast': 'Définie comme connexion par défaut',
'conn.formTitleAdd': 'Ajouter une connexion ComfyUI',
'conn.formTitleEdit': 'Modifier la connexion ComfyUI',
'conn.formClose': 'Fermer',
'conn.fName': 'Nom',
'conn.fNamePh': 'ex. ComfyUI local / machine de rendu',
'conn.fBaseUrl': 'Adresse API',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'Adresse HTTP de l\'API ComfyUI (port 8188 par défaut) ; même format pour local, LAN et cloud.',
'conn.fAuth': 'Authentification',
'conn.authNone': 'Aucune',
'conn.authBearer': 'Jeton Bearer',
'conn.authHeader': 'En-tête personnalisé',
'conn.authHelp': 'Nécessaire seulement si le point de terminaison est derrière une auth (clé de reverse proxy, etc.).',
'conn.fToken': 'Jeton d\'accès',
'conn.fHeaderName': 'Nom d\'en-tête',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': 'stocké côté serveur uniquement, jamais renvoyé',
'conn.keyHelp': 'Le jeton reste dans le fichier de configuration du serveur et n\'atteint jamais le navigateur.',
'conn.fNotes': 'Notes',
'conn.fNotesPh': 'facultatif : ce que fait la machine, VRAM…',
'conn.errName': 'Le nom est requis',
'conn.errUrl': 'L\'adresse doit être une URL http:// ou https:// valide',
'conn.errHeaderName': 'L\'en-tête personnalisé exige un nom',
'conn.testOk': '✓ Test réussi · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': 'Réussissez le test avant d\'enregistrer',
'conn.testBtn': 'Tester',
'conn.save': 'Enregistrer',
'conn.noticeStrong': 'C\'est le serveur qui décide de la connectivité : ',
'conn.noticeBody': 'Une adresse qui s\'ouvre dans le navigateur ne garantit pas que la génération fonctionne ; tests et requêtes sont faits par le serveur. Les jetons restent côté serveur.',
'conn.wfTitle': 'Bibliothèque de workflows',
'conn.wfHint': 'Importez le JSON au format API ComfyUI, classez-le image/vidéo, liez-le à une connexion',
'conn.wfEmpty': 'Aucun workflow importé. Activez le mode développeur dans ComfyUI, exportez en « format API » puis collez-le ici.',
'conn.wfCount': '{n} workflows · {m} image · {k} vidéo',
'conn.wfKindImage': 'Image',
'conn.wfKindVideo': 'Vidéo',
'conn.wfConnection': 'Connexion liée',
'conn.wfConnectionActive': 'Suit la connexion par défaut',
'conn.wfParams': '{n} paramètres remplissables',
'conn.wfImport': 'Importer le workflow',
'conn.wfImporting': 'Import…',
'conn.wfNodes': '{n} nœuds',
'conn.wfImportedN': 'Workflow importé : {n} paramètres détectés',
'conn.wfImportedNoParams': 'Workflow importé, mais aucun paramètre détecté : il sera exécuté avec ses réglages intégrés',
'conn.wfImportFailed': 'Échec de l\'import : {detail}',
'conn.wfImportTitle': 'Importer un workflow ComfyUI (JSON format API)',
'conn.wfImportHint': 'Collez ou choisissez un fichier ; le nom est requis, le type décide de la fenêtre image ou vidéo.',
'conn.wfJsonInvalid': 'JSON invalide au format API (attendu : objet id de nœud → {{…class_type…}})',
'conn.wfJsonPh': 'Collez ici le JSON format API ComfyUI ou choisissez un fichier',
'conn.wfDeleted': 'Workflow supprimé',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
'conn.missingMaterial': 'Un JSON de workflow Qwen Image 2.1 dédié manque encore au dépôt : exportez-le depuis ComfyUI et importez-le ici — le chemin d\'import générique est prêt.',
// --- Génération indépendante image / vidéo (a3/a4, HEL-612) ---
'studio.image.title': 'Image',
'studio.image.subtitle': 'Générez des images avec les workflows importés · connexions et file partagées avec Connexions ComfyUI',
'studio.video.title': 'Vidéo',
'studio.video.subtitle': 'Générez des vidéos avec les workflows importés · connexions et file partagées avec Connexions ComfyUI',
'studio.genParams': 'Paramètres',
'studio.modeTextToImage': 'Texte vers image',
'studio.modeTextToVideo': 'Texte vers vidéo',
'studio.chipIdle': '{name} · file libre',
'studio.chipBusy': '{name} · {n} en file',
'studio.video.resultsTitle': 'Aperçu',
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
'studio.historyCount': '{n} au total',
'studio.lastDone': 'Dernier · {time}',
'studio.kindImageText': 'image',
'studio.kindVideoText': 'vidéo',
'studio.loading': 'Chargement…',
'studio.connLabel': 'Connexion',
'studio.connQueue': 'File {n}',
'studio.connUntested': 'Vérification…',
'studio.connOk': 'Connecté · {ms} ms',
'studio.connFail': 'Injoignable',
'studio.connManage': 'Gérer les connexions',
'studio.wfLabel': 'Workflow',
'studio.wfParams': '{n} paramètres remplissables',
'studio.wfBindTo': 'Lié à {name}',
'studio.wfFollowActive': 'Suit la connexion par défaut',
'studio.promptLabel': 'Prompt positif',
'studio.promptPh': 'Décrivez l’image : sujet, décor, lumière, style…',
'studio.negLabel': 'Prompt négatif',
'studio.seedLabel': 'Graine',
'studio.sizeLabel': 'Dimensions',
'studio.batchLabel': 'Lot',
'studio.durationLabel': 'Durée (s)',
'studio.fpsLabel': 'Images/s',
'studio.stepsLabel': 'Étapes',
'studio.cfgLabel': 'CFG',
'studio.paramsTitle': 'Paramètres du workflow',
'studio.chars': '{n} car.',
'studio.insertStyle': 'Insérer des mots de style',
'studio.styleSoon': 'La bibliothèque de Skills arrivera avec la fonction Agents',
'studio.generateN': 'Générer · {n} images',
'studio.generate': 'Générer',
'studio.submitting': 'Envoi…',
'studio.etaHint': 'Le temps réel dépend de la connexion et du workflow',
'studio.errNoConn': 'Sélectionnez d’abord une connexion de génération',
'studio.errConnDown': 'Cette connexion est indisponible (dernier test échoué à {time}) ; changez de connexion ou corrigez-la dans Connexions ComfyUI',
'studio.errPrompt': 'Remplissez le prompt',
'studio.errPromptLong': 'Le prompt dépasse 2000 caractères ; raccourcissez-le',
'studio.errRefImage': 'Image-vers-image nécessite une image de référence',
'studio.errFirstLast': 'La vidéo premier/dernier cadre exige les deux cadres',
'studio.errUpload': 'Téléversez {label}',
'studio.errFill': 'Remplissez {label}',
'studio.slotRef': 'Image de référence',
'studio.slotRefN': 'Référence {n}',
'studio.slotMask': 'Masque',
'studio.slotFirst': 'Premier cadre',
'studio.slotLast': 'Dernier cadre',
'studio.slotRefVideo': 'Vidéo de référence',
'studio.slotAudio': 'Audio de référence',
'studio.slotPick': 'Cliquez ou déposez',
'studio.slotFromResults': 'Choisir parmi les résultats d’image',
'studio.taskCurrent': 'Tâche en cours',
'studio.taskQueued': 'En file',
'studio.taskRunning': 'Génération',
'studio.taskCancel': 'Annuler la tâche',
'studio.queuedBehind': '{n} tâche(s) devant',
'studio.stepsShort': 'Étapes {v}',
'studio.usedShort': 'Écoulé {v}',
'studio.etaShort': 'Restant {v}',
'studio.targetShort': 'Cible {name}',
'studio.renderFrames': 'Frame {done}/{total}',
'studio.noActiveTask': 'Aucune tâche active · réglez les paramètres puis lancez Générer',
'studio.resultsTitle': 'Résultats récents',
'studio.selectHint': 'Choisie',
'studio.sendToImg2Img': 'Envoyer vers img2img',
'studio.zoom': 'Agrandir',
'studio.download': 'Télécharger',
'studio.downloadMp4': 'Télécharger MP4',
'studio.copyPrompt': 'Copier le prompt',
'studio.copiedToast': 'Copié ; collez-le dans la génération image/vidéo',
'studio.sentToImg2ImgToast': 'Ajouté comme référence ; vérifiez les paramètres et lancez',
'studio.noImg2ImgWf': 'Aucun workflow image ne déclare d’entrée de référence ; importez d’abord un workflow img2img dans Connexions ComfyUI',
'studio.videoRefStrip': 'Référence premier / dernier cadre',
'studio.historyEmptyImage': 'Aucune génération d’image ; réglez les paramètres puis lancez Générer',
'studio.historyEmptyVideo': 'Aucune génération de vidéo ; réglez les paramètres puis lancez Générer',
'studio.jobName': 'Tâche #{id}',
'studio.retryLink': 'Réessayer',
'studio.retryQueuedToast': 'Tâche remise en file',
'studio.retryFailedToast': 'Échec de la remise en file : {detail}',
'studio.errNoWorkflow': 'Choisissez d\'abord un workflow',
'studio.errBlocked': 'Envoi impossible : {reason}',
'studio.queuedToast': 'Envoyé à « {name} », en file',
'studio.submitFailedToast': 'Échec de l\'envoi : {detail}',
'studio.jobFailedToast': 'Échec de la génération : {detail}',
'studio.wfNoParamsNote': 'Ce workflow n\'a aucun paramètre à remplir et sera exécuté avec son prompt et ses réglages intégrés. Pour les remplir ici, ajoutez (Input:prompt) etc. aux titres de nœuds dans ComfyUI puis réimportez.',
'studio.wfAutoParamsNote': 'Aucune balise (Input:…) : {n} paramètres détectés par type de nœud ; les champs vides gardent les valeurs du workflow.',
'studio.promptNotDeclared': 'Ce workflow n\'a pas de nœud de prompt',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Annulation demandée',
'studio.deleteBtn': 'Supprimer',
'studio.delTitle': 'Supprimer la tâche',
'studio.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
'studio.missingQwen': 'Les JSON dédiés Qwen Image 2.1 (t2i / img2img / masque) manquent encore ; la voie d’import générique est prête — exportez-les depuis ComfyUI.',
'studio.missingFirstLast': 'Le JSON dédié MiniMax H3 premier/dernier cadre manque encore ; les exemples t2v / r2v fournis peuvent être importés en attendant.',
'studio.pickerTitle': 'Choisir un résultat d’image',
'studio.pickerEmpty': 'Aucun résultat d’image disponible',
'studio.pickerPick': 'Utiliser',
'studio.uploadFailedToast': 'Échec du téléversement : {detail}',
'studio.playAria': 'Lecture',
'studio.pauseAria': 'Pause',
'studio.fullscreenAria': 'Plein écran',
// --- Page des tâches de génération (HEL-612) ---
'tasks.title': 'Tâches de génération',
'tasks.subtitle': 'Toutes les tâches image / vidéo : fenêtres indépendantes et flux de création',
'tasks.filterAll': 'Toutes',
'tasks.filterImage': 'Images',
'tasks.filterVideo': 'Vidéos',
'tasks.count': '{n} tâches · {m} actives',
'tasks.empty': 'Aucune tâche. Lancez la première dans Image ou Vidéo.',
'tasks.goGenerate': 'Ouvrir Image',
'tasks.sourceStudioImage': 'Image indépendante',
'tasks.sourceStudioVideo': 'Vidéo indépendante',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': 'Flux de création',
'tasks.sourceOther': 'Génération',
'tasks.queued': 'En file',
'tasks.running': 'En cours',
'tasks.done': 'Terminé',
'tasks.failed': 'Échec',
'tasks.retry': 'Réessayer',
'tasks.cancel': 'Annuler',
'tasks.delete': 'Supprimer',
'tasks.delTitle': 'Supprimer la tâche',
'tasks.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
'tasks.outputs': '{n} résultats',
'agentsPage.title': 'Agents',
'agentsPage.subtitle': 'Discutez séparément avec les cinq agents de création ; définissez et attribuez des Skills',
'agentsPage.statusChip': '{n} Skills · {m} activés',
'agentsPage.agentsTitle': 'Cinq agents',
'agentsPage.skillLib': 'Bibliothèque de Skills',
'agentsPage.skillLibCount': '{n}',
'agentsPage.installedTitle': '{name} · Skills installés',
'agentsPage.installedCount': '{n} · {m} actifs',
'agentsPage.newSkill': 'Nouveau Skill',
'agentsPage.skillLibEmpty': 'Aucun Skill pour l’instant — créez-en un avec « Nouveau Skill ».',
'agentsPage.skillsCount': '{n} Skills',
'agentsPage.replying': 'Réponse…',
'agentsPage.idle': 'Inactif',
'agentsPage.assigned': 'attribué {n}',
'agentsPage.edit': 'Modifier',
'agentsPage.toggleSkill': 'Activer ou désactiver le Skill',
'agentsPage.independentNote': 'Chat indépendant · hors du flux de création',
'agentsPage.model': 'Modèle',
'agentsPage.modelDefault': 'Défaut de l’agent',
'agentsPage.newSessionLabel': 'Nouvelle discussion',
'agentsPage.newSession': 'Nouvelle discussion',
'agentsPage.clear': 'Vider',
'agentsPage.export': 'Exporter',
'agentsPage.skillManage': 'Gestion des Skills',
'agentsPage.composerPlaceholder': 'Continuez la discussion ou collez un fragment de scénario… (Entrée pour envoyer, Maj+Entrée pour un saut de ligne)',
'agentsPage.send': 'Envoyer',
'agentsPage.clearTitle': 'Vider la discussion',
'agentsPage.clearConfirm': 'L’historique sera perdu définitivement. Vider la discussion avec l’agent {name} ?',
'agentsPage.skillErrName': 'Donnez un nom au Skill',
'agentsPage.skillErrAssign': 'Attribuez-le à au moins un Agent, sinon il ne prendra jamais effet',
'agentsPage.skillLoadFailed': 'Échec de lecture du contenu du Skill',
'agentsPage.skillSaved': 'Skill enregistré',
'agentsPage.skillSaveFailed': 'Échec de l’enregistrement : {detail}',
'agentsPage.skillEnabled': 'Skill activé',
'agentsPage.skillDisabled': 'Skill désactivé — les nouvelles réponses ne l’utiliseront plus',
'agentsPage.skillDeleted': 'Skill supprimé',
'agentsPage.skillRestored': 'Version précédente restaurée',
'agentsPage.skillTypeTemplate': 'Modèle de prompt',
'agentsPage.skillTypeKnowledge': 'Connaissance de référence',
'agentsPage.skillTypeRules': 'Règles de processus',
'agentsPage.versions': 'Historique des versions',
'agentsPage.versionsEmpty': 'Aucune version — chaque enregistrement archive la précédente.',
'agentsPage.restore': 'Restaurer',
'agentsPage.close': 'Fermer',
'agentsPage.editSkill': 'Modifier le Skill',
'agentsPage.skillName': 'Nom',
'agentsPage.skillNamePh': 'ex. cyberpunk-style-kit',
'agentsPage.skillType': 'Type',
'agentsPage.skillDesc': 'Description',
'agentsPage.skillDescPh': 'Quand l’agent doit l’utiliser (affiché dans sa liste de Skills)',
'agentsPage.skillContent': 'Contenu',
'agentsPage.skillContentPh': 'Markdown pris en charge : modèles, références ou règles…',
'agentsPage.skillContentHint': 'Markdown pris en charge ; l’enregistrement incrémente la version et archive l’ancienne.',
'agentsPage.assignTo': 'Attribuer à',
'agentsPage.saveNext': 'Enregistrer v{v}+1',
'agentsPage.save': 'Enregistrer',
'agentsPage.disable': 'Désactiver',
'agentsPage.enable': 'Activer',
'agentsPage.delete': 'Supprimer',
'agentsPage.delSkillTitle': 'Supprimer le Skill',
'agentsPage.delSkillConfirm': 'Irréversible ; les {n} agents assignés le perdront aussi. Supprimer ?',
'agentsPage.skillIdleHint': 'Choisissez « Modifier » dans la bibliothèque ou cliquez « Nouveau Skill »',
'agentsPage.sugScript1': 'Transformer une idée en plan trois actes',
'agentsPage.sugScript1P': 'Idée : un vendeur de supérette remarque le même client à la même minute chaque nuit. Écrivez un plan en trois actes.',
'agentsPage.sugScript2': 'Peaufiner un scénario existant',
'agentsPage.sugScript2P': 'Je collerai un scénario ; lisez-le puis proposez trois améliorations de dialogue et de rythme.',
'agentsPage.sugStyle1': 'Proposer des directions visuelles',
'agentsPage.sugStyle1P': 'Court-métrage romantique urbain sous la pluie néon : trois directions visuelles distinctes avec mots-clés anglais.',
'agentsPage.sugStyle2': 'Mots-clés cinéma réaliste',
'agentsPage.sugStyle2P': 'Compilez des prompts de style cinéma réaliste (positif + négatif), anglais annoté en chinois.',
'agentsPage.sugBoard1': 'Découper le scénario en storyboard',
'agentsPage.sugBoard1P': 'Découpez ce scénario plan par plan : numéro, échelle, mouvement, contenu, réplique, durée.',
'agentsPage.sugBoard2': 'Storyboard du climax',
'agentsPage.sugBoard2P': 'Climax de poursuite de 30 secondes : une version au rythme marqué.',
'agentsPage.sugAssets1': 'Prompts de fiche personnage',
'agentsPage.sugAssets1P': 'L’héroïne est une détective de 17 ans : des prompts cohérents (face / profil / plein pied).',
'agentsPage.sugAssets2': 'Liste d’assets',
'agentsPage.sugAssets2P': 'À partir de la supérette sous la pluie, listez des prompts de décors et accessoires.',
'agentsPage.sugShot1': 'Un prompt vidéo',
'agentsPage.sugShot1P': 'Travelling avant de l’enseigne néon vers la porte vitrée : un prompt vidéo prêt à l’emploi.',
'agentsPage.sugShot2': 'Plans premier/dernier cadre',
'agentsPage.sugShot2P': 'Montrez comment écrire un prompt premier/dernier cadre : description, mouvement et durée.',
'workbench.title': 'Atelier de création',
'workbench.subtitle': 'Scénario → style → storyboard → assets → plan de tournage, cinq agents en relais',
'workbench.pickerSubtitle': 'Choisissez un projet pour reprendre son flux en cinq étapes',
'workbench.pickerEmpty': 'Aucun projet pour l\'instant',
'workbench.pickerEmptyBody': 'Retournez à la vue d\'ensemble : partez d\'une idée ou importez un scénario.',
'workbench.pickerGoHome': 'Vue d\'ensemble',
'workbench.pickerFootHint': 'Vous pouvez aussi entrer dans l\'atelier depuis la vue d\'ensemble (idée / import de scénario).',
'workbench.stageProgress': 'Progression en cinq étapes',
'workbench.activeJobs': '{n} tâches en cours',
'workbench.stage.script': 'Scénario',
'workbench.stage.style': 'Style visuel',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Plan de tournage',
'workbench.lockedStep': 'Se déverrouille après confirmation de « {stage} »',
'workbench.state.todo': 'Non commencé',
'workbench.state.running': 'Génération',
'workbench.state.draft': 'À confirmer',
'workbench.state.confirmed': 'Confirmé',
'workbench.state.edited': 'Modifié',
'workbench.copied': 'Copié — à coller dans la génération image/vidéo',
'workbench.copyFailed': 'Échec de la copie, copiez manuellement',
'workbench.copy': 'Copier',
'workbench.charCount': '{n} caractères',
'workbench.runStage': 'Générer avec {agent}',
'workbench.runDone': '{agent} a terminé le brouillon — à valider',
'workbench.runFailed': 'Échec de génération, réessayez',
'workbench.runningHint': '{agent} rédige cette étape…',
'workbench.confirmNext': 'Confirmer et continuer',
'workbench.confirmLast': 'Confirmer l\'étape',
'workbench.confirmed': 'Étape « {agent} » confirmée',
'workbench.confirmFailed': 'Échec de confirmation, réessayez',
'workbench.restore': 'Restaurer la version confirmée',
'workbench.restored': 'Version confirmée restaurée',
'workbench.restoreFailed': 'Échec de restauration, réessayez',
'workbench.draftNotice': 'Brouillon non confirmé : l\'agent suivant ne le verra qu\'après confirmation.',
'workbench.draftSaved': 'Brouillon enregistré — confirmation requise',
'workbench.saveFailed': 'Échec de l\'enregistrement, réessayez',
'workbench.saveDraft': 'Enregistrer le brouillon',
'workbench.cancelEdit': 'Annuler',
'workbench.editDraft': 'Modifier',
'workbench.regenerate': 'Régénérer',
'workbench.regenerateHint': 'Regénérer cette étape (la version confirmée est conservée et restaurable)',
'workbench.instructionPh': 'Consignes supplémentaires (facultatif) : plus cinématographique, dialogues plus naturels…',
'workbench.jobSubmitted': 'Tâche de génération envoyée — les résultats reviennent au projet',
'workbench.jobSubmitFailed': 'Échec de l\'envoi de la tâche',
'workbench.jobRequeued': 'Tâche remise en file',
'workbench.retryJob': 'Réessayer',
'workbench.cancelJob': 'Annuler la tâche',
'workbench.openOutput': 'Voir le résultat',
'workbench.jobRunningPct': '{n} %',
'workbench.jobRunning': 'En cours',
'workbench.jobQueued': 'En file',
'workbench.jobDone': 'Terminé',
'workbench.jobFailed': 'Échec',
'workbench.generating': 'Génération…',
'workbench.progressChip': 'Étape {n}/5',
'workbench.backHome': 'Vue d\'ensemble',
'workbench.loadFailed': 'Échec du chargement de l\'atelier.',
'workbench.retryLoad': 'Réessayer',
'workbench.agentSkills': '{n} skills',
'workbench.chatWithAgent': 'Discuter en solo',
'workbench.tasksTitle': 'Tâches de l\'étape',
'workbench.tasksAll': 'Toutes les tâches',
'workbench.tasksEmpty': 'Aucune tâche. Envoyez des prompts depuis l\'étape assets ou plans.',
'workbench.flowTitle': 'Progression en cinq étapes',
'workbench.script.todoTitle': 'Commencez par le scénario : confiez votre idée à l\'agent Scénario',
'workbench.script.todoIdea': 'Votre idée :',
'workbench.script.generate': 'Rédiger le brouillon',
'workbench.script.importBtn': 'Importer un scénario',
'workbench.script.writeManually': 'Écrire manuellement',
'workbench.script.importPlaceholder': 'Collez ici un scénario existant… L\'import saute la rédaction et reprend au style visuel.',
'workbench.script.importHint': 'Le scénario importé devient la base confirmée de cette étape.',
'workbench.script.importConfirm': 'Importer et passer',
'workbench.script.importDone': 'Scénario importé — continuez au style visuel',
'workbench.script.importFailed': 'Échec de l\'import, vérifiez le texte et réessayez',
'workbench.script.editorHint': 'Modifiez le texte directement ; la confirmation reste nécessaire.',
'workbench.script.copyLabel': 'Scénario complet',
'workbench.style.todoTitle': 'Définir le style visuel du scénario',
'workbench.style.todoBody': 'L\'agent Style propose plusieurs directions, chacune avec des prompts image prêts à copier.',
'workbench.style.generate': 'Proposer des directions',
'workbench.style.namePh': 'Nom de la direction, ex. Néon Cyber',
'workbench.style.descPh': 'Description (palette / lumière / matière)',
'workbench.style.promptPh': 'Mots-clés image en anglais, séparés par des virgules',
'workbench.style.negativePh': 'Prompt négatif (facultatif, anglais)',
'workbench.style.negativeLabel': 'Prompt négatif',
'workbench.style.remove': 'Supprimer la direction',
'workbench.style.add': 'Ajouter une direction',
'workbench.style.chosen': 'Sélectionné',
'workbench.style.chooseHint': 'En confirmant, le style choisi est transmis aux étapes suivantes.',
'workbench.style.promptLabel': 'Prompt de style',
'workbench.board.scene': 'Scène',
'workbench.board.size': 'Échelle',
'workbench.board.camera': 'Caméra',
'workbench.board.duration': 'Sec',
'workbench.board.desc': 'Description',
'workbench.board.dialog': 'Dialogue/SFX',
'workbench.board.descPh': 'Ce que montre ce plan',
'workbench.board.dialogPh': 'Dialogue ou effet sonore (facultatif)',
'workbench.board.remove': 'Supprimer le plan',
'workbench.board.add': 'Ajouter un plan',
'workbench.board.todoTitle': 'Découper le scénario plan par plan',
'workbench.board.todoBody': 'L\'agent Storyboard produit numéros, échelles, mouvements et durées.',
'workbench.board.generate': 'Rédiger le storyboard',
'workbench.board.summary': '{n} plans · ~{sec} s',
'workbench.asset.kindCharacter': 'Personnage',
'workbench.asset.kindLocation': 'Lieu',
'workbench.asset.kindProp': 'Accessoire',
'workbench.asset.namePh': 'Nom de l\'asset',
'workbench.asset.descPh': 'Description (apparence, usage)',
'workbench.asset.promptPh': 'Prompt image complet en anglais',
'workbench.asset.promptLabel': 'Prompt image',
'workbench.asset.remove': 'Supprimer l\'asset',
'workbench.asset.add': 'Ajouter un asset',
'workbench.asset.todoTitle': 'Construire la liste d\'assets avec prompts',
'workbench.asset.todoBody': 'L\'agent Assets combine scénario, style et storyboard en prompts prêts à copier.',
'workbench.asset.generate': 'Rédiger la liste',
'workbench.asset.generateImage': 'Générer l\'image',
'workbench.asset.generateHint': 'Génère via le workflow ComfyUI connecté ; les résultats reviennent au projet',
'workbench.asset.summary': '{n} assets',
'workbench.vshot.noPh': 'N° de plan, ex. S1',
'workbench.vshot.descPh': 'Description du plan',
'workbench.vshot.promptPh': 'Prompt vidéo complet (anglais)',
'workbench.vshot.promptLabel': 'Prompt vidéo',
'workbench.vshot.remove': 'Supprimer le plan',
'workbench.vshot.add': 'Ajouter un plan',
'workbench.vshot.todoTitle': 'Produire le script vidéo plan par plan',
'workbench.vshot.todoBody': 'L\'agent Plans transforme le storyboard en prompts prêts pour les modèles vidéo.',
'workbench.vshot.generate': 'Rédiger les plans',
'workbench.vshot.generateVideo': 'Générer la vidéo du plan',
'workbench.vshot.generateHint': 'Génère via le workflow vidéo ComfyUI connecté ; résultats dans le projet',
'workbench.vshot.summary': '{n} plans',
'home.errScriptShort': 'Scénario trop court — vérifiez le collage (30 caractères min.)',
'home.stageProgress': 'Progression en cinq étapes',
'tasks.sourceWorkbench': 'Flux de création',
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
'jobst.done': 'Terminé',
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
'jobst.dryRun': 'Substitut',
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
'jobst.noOutput': 'Aucune sortie',
'jobst.noOutputDetail': 'La tâche s\'est terminée sans fichier de résultat : pas une génération',
'jobst.queued': 'En attente',
'jobst.queuedDetail': 'En attente dans la file de l\'atelier avant envoi à ComfyUI',
'jobst.connecting': 'Connexion',
'jobst.connectingDetail': 'Connexion à ComfyUI et envoi des entrées',
'jobst.comfyQueued': 'Acceptée',
'jobst.comfyQueuedDetail': 'ComfyUI a accepté la tâche et l\'a mise en file',
'jobst.comfyQueuedAhead': 'ComfyUI a accepté la tâche — {n} devant dans sa file',
'jobst.comfyRunning': 'En cours',
'jobst.comfyRunningDetail': 'ComfyUI exécute la tâche',
'jobst.saving': 'Enregistrement',
'jobst.savingDetail': 'ComfyUI a renvoyé des résultats, téléchargement',
'jobst.failed': 'Échec',
'jobst.cancelled': 'Annulée',
'studio.ratioLabel': 'Format d\'image',
'studio.megapixelsLabel': 'Mégapixels',
'studio.outputSize': 'Sortie {w} × {h}',
'studio.denoiseLabel': 'Débruitage',
'studio.errNumber': '« {label} » doit être un nombre',
'studio.errInteger': '« {label} » doit être un entier',
'studio.errMin': '« {label} » doit valoir au moins {v}',
'studio.errMax': '« {label} » doit valoir au plus {v}',
'studio.errOption': '« {label} » n\'est pas une option du workflow',
'studio.reuseParams': 'Réutiliser les réglages',
'studio.reusedToast': 'Réglages de cette tâche repris : ajustez puis relancez',
'studio.reuseMissingWf': 'Le workflow de cette tâche n\'existe plus',
'studio.doneToast': 'Terminé : {n} fichier(s) (ID de prompt ComfyUI {id})',
'studio.dryRunToast': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
'studio.dryRunNote': 'Dry-run actif : Générer n\'écrit que des substituts et n\'appelle jamais ComfyUI. Désactivez-le dans Réglages.',
};
+699 -1
View File
@@ -34,6 +34,8 @@ export const ja: Dict = {
'nav.projects': 'プロジェクト',
'nav.buildScene': 'シーン構築',
'nav.settings': '設定',
'nav.agents': 'エージェント',
'nav.models': 'モデル設定',
'nav.home': 'ホーム',
'nav.primary': 'メイン',
'nav.projectStages': 'プロジェクトの段階',
@@ -162,6 +164,8 @@ export const ja: Dict = {
// --- API errors ---
'api.unknownError': '問題が発生しました',
'api.networkError': 'ワークベンチのバックエンドに接続できません。起動を確認して再試行してください',
'api.httpError': 'リクエスト失敗(HTTP {status}):{detail}',
'playground.openFailed': 'プレイグラウンドを開けませんでした',
'playground.opening': 'プレイグラウンドを開いています…',
'agents.opening': 'エージェントを開いています…',
@@ -962,6 +966,27 @@ export const ja: Dict = {
'library.emptyFilterBody': 'このフィルターに一致するものはまだありません – 「すべて」を試すか、新しく生成してください。',
// --- Settings ---
'settings.pageSub': '生成キュー・エージェント実行・保存パス · LLM、ComfyUI 接続、Skill はそれぞれのページで管理',
'settings.secQueue': '生成とキュー',
'settings.secQueueBlurb': '並列数・再試行・ポーリング・試運転',
'settings.secAgent': 'エージェント実行',
'settings.secAgentBlurb': 'ステップ・コンテキスト・プロンプト強化・Shell',
'settings.secStorage': '保存パス',
'settings.secStorageBlurb': 'データ・素材・作業フォルダ',
'settings.elsewhere': 'その他の設定',
'settings.linkModelsBlurb': 'LLM と各エージェントの既定モデル',
'settings.linkConnectionsBlurb': '接続先・接続テスト・ワークフロー',
'settings.linkAgentsBlurb': '5 つのエージェントの Skill と個別チャット',
'settings.dryRunTitle': '試運転モード',
'settings.dryRunHint': 'オンの間は ComfyUI を呼ばず、仮の画像 / 動画を返して流れを確認できます。実際に生成するにはオフにします。',
'settings.agentRunSection': 'エージェント実行',
'settings.agentRunLead': '1 回の実行のツール呼び出し数と持ち込む履歴を制限します。',
'settings.rangeHint': '範囲 {min}–{max}',
'settings.loadFailed': '設定を読み込めません。サーバーが起動しているか確認してください',
'skillName.character-consistency': 'キャラクター一貫性',
'skillName.h3-video-prompt-enhancer': 'H3 動画プロンプト強化',
'skillName.scene-to-video': 'シーンから動画',
'skillName.shot-composer-blockout': 'ショット構図プレビュー',
'settings.loading': '設定を読み込み中',
'settings.allSaved': 'すべての変更を保存しました',
'settings.unsaved': '未保存の変更',
@@ -995,6 +1020,12 @@ export const ja: Dict = {
'settings.removeLlmTitle': 'LLM を削除',
'settings.name': '名前',
'settings.active': '有効',
'settings.testLlm': '接続テスト',
'settings.llmTestOk': '接続成功({ms} ms)',
'settings.llmTestFail': '接続失敗:{detail}',
'settings.llmTestAuth': 'キーが拒否されました({detail})',
'settings.llmTestTimeout': '15 秒以内に応答なし',
'settings.llmTestConnect': 'サーバーに接続できません({detail})',
'settings.agentModelsSection': 'エージェントモデル',
'settings.agentModelsLead': 'ストーリーの下書きやエージェントタスクに使用するモデル。',
'settings.agentMaxStepsField': '最大ステップ数',
@@ -1002,7 +1033,7 @@ export const ja: Dict = {
'settings.queueSection': 'ジョブキュー',
'settings.queueLead': '生成ジョブとエージェントのツール呼び出しの並列数とタイムアウト。',
'settings.concurrencyField': '同時実行ジョブ数',
'settings.pollIntervalField': 'ポーリング間隔(ms)',
'settings.pollIntervalField': 'ポーリング間隔(秒)',
'settings.pollTimeoutField': 'ジョブタイムアウト(秒)',
'settings.pollTimeoutHint': 'ComfyUI のジョブ、またはエージェントのツール呼び出し 1 回の上限。0 は完了まで待つ。',
'settings.maxRetriesField': '最大再試行回数',
@@ -1128,4 +1159,671 @@ export const ja: Dict = {
'shot.errBlob': 'キャプチャしたフレームを読み取れませんでした',
'shot.errRecorder': 'レコーダーを開始できませんでした',
'shot.errNoData': '書き出すシーンデータがありません',
// Models settings page (spec-v2 §9)
'models.title': 'モデル設定',
'models.subtitle': 'エージェントが使うカスタム LLM を追加 · 第一段階は LLM のみ、画像 / 動画モデルは後日',
'models.statusCount': 'モデル {n} 件 · 利用可能 {m} 件',
'models.add': 'モデルを追加',
'models.tabLlm': '大規模言語モデル',
'models.tabImage': '画像モデル',
'models.tabVideo': '動画モデル',
'models.comingSoon': '近日公開',
'models.empty': 'モデルが未設定です。右上の「モデルを追加」から最初の LLM を登録してください',
'models.typeLocal': 'ローカル',
'models.typeLan': 'LAN',
'models.typeCloud': 'クラウド',
'models.defaultBadge': 'エージェント既定',
'models.setDefault': '既定にする',
'models.defaultSetToast': 'エージェント既定モデルを設定しました',
'models.test': '接続テスト',
'models.testing': 'テスト中…',
'models.edit': '編集',
'models.delete': '削除',
'models.statusUntested': '未テスト',
'models.statusConnected': '接続済み',
'models.statusFailed': '接続失敗',
'models.latency': '遅延 {ms}ms',
'models.agentsUsing': '{n} 個のエージェントが使用',
'models.lastTestAt': '前回テスト {time}',
'models.consecutiveFails': '連続失敗 {n} 回',
'models.cloudPaused': 'クラウド呼び出しを一時停止',
'models.formTitleAdd': 'モデルを追加',
'models.formTitleEdit': 'モデルを編集',
'models.formClose': '閉じる',
'models.fName': '名前',
'models.fNamePh': 'モデルの名前(例:DeepSeek 公式)',
'models.fApiType': 'API タイプ',
'models.apiOpenai': 'OpenAI 互換',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': 'その他',
'models.apiTypeHelp': '第一段階は OpenAI 互換 API のみ対応。他のプロトコルは画像 / 動画モデルと同時に提供予定',
'models.fBaseUrl': 'API アドレス',
'models.baseUrlHelp': 'プロトコルとポートを含めてください(例:http://192.168.200.18:11434/v1)',
'models.fModel': 'モデル名',
'models.modelHelp': 'プロバイダ提供のモデル ID を指定してください',
'models.fApiKey': 'API キー',
'models.keyNoEcho': '表示されません',
'models.keyHelp': 'このサーバーにのみ保存され、完全なキーが画面に表示されることはありません',
'models.keyKeepHint': 'キー保存済み — 空欄のままなら変更されません',
'models.fTimeout': 'タイムアウト',
'models.timeoutSec': '{n} 秒',
'models.testOk': '✓ テスト成功 · {ms}ms · {model} 利用可能',
'models.testFail': '✕ テスト失敗 · {reason}',
'models.needTest': 'テスト成功後に保存できます',
'models.testBtn': '接続テスト',
'models.save': 'モデルを保存',
'models.errName': 'モデルに名前を付けてください',
'models.errUrl': 'アドレス形式が不正です。プロトコルとパスを含めてください(例:https://api.deepseek.com/v1)',
'models.errModel': 'プロバイダ提供のモデル名を入力してください',
'models.errKey': 'API キーを入力してください',
'models.failTimeout': 'タイムアウト({sec} 秒無応答):サーバーからこのアドレスに届きません。アドレス・ポート・ファイアウォールを確認してください',
'models.failAuth': '認証失敗(401):API キーが正しいか・期限切れでないか確認してください。集約サービスの場合はモデル権限もご確認ください',
'models.failNotFound': '接続はできるがモデルが見つかりません(404):モデル名の綴りを確認してください',
'models.failNotOpenai': 'アドレスにはアクセスできるものの OpenAI 互換 API と識別できません。パスが /v1 で終わるか確認してください',
'models.failConnect': 'サーバーからこのアドレスに接続できません。アドレスとポートを確認してください',
'models.failHttp': 'Test failed (HTTP {status})',
'models.delTitle': 'モデルを削除',
'models.delConfirmUsed': '{n} 個のエージェントが使用中です。削除後は「エージェント既定」に切り替わります。削除しますか?',
'models.delConfirmFree': 'このモデルを削除しますか?取り消せません。',
'models.delDefaultHint': 'このモデルは「エージェント既定」です。先に別の既定モデルを指定してください',
'models.agentsTitle': 'エージェント既定モデル',
'models.agentsHint': '新しいセッションから適用 · 個別設定はエージェントページで',
'models.agentDefault': 'エージェント既定',
'models.agentStory': '脚本制作',
'models.agentStyle': 'ビジュアルデザイン',
'models.agentScript': '絵コンテ',
'models.agentAssets': '素材アセット',
'models.agentVideo': 'ショットリスト',
'models.unavailable': '(利用不可)',
'models.noticeStrong': '接続性はワークベンチのサーバー基準です:',
'models.noticeBody': 'ブラウザで開けるアドレスでも、ワークベンチのサーバーからアクセスできるとは限りません。接続テストと対話リクエストはサーバーが行います。API キーはサーバーにのみ保存され、画面に表示されることはありません。',
'models.saved': 'モデルを保存しました',
'models.deleted': 'モデルを削除しました',
'models.assignSaved': 'エージェントのモデルを更新しました',
'models.saveFailed': '保存に失敗しました:{detail}',
'models.loading': '読み込み中…',
'models.loadFailed': '設定の読み込みに失敗しました',
'models.idleFormHint': 'カードの「編集」を選ぶか、上の「モデルを追加」をクリックしてください',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': '映画工房',
'shell.brandMark': '映',
'shell.brandHome': 'プロジェクト概要へ戻る',
'shell.navAria': 'メインナビゲーション',
'shell.navProjects': 'プロジェクト',
'shell.navWorkbench': '制作ワークベンチ',
'shell.navImage': '画像生成',
'shell.navVideo': '動画生成',
'shell.navTasks': '生成タスク',
'shell.navAgents': 'エージェント',
'shell.navConnections': 'ComfyUI 接続',
'shell.navModels': 'モデル設定',
'shell.navSettings': '設定',
'shell.wip': '開発中',
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
'shell.comfy_ok': '{name} 接続済み',
'shell.comfy_fail': '{name} 接続失敗',
'shell.comfy_testing': '{name} テスト中…',
'shell.comfyUnset': 'ComfyUI · 未設定',
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
'home.title': 'プロジェクト概要',
'home.subtitle': '創作プロジェクトを管理 — アイデアでも完成台本でも始められます。',
'home.projectCount': 'プロジェクト {n} 件 · 進行中 {m} 件',
'home.startIdea': 'アイデアから始める',
'home.importScript': '台本を取り込む',
'home.entryIdeaTitle': 'アイデアから始める',
'home.entryIdeaBody': 'アイデアを書くと、エージェントが物語・絵コンテ・クリップへ順を追って仕上げます。',
'home.entryScriptTitle': '台本を取り込む',
'home.entryScriptBody': '完成した台本があれば貼り付けてプロジェクトを作成。台本エージェントが順序立ったシーンに整理します。',
'home.ideaPlaceholder': '雨の夜の小さな港で、灯台守が見つけたものは……',
'home.scriptField': '台本全文',
'home.scriptPlaceholder': '台本の全文をここに貼り付けてください……',
'home.scriptHint': '作成後「エージェント」ページが開き、台本エージェントが順序立ったシーンに整理します。',
'home.errTitle': 'プロジェクトにタイトルを付けてください',
'home.errScript': '台本の全文を貼り付けてください',
'home.myProjects': 'マイプロジェクト',
'home.myProjectsHint': 'プロジェクトをクリックして作業を続ける',
'home.emptyProjects': 'まだプロジェクトがありません。上の「アイデアから始める」か「台本を取り込む」で最初のプロジェクトを作成しましょう。',
'home.noIdea': 'アイデアの説明はまだありません',
'home.statScenes': 'シーン {n}',
'home.statCharacters': '登場人物 {n}',
'home.statAssets': '素材 {ready}/{total}',
'home.statusDraft': '下書き',
'home.statusProgress': '進行中',
'home.statusDone': '完了',
'home.tasksTitle': '生成タスク',
'home.activeJobs': '{n} 件実行中',
'home.emptyJobs': '生成タスクはまだありません',
'home.emptyJobsSub': 'プロジェクトやワークベンチから生成を開始すると、ここに表示されます。',
'home.tasksNote': '生成履歴の全体とフィルタは左ナビの「生成タスク」ページで確認できます。',
'home.kindImage': '画像',
'home.kindVideo': '動画',
'home.kindExport': '映画',
'home.jobDone': '完了',
'home.jobRunning': '実行中',
'home.jobQueued': '待機中',
'home.jobFailed': '失敗',
'home.jobRequeued': 'タスクを再び待機列に入れました',
'home.jobRetryFailed': 'タスクを再待機できませんでした',
'home.jobProject': 'プロジェクト #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'settings.comfyMultiHint': 'ComfyUI アドレスは複数接続(ローカル / LAN / クラウド)に対応し、ワークフローごとに紐付けできます。',
'settings.comfyGoto': '「ComfyUI 接続」ページで管理 →',
'conn.title': 'ComfyUI 接続',
'conn.subtitle': 'ローカル / LAN / クラウドの ComfyUI API アドレスを登録 · 接続性はサーバーが実測します',
'conn.statusCount': '{n} 件の接続 · {m} 件成功',
'conn.add': '接続を追加',
'conn.loading': '接続を読み込み中…',
'conn.loadFailed': '接続情報の読み込みに失敗しました',
'conn.empty': 'ComfyUI 接続がまだありません。右上の「接続を追加」で最初のアドレスを登録してください。',
'conn.targetsTitle': 'タスクの実行先',
'conn.targetImage': '単体画像生成',
'conn.targetVideo': '単体動画生成',
'conn.targetFlow': '制作フロー内のタスク',
'conn.targetChange': '変更',
'conn.targetMixed': '{n} 件の接続',
'conn.targetNote': '各ワークフローの割り当てに従い、未割り当てなら既定の接続を使います。割り当てはワークフローライブラリで変更。画像 / 動画ページでも生成前に切り替えられます。',
'conn.defaultBadge': 'デフォルト',
'conn.test': '接続テスト',
'conn.testing': 'テスト中…',
'conn.edit': '編集',
'conn.delete': '削除',
'conn.setDefault': 'デフォルトに設定',
'conn.delDefaultHint': 'デフォルト接続は削除できません。先に他の接続をデフォルトにしてください',
'conn.delTitle': '接続を削除',
'conn.delConfirmUsed': 'この接続に紐付いたワークフローはデフォルト接続に戻ります。既存ジョブのスナップショットは保持されます。「{name}」を削除しますか?',
'conn.delConfirmFree': '接続「{name}」を削除しますか?',
'conn.statusUntested': '未テスト',
'conn.statusConnected': 'テスト成功',
'conn.statusFailed': 'テスト失敗',
'conn.latency': 'レイテンシ {ms} ms',
'conn.version': 'バージョン {v}',
'conn.lastTestAt': '前回テスト {time}',
'conn.boundWorkflows': '{n} 件のワークフローに紐付け',
'conn.tokenSaved': 'トークン保存済み。空欄で変更なし',
'conn.saved': '接続を保存しました',
'conn.deleted': '接続を削除しました',
'conn.saveFailed': '保存に失敗しました:{detail}',
'conn.defaultSetToast': 'デフォルト接続に設定しました',
'conn.formTitleAdd': 'ComfyUI 接続を追加',
'conn.formTitleEdit': 'ComfyUI 接続を編集',
'conn.formClose': '閉じる',
'conn.fName': '名前',
'conn.fNamePh': '例:ローカル ComfyUI / レンダーマシン',
'conn.fBaseUrl': 'API アドレス',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'ComfyUI の HTTP API アドレス(既定ポート 8188)。ローカル / LAN / クラウドで書式は同じです。',
'conn.fAuth': '認証',
'conn.authNone': 'なし',
'conn.authBearer': 'Bearer トークン',
'conn.authHeader': 'カスタムヘッダー',
'conn.authHelp': 'エンドポイントが認証の背後にある場合(リバースプロキシのキーなど)のみ必要です。',
'conn.fToken': 'アクセストークン',
'conn.fHeaderName': 'ヘッダー名',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': 'サーバー側のみに保存され、画面には表示されません',
'conn.keyHelp': 'トークンはワークベンチ サーバーの設定ファイルに保存され、ブラウザには渡りません。',
'conn.fNotes': 'メモ',
'conn.fNotesPh': '任意:このマシンのモデル・VRAMなど',
'conn.errName': '名前を入力してください',
'conn.errUrl': 'アドレスは http:// または https:// で始まる有効な URL である必要があります',
'conn.errHeaderName': 'カスタムヘッダーにはヘッダー名が必要です',
'conn.testOk': '✓ テスト成功 · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': '保存前にテストを通過してください',
'conn.testBtn': 'テスト',
'conn.save': '保存',
'conn.noticeStrong': '接続性の判定はサーバーが行います:',
'conn.noticeBody': 'ブラウザで開けるアドレス=生成可能ではありません。テストも生成リクエストもサーバーが行い、トークンはサーバー側に保存されます。',
'conn.wfTitle': 'ワークフローライブラリ',
'conn.wfHint': 'ComfyUI の API 形式 JSON を取り込み、画像 / 動画に分類して接続に紐付けます',
'conn.wfEmpty': 'まだワークフローがありません。ComfyUI の開発者モードで「API 形式」を書き出し、ここに貼り付けてください。',
'conn.wfCount': 'ワークフロー {n} 件 · 画像 {m} · 動画 {k}',
'conn.wfKindImage': '画像',
'conn.wfKindVideo': '動画',
'conn.wfConnection': '紐付ける接続',
'conn.wfConnectionActive': 'デフォルト接続に従う',
'conn.wfParams': '記入可能パラメータ {n} 件',
'conn.wfImport': 'ワークフローを取り込む',
'conn.wfImporting': '取り込み中…',
'conn.wfNodes': '{n} ノード',
'conn.wfImportedN': 'ワークフローを取り込みました(記入可能パラメータ {n} 件)',
'conn.wfImportedNoParams': '取り込みましたが記入可能パラメータは検出されませんでした。内蔵設定のまま実行されます',
'conn.wfImportFailed': '取り込みに失敗しました:{detail}',
'conn.wfImportTitle': 'ComfyUI ワークフローを取り込む(API 形式 JSON)',
'conn.wfImportHint': '貼り付けまたはファイル選択。名前は必須で、種類が画像 / 動画ウィンドウのどちらに表示されるかを決めます。',
'conn.wfJsonInvalid': '有効な API 形式 JSON ではありません(ノード ID → {{…class_type…}} のオブジェクトが必要)',
'conn.wfJsonPh': 'ここに ComfyUI の API 形式 JSON を貼り付けるか、右のボタンでファイルを選択',
'conn.wfDeleted': 'ワークフローを削除しました',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
'conn.missingMaterial': 'Qwen Image 2.1 専用のワークフロー JSON はまだリポジトリにありません。ComfyUI から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
// --- 独立画像/動画生成(a3/a4、HEL-612)---
'studio.image.title': '画像生成',
'studio.image.subtitle': '取り込んだ画像ワークフローで生成 · 接続とキューは「ComfyUI 接続」と共通',
'studio.video.title': '動画生成',
'studio.video.subtitle': '取り込んだ動画ワークフローで生成 · 接続とキューは「ComfyUI 接続」と共通',
'studio.genParams': '生成パラメータ',
'studio.modeTextToImage': 'テキストから画像',
'studio.modeTextToVideo': 'テキストから動画',
'studio.chipIdle': '{name} · キュー空き',
'studio.chipBusy': '{name} · キュー {n} 件',
'studio.video.resultsTitle': 'プレビュー',
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
'studio.historyCount': '計 {n} 回',
'studio.lastDone': '最終完了 · {time}',
'studio.kindImageText': '画像',
'studio.kindVideoText': '動画',
'studio.loading': '読み込み中…',
'studio.connLabel': '生成接続',
'studio.connQueue': 'キュー {n}',
'studio.connUntested': '確認中…',
'studio.connOk': '接続済み · {ms} ms',
'studio.connFail': '接続失敗',
'studio.connManage': '接続を管理',
'studio.wfLabel': 'ワークフロー',
'studio.wfParams': '入力可能パラメータ {n} 件',
'studio.wfBindTo': '{name} にバインド済み',
'studio.wfFollowActive': 'デフォルト接続に従う',
'studio.promptLabel': 'ポジティブプロンプト',
'studio.promptPh': '被写体・環境・光・スタイルを記述…',
'studio.negLabel': 'ネガティブプロンプト',
'studio.seedLabel': 'シード',
'studio.sizeLabel': 'サイズ',
'studio.batchLabel': 'バッチ',
'studio.durationLabel': '長さ(秒)',
'studio.fpsLabel': 'フレームレート',
'studio.stepsLabel': 'ステップ',
'studio.cfgLabel': 'CFG',
'studio.paramsTitle': 'ワークフローパラメータ',
'studio.chars': '{n} 字',
'studio.insertStyle': 'スタイル語を挿入',
'studio.styleSoon': 'スキル語彙は「エージェント」機能とともに提供予定',
'studio.generateN': '生成開始 · {n} 枚',
'studio.generate': '生成開始',
'studio.submitting': '送信中…',
'studio.etaHint': '実際の所要時間は接続とワークフロー次第',
'studio.errNoConn': '生成接続を先に選んでください',
'studio.errConnDown': 'この接続は利用できません(前回 {time} にテスト失敗)。切り替えるか「ComfyUI 接続」で修復してください',
'studio.errPrompt': 'プロンプトを入力してください',
'studio.errPromptLong': 'プロンプトが 2000 字を超えています。短くしてください',
'studio.errRefImage': 'img2img には参照画像が必要です',
'studio.errFirstLast': '始端/終端フレーム生成には両方のフレームが必要です',
'studio.errUpload': '{label} をアップロードしてください',
'studio.errFill': '{label} を入力してください',
'studio.slotRef': '参照画像',
'studio.slotRefN': '参照 {n}',
'studio.slotMask': 'マスク',
'studio.slotFirst': '始端フレーム',
'studio.slotLast': '終端フレーム',
'studio.slotRefVideo': '参照動画',
'studio.slotAudio': '参照音声',
'studio.slotPick': 'クリックまたはドロップ',
'studio.slotFromResults': '画像結果から選ぶ',
'studio.taskCurrent': '現在のタスク',
'studio.taskQueued': '待機中',
'studio.taskRunning': '生成中',
'studio.taskCancel': 'タスクをキャンセル',
'studio.queuedBehind': '前に {n} 件',
'studio.stepsShort': 'ステップ {v}',
'studio.usedShort': '経過 {v}',
'studio.etaShort': '残り {v}',
'studio.targetShort': '接続先 {name}',
'studio.renderFrames': 'フレーム {done}/{total}',
'studio.noActiveTask': '実行中のタスクはありません · 左でパラメータを設定して生成開始',
'studio.resultsTitle': '最近の結果',
'studio.selectHint': '選択中',
'studio.sendToImg2Img': 'img2img へ送る',
'studio.zoom': '拡大',
'studio.download': 'ダウンロード',
'studio.downloadMp4': 'MP4 をダウンロード',
'studio.copyPrompt': 'プロンプトをコピー',
'studio.copiedToast': 'コピーしました。画像/動画生成に貼り付けできます',
'studio.sentToImg2ImgToast': '参照画像として設定しました。パラメータを確認して生成してください',
'studio.noImg2ImgWf': '参照画像入力を持つ画像ワークフローがまだありません。先に「ComfyUI 接続」で img2img を取り込んでください',
'studio.videoRefStrip': '始端 / 終端フレーム参照',
'studio.historyEmptyImage': '画像生成の記録がありません。左でパラメータを設定して生成開始',
'studio.historyEmptyVideo': '動画生成の記録がありません。左でパラメータを設定して生成開始',
'studio.jobName': 'タスク #{id}',
'studio.retryLink': '再試行',
'studio.retryQueuedToast': 'タスクを再びキューに入れました',
'studio.retryFailedToast': '再キュー失敗:{detail}',
'studio.errNoWorkflow': '先にワークフローを選択してください',
'studio.errBlocked': '送信できません:{reason}',
'studio.queuedToast': '「{name}」に送信しました(待機中)',
'studio.submitFailedToast': '送信失敗:{detail}',
'studio.jobFailedToast': '生成失敗:{detail}',
'studio.wfNoParamsNote': 'このワークフローには記入可能なパラメータがなく、内蔵のプロンプトと設定で実行されます。ここで記入するには ComfyUI でノードタイトルに (Input:prompt) などを付けて再度取り込んでください。',
'studio.wfAutoParamsNote': '(Input:…) タグがないため、ノード種別から {n} 件のパラメータを自動検出しました。空欄はワークフローの値を使います。',
'studio.promptNotDeclared': 'このワークフローにはプロンプトノードがありません',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'キャンセルを要求しました',
'studio.deleteBtn': '削除',
'studio.delTitle': 'タスク記録を削除',
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
'studio.missingQwen': 'Qwen Image 2.1(t2i / img2img / マスク)専用 JSON はまだありません。汎用取り込みは利用可能なので、ComfyUI から書き出して取り込んでください。',
'studio.missingFirstLast': 'MiniMax H3 始端/終端フレーム専用 JSON はまだありません。同梱の t2v / 参照動画サンプルを先用できます。',
'studio.pickerTitle': '画像結果を選ぶ',
'studio.pickerEmpty': '利用できる画像結果がまだありません',
'studio.pickerPick': '使用する',
'studio.uploadFailedToast': 'アップロード失敗:{detail}',
'studio.playAria': '再生',
'studio.pauseAria': '一時停止',
'studio.fullscreenAria': '全画面',
// --- 生成タスクページ(HEL-612)---
'tasks.title': '生成タスク',
'tasks.subtitle': '独立画像・独立動画・創作フロー内の画像/動画タスクをまとめて表示',
'tasks.filterAll': 'すべて',
'tasks.filterImage': '画像',
'tasks.filterVideo': '動画',
'tasks.count': '{n} 件 · 実行中 {m} 件',
'tasks.empty': '生成タスクがまだありません。「画像生成」「動画生成」から最初のタスクを送信してください。',
'tasks.goGenerate': '画像生成を開く',
'tasks.sourceStudioImage': '独立画像',
'tasks.sourceStudioVideo': '独立動画',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': '創作フロー',
'tasks.sourceOther': '生成',
'tasks.queued': '待機',
'tasks.running': '実行中',
'tasks.done': '完了',
'tasks.failed': '失敗',
'tasks.retry': '再試行',
'tasks.cancel': 'キャンセル',
'tasks.delete': '削除',
'tasks.delTitle': 'タスクを削除',
'tasks.delConfirm': '結果ファイルも一緒に削除されます。削除しますか?',
'tasks.outputs': '結果 {n} 件',
'agentsPage.title': 'エージェント',
'agentsPage.subtitle': '5つの制作エージェントと個別に会話し、Skill を定義・割り当て',
'agentsPage.statusChip': 'Skill {n} 件 · 有効 {m} 件',
'agentsPage.agentsTitle': '5 つの Agent',
'agentsPage.skillLib': 'Skill ライブラリ',
'agentsPage.skillLibCount': '{n} 件',
'agentsPage.installedTitle': '{name} · 導入済み Skill',
'agentsPage.installedCount': '{n} 件 · {m} 有効',
'agentsPage.newSkill': 'Skill を追加',
'agentsPage.skillLibEmpty': 'Skill がありません。上の「Skill を追加」から作成してください。',
'agentsPage.skillsCount': 'Skill {n} 件',
'agentsPage.replying': '返信中…',
'agentsPage.idle': '待機中',
'agentsPage.assigned': '割り当て {n}',
'agentsPage.edit': '編集',
'agentsPage.toggleSkill': 'Skill の有効/無効を切り替え',
'agentsPage.independentNote': '独立セッション · 制作フローには参加しません',
'agentsPage.model': 'モデル',
'agentsPage.modelDefault': 'エージェント既定',
'agentsPage.newSessionLabel': '新しい会話',
'agentsPage.newSession': '新しい会話',
'agentsPage.clear': 'クリア',
'agentsPage.export': 'エクスポート',
'agentsPage.skillManage': 'Skill 管理',
'agentsPage.composerPlaceholder': '会話を続けるか、台本の断片を貼り付けて…(Enter で送信、Shift+Enter で改行)',
'agentsPage.send': '送信',
'agentsPage.clearTitle': '会話をクリア',
'agentsPage.clearConfirm': 'クリアすると履歴は復元できません。{name}エージェントとの会話をクリアしますか?',
'agentsPage.skillErrName': 'Skill に名前を付けてください',
'agentsPage.skillErrAssign': '少なくとも1つの Agent に割り当てないと有効になりません',
'agentsPage.skillLoadFailed': 'Skill の内容を読み込めませんでした',
'agentsPage.skillSaved': 'Skill を保存しました',
'agentsPage.skillSaveFailed': '保存に失敗:{detail}',
'agentsPage.skillEnabled': 'Skill を有効にしました',
'agentsPage.skillDisabled': 'Skill を無効にしました。以後の返信では使用されません',
'agentsPage.skillDeleted': 'Skill を削除しました',
'agentsPage.skillRestored': '過去のバージョンに戻しました',
'agentsPage.skillTypeTemplate': 'プロンプトテンプレート',
'agentsPage.skillTypeKnowledge': '参考知識',
'agentsPage.skillTypeRules': 'プロセスルール',
'agentsPage.versions': 'バージョン履歴',
'agentsPage.versionsEmpty': '履歴はまだありません。保存のたびに旧版をアーカイブします。',
'agentsPage.restore': '戻す',
'agentsPage.close': '閉じる',
'agentsPage.editSkill': 'Skill を編集',
'agentsPage.skillName': '名前',
'agentsPage.skillNamePh': '例:cyberpunk-style-kit',
'agentsPage.skillType': 'タイプ',
'agentsPage.skillDesc': '説明',
'agentsPage.skillDescPh': 'エージェントがいつ使うか(Skill 一覧に表示されます)',
'agentsPage.skillContent': '内容',
'agentsPage.skillContentPh': 'Markdown 対応:テンプレート・参考資料・ルールなどを書けます…',
'agentsPage.skillContentHint': 'Markdown 対応。保存時にバージョンが上がり、旧版はアーカイブされます。',
'agentsPage.assignTo': '割り当て先',
'agentsPage.saveNext': 'v{v}+1 を保存',
'agentsPage.save': '保存',
'agentsPage.disable': '無効化',
'agentsPage.enable': '有効化',
'agentsPage.delete': '削除',
'agentsPage.delSkillTitle': 'Skill を削除',
'agentsPage.delSkillConfirm': '元に戻せません。割り当て中の {n} 個の Agent からも外れます。削除しますか?',
'agentsPage.skillIdleHint': 'ライブラリの「編集」を選ぶか「Skill を追加」を押してください',
'agentsPage.sugScript1': 'アイデアを三幕構成に',
'agentsPage.sugScript1P': 'アイデア:コンビニ店員が毎晩同じ時刻に同じ客が来ることに気づく。三幕構成のアウトラインを書いて。',
'agentsPage.sugScript2': '既存の台本を推敲',
'agentsPage.sugScript2P': '台本を貼るので、まず読んで、台詞とテンポ改善点を3つ挙げて。',
'agentsPage.sugStyle1': 'スタイル方向を提案',
'agentsPage.sugStyle1P': '雨のネオン街の都市型ラブショート:英語の画像プロンプト付きで3方向のビジュアルを提案して。',
'agentsPage.sugStyle2': '映像実写風キーワード',
'agentsPage.sugStyle2P': 'シネマティック・リアリズムのスタイルプロンプト(ポジ+ネガ)を中国語注記付きで整理して。',
'agentsPage.sugBoard1': '台本を絵コンテに分解',
'agentsPage.sugBoard1P': 'この台本をカット表に分解して:カット番号、サイズ、カメラワーク、内容、台詞、秒数。',
'agentsPage.sugBoard2': 'クライマックスの絵コンテ',
'agentsPage.sugBoard2P': '30秒の追跡シーンクライマックスに、メリハリある絵コンテを一版。',
'agentsPage.sugAssets1': 'キャラシートプロンプト',
'agentsPage.sugAssets1P': '主人公は17歳の夜行性少女探偵。一貫性のあるキャラシートプロンプト(正面向き/横向き/全身)を書いて。',
'agentsPage.sugAssets2': 'アセットリスト',
'agentsPage.sugAssets2P': '雨のコンビニのシーンから、セットと小道具のプロンプト一覧を作って。',
'agentsPage.sugShot1': '動画プロンプトを1つ',
'agentsPage.sugShot1P': 'ネオン看板からガラス扉へのプッシュイン。そのまま使える動画モデルプロンプトを書いて。',
'agentsPage.sugShot2': '最初・最終フレームの書き方',
'agentsPage.sugShot2P': '最初/最終フレームの動画プロンプトの書き方を実演して:描写・カメラワーク・秒数。',
'workbench.title': '制作ワークベンチ',
'workbench.subtitle': '脚本 → ビジュアル → 絵コンテ → 素材 → ショットリスト、5つのエージェントがリレーで制作',
'workbench.pickerSubtitle': 'プロジェクトを選んで五段階フローを続行',
'workbench.pickerEmpty': 'プロジェクトがまだありません',
'workbench.pickerEmptyBody': 'まずトップページへ戻り、アイデアから始めるか脚本を取り込んでください。',
'workbench.pickerGoHome': 'トップへ戻る',
'workbench.pickerFootHint': 'トップの「アイデアから始める / 脚本を取り込む」からも入れます。',
'workbench.stageProgress': '五段階の進捗',
'workbench.activeJobs': '{n} 件のタスク実行中',
'workbench.stage.script': '脚本制作',
'workbench.stage.style': 'ビジュアル設計',
'workbench.stage.storyboard': '絵コンテ',
'workbench.stage.assets': '素材アセット',
'workbench.stage.shot': 'ショットリスト',
'workbench.lockedStep': '「{stage}」を確認すると解放されます',
'workbench.state.todo': '未着手',
'workbench.state.running': '生成中',
'workbench.state.draft': '確認待ち',
'workbench.state.confirmed': '確認済み',
'workbench.state.edited': '編集済み',
'workbench.copied': 'コピーしました。生成画面にそのまま貼り付けられます',
'workbench.copyFailed': 'コピーに失敗しました。手動で選択してコピーしてください',
'workbench.copy': 'コピー',
'workbench.charCount': '{n} 文字',
'workbench.runStage': '{agent}に生成させる',
'workbench.runDone': '{agent}がドラフトを完成させました。確認してください',
'workbench.runFailed': '生成に失敗しました。再試行してください',
'workbench.runningHint': '{agent}がこの段階を書いています…',
'workbench.confirmNext': '確認して次へ',
'workbench.confirmLast': 'この段階を確認',
'workbench.confirmed': '「{agent}」段階を確認しました',
'workbench.confirmFailed': '確認に失敗しました。再試行してください',
'workbench.restore': '確認済み版に戻す',
'workbench.restored': '直近の確認済み版に戻しました',
'workbench.restoreFailed': '復元に失敗しました。再試行してください',
'workbench.draftNotice': 'これは未確認のドラフトです。確認後にのみ次のエージェントへ渡ります。',
'workbench.draftSaved': 'ドラフトを保存しました。確認が必要です',
'workbench.saveFailed': '保存に失敗しました。再試行してください',
'workbench.saveDraft': 'ドラフトを保存',
'workbench.cancelEdit': 'キャンセル',
'workbench.editDraft': '編集',
'workbench.regenerate': '再生成',
'workbench.regenerateHint': 'この段階を再度生成します(確認済み版は保持され、いつでも戻せます)',
'workbench.instructionPh': '追加の要望(任意)。例:映画的に、台詞をより口語的に…',
'workbench.jobSubmitted': '生成タスクを投入しました。結果はこのプロジェクトに戻ります',
'workbench.jobSubmitFailed': 'タスクの投入に失敗しました',
'workbench.jobRequeued': 'タスクを再び並べました',
'workbench.retryJob': '再試行',
'workbench.cancelJob': 'タスクをキャンセル',
'workbench.openOutput': '結果を見る',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': '実行中',
'workbench.jobQueued': '待機中',
'workbench.jobDone': '完了',
'workbench.jobFailed': '失敗',
'workbench.generating': '生成中…',
'workbench.progressChip': '段階 {n}/5',
'workbench.backHome': 'トップ',
'workbench.loadFailed': 'ワークベンチの読み込みに失敗しました。',
'workbench.retryLoad': '再試行',
'workbench.agentSkills': 'Skill {n} 件',
'workbench.chatWithAgent': '個別にチャット',
'workbench.tasksTitle': '段階タスク',
'workbench.tasksAll': 'すべてのタスク',
'workbench.tasksEmpty': '生成タスクはまだありません。素材・ショット段階からプロンプトを送れます。',
'workbench.flowTitle': '五段階の進捗',
'workbench.script.todoTitle': '脚本から始める:アイデアを脚本エージェントへ',
'workbench.script.todoIdea': 'あなたのアイデア:',
'workbench.script.generate': '脚本のドラフトを生成',
'workbench.script.importBtn': '既存の脚本を取り込む',
'workbench.script.writeManually': '手動で書く',
'workbench.script.importPlaceholder': '既存の脚本をここに貼り付け…取り込むと脚本制作をスキップし、ビジュアル設計から始まります。',
'workbench.script.importHint': '取り込んだ脚本はこの段階の確認済みベースラインになります。',
'workbench.script.importConfirm': '取り込んで脚本制作をスキップ',
'workbench.script.importDone': '脚本を取り込みました。ビジュアル設計から続行',
'workbench.script.importFailed': '取り込みに失敗しました。内容を確認して再試行してください',
'workbench.script.editorHint': '脚本本文を直接編集できます。保存後も確認が必要です。',
'workbench.script.copyLabel': '脚本全文',
'workbench.style.todoTitle': '脚本にビジュアルスタイルを定める',
'workbench.style.todoBody': 'ビジュアル設計エージェントが、コピーして使える画像プロンプト付きの複数のスタイル方向を提案します。',
'workbench.style.generate': 'スタイル方向を生成',
'workbench.style.namePh': 'スタイル名(例:ネオンサイバー)',
'workbench.style.descPh': 'スタイル説明(色調 / 光 / 質感)',
'workbench.style.promptPh': '英語の画像キーワード(カンマ区切り)',
'workbench.style.negativePh': 'ネガティブプロンプト(任意・英語)',
'workbench.style.negativeLabel': 'ネガティブプロンプト',
'workbench.style.remove': 'この方向を削除',
'workbench.style.add': 'スタイル方向を追加',
'workbench.style.chosen': '選択中',
'workbench.style.chooseHint': 'この段階を確認すると、選んだスタイルが以降の段階に渡ります。',
'workbench.style.promptLabel': 'スタイルプロンプト',
'workbench.board.scene': '場面',
'workbench.board.size': 'サイズ',
'workbench.board.camera': 'カメラ',
'workbench.board.duration': '秒',
'workbench.board.desc': '内容',
'workbench.board.dialog': '台詞・効果音',
'workbench.board.descPh': 'このショットの内容',
'workbench.board.dialogPh': '台詞や効果音(任意)',
'workbench.board.remove': 'このショットを削除',
'workbench.board.add': 'ショットを追加',
'workbench.board.todoTitle': '脚本をショットごとの絵コンテに分解',
'workbench.board.todoBody': '絵コンテエージェントがショット番号・サイズ・カメラワーク・内容・秒数を作成します。',
'workbench.board.generate': '絵コンテを生成',
'workbench.board.summary': '全 {n} ショット · 約 {sec} 秒',
'workbench.asset.kindCharacter': 'キャラクター',
'workbench.asset.kindLocation': 'ロケーション',
'workbench.asset.kindProp': '小道具',
'workbench.asset.namePh': 'アセット名',
'workbench.asset.descPh': '説明(外見・用途)',
'workbench.asset.promptPh': '完全な英語の画像プロンプト',
'workbench.asset.promptLabel': '画像プロンプト',
'workbench.asset.remove': 'このアセットを削除',
'workbench.asset.add': 'アセットを追加',
'workbench.asset.todoTitle': '素材リストと画像プロンプトを作成',
'workbench.asset.todoBody': '素材エージェントが脚本・スタイル・絵コンテから、复制可能な完全なプロンプトを作成します。',
'workbench.asset.generate': '素材リストを生成',
'workbench.asset.generateImage': 'この画像を生成',
'workbench.asset.generateHint': '接続済み ComfyUI ワークフローで生成し、結果はこのプロジェクトに戻ります',
'workbench.asset.summary': '素材 {n} 点',
'workbench.vshot.noPh': 'ショット番号(例 S1)',
'workbench.vshot.descPh': 'ショットの説明',
'workbench.vshot.promptPh': '完全な動画モデル用プロンプト(英語)',
'workbench.vshot.promptLabel': '動画プロンプト',
'workbench.vshot.remove': 'このショットを削除',
'workbench.vshot.add': 'ショットを追加',
'workbench.vshot.todoTitle': 'ショットごとの動画スクリプトを作成',
'workbench.vshot.todoBody': 'ショットリストエージェントが絵コンテを動画モデル用プロンプトに変換します。',
'workbench.vshot.generate': 'ショットリストを生成',
'workbench.vshot.generateVideo': 'このショットの動画を生成',
'workbench.vshot.generateHint': '接続済み ComfyUI 動画ワークフローで生成し、結果はこのプロジェクトに戻ります',
'workbench.vshot.summary': '全 {n} ショット',
'home.errScriptShort': '脚本が短すぎます。貼り付けが完全か確認してください(30 文字以上)',
'home.stageProgress': '五段階の進捗',
'tasks.sourceWorkbench': '制作フロー',
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
'jobst.done': '完了',
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
'jobst.dryRun': 'プレースホルダー',
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
'jobst.noOutput': '出力なし',
'jobst.noOutputDetail': '結果ファイルなしで終了しました(生成完了ではありません)',
'jobst.queued': '待機中',
'jobst.queuedDetail': 'ワークベンチのキューで ComfyUI への送信を待っています',
'jobst.connecting': '接続中',
'jobst.connectingDetail': 'ComfyUI に接続して素材をアップロードしています',
'jobst.comfyQueued': '受付済み',
'jobst.comfyQueuedDetail': 'ComfyUI が受け付け、実行待ちです',
'jobst.comfyQueuedAhead': 'ComfyUI が受け付けました。前に {n} 件あります',
'jobst.comfyRunning': '実行中',
'jobst.comfyRunningDetail': 'ComfyUI が実行中です',
'jobst.saving': '保存中',
'jobst.savingDetail': 'ComfyUI の結果をダウンロードしています',
'jobst.failed': '失敗',
'jobst.cancelled': 'キャンセル済み',
'studio.ratioLabel': 'アスペクト比',
'studio.megapixelsLabel': '総画素数(MP)',
'studio.outputSize': '出力 {w} × {h}',
'studio.denoiseLabel': 'ノイズ除去',
'studio.errNumber': '「{label}」は数値で入力してください',
'studio.errInteger': '「{label}」は整数で入力してください',
'studio.errMin': '「{label}」は {v} 以上にしてください',
'studio.errMax': '「{label}」は {v} 以下にしてください',
'studio.errOption': '「{label}」はワークフローの選択肢にありません',
'studio.reuseParams': '設定を再利用',
'studio.reusedToast': 'このジョブの設定を反映しました。調整して再生成できます',
'studio.reuseMissingWf': 'このジョブのワークフローはもう存在しません',
'studio.doneToast': '完了:{n} ファイル(ComfyUI プロンプト ID {id})',
'studio.dryRunToast': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
'studio.dryRunNote': 'Dry-run が有効です:生成はプレースホルダーを書き出すだけで ComfyUI を呼び出しません。設定でオフにできます。',
};
+699 -1
View File
@@ -34,6 +34,8 @@ export const ko: Dict = {
'nav.projects': '프로젝트',
'nav.buildScene': '장면 구성',
'nav.settings': '설정',
'nav.agents': '에이전트',
'nav.models': '모델 설정',
'nav.home': '홈',
'nav.primary': '기본',
'nav.projectStages': '프로젝트 단계',
@@ -162,6 +164,8 @@ export const ko: Dict = {
// --- API errors ---
'api.unknownError': '문제가 발생했습니다',
'api.networkError': '워크벤치 백엔드에 연결할 수 없습니다. 실행 중인지 확인 후 다시 시도하세요',
'api.httpError': '요청 실패 (HTTP {status}): {detail}',
'playground.openFailed': '플레이그라운드를 열지 못했습니다',
'playground.opening': '플레이그라운드 여는 중…',
'agents.opening': '에이전트 여는 중…',
@@ -962,6 +966,27 @@ export const ko: Dict = {
'library.emptyFilterBody': '이 필터와 일치하는 항목이 아직 없습니다 – 전체를 시도하거나 새로 생성해 보세요.',
// --- Settings ---
'settings.pageSub': '생성 대기열·에이전트 실행·저장 경로 · LLM, ComfyUI 연결, Skill은 각 페이지에서 관리',
'settings.secQueue': '대기열 및 생성',
'settings.secQueueBlurb': '동시 실행, 재시도, 폴링, 시험 실행',
'settings.secAgent': '에이전트 실행',
'settings.secAgentBlurb': '단계, 컨텍스트, 프롬프트 강화, Shell',
'settings.secStorage': '저장 경로',
'settings.secStorageBlurb': '데이터·에셋·작업 폴더',
'settings.elsewhere': '다른 설정',
'settings.linkModelsBlurb': 'LLM과 에이전트별 기본 모델',
'settings.linkConnectionsBlurb': '주소, 연결 테스트, 워크플로 라이브러리',
'settings.linkAgentsBlurb': '다섯 에이전트의 Skill과 개별 대화',
'settings.dryRunTitle': '시험 실행 모드',
'settings.dryRunHint': '켜면 ComfyUI를 호출하지 않고 임시 이미지/영상을 반환해 흐름을 점검합니다. 실제 생성하려면 끄세요.',
'settings.agentRunSection': '에이전트 실행',
'settings.agentRunLead': '실행당 도구 호출 단계와 가져오는 기록을 제한합니다.',
'settings.rangeHint': '범위 {min}–{max}',
'settings.loadFailed': '설정을 불러오지 못했습니다. 서버가 실행 중인지 확인하세요',
'skillName.character-consistency': '캐릭터 일관성',
'skillName.h3-video-prompt-enhancer': 'H3 영상 프롬프트 강화',
'skillName.scene-to-video': '장면을 영상으로',
'skillName.shot-composer-blockout': '숏 구도 블로킹',
'settings.loading': '설정 불러오는 중',
'settings.allSaved': '모든 변경 사항이 저장되었습니다',
'settings.unsaved': '저장되지 않은 변경 사항',
@@ -995,6 +1020,12 @@ export const ko: Dict = {
'settings.removeLlmTitle': 'LLM 제거',
'settings.name': '이름',
'settings.active': '활성',
'settings.testLlm': '연결 테스트',
'settings.llmTestOk': '연결 성공 ({ms}ms)',
'settings.llmTestFail': '연결 실패: {detail}',
'settings.llmTestAuth': '키가 거부됨 ({detail})',
'settings.llmTestTimeout': '15초 내 응답 없음',
'settings.llmTestConnect': '서버에 연결할 수 없음 ({detail})',
'settings.agentModelsSection': '에이전트 모델',
'settings.agentModelsLead': '스토리 초안 작성과 에이전트 작업에 사용하는 모델입니다.',
'settings.agentMaxStepsField': '최대 단계',
@@ -1002,7 +1033,7 @@ export const ko: Dict = {
'settings.queueSection': '작업 대기열',
'settings.queueLead': '생성 작업과 에이전트 도구 호출의 동시 실행 수와 시간 초과를 설정합니다.',
'settings.concurrencyField': '동시 작업 수',
'settings.pollIntervalField': '폴링 간격 (ms)',
'settings.pollIntervalField': '폴링 간격 (초)',
'settings.pollTimeoutField': '작업 시간 초과 (초)',
'settings.pollTimeoutHint': 'ComfyUI 작업 또는 에이전트 도구 호출 한 번의 제한 시간. 0이면 끝날 때까지 기다립니다.',
'settings.maxRetriesField': '최대 재시도',
@@ -1128,4 +1159,671 @@ export const ko: Dict = {
'shot.errBlob': '캡처한 프레임을 읽지 못했습니다',
'shot.errRecorder': '녹화를 시작하지 못했습니다',
'shot.errNoData': '내보낼 장면 데이터가 없습니다',
// Models settings page (spec-v2 §9)
'models.title': '모델 설정',
'models.subtitle': '에이전트가 사용할 사용자 지정 LLM을 추가합니다 · 1단계는 LLM만 지원, 이미지 / 동영상 모델은 추후 제공',
'models.statusCount': '모델 {n}개 · 사용 가능 {m}개',
'models.add': '모델 추가',
'models.tabLlm': '대규모 언어 모델',
'models.tabImage': '이미지 모델',
'models.tabVideo': '동영상 모델',
'models.comingSoon': '추후 제공',
'models.empty': '아직 모델이 없습니다. 오른쪽 위 «모델 추가»를 눌러 첫 LLM을 연결하세요',
'models.typeLocal': '로컬',
'models.typeLan': 'LAN',
'models.typeCloud': '클라우드',
'models.defaultBadge': '에이전트 기본',
'models.setDefault': '기본으로 설정',
'models.defaultSetToast': '에이전트 기본 모델로 설정했습니다',
'models.test': '연결 테스트',
'models.testing': '테스트 중…',
'models.edit': '편집',
'models.delete': '삭제',
'models.statusUntested': '미테스트',
'models.statusConnected': '연결됨',
'models.statusFailed': '연결 실패',
'models.latency': '지연 {ms}ms',
'models.agentsUsing': '{n}개 에이전트 사용 중',
'models.lastTestAt': '마지막 테스트 {time}',
'models.consecutiveFails': '{n}회 연속 실패',
'models.cloudPaused': '클라우드 호출 일시 중지',
'models.formTitleAdd': '모델 추가',
'models.formTitleEdit': '모델 편집',
'models.formClose': '닫기',
'models.fName': '이름',
'models.fNamePh': '모델 이름, 예: DeepSeek 공식',
'models.fApiType': 'API 유형',
'models.apiOpenai': 'OpenAI 호환',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': '기타',
'models.apiTypeHelp': '1단계는 OpenAI 호환 API만 지원하며, 다른 프로토콜은 이미지 / 동영상 모델과 함께 제공됩니다',
'models.fBaseUrl': 'API 주소',
'models.baseUrlHelp': '프로토콜과 포트를 포함하세요, 예: http://192.168.200.18:11434/v1',
'models.fModel': '모델 이름',
'models.modelHelp': '공급자가 제공하는 모델 ID를 사용하세요',
'models.fApiKey': 'API 키',
'models.keyNoEcho': '표시되지 않음',
'models.keyHelp': '이 서버에만 저장되며 전체 키는 화면에 절대 표시되지 않습니다',
'models.keyKeepHint': '키 저장됨 — 비워 두면 유지됩니다',
'models.fTimeout': '시간 제한',
'models.timeoutSec': '{n}초',
'models.testOk': '✓ 테스트 통과 · {ms}ms · {model} 사용 가능',
'models.testFail': '✕ 테스트 실패 · {reason}',
'models.needTest': '테스트 통과 후 저장할 수 있습니다',
'models.testBtn': '연결 테스트',
'models.save': '모델 저장',
'models.errName': '모델 이름을 입력하세요',
'models.errUrl': '주소 형식이 올바르지 않습니다. 프로토콜과 경로를 포함하세요, 예: https://api.deepseek.com/v1',
'models.errModel': '공급자가 제공하는 모델 이름을 입력하세요',
'models.errKey': 'API 키를 입력하세요',
'models.failTimeout': '시간 초과({sec}초 무응답): 서버가 이 주소에 접근할 수 없습니다. 주소·포트·방화벽을 확인하세요',
'models.failAuth': '인증 실패(401): API 키가 올바르고 만료되지 않았는지 확인하세요. 통합 서비스라면 모델 권한도 확인하세요',
'models.failNotFound': '접속은 되지만 모델을 찾을 수 없음(404): 모델 이름 철자를 확인하세요',
'models.failNotOpenai': '주소에 접근할 수 있으나 OpenAI 호환 API로 식별되지 않습니다. 경로가 /v1로 끝나는지 확인하세요',
'models.failConnect': '서버가 이 주소에 연결할 수 없습니다. 주소와 포트를 확인하세요',
'models.failHttp': 'Test failed (HTTP {status})',
'models.delTitle': '모델 삭제',
'models.delConfirmUsed': '{n}개 에이전트가 이 모델을 사용 중입니다. 삭제 후에는 «에이전트 기본»으로 전환됩니다. 삭제할까요?',
'models.delConfirmFree': '이 모델을 삭제할까요? 되돌릴 수 없습니다.',
'models.delDefaultHint': '이 모델은 «에이전트 기본»입니다. 먼저 다른 기본 모델을 지정하세요',
'models.agentsTitle': '에이전트 기본 모델',
'models.agentsHint': '새 세션부터 적용 · 에이전트별 설정은 에이전트 페이지에서',
'models.agentDefault': '에이전트 기본',
'models.agentStory': '시나리오',
'models.agentStyle': '비주얼 디자인',
'models.agentScript': '스토리보드',
'models.agentAssets': '에셋 라이브러리',
'models.agentVideo': '숏리스트',
'models.unavailable': '(사용 불가)',
'models.noticeStrong': '연결성은 워크벤치 서버 기준입니다:',
'models.noticeBody': '브라우저에서 열리는 주소라도 워크벤치 서버가 접근 가능한 것은 아닙니다. 연결 테스트와 대화 요청은 서버가 수행합니다. API 키는 서버에만 저장되며 화면에 표시되지 않습니다.',
'models.saved': '모델을 저장했습니다',
'models.deleted': '모델을 삭제했습니다',
'models.assignSaved': '에이전트 모델을 업데이트했습니다',
'models.saveFailed': '저장 실패: {detail}',
'models.loading': '불러오는 중…',
'models.loadFailed': '설정을 불러오지 못했습니다',
'models.idleFormHint': '카드의 «편집»을 누르거나 위의 «모델 추가»를 클릭하세요',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': '영화 공작소',
'shell.brandMark': '영',
'shell.brandHome': '프로젝트 개요로 돌아가기',
'shell.navAria': '주요 탐색',
'shell.navProjects': '프로젝트',
'shell.navWorkbench': '창작 워크벤치',
'shell.navImage': '이미지 생성',
'shell.navVideo': '영상 생성',
'shell.navTasks': '생성 작업',
'shell.navAgents': '에이전트',
'shell.navConnections': 'ComfyUI 연결',
'shell.navModels': '모델 설정',
'shell.navSettings': '설정',
'shell.wip': '개발 중',
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
'shell.comfy_ok': '{name} 연결됨',
'shell.comfy_fail': '{name} 연결 실패',
'shell.comfy_testing': '{name} 테스트 중…',
'shell.comfyUnset': 'ComfyUI · 미설정',
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
'home.title': '프로젝트 개요',
'home.subtitle': '창작 프로젝트를 관리하세요 — 아이디어나 완성된 대본으로 시작할 수 있습니다.',
'home.projectCount': '프로젝트 {n}개 · 진행 중 {m}개',
'home.startIdea': '아이디어로 시작',
'home.importScript': '대본 가져오기',
'home.entryIdeaTitle': '아이디어로 시작',
'home.entryIdeaBody': '아이디어를 적으면 에이전트가 이야기·스토리보드·클립을 단계별로 완성합니다.',
'home.entryScriptTitle': '대본 가져오기',
'home.entryScriptBody': '이미 대본이 있나요? 붙여넣어 프로젝트를 만들면 대본 에이전트가 순서 있는 장면으로 정리합니다.',
'home.ideaPlaceholder': '비 내리는 밤의 작은 항구, 등대지기가 발견한 것은……',
'home.scriptField': '대본 전문',
'home.scriptPlaceholder': '대본 전문을 여기에 붙여넣으세요……',
'home.scriptHint': '만들면 「에이전트」 페이지가 열리고 대본 에이전트가 순서 있는 장면으로 정리합니다.',
'home.errTitle': '프로젝트 제목을 입력하세요',
'home.errScript': '대본 전문을 붙여넣으세요',
'home.myProjects': '내 프로젝트',
'home.myProjectsHint': '프로젝트를 클릭해 이어서 작업',
'home.emptyProjects': '아직 프로젝트가 없습니다. 위의 「아이디어로 시작」 또는 「대본 가져오기」로 첫 프로젝트를 만들어 보세요.',
'home.noIdea': '아이디어 설명이 아직 없습니다',
'home.statScenes': '장면 {n}',
'home.statCharacters': '등장인물 {n}',
'home.statAssets': '소재 {ready}/{total}',
'home.statusDraft': '초안',
'home.statusProgress': '진행 중',
'home.statusDone': '완료',
'home.tasksTitle': '생성 작업',
'home.activeJobs': '{n}개 실행 중',
'home.emptyJobs': '아직 생성 작업이 없습니다',
'home.emptyJobsSub': '프로젝트나 워크벤치에서 생성을 시작하면 여기에 표시됩니다.',
'home.tasksNote': '전체 생성 기록과 필터는 왼쪽 탐색의 「생성 작업」 페이지에서 확인할 수 있습니다.',
'home.kindImage': '이미지',
'home.kindVideo': '영상',
'home.kindExport': '필름',
'home.jobDone': '완료',
'home.jobRunning': '실행 중',
'home.jobQueued': '대기 중',
'home.jobFailed': '실패',
'home.jobRequeued': '작업이 다시 대기열에 추가되었습니다',
'home.jobRetryFailed': '작업을 다시 대기열에 넣지 못했습니다',
'home.jobProject': '프로젝트 #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'settings.comfyMultiHint': 'ComfyUI 주소는 이제 다중 연결(로컬 / LAN / 클라우드)을 지원하며 워크플로별로 바인딩할 수 있습니다.',
'settings.comfyGoto': '\'ComfyUI 연결\' 페이지에서 관리 →',
'conn.title': 'ComfyUI 연결',
'conn.subtitle': '로컬·LAN·클라우드 ComfyUI API 주소를 등록하세요 · 연결성은 서버가 실측합니다',
'conn.statusCount': '연결 {n}개 · 통과 {m}개',
'conn.add': '연결 추가',
'conn.loading': '연결 불러오는 중…',
'conn.loadFailed': '연결 정보를 불러오지 못했습니다',
'conn.empty': '아직 ComfyUI 연결이 없습니다. 우측 상단 \'연결 추가\'로 첫 주소를 등록하세요.',
'conn.targetsTitle': '작업 실행 대상',
'conn.targetImage': '단독 이미지 생성',
'conn.targetVideo': '단독 영상 생성',
'conn.targetFlow': '제작 흐름 내 작업',
'conn.targetChange': '변경',
'conn.targetMixed': '연결 {n}개',
'conn.targetNote': '작업은 워크플로 바인딩을 따르고, 바인딩이 없으면 기본 연결을 사용합니다. 바인딩은 워크플로 라이브러리에서 변경하며, 이미지 / 영상 페이지에서도 생성 전에 바꿀 수 있습니다.',
'conn.defaultBadge': '기본',
'conn.test': '연결 테스트',
'conn.testing': '테스트 중…',
'conn.edit': '편집',
'conn.delete': '삭제',
'conn.setDefault': '기본으로 설정',
'conn.delDefaultHint': '기본 연결은 삭제할 수 없습니다. 다른 연결을 먼저 기본으로 지정하세요',
'conn.delTitle': '연결 삭제',
'conn.delConfirmUsed': '이 연결에 바인딩된 워크플로는 기본 연결로 되돌아갑니다. 기존 작업 스냅샷은 유지됩니다. \'{name}\'을(를) 삭제할까요?',
'conn.delConfirmFree': '연결 \'{name}\'을(를) 삭제할까요?',
'conn.statusUntested': '미테스트',
'conn.statusConnected': '테스트 통과',
'conn.statusFailed': '테스트 실패',
'conn.latency': '지연 {ms} ms',
'conn.version': '버전 {v}',
'conn.lastTestAt': '마지막 테스트 {time}',
'conn.boundWorkflows': '워크플로 {n}개 바인딩',
'conn.tokenSaved': '토큰 저장됨 — 비워 두면 유지',
'conn.saved': '연결이 저장되었습니다',
'conn.deleted': '연결이 삭제되었습니다',
'conn.saveFailed': '저장 실패: {detail}',
'conn.defaultSetToast': '기본 연결로 설정되었습니다',
'conn.formTitleAdd': 'ComfyUI 연결 추가',
'conn.formTitleEdit': 'ComfyUI 연결 편집',
'conn.formClose': '닫기',
'conn.fName': '이름',
'conn.fNamePh': '예: 로컬 ComfyUI / 렌더 머신',
'conn.fBaseUrl': 'API 주소',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'ComfyUI의 HTTP API 주소(기본 포트 8188). 로컬·LAN·클라우드 모두 같은 형식입니다.',
'conn.fAuth': '인증',
'conn.authNone': '없음',
'conn.authBearer': 'Bearer 토큰',
'conn.authHeader': '사용자 지정 헤더',
'conn.authHelp': '엔드포인트가 인증 뒤에 있을 때만 필요합니다(리버스 프록시 키 등).',
'conn.fToken': '액세스 토큰',
'conn.fHeaderName': '헤더 이름',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': '서버에만 저장되며 표시되지 않습니다',
'conn.keyHelp': '토큰은 워크벤치 서버의 설정 파일에 저장되며 브라우저로 전송되지 않습니다.',
'conn.fNotes': '메모',
'conn.fNotesPh': '선택: 이 머신이 실행하는 것, VRAM 등',
'conn.errName': '이름이 필요합니다',
'conn.errUrl': '주소는 유효한 http:// 또는 https:// URL이어야 합니다',
'conn.errHeaderName': '사용자 지정 헤더에는 헤더 이름이 필요합니다',
'conn.testOk': '✓ 테스트 통과 · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': '저장 전 테스트를 통과해야 합니다',
'conn.testBtn': '테스트',
'conn.save': '저장',
'conn.noticeStrong': '연결성 판정은 서버가 합니다: ',
'conn.noticeBody': '브라우저에서 열리는 주소가 생성 가능을 뜻하지 않습니다. 테스트와 생성 요청은 모두 서버가 수행하며 토큰은 서버에만 저장됩니다.',
'conn.wfTitle': '워크플로 라이브러리',
'conn.wfHint': 'ComfyUI API 형식 JSON을 가져와 이미지/동영상으로 분류하고 연결에 바인딩합니다',
'conn.wfEmpty': '아직 가져온 워크플로가 없습니다. ComfyUI 개발자 모드에서 \'API 형식\'으로 내보낸 뒤 여기에 붙여넣으세요.',
'conn.wfCount': '워크플로 {n}개 · 이미지 {m} · 동영상 {k}',
'conn.wfKindImage': '이미지',
'conn.wfKindVideo': '동영상',
'conn.wfConnection': '바인딩할 연결',
'conn.wfConnectionActive': '기본 연결을 따름',
'conn.wfParams': '채울 수 있는 매개변수 {n}개',
'conn.wfImport': '워크플로 가져오기',
'conn.wfImporting': '가져오는 중…',
'conn.wfNodes': '노드 {n}개',
'conn.wfImportedN': '워크플로를 가져왔습니다 — 매개변수 {n}개 감지',
'conn.wfImportedNoParams': '가져왔지만 채울 수 있는 매개변수가 없습니다. 내장 설정으로 실행됩니다',
'conn.wfImportFailed': '가져오기 실패: {detail}',
'conn.wfImportTitle': 'ComfyUI 워크플로 가져오기(API 형식 JSON)',
'conn.wfImportHint': '붙여넣기 또는 파일 선택. 이름은 필수이며 종류가 이미지/동영상 창 표시를 결정합니다.',
'conn.wfJsonInvalid': '유효한 API 형식 JSON이 아닙니다(노드 ID → {{…class_type…}} 객체 필요)',
'conn.wfJsonPh': '여기에 ComfyUI API 형식 JSON을 붙여넣거나 오른쪽 버튼으로 파일을 선택하세요',
'conn.wfDeleted': '워크플로가 삭제되었습니다',
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
'conn.missingMaterial': 'Qwen Image 2.1 전용 워크플로 JSON이 아직 저장소에 없습니다. ComfyUI에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
// --- 독립 이미지/비디오 생성 (a3/a4, HEL-612) ---
'studio.image.title': '이미지 생성',
'studio.image.subtitle': '가져온 이미지 워크플로로 생성 · 연결과 큐는 ComfyUI 연결과 공유',
'studio.video.title': '비디오 생성',
'studio.video.subtitle': '가져온 비디오 워크플로로 생성 · 연결과 큐는 ComfyUI 연결과 공유',
'studio.genParams': '생성 매개변수',
'studio.modeTextToImage': '텍스트→이미지',
'studio.modeTextToVideo': '텍스트→영상',
'studio.chipIdle': '{name} · 대기열 비어 있음',
'studio.chipBusy': '{name} · 대기열 {n}건',
'studio.video.resultsTitle': '미리보기',
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
'studio.historyCount': '총 {n}회',
'studio.lastDone': '최근 완료 · {time}',
'studio.kindImageText': '이미지',
'studio.kindVideoText': '비디오',
'studio.loading': '불러오는 중…',
'studio.connLabel': '생성 연결',
'studio.connQueue': '대기열 {n}',
'studio.connUntested': '확인 중…',
'studio.connOk': '연결됨 · {ms} ms',
'studio.connFail': '연결 실패',
'studio.connManage': '연결 관리',
'studio.wfLabel': '워크플로',
'studio.wfParams': '채울 수 있는 매개변수 {n}개',
'studio.wfBindTo': '{name}에 바인딩됨',
'studio.wfFollowActive': '기본 연결을 따름',
'studio.promptLabel': '긍정 프롬프트',
'studio.promptPh': '피사체·환경·빛·스타일을 묘사…',
'studio.negLabel': '부정 프롬프트',
'studio.seedLabel': '시드',
'studio.sizeLabel': '크기',
'studio.batchLabel': '배치',
'studio.durationLabel': '길이(초)',
'studio.fpsLabel': '프레임 레이트',
'studio.stepsLabel': '스텝',
'studio.cfgLabel': 'CFG',
'studio.paramsTitle': '워크플로 매개변수',
'studio.chars': '{n}자',
'studio.insertStyle': '스타일 단어 삽입',
'studio.styleSoon': 'Skill 단어장은 에이전트 기능과 함께 제공됩니다',
'studio.generateN': '생성 시작 · {n}장',
'studio.generate': '생성 시작',
'studio.submitting': '제출 중…',
'studio.etaHint': '실제 소요 시간은 대상 연결과 워크플로에 따라 다릅니다',
'studio.errNoConn': '먼저 생성 연결을 선택하세요',
'studio.errConnDown': '이 연결을 사용할 수 없습니다(마지막 테스트 {time} 실패). 다른 연결로 바꾸거나 ComfyUI 연결에서 수정하세요',
'studio.errPrompt': '프롬프트를 입력하세요',
'studio.errPromptLong': '프롬프트가 2000자를 넘습니다. 줄여 주세요',
'studio.errRefImage': 'img2img에는 참조 이미지가 필요합니다',
'studio.errFirstLast': '첫/마지막 프레임 생성에는 두 프레임이 모두 필요합니다',
'studio.errUpload': '{label}을(를) 업로드하세요',
'studio.errFill': '{label}을(를) 입력하세요',
'studio.slotRef': '참조 이미지',
'studio.slotRefN': '참조 {n}',
'studio.slotMask': '마스크',
'studio.slotFirst': '첫 프레임',
'studio.slotLast': '마지막 프레임',
'studio.slotRefVideo': '참조 비디오',
'studio.slotAudio': '참조 오디오',
'studio.slotPick': '클릭하거나 끌어다 놓기',
'studio.slotFromResults': '이미지 결과에서 선택',
'studio.taskCurrent': '현재 작업',
'studio.taskQueued': '대기 중',
'studio.taskRunning': '생성 중',
'studio.taskCancel': '작업 취소',
'studio.queuedBehind': '앞에 {n}건',
'studio.stepsShort': '스텝 {v}',
'studio.usedShort': '경과 {v}',
'studio.etaShort': '남음 {v}',
'studio.targetShort': '대상 {name}',
'studio.renderFrames': '프레임 {done}/{total}',
'studio.noActiveTask': '진행 중인 작업 없음 · 왼쪽에서 매개변수를 설정하고 생성 시작',
'studio.resultsTitle': '최근 결과',
'studio.selectHint': '선택됨',
'studio.sendToImg2Img': 'img2img로 보내기',
'studio.zoom': '확대',
'studio.download': '다운로드',
'studio.downloadMp4': 'MP4 다운로드',
'studio.copyPrompt': '프롬프트 복사',
'studio.copiedToast': '복사됨 — 이미지/비디오 생성에 붙여넣으세요',
'studio.sentToImg2ImgToast': '참조 이미지로 채웠습니다 — 매개변수를 확인하고 생성하세요',
'studio.noImg2ImgWf': '참조 이미지 입력을 선언한 이미지 워크플로가 아직 없습니다. 먼저 ComfyUI 연결에서 img2img 워크플로를 가져오세요',
'studio.videoRefStrip': '첫 / 마지막 프레임 참조',
'studio.historyEmptyImage': '이미지 생성 기록이 없습니다. 왼쪽에서 매개변수를 설정하고 생성 시작',
'studio.historyEmptyVideo': '비디오 생성 기록이 없습니다. 왼쪽에서 매개변수를 설정하고 생성 시작',
'studio.jobName': '작업 #{id}',
'studio.retryLink': '재시도',
'studio.retryQueuedToast': '작업이 다시 대기열에 들어갔습니다',
'studio.retryFailedToast': '재대기열 실패: {detail}',
'studio.errNoWorkflow': '먼저 워크플로를 선택하세요',
'studio.errBlocked': '제출할 수 없음: {reason}',
'studio.queuedToast': '“{name}”에 제출됨, 대기 중',
'studio.submitFailedToast': '제출 실패: {detail}',
'studio.jobFailedToast': '생성 실패: {detail}',
'studio.wfNoParamsNote': '이 워크플로에는 채울 매개변수가 없어 내장 프롬프트와 설정으로 실행됩니다. 여기서 입력하려면 ComfyUI에서 노드 제목에 (Input:prompt) 등을 붙인 뒤 다시 가져오세요.',
'studio.wfAutoParamsNote': '(Input:…) 제목 태그가 없어 노드 유형으로 매개변수 {n}개를 자동 감지했습니다. 빈 칸은 워크플로 값을 유지합니다.',
'studio.promptNotDeclared': '이 워크플로에는 프롬프트 노드가 없습니다',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': '취소를 요청했습니다',
'studio.deleteBtn': '삭제',
'studio.delTitle': '작업 기록 삭제',
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
'studio.missingQwen': 'Qwen Image 2.1(t2i / img2img / 마스크) 전용 JSON이 아직 없습니다. 범용 가져오기 경로는 준비되어 있으니 ComfyUI에서 내보내 가져오세요.',
'studio.missingFirstLast': 'MiniMax H3 첫/마지막 프레임 전용 JSON이 아직 없습니다. 함께 제공되는 t2v / 참조 비디오 예제를 먼저 사용할 수 있습니다.',
'studio.pickerTitle': '이미지 결과 선택',
'studio.pickerEmpty': '사용할 수 있는 이미지 결과가 아직 없습니다',
'studio.pickerPick': '사용',
'studio.uploadFailedToast': '업로드 실패: {detail}',
'studio.playAria': '재생',
'studio.pauseAria': '일시정지',
'studio.fullscreenAria': '전체 화면',
// --- 생성 작업 페이지 (HEL-612) ---
'tasks.title': '생성 작업',
'tasks.subtitle': '독립 이미지·독립 비디오·창작 흐름의 이미지/비디오 작업 모아 보기',
'tasks.filterAll': '전체',
'tasks.filterImage': '이미지',
'tasks.filterVideo': '비디오',
'tasks.count': '{n}건 · 진행 중 {m}건',
'tasks.empty': '생성 작업이 아직 없습니다. 이미지/비디오 생성에서 첫 작업을 제출하세요.',
'tasks.goGenerate': '이미지 생성 열기',
'tasks.sourceStudioImage': '독립 이미지',
'tasks.sourceStudioVideo': '독립 비디오',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': '창작 흐름',
'tasks.sourceOther': '생성',
'tasks.queued': '대기',
'tasks.running': '진행 중',
'tasks.done': '완료',
'tasks.failed': '실패',
'tasks.retry': '재시도',
'tasks.cancel': '취소',
'tasks.delete': '삭제',
'tasks.delTitle': '작업 삭제',
'tasks.delConfirm': '결과 파일도 함께 삭제됩니다. 삭제할까요?',
'tasks.outputs': '결과 {n}개',
'agentsPage.title': '에이전트',
'agentsPage.subtitle': '다섯 제작 에이전트와 개별 대화하며 Skill을 정의·할당',
'agentsPage.statusChip': 'Skill {n}개 · 활성 {m}개',
'agentsPage.agentsTitle': '다섯 Agent',
'agentsPage.skillLib': 'Skill 라이브러리',
'agentsPage.skillLibCount': '{n}개',
'agentsPage.installedTitle': '{name} · 설치된 Skill',
'agentsPage.installedCount': '{n}개 · {m} 사용',
'agentsPage.newSkill': 'Skill 추가',
'agentsPage.skillLibEmpty': 'Skill이 없습니다. 위의「Skill 추가」로 만들어 보세요.',
'agentsPage.skillsCount': 'Skill {n}개',
'agentsPage.replying': '응답 중…',
'agentsPage.idle': '대기 중',
'agentsPage.assigned': '할당 {n}',
'agentsPage.edit': '편집',
'agentsPage.toggleSkill': 'Skill 켜기/끄기',
'agentsPage.independentNote': '독립 세션 · 제작 흐름에 참여하지 않음',
'agentsPage.model': '모델',
'agentsPage.modelDefault': '에이전트 기본',
'agentsPage.newSessionLabel': '새 대화',
'agentsPage.newSession': '새 대화',
'agentsPage.clear': '비우기',
'agentsPage.export': '내보내기',
'agentsPage.skillManage': 'Skill 관리',
'agentsPage.composerPlaceholder': '계속 대화하거나 대본 조각을 붙여넣으세요… (Enter 전송, Shift+Enter 줄바꿈)',
'agentsPage.send': '전송',
'agentsPage.clearTitle': '대화 비우기',
'agentsPage.clearConfirm': '비우면 기록을 복구할 수 없습니다. {name} 에이전트와의 대화를 비울까요?',
'agentsPage.skillErrName': 'Skill 이름을 지어주세요',
'agentsPage.skillErrAssign': '최소 한 개 Agent에 할당해야 적용됩니다',
'agentsPage.skillLoadFailed': 'Skill 내용을 읽지 못했습니다',
'agentsPage.skillSaved': 'Skill 저장됨',
'agentsPage.skillSaveFailed': '저장 실패: {detail}',
'agentsPage.skillEnabled': 'Skill 활성화됨',
'agentsPage.skillDisabled': 'Skill 비활성화됨 — 이후 응답에서는 사용되지 않습니다',
'agentsPage.skillDeleted': 'Skill 삭제됨',
'agentsPage.skillRestored': '이전 버전으로 복원됨',
'agentsPage.skillTypeTemplate': '프롬프트 템플릿',
'agentsPage.skillTypeKnowledge': '참고 지식',
'agentsPage.skillTypeRules': '프로세스 규칙',
'agentsPage.versions': '버전 기록',
'agentsPage.versionsEmpty': '버전이 아직 없습니다. 저장할 때마다 이전 버전이 보관됩니다.',
'agentsPage.restore': '복원',
'agentsPage.close': '닫기',
'agentsPage.editSkill': 'Skill 편집',
'agentsPage.skillName': '이름',
'agentsPage.skillNamePh': '예: cyberpunk-style-kit',
'agentsPage.skillType': '유형',
'agentsPage.skillDesc': '설명',
'agentsPage.skillDescPh': '에이전트가 언제 사용할지(스킬 목록에 표시됨)',
'agentsPage.skillContent': '내용',
'agentsPage.skillContentPh': '마크다운 지원: 템플릿, 참고 자료, 규칙 등…',
'agentsPage.skillContentHint': '마크다운 지원. 저장 시 버전이 올라가고 이전 버전이 보관됩니다.',
'agentsPage.assignTo': '할당 대상',
'agentsPage.saveNext': 'v{v}+1 저장',
'agentsPage.save': '저장',
'agentsPage.disable': '비활성화',
'agentsPage.enable': '활성화',
'agentsPage.delete': '삭제',
'agentsPage.delSkillTitle': 'Skill 삭제',
'agentsPage.delSkillConfirm': '되돌릴 수 없습니다. 할당된 {n}개 Agent에서도 제거됩니다. 삭제할까요?',
'agentsPage.skillIdleHint': '라이브러리에서「편집」을 고르거나「Skill 추가」를 누르세요',
'agentsPage.sugScript1': '아이디어를 3막 개요로',
'agentsPage.sugScript1P': '아이디어: 편의점 직원이 매일 같은 시각 같은 손님을 알아챈다. 3막 단편 개요를 써 줘.',
'agentsPage.sugScript2': '기존 대본 다듬기',
'agentsPage.sugScript2P': '대본을 붙여 넣을게. 먼저 읽고 대사와 리듬 개선점 세 가지를 알려 줘.',
'agentsPage.sugStyle1': '스타일 방향 제안',
'agentsPage.sugStyle1P': '비 내리는 네온 도시의 러브 숏폼. 영어 이미지 프롬프트 키워드와 함께 서로 다른 세 방향을 제안해 줘.',
'agentsPage.sugStyle2': '영화적 리얼리즘 키워드',
'agentsPage.sugStyle2P': '시네마틱 리얼리즘 스타일 프롬프트(긍정+부정) 세트를 중국어 주석과 함께 정리해 줘.',
'agentsPage.sugBoard1': '대본을 스토리보드로',
'agentsPage.sugBoard1P': '이 대본을 컷 단위로 나눠 줘: 컷 번호, 크기, 카메라 무브, 내용, 대사, 길이.',
'agentsPage.sugBoard2': '클라이맥스 스토리보드',
'agentsPage.sugBoard2P': '30초 추격전 클라이맥스에 리듬이 살아있는 스토리보드 한 판을 줘.',
'agentsPage.sugAssets1': '캐릭터 시트 프롬프트',
'agentsPage.sugAssets1P': '주인공은 17세 야행성 소녀 탐정. 일관된 캐릭터 시트 프롬프트(정면/측면/전신)를 써 줘.',
'agentsPage.sugAssets2': '에셋 목록',
'agentsPage.sugAssets2P': '비 오는 밤 편의점 장면을 바탕으로 세트와 소품 프롬프트 목록을 만들어 줘.',
'agentsPage.sugShot1': '영상 프롬프트 하나',
'agentsPage.sugShot1P': '네간 간판에서 유리문으로 밀어 들어가는 푸시인 샷. 바로 쓸 수 있는 영상 모델 프롬프트를 써 줘.',
'agentsPage.sugShot2': '첫/끝 프레임 샷 쓰기',
'agentsPage.sugShot2P': '첫/끝 프레임 영상 프롬프트 작성법을 보여 줘: 묘사, 카메라 무브, 길이.',
'workbench.title': '제작 워크벤치',
'workbench.subtitle': '대본 → 비주얼 → 스토리보드 → 소재 → 숏리스트, 다섯 에이전트의 릴레이',
'workbench.pickerSubtitle': '프로젝트를 골라 5단계 흐름을 이어가세요',
'workbench.pickerEmpty': '아직 프로젝트가 없습니다',
'workbench.pickerEmptyBody': '먼저 개요로 돌아가 아이디어로 시작하거나 대본을 가져오세요.',
'workbench.pickerGoHome': '개요로 가기',
'workbench.pickerFootHint': '개요의 \'아이디어로 시작 / 대본 가져오기\'에서도 들어갈 수 있습니다.',
'workbench.stageProgress': '5단계 진행',
'workbench.activeJobs': '작업 {n}개 실행 중',
'workbench.stage.script': '대본 작성',
'workbench.stage.style': '비주얼 디자인',
'workbench.stage.storyboard': '스토리보드',
'workbench.stage.assets': '소재 에셋',
'workbench.stage.shot': '숏 리스트',
'workbench.lockedStep': '\'{stage}\'을(를) 확인하면 잠금 해제됩니다',
'workbench.state.todo': '시작 전',
'workbench.state.running': '생성 중',
'workbench.state.draft': '확인 대기',
'workbench.state.confirmed': '확인됨',
'workbench.state.edited': '수정됨',
'workbench.copied': '복사됐습니다. 이미지/영상 생성에 바로 붙여넣으세요',
'workbench.copyFailed': '복사 실패, 직접 선택해 복사해 주세요',
'workbench.copy': '복사',
'workbench.charCount': '{n}자',
'workbench.runStage': '{agent} 에이전트가 생성',
'workbench.runDone': '{agent} 에이전트가 초안을 완성했습니다. 확인해 주세요',
'workbench.runFailed': '생성 실패, 다시 시도해 주세요',
'workbench.runningHint': '{agent} 에이전트가 이 단계를 작성 중입니다…',
'workbench.confirmNext': '확인하고 다음 단계로',
'workbench.confirmLast': '이 단계 확인',
'workbench.confirmed': '\'{agent}\' 단계가 확인되었습니다',
'workbench.confirmFailed': '확인 실패, 다시 시도해 주세요',
'workbench.restore': '확인본 복원',
'workbench.restored': '마지막 확인 버전으로 복원했습니다',
'workbench.restoreFailed': '복원 실패, 다시 시도해 주세요',
'workbench.draftNotice': '확인되지 않은 초안입니다. 확인 후 다음 에이전트에 전달됩니다.',
'workbench.draftSaved': '초안 저장됨 — 여전히 확인이 필요합니다',
'workbench.saveFailed': '저장 실패, 다시 시도해 주세요',
'workbench.saveDraft': '초안 저장',
'workbench.cancelEdit': '취소',
'workbench.editDraft': '편집',
'workbench.regenerate': '다시 생성',
'workbench.regenerateHint': '이 단계를 다시 생성합니다(확인본은 유지되어 언제든 복원 가능)',
'workbench.instructionPh': '추가 요청(선택). 예: 영화감 있게, 대사를 구어체로…',
'workbench.jobSubmitted': '생성 작업이 등록되었습니다. 결과는 이 프로젝트로 돌아옵니다',
'workbench.jobSubmitFailed': '작업 등록 실패',
'workbench.jobRequeued': '작업이 다시 대기열에 등록되었습니다',
'workbench.retryJob': '재시도',
'workbench.cancelJob': '작업 취소',
'workbench.openOutput': '결과 보기',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': '실행 중',
'workbench.jobQueued': '대기 중',
'workbench.jobDone': '완료',
'workbench.jobFailed': '실패',
'workbench.generating': '생성 중…',
'workbench.progressChip': '단계 {n}/5',
'workbench.backHome': '개요',
'workbench.loadFailed': '워크벤치를 불러오지 못했습니다.',
'workbench.retryLoad': '재시도',
'workbench.agentSkills': '스킬 {n}개',
'workbench.chatWithAgent': '따로 대화하기',
'workbench.tasksTitle': '단계 작업',
'workbench.tasksAll': '전체 작업',
'workbench.tasksEmpty': '아직 생성 작업이 없습니다. 소재·숏 단계에서 프롬프트를 보내세요.',
'workbench.flowTitle': '5단계 진행',
'workbench.script.todoTitle': '대본부터 시작: 아이디어를 대본 에이전트에게',
'workbench.script.todoIdea': '당신의 아이디어:',
'workbench.script.generate': '대본 초안 생성',
'workbench.script.importBtn': '기존 대본 가져오기',
'workbench.script.writeManually': '직접 작성',
'workbench.script.importPlaceholder': '기존 대본을 여기에 붙여넣으세요… 가져오면 대본 작성을 건너뛰고 비주얼 디자인부터 시작합니다.',
'workbench.script.importHint': '가져온 대본은 이 단계의 확인된 기준선이 됩니다.',
'workbench.script.importConfirm': '가져오고 대본 작성 건너뛰기',
'workbench.script.importDone': '대본을 가져왔습니다. 비주얼 디자인부터 계속',
'workbench.script.importFailed': '가져오기 실패, 내용을 확인하고 다시 시도하세요',
'workbench.script.editorHint': '대본 본문을 직접 수정할 수 있습니다. 저장 후에도 확인이 필요합니다.',
'workbench.script.copyLabel': '대본 전문',
'workbench.style.todoTitle': '대본에 어울리는 비주얼 스타일 정하기',
'workbench.style.todoBody': '비주얼 디자인 에이전트가 복사해 바로 쓸 수 있는 이미지 프롬프트와 함께 여러 스타일 방향을 제안합니다.',
'workbench.style.generate': '스타일 방향 생성',
'workbench.style.namePh': '스타일 이름(예: 네온 사이버)',
'workbench.style.descPh': '스타일 설명(톤 / 조명 / 질감)',
'workbench.style.promptPh': '영어 이미지 키워드, 쉼표 구분',
'workbench.style.negativePh': '네거티브 프롬프트(선택, 영어)',
'workbench.style.negativeLabel': '네거티브 프롬프트',
'workbench.style.remove': '이 방향 삭제',
'workbench.style.add': '스타일 방향 추가',
'workbench.style.chosen': '선택됨',
'workbench.style.chooseHint': '이 단계를 확인하면 선택한 스타일이 이후 단계에 전달됩니다.',
'workbench.style.promptLabel': '스타일 프롬프트',
'workbench.board.scene': '장면',
'workbench.board.size': '샷 크기',
'workbench.board.camera': '카메라',
'workbench.board.duration': '초',
'workbench.board.desc': '내용',
'workbench.board.dialog': '대사/효과음',
'workbench.board.descPh': '이 샷의 내용',
'workbench.board.dialogPh': '대사나 효과음(선택)',
'workbench.board.remove': '이 샷 삭제',
'workbench.board.add': '샷 추가',
'workbench.board.todoTitle': '대본을 샷 단위 스토리보드로 분해',
'workbench.board.todoBody': '스토리보드 에이전트가 샷 번호·크기·카메라·내용·길이를 만듭니다.',
'workbench.board.generate': '스토리보드 생성',
'workbench.board.summary': '총 {n}샷 · 약 {sec}초',
'workbench.asset.kindCharacter': '캐릭터',
'workbench.asset.kindLocation': '장소',
'workbench.asset.kindProp': '소품',
'workbench.asset.namePh': '에셋 이름',
'workbench.asset.descPh': '설명(외형, 용도)',
'workbench.asset.promptPh': '완전한 영어 이미지 프롬프트',
'workbench.asset.promptLabel': '이미지 프롬프트',
'workbench.asset.remove': '이 에셋 삭제',
'workbench.asset.add': '에셋 추가',
'workbench.asset.todoTitle': '소재 목록과 이미지 프롬프트 만들기',
'workbench.asset.todoBody': '소재 에이전트가 대본·스타일·스토리보드를 조합해 복사 가능한 완전한 프롬프트를 만듭니다.',
'workbench.asset.generate': '소재 목록 생성',
'workbench.asset.generateImage': '이 이미지 생성',
'workbench.asset.generateHint': '연결된 ComfyUI 워크플로로 생성하며 결과는 이 프로젝트로 돌아옵니다',
'workbench.asset.summary': '소재 {n}건',
'workbench.vshot.noPh': '샷 번호(예 S1)',
'workbench.vshot.descPh': '샷 설명',
'workbench.vshot.promptPh': '완전한 영어 영상 모델 프롬프트',
'workbench.vshot.promptLabel': '영상 프롬프트',
'workbench.vshot.remove': '이 샷 삭제',
'workbench.vshot.add': '샷 추가',
'workbench.vshot.todoTitle': '샷별 영상 스크립트 만들기',
'workbench.vshot.todoBody': '숏 리스트 에이전트가 스토리보드를 영상 모델용 프롬프트로 바꿉니다.',
'workbench.vshot.generate': '숏 리스트 생성',
'workbench.vshot.generateVideo': '이 샷 영상 생성',
'workbench.vshot.generateHint': '연결된 ComfyUI 영상 워크플로로 생성하며 결과는 이 프로젝트로 돌아옵니다',
'workbench.vshot.summary': '총 {n}샷',
'home.errScriptShort': '대본이 너무 짧습니다. 붙여넣기가 완전한지 확인하세요(30자 이상)',
'home.stageProgress': '5단계 진행',
'tasks.sourceWorkbench': '제작 흐름',
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
'jobst.done': '완료',
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
'jobst.dryRun': '자리표시자',
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
'jobst.noOutput': '출력 없음',
'jobst.noOutputDetail': '결과 파일 없이 끝났습니다 — 생성 완료가 아닙니다',
'jobst.queued': '대기 중',
'jobst.queuedDetail': '워크벤치 대기열에서 ComfyUI 전송을 기다리는 중',
'jobst.connecting': '연결 중',
'jobst.connectingDetail': 'ComfyUI에 연결하고 입력을 업로드하는 중',
'jobst.comfyQueued': '접수됨',
'jobst.comfyQueuedDetail': 'ComfyUI가 작업을 접수해 대기열에 넣었습니다',
'jobst.comfyQueuedAhead': 'ComfyUI가 접수함 — 앞에 {n}개 대기',
'jobst.comfyRunning': '실행 중',
'jobst.comfyRunningDetail': 'ComfyUI가 실행 중입니다',
'jobst.saving': '저장 중',
'jobst.savingDetail': 'ComfyUI 결과를 내려받는 중',
'jobst.failed': '실패',
'jobst.cancelled': '취소됨',
'studio.ratioLabel': '화면 비율',
'studio.megapixelsLabel': '메가픽셀',
'studio.outputSize': '출력 {w} × {h}',
'studio.denoiseLabel': '디노이즈',
'studio.errNumber': '‘{label}’은(는) 숫자여야 합니다',
'studio.errInteger': '‘{label}’은(는) 정수여야 합니다',
'studio.errMin': '‘{label}’은(는) {v} 이상이어야 합니다',
'studio.errMax': '‘{label}’은(는) {v} 이하여야 합니다',
'studio.errOption': '‘{label}’ 값이 워크플로 옵션에 없습니다',
'studio.reuseParams': '설정 재사용',
'studio.reusedToast': '이 작업의 설정을 채웠습니다. 수정 후 다시 생성하세요',
'studio.reuseMissingWf': '이 작업의 워크플로가 더 이상 없습니다',
'studio.doneToast': '완료: 파일 {n}개 (ComfyUI 프롬프트 ID {id})',
'studio.dryRunToast': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
'studio.dryRunNote': 'Dry-run이 켜져 있습니다: 생성은 자리표시자만 쓰고 ComfyUI를 호출하지 않습니다. 설정에서 끌 수 있습니다.',
};
+699 -1
View File
@@ -34,6 +34,8 @@ export const zh: Dict = {
'nav.projects': '项目',
'nav.buildScene': '搭建场景',
'nav.settings': '设置',
'nav.agents': '智能体',
'nav.models': '模型设置',
'nav.home': '首页',
'nav.primary': '主导航',
'nav.projectStages': '项目阶段',
@@ -162,6 +164,8 @@ export const zh: Dict = {
// --- API errors ---
'api.unknownError': '出错了',
'api.networkError': '无法连接到工作台后端,请确认服务在运行、网络正常后重试',
'api.httpError': '请求失败(HTTP {status}):{detail}',
'playground.openFailed': '无法打开演练场',
'playground.opening': '正在打开演练场……',
'agents.opening': '正在打开智能体……',
@@ -962,6 +966,27 @@ export const zh: Dict = {
'library.emptyFilterBody': '此筛选条件暂无匹配项 – 试试“全部”或生成新内容。',
// --- Settings ---
'settings.pageSub': '生成队列、智能体运行与存储路径 · 大模型、ComfyUI 连接与 Skill 在各自页面管理',
'settings.secQueue': '生成与队列',
'settings.secQueueBlurb': '并发、重试、轮询与试运行',
'settings.secAgent': '智能体运行',
'settings.secAgentBlurb': '步数、上下文、提示词加固与 Shell',
'settings.secStorage': '存储路径',
'settings.secStorageBlurb': '数据、素材与智能体工作区目录',
'settings.elsewhere': '其他设置入口',
'settings.linkModelsBlurb': '大语言模型与智能体默认模型',
'settings.linkConnectionsBlurb': '连接地址、连通测试与工作流库',
'settings.linkAgentsBlurb': '五个智能体的 Skill 与独立会话',
'settings.dryRunTitle': '试运行模式',
'settings.dryRunHint': '开启后不调用 ComfyUI,生成任务直接产出占位图片 / 视频,用于走通流程;关闭后才会真实生成。',
'settings.agentRunSection': '智能体运行',
'settings.agentRunLead': '限制每次运行的工具调用步数与带入的历史上下文。',
'settings.rangeHint': '范围 {min}–{max}',
'settings.loadFailed': '设置加载失败,请检查工作台服务是否在运行',
'skillName.character-consistency': '角色一致性',
'skillName.h3-video-prompt-enhancer': 'H3 视频提示词增强',
'skillName.scene-to-video': '分镜转视频',
'skillName.shot-composer-blockout': '镜头构图预演',
'settings.loading': '正在加载设置',
'settings.allSaved': '所有更改已保存',
'settings.unsaved': '未保存的更改',
@@ -995,6 +1020,12 @@ export const zh: Dict = {
'settings.removeLlmTitle': '移除 LLM',
'settings.name': '名称',
'settings.active': '激活',
'settings.testLlm': '测试连接',
'settings.llmTestOk': '连接成功({ms} 毫秒)',
'settings.llmTestFail': '连接失败:{detail}',
'settings.llmTestAuth': '密钥被拒绝({detail})',
'settings.llmTestTimeout': '15 秒内无响应',
'settings.llmTestConnect': '无法连接服务器({detail})',
'settings.agentModelsSection': '智能体模型',
'settings.agentModelsLead': '用于故事起草和智能体任务的模型。',
'settings.agentMaxStepsField': '最大步数',
@@ -1002,7 +1033,7 @@ export const zh: Dict = {
'settings.queueSection': '任务队列',
'settings.queueLead': '生成任务和代理工具调用的并发数与超时设置。',
'settings.concurrencyField': '并发任务数',
'settings.pollIntervalField': '轮询间隔(毫秒)',
'settings.pollIntervalField': '轮询间隔(秒)',
'settings.pollTimeoutField': '任务超时(秒)',
'settings.pollTimeoutHint': 'ComfyUI 任务或单次代理工具调用的最长时长。0 表示一直等到结束。',
'settings.maxRetriesField': '最大重试次数',
@@ -1012,6 +1043,8 @@ export const zh: Dict = {
'settings.comfySection': 'ComfyUI',
'settings.comfyLead': 'ComfyUI 的运行位置。',
'settings.comfyHint': '必须能从本机访问。',
'settings.comfyMultiHint': 'ComfyUI 地址已支持多连接管理(本机 / 局域网 / 云端),并可在导入工作流时绑定。',
'settings.comfyGoto': '前往「ComfyUI 连接」页管理 →',
'settings.storageSection': '系统路径',
'settings.storageLead': '文件在磁盘上的位置,以及 AI 代理可以工作的位置。',
'settings.storageCalloutStrong': '更改数据目录',
@@ -1128,4 +1161,669 @@ export const zh: Dict = {
'shot.errBlob': '无法读取截取的帧',
'shot.errRecorder': '无法启动录制器',
'shot.errNoData': '没有可导出的场景数据',
// Models settings page (spec-v2 §9)
'models.title': '模型设置',
'models.subtitle': '添加自定义大语言模型(LLM),供智能体选用 · 第一阶段仅开放 LLM,图片 / 视频模型后续接入',
'models.statusCount': '{n} 个模型 · {m} 个可用',
'models.add': '添加模型',
'models.tabLlm': '大语言模型',
'models.tabImage': '图片模型',
'models.tabVideo': '视频模型',
'models.comingSoon': '后续开放',
'models.empty': '还没有配置模型,点右上角「添加模型」接入第一个大语言模型',
'models.typeLocal': '本机',
'models.typeLan': '局域网',
'models.typeCloud': '云端',
'models.defaultBadge': '智能体默认',
'models.setDefault': '设为默认',
'models.defaultSetToast': '已设为智能体默认模型',
'models.test': '测试连接',
'models.testing': '测试中…',
'models.edit': '编辑',
'models.delete': '删除',
'models.statusUntested': '未测试',
'models.statusConnected': '已连接',
'models.statusFailed': '连接失败',
'models.latency': '延迟 {ms}ms',
'models.agentsUsing': '{n} 个智能体选用',
'models.lastTestAt': '上次测试 {time}',
'models.consecutiveFails': '已连续失败 {n} 次',
'models.cloudPaused': '云端调用已暂停',
'models.formTitleAdd': '添加模型',
'models.formTitleEdit': '编辑模型',
'models.formClose': '关闭',
'models.fName': '名称',
'models.fNamePh': '给模型起个名字,如 DeepSeek 官方',
'models.fApiType': '接口类型',
'models.apiOpenai': 'OpenAI 兼容',
'models.apiAnthropic': 'Anthropic',
'models.apiOther': '其他协议',
'models.apiTypeHelp': '第一阶段仅支持 OpenAI 兼容接口,其余协议随图片 / 视频模型一同开放',
'models.fBaseUrl': '接口地址',
'models.baseUrlHelp': '需含协议与端口,如 http://192.168.200.18:11434/v1',
'models.fModel': '模型名称',
'models.modelHelp': '以服务商提供的模型 ID 为准',
'models.fApiKey': 'API 密钥',
'models.keyNoEcho': '密钥不回显',
'models.keyHelp': '仅保存在本机服务器,页面永不显示完整密钥',
'models.keyKeepHint': '已保存密钥,留空表示不修改',
'models.fTimeout': '超时时间',
'models.timeoutSec': '{n} 秒',
'models.testOk': '✓ 测试通过 · 延迟 {ms}ms · {model} 可用',
'models.testFail': '✕ 测试失败 · {reason}',
'models.needTest': '未测试通过前无法保存',
'models.testBtn': '测试连接',
'models.save': '保存模型',
'models.errName': '请给模型起个名字',
'models.errUrl': '地址格式不正确,需含协议与路径,示例:https://api.deepseek.com/v1',
'models.errModel': '请填写模型名称,以服务商提供的模型 ID 为准',
'models.errKey': '请填写 API 密钥',
'models.failTimeout': '连接超时({sec} 秒无响应):服务器无法访问该地址,请检查地址、端口与防火墙',
'models.failAuth': '鉴权失败(401):请检查 API 密钥是否正确或已过期;使用聚合服务时,请确认账号已开通该模型权限',
'models.failNotFound': '连接正常但未找到该模型(404):请核对模型名称拼写',
'models.failNotOpenai': '地址可访问但未识别为 OpenAI 兼容接口,请确认路径是否以 /v1 结尾',
'models.failConnect': '服务器无法连接到该地址,请检查地址与端口',
'models.failHttp': '测试失败(HTTP {status})',
'models.delTitle': '删除模型',
'models.delConfirmUsed': '该模型正被 {n} 个智能体使用,删除后这些智能体将改用「智能体默认」。确定删除?',
'models.delConfirmFree': '确定删除该模型?删除后不可恢复。',
'models.delDefaultHint': '该模型是「智能体默认」,请先指定其他默认模型',
'models.agentsTitle': '智能体默认模型',
'models.agentsHint': '更改后新会话立即生效 · 也可在「智能体」页单独调整',
'models.agentDefault': '智能体默认',
'models.agentStory': '剧本创作',
'models.agentStyle': '视觉设计',
'models.agentScript': '分镜设计',
'models.agentAssets': '素材资产',
'models.agentVideo': '镜头脚本',
'models.unavailable': '(不可用)',
'models.noticeStrong': '连通性以工作台服务器为准:',
'models.noticeBody': '浏览器能打开某个地址,不代表运行工作台的服务器也能访问;连接测试与对话请求均由服务器发起。API 密钥仅保存在服务器本地,任何页面不回显。',
'models.saved': '模型已保存',
'models.deleted': '模型已删除',
'models.assignSaved': '智能体模型已更新',
'models.saveFailed': '保存失败:{detail}',
'models.loading': '加载中…',
'models.loadFailed': '设置加载失败',
'models.idleFormHint': '从左侧选择「编辑」,或点上方「添加模型」新增配置',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': '影工坊',
'shell.brandMark': '影',
'shell.brandHome': '返回项目总览',
'shell.navAria': '主导航',
'shell.navProjects': '项目',
'shell.navWorkbench': '创作工作台',
'shell.navImage': '生图',
'shell.navVideo': '生视频',
'shell.navTasks': '生成任务',
'shell.navAgents': '智能体',
'shell.navConnections': 'ComfyUI 连接',
'shell.navModels': '模型设置',
'shell.navSettings': '设置',
'shell.wip': '开发中',
'shell.wipHint': '该页面正在开发中,稍后开放',
'shell.comfy_ok': '{name} 已连接',
'shell.comfy_fail': '{name} 连接失败',
'shell.comfy_testing': '{name} 测试中…',
'shell.comfyUnset': 'ComfyUI · 未配置',
'shell.comfyManage': '管理 ComfyUI 连接',
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
'home.title': '项目总览',
'home.subtitle': '管理创作项目,从一个创意或一份现成剧本开始。',
'home.projectCount': '{n} 个项目 · {m} 个进行中',
'home.startIdea': '从创意开始',
'home.importScript': '导入剧本',
'home.entryIdeaTitle': '从创意开始',
'home.entryIdeaBody': '填一个想法,交给智能体逐步完成故事、分镜与成片。',
'home.entryScriptTitle': '导入剧本',
'home.entryScriptBody': '已有现成剧本?粘贴进来创建项目,交给剧本智能体整理为有序场景。',
'home.ideaPlaceholder': '一个雨夜的小港,灯塔看守人发现了……',
'home.scriptField': '剧本全文',
'home.scriptPlaceholder': '把完整剧本粘贴到这里……',
'home.scriptHint': '创建后会打开「智能体」,由剧本智能体把剧本整理成有序场景。',
'home.errTitle': '请给项目起个标题',
'home.errScript': '请粘贴剧本全文',
'home.myProjects': '我的项目',
'home.myProjectsHint': '点击项目继续创作',
'home.emptyProjects': '还没有项目。从上方「从创意开始」或「导入剧本」创建第一个项目。',
'home.noIdea': '暂无创意描述',
'home.statScenes': '场景 {n}',
'home.statCharacters': '角色 {n}',
'home.statAssets': '素材 {ready}/{total}',
'home.statusDraft': '待开始',
'home.statusProgress': '进行中',
'home.statusDone': '已完成',
'home.tasksTitle': '生成任务',
'home.activeJobs': '{n} 进行中',
'home.emptyJobs': '还没有生成任务',
'home.emptyJobsSub': '进入项目或创作工作台发起生成后,任务会显示在这里。',
'home.tasksNote': '完整生成历史与筛选见左侧导航「生成任务」页。',
'home.kindImage': '图片',
'home.kindVideo': '视频',
'home.kindExport': '成片',
'home.jobDone': '完成',
'home.jobRunning': '进行中',
'home.jobQueued': '排队',
'home.jobFailed': '失败',
'home.jobRequeued': '任务已重新排队',
'home.jobRetryFailed': '重新排队失败',
'home.jobProject': '项目 #{id}',
// --- ComfyUI connections page (a6, HEL-611) ---
'conn.title': 'ComfyUI 连接',
'conn.subtitle': '登记本机、局域网或云端的 ComfyUI API 地址 · 连通性由运行本工作台的服务器实测',
'conn.statusCount': '{n} 个连接 · {m} 个测试通过',
'conn.add': '添加连接',
'conn.loading': '正在加载连接…',
'conn.loadFailed': '连接信息加载失败',
'conn.empty': '还没有 ComfyUI 连接,点右上角「添加连接」登记第一个地址。',
'conn.targetsTitle': '任务使用目标',
'conn.targetImage': '独立生图',
'conn.targetVideo': '独立生视频',
'conn.targetFlow': '创作流程内任务',
'conn.targetChange': '更改',
'conn.targetMixed': '{n} 个连接',
'conn.targetNote': '按工作流绑定调度,未绑定的工作流使用默认连接;在工作流库里改绑定。生图 / 生视频页也可在生成前临时切换。',
'conn.defaultBadge': '默认',
'conn.test': '测试连接',
'conn.testing': '测试中…',
'conn.edit': '编辑',
'conn.delete': '删除',
'conn.setDefault': '设为默认',
'conn.delDefaultHint': '默认连接不能删除,请先把其他连接设为默认',
'conn.delTitle': '删除连接',
'conn.delConfirmUsed': '删除后绑定此连接的工作流将回退到默认连接;已生成任务的快照不受影响。确定删除「{name}」?',
'conn.delConfirmFree': '确定删除连接「{name}」?',
'conn.statusUntested': '未测试',
'conn.statusConnected': '测试通过',
'conn.statusFailed': '测试失败',
'conn.latency': '延迟 {ms} ms',
'conn.version': '版本 {v}',
'conn.lastTestAt': '上次测试 {time}',
'conn.boundWorkflows': '{n} 个工作流绑定',
'conn.tokenSaved': '已保存密钥,留空表示不修改',
'conn.saved': '连接已保存',
'conn.deleted': '连接已删除',
'conn.saveFailed': '保存失败:{detail}',
'conn.defaultSetToast': '已设为默认连接',
'conn.formTitleAdd': '添加 ComfyUI 连接',
'conn.formTitleEdit': '编辑 ComfyUI 连接',
'conn.formClose': '关闭',
'conn.fName': '名称',
'conn.fNamePh': '例如:本机 ComfyUI / 机房渲染机',
'conn.fBaseUrl': 'API 地址',
'conn.fBaseUrlPh': 'http://192.168.200.18:8188',
'conn.baseUrlHelp': 'ComfyUI 的 HTTP API 地址(默认端口 8188);本机、局域网、云端写法相同。',
'conn.fAuth': '鉴权方式',
'conn.authNone': '无鉴权',
'conn.authBearer': 'Bearer Token',
'conn.authHeader': '自定义 Header',
'conn.authHelp': '仅当服务端启用了鉴权(反代加的 Key 等)才需要填写。',
'conn.fToken': '访问令牌',
'conn.fHeaderName': 'Header 名称',
'conn.fHeaderNamePh': 'X-Api-Key',
'conn.keyNoEcho': '密钥仅保存在服务器,不回显',
'conn.keyHelp': '令牌保存在工作台服务器的配置文件里,不会出现在浏览器中。',
'conn.fNotes': '备注',
'conn.fNotesPh': '选填:这台机器跑什么模型、显存等',
'conn.errName': '请填写名称',
'conn.errUrl': '地址必须是 http:// 或 https:// 开头的有效 URL',
'conn.errHeaderName': '自定义 Header 需要填写 Header 名称',
'conn.testOk': '✓ 测试通过 · {ms} ms · ComfyUI {v}',
'conn.testFail': '✕ {reason}',
'conn.needTest': '保存前需测试通过',
'conn.testBtn': '测试',
'conn.save': '保存',
'conn.noticeStrong': '连通性以服务器为准:',
'conn.noticeBody': '地址在浏览器能打开不代表生成可用;测试与生成请求都由运行影工坊的服务器发起。令牌仅存服务端。',
'conn.wfTitle': '工作流库',
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,分类为图片 / 视频,并绑定到某个连接',
'conn.wfEmpty': '还没有导入工作流。在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON 后粘贴导入。',
'conn.wfCount': '{n} 个工作流 · {m} 图片 · {k} 视频',
'conn.wfKindImage': '图片',
'conn.wfKindVideo': '视频',
'conn.wfConnection': '绑定连接',
'conn.wfConnectionActive': '跟随默认连接',
'conn.wfParams': '{n} 个可填参数',
'conn.wfImport': '导入工作流',
'conn.wfImporting': '导入中…',
'conn.wfNodes': '{n} 个节点',
'conn.wfImportedN': '工作流已导入,识别到 {n} 个可填参数',
'conn.wfImportedNoParams': '工作流已导入,但没有识别到可填参数:生成时将按工作流内置设置运行',
'conn.wfImportFailed': '导入失败:{detail}',
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
'conn.wfImportHint': '粘贴或选择文件;名称必填,分类决定它出现在生图还是生视频窗口。',
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
'conn.wfDeleted': '工作流已删除',
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
'conn.wfBrokenBadge': '文件不完整,无法生成',
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
// --- 独立生图 / 生视频(a3/a4,HEL-612)---
'studio.image.title': '生图',
'studio.image.subtitle': '用已导入的图片工作流生成图片 · 连接与队列和「ComfyUI 连接」共用',
'studio.video.title': '生视频',
'studio.video.subtitle': '用已导入的视频工作流生成视频 · 连接与队列和「ComfyUI 连接」共用',
'studio.genParams': '生成参数',
'studio.modeTextToImage': '文生图',
'studio.modeTextToVideo': '文生视频',
'studio.chipIdle': '{name} · 队列空闲',
'studio.chipBusy': '{name} · 队列 {n} 个任务',
'studio.video.resultsTitle': '成片预览',
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
'studio.historyCount': '共 {n} 次',
'studio.lastDone': '最近完成 · {time}',
'studio.kindImageText': '图片',
'studio.kindVideoText': '视频',
'studio.loading': '正在加载…',
'studio.connLabel': '生成连接',
'studio.connQueue': '队列 {n}',
'studio.connUntested': '检测中…',
'studio.connOk': '已连接 · {ms} ms',
'studio.connFail': '连接失败',
'studio.connManage': '管理连接',
'studio.wfLabel': '工作流',
'studio.wfParams': '{n} 个可填参数',
'studio.wfBindTo': '已绑定 {name}',
'studio.wfFollowActive': '跟随默认连接',
'studio.promptLabel': '正向提示词',
'studio.promptPh': '描述画面:主体、环境、光线、风格…',
'studio.negLabel': '负面提示词',
'studio.seedLabel': '种子',
'studio.sizeLabel': '尺寸',
'studio.batchLabel': '批次',
'studio.durationLabel': '时长(秒)',
'studio.fpsLabel': '帧率',
'studio.stepsLabel': '步数',
'studio.cfgLabel': '引导 CFG',
'studio.paramsTitle': '工作流参数',
'studio.chars': '{n} 字',
'studio.insertStyle': '插入风格词',
'studio.styleSoon': 'Skill 词库将随「智能体」功能开放后接入',
'studio.generateN': '开始生成 · {n} 张',
'studio.generate': '开始生成',
'studio.submitting': '提交中…',
'studio.etaHint': '实际耗时以目标连接与工作流为准',
'studio.errNoConn': '请先选择生成连接',
'studio.errConnDown': '该连接当前不可用(最后测试 {time} 失败),请更换或到「ComfyUI 连接」修复',
'studio.errPrompt': '请填写提示词',
'studio.errPromptLong': '提示词超出 2000 字,请精简',
'studio.errRefImage': '图生图需要上传参考图',
'studio.errFirstLast': '首尾帧生视频需要同时上传首帧和尾帧',
'studio.errUpload': '请上传{label}',
'studio.errFill': '请填写{label}',
'studio.slotRef': '参考图',
'studio.slotRefN': '参考图 {n}',
'studio.slotMask': '遮罩',
'studio.slotFirst': '首帧',
'studio.slotLast': '尾帧',
'studio.slotRefVideo': '参考视频',
'studio.slotAudio': '参考音频',
'studio.slotPick': '点击或拖入',
'studio.slotFromResults': '从生图结果选择',
'studio.taskCurrent': '当前任务',
'studio.taskQueued': '排队中',
'studio.taskRunning': '生成中',
'studio.taskCancel': '取消任务',
'studio.queuedBehind': '前面还有 {n} 个任务',
'studio.stepsShort': '步数 {v}',
'studio.usedShort': '已用 {v}',
'studio.etaShort': '预计剩余 {v}',
'studio.targetShort': '目标 {name}',
'studio.renderFrames': '渲染帧 {done}/{total}',
'studio.noActiveTask': '没有进行中的任务 · 填好左侧参数点「开始生成」',
'studio.resultsTitle': '最近结果',
'studio.selectHint': '已选',
'studio.sendToImg2Img': '发送到图生图',
'studio.zoom': '放大',
'studio.download': '下载',
'studio.downloadMp4': '下载 MP4',
'studio.copyPrompt': '复制提示词',
'studio.copiedToast': '已复制,可直接粘贴到生图/生视频',
'studio.sentToImg2ImgToast': '已填入为参考图,请检查参数后生成',
'studio.noImg2ImgWf': '还没有声明参考图输入的图片工作流,请先在「ComfyUI 连接」导入图生图工作流',
'studio.videoRefStrip': '首帧 / 尾帧参考',
'studio.historyEmptyImage': '还没有生图记录,从左边填好参数点「开始生成」',
'studio.historyEmptyVideo': '还没有生视频记录,从左边填好参数点「开始生成」',
'studio.jobName': '任务 #{id}',
'studio.retryLink': '重试',
'studio.retryQueuedToast': '任务已重新排队',
'studio.retryFailedToast': '重新排队失败:{detail}',
'studio.errNoWorkflow': '请先选择工作流',
'studio.errBlocked': '无法提交:{reason}',
'studio.queuedToast': '已提交到「{name}」,排队中',
'studio.submitFailedToast': '提交失败:{detail}',
'studio.jobFailedToast': '生成失败:{detail}',
'studio.wfNoParamsNote': '该工作流没有可填写的参数,将按工作流内置的提示词和设置直接生成。如需在这里填写,请在 ComfyUI 中把节点标题加上 (Input:prompt) 等标记后重新导入。',
'studio.wfAutoParamsNote': '工作流未使用 (Input:…) 标题标记,已按节点类型自动识别出 {n} 个参数;留空的项沿用工作流原值。',
'studio.promptNotDeclared': '该工作流没有提示词节点,无需填写',
// --- 图片输入工作流(HEL-633,ref-upload-spec)---
'studio.modeImageEdit': '图片编辑',
'studio.modeImageToVideo': '图生视频',
'studio.modeRefToVideo': '参考生视频',
'studio.slotInput': '输入图',
'studio.slotHintOne': '必填 · 1 张',
'studio.slotEmptyInput': '点击或拖入输入图',
'studio.slotEmptyFirst': '点击或拖入首帧图',
'studio.slotEmptySub': 'PNG / JPG / WebP · 单张 ≤ 20MB',
'studio.slotFromResultsShort': '从结果选',
'studio.errImageFormat': '「{name}」格式不支持,请使用 PNG / JPG / WebP;该文件不会参与生成',
'studio.errImageSize': '「{name}」超过 20MB,请压缩后再传',
'studio.errNeedInputImage': '图片编辑需要上传输入图',
'studio.errNeedFirstFrame': '图生视频需要上传首帧图',
'studio.errNeedRef': '参考生视频需要至少 1 张参考图',
'studio.errRefMax': '参考图最多 {max} 张:已选 {n} 张,本次的 {k} 张未添加',
'studio.refHint': '必填 · 1–{max} 张 · 已选 {n}',
'studio.refAdd': '添加参考图',
'studio.refAddSub': '还可加 {n} 张 · 上限 {max}',
'studio.refReplace': '替换',
'studio.refRemove': '移除参考图 {n}',
'studio.refRejectedShort': '未添加',
'studio.refFull': '已达上限 {max} 张,移除后才能再添加',
'studio.refStrip': '参考图({n} 张参与生成)',
'studio.resolutionLabel': '输出分辨率',
'studio.resolutionHint': '约 {v}×{v} 像素,按输入图比例;0 = 保持原尺寸',
'studio.wfBrokenTitle': '该工作流文件不完整,ComfyUI 无法执行,已禁止生成',
'studio.wfBrokenHint': '请在已安装对应自定义节点的 ComfyUI 中重新导出 API 格式 JSON,再到「ComfyUI 连接」重新导入。',
'studio.errWfBroken': '工作流文件不完整,需重新导出后才能生成',
'studio.cancelToast': '已发送取消请求',
'studio.deleteBtn': '删除',
'studio.delTitle': '删除任务记录',
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
'studio.wfEmptyTitle': '还没有{kind}工作流',
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
'studio.missingQwen': 'Qwen Image 2.1 文生图 / 图生图 / 遮罩专用 JSON 暂缺:通用导入通道已就绪,请从 ComfyUI 导出后导入。',
'studio.missingFirstLast': 'MiniMax H3 首尾帧生视频专用 JSON 暂缺:仓库自带文生视频 / 参考生视频示例,可先导入使用。',
'studio.pickerTitle': '选择生图结果',
'studio.pickerEmpty': '还没有可用的生图结果',
'studio.pickerPick': '选用',
'studio.uploadFailedToast': '上传失败:{detail}',
'studio.playAria': '播放',
'studio.pauseAria': '暂停',
'studio.fullscreenAria': '全屏',
// --- 生成任务页(HEL-612)---
'tasks.title': '生成任务',
'tasks.subtitle': '汇总独立生图、独立生视频与创作流程内的图片 / 视频任务',
'tasks.filterAll': '全部',
'tasks.filterImage': '图片',
'tasks.filterVideo': '视频',
'tasks.count': '{n} 个任务 · {m} 个进行中',
'tasks.empty': '还没有生成任务。到「生图」「生视频」提交第一个任务。',
'tasks.goGenerate': '去生图',
'tasks.sourceStudioImage': '独立生图',
'tasks.sourceStudioVideo': '独立生视频',
'tasks.sourcePlayground': 'Playground',
'tasks.sourceFlow': '创作流程',
'tasks.sourceOther': '生成任务',
'tasks.queued': '排队',
'tasks.running': '生成中',
'tasks.done': '完成',
'tasks.failed': '失败',
'tasks.retry': '重试',
'tasks.cancel': '取消',
'tasks.delete': '删除',
'tasks.delTitle': '删除任务',
'tasks.delConfirm': '删除后该任务的结果文件会一并移除。确定删除?',
'tasks.outputs': '{n} 个结果',
'agentsPage.title': '智能体',
'agentsPage.subtitle': '与五个创作智能体独立对话,自定义并分配 Skill',
'agentsPage.statusChip': '{n} 个 Skill · {m} 个启用',
'agentsPage.agentsTitle': '五个 Agent',
'agentsPage.skillLib': 'Skill 库',
'agentsPage.skillLibCount': '{n} 个',
'agentsPage.installedTitle': '{name} · 已装 Skill',
'agentsPage.installedCount': '{n} 个 · {m} 启用',
'agentsPage.newSkill': '新增 Skill',
'agentsPage.skillLibEmpty': '还没有 Skill,点上方「新增 Skill」创建一个。',
'agentsPage.skillsCount': '{n} 个 Skill',
'agentsPage.replying': '回复中…',
'agentsPage.idle': '空闲',
'agentsPage.assigned': '分配 {n}',
'agentsPage.edit': '编辑',
'agentsPage.toggleSkill': '启用或停用 Skill',
'agentsPage.independentNote': '独立会话 · 不参与创作流程',
'agentsPage.model': '模型',
'agentsPage.modelDefault': '智能体默认',
'agentsPage.newSessionLabel': '新会话',
'agentsPage.newSession': '新会话',
'agentsPage.clear': '清空',
'agentsPage.export': '导出',
'agentsPage.skillManage': 'Skill 管理',
'agentsPage.composerPlaceholder': '继续聊,或贴入新剧本片段…(Enter 发送,Shift+Enter 换行)',
'agentsPage.send': '发送',
'agentsPage.clearTitle': '清空会话',
'agentsPage.clearConfirm': '清空后历史不可恢复。确定清空与{name}智能体的会话?',
'agentsPage.skillErrName': '请给 Skill 起个名字',
'agentsPage.skillErrAssign': '至少分配给一个 Agent,否则不会生效',
'agentsPage.skillLoadFailed': '读取 Skill 内容失败',
'agentsPage.skillSaved': 'Skill 已保存',
'agentsPage.skillSaveFailed': '保存失败:{detail}',
'agentsPage.skillEnabled': 'Skill 已启用',
'agentsPage.skillDisabled': 'Skill 已停用,新回复不再使用它',
'agentsPage.skillDeleted': 'Skill 已删除',
'agentsPage.skillRestored': '已回退到历史版本',
'agentsPage.skillTypeTemplate': '提示词模板',
'agentsPage.skillTypeKnowledge': '参考知识',
'agentsPage.skillTypeRules': '流程规则',
'agentsPage.versions': '版本历史',
'agentsPage.versionsEmpty': '还没有历史版本;每次保存会自动留档。',
'agentsPage.restore': '回退',
'agentsPage.close': '关闭',
'agentsPage.editSkill': '编辑 Skill',
'agentsPage.skillName': '名称',
'agentsPage.skillNamePh': '例如:cyberpunk-style-kit',
'agentsPage.skillType': '类型',
'agentsPage.skillDesc': '描述',
'agentsPage.skillDescPh': '什么时候用这个 Skill(展示在智能体的技能列表里)',
'agentsPage.skillContent': '内容',
'agentsPage.skillContentPh': '支持 Markdown:写提示词模板、参考资料或流程规则…',
'agentsPage.skillContentHint': '内容支持 Markdown;保存后自动升版并留档旧版本。',
'agentsPage.assignTo': '分配给',
'agentsPage.saveNext': '保存 v{v} → v{v}+1',
'agentsPage.save': '保存',
'agentsPage.disable': '停用',
'agentsPage.enable': '启用',
'agentsPage.delete': '删除',
'agentsPage.delSkillTitle': '删除 Skill',
'agentsPage.delSkillConfirm': '删除后不可恢复,已分配的 {n} 个 Agent 将同时移除。确定删除?',
'agentsPage.skillIdleHint': '从左侧 Skill 库选择「编辑」,或点「新增 Skill」',
'agentsPage.sugScript1': '把一个创意写成三幕式短片大纲',
'agentsPage.sugScript1P': '我有一个创意:凌晨的便利店,店员发现每天同一时刻都有同一位顾客光顾。请帮我写成三幕式短片大纲。',
'agentsPage.sugScript2': '帮我修改现成剧本',
'agentsPage.sugScript2P': '我会贴一段现成剧本,请你先读懂,再指出对白和节奏上可以改进的三处。',
'agentsPage.sugStyle1': '给剧本出几套风格方向',
'agentsPage.sugStyle1P': '这是一个雨夜霓虹灯下的都市爱情短片,请给出三套差异化视觉风格方向,每套附英文生图关键词组。',
'agentsPage.sugStyle2': '写实电影感风格关键词',
'agentsPage.sugStyle2P': '请为「写实电影感」整理一组可直接使用的生图风格提示词(正向+负面),中英对照。',
'agentsPage.sugBoard1': '把剧本拆成分镜表',
'agentsPage.sugBoard1P': '请把这段剧本拆成逐镜分镜表:镜号、景别、运镜、画面内容、台词起点、时长建议。',
'agentsPage.sugBoard2': '高潮段落怎么分镜',
'agentsPage.sugBoard2P': '追逐戏的高潮段落,三十秒,请给一版有节奏起伏的分镜方案。',
'agentsPage.sugAssets1': '整理角色生图提示词',
'agentsPage.sugAssets1P': '主角是 17 岁的夜行少女侦探,请写一套角色立绘生图提示词(正面/侧面/全身),保持一致性。',
'agentsPage.sugAssets2': '场景资产清单',
'agentsPage.sugAssets2P': '请根据雨夜便利店场景,列一份场景与道具资产生图提示词清单。',
'agentsPage.sugShot1': '写一镜视频提示词',
'agentsPage.sugShot1P': '雨夜便利店门口的推镜,从霓虹招牌推向玻璃门内,请写一条可直接使用的视频模型提示词。',
'agentsPage.sugShot2': '首尾帧镜头怎么写',
'agentsPage.sugShot2P': '请演示一个首尾帧生视频镜头的提示词写法:首帧画面、尾帧画面、运镜与时长分别怎么描述。',
'workbench.title': '创作工作台',
'workbench.subtitle': '剧本 → 视觉 → 分镜 → 素材 → 镜头脚本,五个智能体接力完成创作',
'workbench.pickerSubtitle': '选择一个项目,继续它的五段创作流程',
'workbench.pickerEmpty': '还没有项目',
'workbench.pickerEmptyBody': '先回到项目总览,从创意开始或导入现成剧本。',
'workbench.pickerGoHome': '去项目总览',
'workbench.pickerFootHint': '也可以在项目总览直接「从创意开始」或「导入剧本」进入工作台。',
'workbench.stageProgress': '五段进度',
'workbench.activeJobs': '{n} 个任务进行中',
'workbench.stage.script': '剧本创作',
'workbench.stage.style': '视觉设计',
'workbench.stage.storyboard': '分镜设计',
'workbench.stage.assets': '素材资产',
'workbench.stage.shot': '镜头脚本',
'workbench.lockedStep': '先完成并确认「{stage}」后解锁',
'workbench.state.todo': '待开始',
'workbench.state.running': '生成中',
'workbench.state.draft': '待确认',
'workbench.state.confirmed': '已确认',
'workbench.state.edited': '已修改',
'workbench.copied': '已复制,可直接粘贴到生图/生视频',
'workbench.copyFailed': '复制失败,请手动选择复制',
'workbench.copy': '复制',
'workbench.charCount': '{n} 字',
'workbench.runStage': '让{agent}生成',
'workbench.runDone': '{agent}已完成本段初稿,请确认',
'workbench.runFailed': '生成失败,请重试',
'workbench.runningHint': '{agent}正在生成本段内容,请稍候……',
'workbench.confirmNext': '确认,进入下一阶段',
'workbench.confirmLast': '确认本阶段',
'workbench.confirmed': '已确认「{agent}」阶段产出',
'workbench.confirmFailed': '确认失败,请重试',
'workbench.restore': '恢复已确认版',
'workbench.restored': '已恢复到最近确认的版本',
'workbench.restoreFailed': '恢复失败,请重试',
'workbench.draftNotice': '当前是未确认草稿:确认后才会交给下一阶段的智能体使用。',
'workbench.draftSaved': '草稿已保存,仍需确认后才生效',
'workbench.saveFailed': '保存失败,请重试',
'workbench.saveDraft': '保存草稿',
'workbench.cancelEdit': '取消',
'workbench.editDraft': '编辑',
'workbench.regenerate': '重新生成',
'workbench.regenerateHint': '重新让智能体生成本段内容(已确认版本会保留,可随时恢复)',
'workbench.instructionPh': '补充要求(可选),例如:偏电影感、台词更口语化……',
'workbench.jobSubmitted': '已提交生成任务,结果会自动回到本项目',
'workbench.jobSubmitFailed': '任务提交失败',
'workbench.jobRequeued': '任务已重新排队',
'workbench.retryJob': '重试',
'workbench.cancelJob': '取消任务',
'workbench.openOutput': '查看结果',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': '生成中',
'workbench.jobQueued': '排队',
'workbench.jobDone': '完成',
'workbench.jobFailed': '失败',
'workbench.generating': '生成中…',
'workbench.progressChip': '阶段 {n}/5',
'workbench.backHome': '项目总览',
'workbench.loadFailed': '工作台加载失败。',
'workbench.retryLoad': '重试',
'workbench.agentSkills': '{n} 个 Skill',
'workbench.chatWithAgent': '单独聊聊',
'workbench.tasksTitle': '阶段任务',
'workbench.tasksAll': '全部任务',
'workbench.tasksEmpty': '还没有生成任务。在素材资产或镜头脚本阶段可把提示词送入生成。',
'workbench.flowTitle': '五段进度',
'workbench.script.todoTitle': '从剧本开始:写下创意,交给「剧本创作」智能体',
'workbench.script.todoIdea': '你的创意:',
'workbench.script.generate': '生成剧本初稿',
'workbench.script.importBtn': '导入已有剧本',
'workbench.script.writeManually': '手动撰写',
'workbench.script.importPlaceholder': '把现成剧本粘贴到这里……导入后将跳过剧本创作,直接从视觉设计开始。',
'workbench.script.importHint': '导入的剧本会直接作为已确认的剧本阶段基线。',
'workbench.script.importConfirm': '导入并跳过剧本创作',
'workbench.script.importDone': '剧本已导入,从视觉设计继续',
'workbench.script.importFailed': '剧本导入失败,请检查内容后重试',
'workbench.script.editorHint': '直接编辑剧本正文,保存后仍需确认。',
'workbench.script.copyLabel': '剧本全文',
'workbench.style.todoTitle': '为剧本确定视觉风格',
'workbench.style.todoBody': '「视觉设计」智能体会给出多套差异化风格方向,每套附可直接复制的生图提示词。',
'workbench.style.generate': '生成风格方向',
'workbench.style.namePh': '风格名称,如:霓虹赛博',
'workbench.style.descPh': '中文风格说明(色调/光影/材质)',
'workbench.style.promptPh': '英文生图关键词,逗号分隔',
'workbench.style.negativePh': '负面提示词(可选,英文)',
'workbench.style.negativeLabel': '负面提示词',
'workbench.style.remove': '删除该方向',
'workbench.style.add': '添加风格方向',
'workbench.style.chosen': '当前选择',
'workbench.style.chooseHint': '确认视觉设计阶段时,会把你选中的风格交给后续阶段。',
'workbench.style.promptLabel': '风格提示词',
'workbench.board.scene': '场次',
'workbench.board.size': '景别',
'workbench.board.camera': '运镜',
'workbench.board.duration': '时长(秒)',
'workbench.board.desc': '画面内容',
'workbench.board.dialog': '台词/音效',
'workbench.board.descPh': '这一镜的画面内容描述',
'workbench.board.dialogPh': '台词或音效起点(可空)',
'workbench.board.remove': '删除该镜',
'workbench.board.add': '添加镜头',
'workbench.board.todoTitle': '把剧本拆成逐镜分镜表',
'workbench.board.todoBody': '「分镜设计」智能体按剧本产出镜号、景别、运镜、画面与时长建议。',
'workbench.board.generate': '生成分镜表',
'workbench.board.summary': '共 {n} 镜 · 约 {sec} 秒',
'workbench.asset.kindCharacter': '角色',
'workbench.asset.kindLocation': '场景',
'workbench.asset.kindProp': '道具',
'workbench.asset.namePh': '资产名称',
'workbench.asset.descPh': '中文说明(外观细节、用途)',
'workbench.asset.promptPh': '完整英文生图提示词',
'workbench.asset.promptLabel': '生图提示词',
'workbench.asset.remove': '删除该资产',
'workbench.asset.add': '添加素材',
'workbench.asset.todoTitle': '整理素材清单与生图提示词',
'workbench.asset.todoBody': '「素材资产」智能体结合剧本、风格与分镜,为角色/场景/道具产出完整可复制的生图提示词。',
'workbench.asset.generate': '生成素材清单',
'workbench.asset.generateImage': '生成本图',
'workbench.asset.generateHint': '用本地/已连接的 ComfyUI 工作流生成这张图,结果回到本项目',
'workbench.asset.summary': '共 {n} 项素材',
'workbench.vshot.noPh': '镜号,如 S1',
'workbench.vshot.descPh': '中文画面说明',
'workbench.vshot.promptPh': '完整英文视频模型提示词',
'workbench.vshot.promptLabel': '视频提示词',
'workbench.vshot.remove': '删除该镜',
'workbench.vshot.add': '添加镜头',
'workbench.vshot.todoTitle': '产出逐镜视频脚本',
'workbench.vshot.todoBody': '「镜头脚本」智能体把分镜转化为视频模型可用的逐镜提示词。',
'workbench.vshot.generate': '生成镜头脚本',
'workbench.vshot.generateVideo': '生成本镜视频',
'workbench.vshot.generateHint': '用已连接的 ComfyUI 视频工作流生成这一镜,结果回到本项目',
'workbench.vshot.summary': '共 {n} 镜',
'home.errScriptShort': '剧本内容太短,请检查粘贴是否完整(至少 30 字)',
'home.stageProgress': '五段进度',
'tasks.sourceWorkbench': '创作流程',
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
'jobst.done': '完成',
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
'jobst.dryRun': '占位结果',
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
'jobst.noOutput': '无产出',
'jobst.noOutputDetail': '任务结束但没有任何结果文件,不算生成完成',
'jobst.queued': '排队中',
'jobst.queuedDetail': '在工作台队列中等待提交到 ComfyUI',
'jobst.connecting': '连接中',
'jobst.connectingDetail': '正在连接 ComfyUI 并上传素材',
'jobst.comfyQueued': '已接收',
'jobst.comfyQueuedDetail': 'ComfyUI 已接收,排队等待执行',
'jobst.comfyQueuedAhead': 'ComfyUI 已接收,前面还有 {n} 个任务',
'jobst.comfyRunning': '执行中',
'jobst.comfyRunningDetail': 'ComfyUI 正在执行',
'jobst.saving': '保存中',
'jobst.savingDetail': 'ComfyUI 已出结果,正在下载保存',
'jobst.failed': '失败',
'jobst.cancelled': '已取消',
'studio.ratioLabel': '画面比例',
'studio.megapixelsLabel': '总像素(百万)',
'studio.outputSize': '输出 {w} × {h}',
'studio.denoiseLabel': '降噪',
'studio.errNumber': '「{label}」必须是数字',
'studio.errInteger': '「{label}」必须是整数',
'studio.errMin': '「{label}」不能小于 {v}',
'studio.errMax': '「{label}」不能大于 {v}',
'studio.errOption': '「{label}」的取值不在工作流支持的选项中',
'studio.reuseParams': '复用参数',
'studio.reusedToast': '已回填该任务的参数,可修改后重新生成',
'studio.reuseMissingWf': '该任务使用的工作流已不存在,无法回填',
'studio.doneToast': '生成完成:{n} 个文件(ComfyUI 任务 ID {id})',
'studio.dryRunToast': '占位完成:当前开启了 Dry-run,没有调用 ComfyUI,结果不是真实生成',
'studio.dryRunNote': '已开启 Dry-run:点生成只会写占位文件,不会调用 ComfyUI。可在「设置」中关闭。',
};
+31
View File
@@ -0,0 +1,31 @@
/**
* ID generation that survives insecure contexts.
*
* `crypto.randomUUID()` is only exposed on secure contexts (HTTPS or
* localhost/127.0.0.1). On a LAN HTTP deployment (e.g.
* `http://192.168.200.15:8080`) the method is missing and direct calls crash
* with "crypto.randomUUID is not a function". `crypto.getRandomValues()` has
* no secure-context requirement, so the fallback builds a real RFC 4122 v4
* UUID from it; only when `crypto` is absent entirely do we degrade to a
* best-effort Math.random id.
*/
export function randomId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
if (c && typeof c.getRandomValues === "function")
return uuidV4(c.getRandomValues.bind(c));
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random()
.toString(36)
.slice(2)}${Math.random().toString(36).slice(2)}`;
}
/** RFC 4122 version 4 UUID from a crypto-strength byte source. */
function uuidV4(getRandomValues: (array: Uint8Array) => Uint8Array): string {
const bytes = getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
"",
);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+20
View File
@@ -0,0 +1,20 @@
import type { Job } from '$lib/api';
import { t } from '$lib/i18n.svelte';
/** Where a generation job came from: 独立生图 / 独立生视频 / 创作流程 / Playground. */
export function jobSourceLabel(job: Job): string {
const src = job.payload?.source;
if (src === 'studio-image') return t('tasks.sourceStudioImage');
if (src === 'studio-video') return t('tasks.sourceStudioVideo');
if (src === 'workbench') return t('tasks.sourceWorkbench');
if (src === 'playground') return t('tasks.sourcePlayground');
if (job.scene_id || job.clip_id) return t('tasks.sourceFlow');
if (!src) return t('tasks.sourceFlow');
return t('tasks.sourceOther');
}
/** Jobs started outside any project (standalone windows / Playground). */
export function isStandaloneJob(job: Job): boolean {
const src = job.payload?.source;
return src === 'studio-image' || src === 'studio-video' || src === 'playground';
}
+12 -7
View File
@@ -8,14 +8,14 @@ import { t } from './i18n.svelte';
* Backend reality (see calliope-backend/src/calliope/queue/worker.py):
* - `job.created` { job_id, kind, ... }
* - `job.started` { job_id, kind, message } ← message = job label
* - `job.progress` { prompt_id, message } ← NO job_id, NO percent
* - `job.progress` { job_id, prompt_id, phase, queue_ahead, message } ← NO percent
* - `job.completed` { job_id, outputs, message }
* - `job.failed` { job_id, error, message }
* - `job.deleted` { job_id }
*
* Because progress ticks carry neither a job id nor a percent, the store:
* 1. Attributes a tick to the currently running job (tracked from job.started;
* explicit `job_id` wins if a future backend sends one).
* Because progress ticks carry no percent (and ticks without a job id exist), the store:
* 1. Attributes a tick to its `job_id`, else to the currently running job
* (tracked from job.started).
* 2. Honors a numeric payload field (`progress` / `percent` / `value`, 0–1 or
* 0–100) when present, otherwise synthesizes an asymptotic value that
* approaches TICK_CAP but never reaches it — only job.completed sets 100.
@@ -32,6 +32,10 @@ export type JobProgressEntry = {
progress: number;
/** Latest human-readable status line from the worker. */
message?: string;
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
phase?: string;
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
queueAhead?: number | null;
/** True once the job reached a terminal state (done/failed). */
final: boolean;
/** Terminal status when final: 'done' | 'failed'. */
@@ -115,6 +119,8 @@ class JobProgressStore {
this.map.set(id, {
progress,
message: str(d.message) || prev?.message,
phase: str(d.phase) || prev?.phase,
queueAhead: d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
final: false,
updatedAt: Date.now(),
});
@@ -151,9 +157,8 @@ class JobProgressStore {
}
/**
* Today's job.progress payload is {prompt_id, message} with no job id.
* Attribute the tick to the only running job, else the most recently
* started one. Explicit job_id wins when a backend sends it.
* Explicit job_id wins; a tick without one goes to the only running job,
* else the most recently started one.
*/
private resolveJobId(d: Record<string, unknown>): number | null {
const explicit = num(d.job_id);
+53
View File
@@ -0,0 +1,53 @@
import type { Job } from './comfy/types';
import { progressFor } from './jobProgress';
import { t } from './i18n.svelte';
export type JobStatusTone = 'ok' | 'run' | 'queue' | 'fail';
export interface JobStatusInfo {
tone: JobStatusTone;
/** Short label for status columns. */
label: string;
/** Full sentence for the task card / tooltip. */
detail: string;
}
/**
* What a generation job really is — "done" only when ComfyUI returned files.
* A dry-run placeholder or a done row without outputs never reads as done.
*/
export function jobStatusInfo(job: Job): JobStatusInfo {
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase ?? null;
const idSuffix = job.comfy_prompt_id ? t('jobst.idSuffix', { id: job.comfy_prompt_id }) : '';
switch (job.status) {
case 'done':
if (phase === 'dry_run')
return { tone: 'queue', label: t('jobst.dryRun'), detail: t('jobst.dryRunDetail') };
if (!(job.output_paths?.length ?? 0))
return { tone: 'fail', label: t('jobst.noOutput'), detail: t('jobst.noOutputDetail') };
return { tone: 'ok', label: t('jobst.done'), detail: t('jobst.doneDetail') + idSuffix };
case 'pending':
return { tone: 'queue', label: t('jobst.queued'), detail: t('jobst.queuedDetail') };
case 'running':
if (phase === 'comfy_queued') {
const ahead = live?.queueAhead;
const detail =
ahead != null && ahead > 0
? t('jobst.comfyQueuedAhead', { n: ahead })
: t('jobst.comfyQueuedDetail');
return { tone: 'queue', label: t('jobst.comfyQueued'), detail: detail + idSuffix };
}
if (phase === 'comfy_running')
return { tone: 'run', label: t('jobst.comfyRunning'), detail: t('jobst.comfyRunningDetail') + idSuffix };
if (phase === 'saving')
return { tone: 'run', label: t('jobst.saving'), detail: t('jobst.savingDetail') + idSuffix };
return { tone: 'run', label: t('jobst.connecting'), detail: t('jobst.connectingDetail') };
default:
return {
tone: 'fail',
label: job.error === 'cancelled' ? t('jobst.cancelled') : t('jobst.failed'),
detail: job.error ?? t('jobst.failed'),
};
}
}
@@ -21,6 +21,7 @@ import {
} from './helpers/posture';
import { sampleTrack } from './motion/sampleTrack';
import { normalizeShotParams, type ShotParams } from './calibration/shotSolver';
import { randomId } from '$lib/ids';
export type ComposerTool = 'select' | 'move' | 'rotate' | 'scale' | 'pose';
export type CharacterType = 'male' | 'female' | 'child';
@@ -117,9 +118,7 @@ export function isCharacterType(type: ObjectType): type is CharacterType {
}
function uid(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `id-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
return randomId();
}
function findNearKeyframeIndex(keyframes: Keyframe[], time: number): number {

Some files were not shown because too many files have changed in this diff Show More