Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7a682cd0 | ||
|
|
d2a8ebf7dd | ||
|
|
06b15dc3b6 | ||
|
|
eeafe739f7 | ||
|
|
88660cb3b4 | ||
|
|
cdd6eba8ba | ||
|
|
7be478dafd | ||
|
|
d49797fe5d | ||
|
|
df84c22143 | ||
|
|
1cf9188955 | ||
|
|
25d1e05a30 | ||
|
|
4945d29e52 | ||
|
|
5d909aca3b | ||
|
|
786b349752 |
@@ -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:
|
||||
|
||||
@@ -53,10 +53,11 @@ def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -147,11 +147,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 +283,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 +376,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")
|
||||
@@ -367,6 +429,27 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN origin TEXT NOT NULL DEFAULT 'chat'")
|
||||
# Backfill: existing Scene · N sessions were born in Build Scene.
|
||||
conn.execute("UPDATE agent_sessions SET origin = 'scene' WHERE title LIKE 'Scene · %'")
|
||||
if "agent_key" not in session_cols:
|
||||
# HEL-613: named-agent sessions (five AIGC roles). NULL = a plain
|
||||
# chat/scene session (every session created before this column).
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN agent_key TEXT")
|
||||
# Seed per-agent bindings once, when the skill_bindings table is fresh:
|
||||
# every skill that exists at upgrade time (the builtins) becomes visible
|
||||
# to all five agents, so agent chats keep the current out-of-the-box
|
||||
# behavior; new user skills opt in via the editor's 分配给 row.
|
||||
binding_count = conn.execute("SELECT COUNT(*) FROM skill_bindings").fetchone()[0]
|
||||
if binding_count == 0:
|
||||
from calliope.agent.agents_registry import AGENT_KEYS
|
||||
from calliope.agent.skills_store import ensure_builtin_skills, skills_root
|
||||
|
||||
ensure_builtin_skills()
|
||||
for skill_dir in sorted(skills_root().glob("*/SKILL.md")):
|
||||
name = skill_dir.parent.name
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO skill_bindings(agent_key, skill_name, is_enabled) "
|
||||
"VALUES (?, ?, 1)",
|
||||
[(key, name) for key in AGENT_KEYS],
|
||||
)
|
||||
project_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
if "cover_path" not in project_cols:
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN cover_path TEXT")
|
||||
|
||||
@@ -20,6 +20,7 @@ from calliope.routers import (
|
||||
agent,
|
||||
assets,
|
||||
canvas,
|
||||
comfyui as comfy,
|
||||
events,
|
||||
jobs,
|
||||
library,
|
||||
@@ -29,6 +30,7 @@ from calliope.routers import (
|
||||
settings as settings_router,
|
||||
shots,
|
||||
story,
|
||||
workbench,
|
||||
workflows,
|
||||
)
|
||||
|
||||
@@ -135,10 +137,12 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app.include_router(workflows.router, prefix="/api/workflows", tags=["workflows"])
|
||||
app.include_router(jobs.router, prefix="/api/jobs", tags=["jobs"])
|
||||
app.include_router(playground.router, prefix="/api/playground", tags=["playground"])
|
||||
app.include_router(comfy.router, prefix="/api/comfyui", tags=["comfyui"])
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
|
||||
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
|
||||
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
|
||||
app.include_router(workbench.router, prefix="/api/workbench", tags=["workbench"])
|
||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -39,6 +39,10 @@ class ProjectStats(BaseModel):
|
||||
character_count: int
|
||||
asset_ready_count: int
|
||||
asset_total_count: int
|
||||
# Five-stage workbench progress (HEL-614) — real stage rows + live jobs.
|
||||
stage_confirmed: int = 0
|
||||
stage_status: dict[str, str] = Field(default_factory=dict)
|
||||
active_jobs: int = 0
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
@@ -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):
|
||||
|
||||
@@ -38,12 +38,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 +52,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
|
||||
|
||||
@@ -151,7 +151,8 @@ 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:
|
||||
return await self._dry_run(job, payload)
|
||||
@@ -159,8 +160,8 @@ class QueueWorker:
|
||||
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")
|
||||
@@ -208,6 +209,37 @@ class QueueWorker:
|
||||
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],
|
||||
|
||||
@@ -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 {}),
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -23,9 +23,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):
|
||||
@@ -339,11 +348,15 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
kind=kind,
|
||||
workflow_id=payload.workflow_id,
|
||||
scene_id=None,
|
||||
payload={"input_values": payload.input_values, "source": "playground"},
|
||||
payload={
|
||||
"input_values": payload.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)}
|
||||
@@ -129,8 +129,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 +141,7 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
json.dumps(outputs),
|
||||
payload.description,
|
||||
profile,
|
||||
payload.endpoint_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -171,7 +173,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,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,558 @@
|
||||
"""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 = {
|
||||
"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,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
|
||||
@@ -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,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,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
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regression guard for the "crypto.randomUUID is not a function" crash.
|
||||
*
|
||||
* `crypto.randomUUID()` only exists in secure contexts (HTTPS, localhost,
|
||||
* 127.0.0.1). On a LAN HTTP page — e.g. http://192.168.200.15:8080 — the
|
||||
* browser exposes `crypto` WITHOUT `randomUUID`, so any direct call throws
|
||||
* and model saving dies. `crypto.getRandomValues()` has no secure-context
|
||||
* requirement, so the shared helper can always mint a proper v4 UUID.
|
||||
*
|
||||
* Two layers of protection, mirroring check-storage-ssr.mjs:
|
||||
* 1. behavioural — lib/ids.ts must produce unique, well-formed v4 UUIDs
|
||||
* under every crypto shape a browser can present, including the exact
|
||||
* insecure-context shape that produced the original crash.
|
||||
* 2. static — direct `crypto.randomUUID(` calls are banned outside
|
||||
* lib/ids.ts, so the crash cannot be reintroduced by copying the old
|
||||
* pattern into a new page.
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-random-uuid.mjs`).
|
||||
*/
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const HELPER = join(SRC, "lib", "ids.ts");
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function fail(message) {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${message}`);
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
console.log(`ok ${message}`);
|
||||
}
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
/** Capture the platform crypto before any test replaces it. */
|
||||
const REAL_CRYPTO = globalThis.crypto;
|
||||
|
||||
/** Replace the global under test. Node's own binding may be getter-only. */
|
||||
function setGlobalCrypto(value) {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { randomId } = await import(pathToFileURL(HELPER).href);
|
||||
|
||||
// ---- 1. behavioural ----------------------------------------------------
|
||||
|
||||
// The exact shape a LAN HTTP browser presents: crypto WITHOUT randomUUID.
|
||||
// This is the configuration that produced the original crash.
|
||||
const insecureContextCrypto = {
|
||||
getRandomValues: REAL_CRYPTO.getRandomValues.bind(REAL_CRYPTO),
|
||||
};
|
||||
setGlobalCrypto(insecureContextCrypto);
|
||||
try {
|
||||
const ids = new Set(Array.from({ length: 1000 }, randomId));
|
||||
if (ids.size !== 1000)
|
||||
fail(`insecure context: duplicate ids (${1000 - ids.size} collisions)`);
|
||||
else if (![...ids].every((id) => UUID_V4.test(id))) {
|
||||
fail("insecure context: ids are not all well-formed v4 UUIDs");
|
||||
} else {
|
||||
ok("insecure-context crypto (getRandomValues only) yields unique v4 UUIDs");
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`insecure-context crypto threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// crypto present but BOTH sources missing (exotic embed / ancient runtime).
|
||||
setGlobalCrypto({});
|
||||
try {
|
||||
const ids = new Set(Array.from({ length: 1000 }, randomId));
|
||||
if (ids.size !== 1000)
|
||||
fail(`method-less crypto: duplicate ids (${1000 - ids.size} collisions)`);
|
||||
else if (![...ids].every((id) => id.length >= 16)) {
|
||||
fail("method-less crypto: ids too short to be collision-resistant");
|
||||
} else {
|
||||
ok(
|
||||
"method-less crypto degrades to unique best-effort ids without throwing",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`method-less crypto threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// crypto entirely absent.
|
||||
setGlobalCrypto(undefined);
|
||||
try {
|
||||
const ids = new Set(Array.from({ length: 1000 }, randomId));
|
||||
if (ids.size !== 1000)
|
||||
fail(`absent crypto: duplicate ids (${1000 - ids.size} collisions)`);
|
||||
else ok("absent crypto degrades to unique best-effort ids without throwing");
|
||||
} catch (err) {
|
||||
fail(`absent crypto threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// A fully-featured crypto (secure context / Node default) must still be used
|
||||
// directly — guards against a helper that ignores randomUUID.
|
||||
setGlobalCrypto(REAL_CRYPTO);
|
||||
try {
|
||||
const probe = { used: false };
|
||||
setGlobalCrypto({
|
||||
randomUUID: () => {
|
||||
probe.used = true;
|
||||
return REAL_CRYPTO.randomUUID();
|
||||
},
|
||||
getRandomValues: REAL_CRYPTO.getRandomValues.bind(REAL_CRYPTO),
|
||||
});
|
||||
const id = randomId();
|
||||
if (!probe.used) fail("native randomUUID available but not preferred");
|
||||
else if (!UUID_V4.test(id)) fail(`secure-context id malformed: ${id}`);
|
||||
else ok("native randomUUID preferred when available");
|
||||
} catch (err) {
|
||||
fail(`secure-context crypto threw: ${err.message}`);
|
||||
} finally {
|
||||
setGlobalCrypto(REAL_CRYPTO);
|
||||
}
|
||||
|
||||
// ---- 2. static ---------------------------------------------------------
|
||||
|
||||
const UNSAFE = /crypto\.randomUUID\s*\(/;
|
||||
const EXTENSIONS = [".ts", ".js", ".svelte"];
|
||||
|
||||
function walk(dir) {
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
found.push(...walk(full));
|
||||
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const offenders = [];
|
||||
for (const file of walk(SRC)) {
|
||||
if (file === HELPER) continue; // the one place allowed to call the native API
|
||||
const lines = readFileSync(file, "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (UNSAFE.test(line))
|
||||
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (offenders.length) {
|
||||
fail(
|
||||
`direct \`crypto.randomUUID()\` call outside lib/ids.ts — use randomId() instead (insecure contexts crash on it):\n ${offenders.join("\n ")}`,
|
||||
);
|
||||
} else {
|
||||
ok("no direct `crypto.randomUUID()` calls in src/");
|
||||
}
|
||||
|
||||
// ---- result ------------------------------------------------------------
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} randomId/insecure-context check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nrandomId insecure-context checks passed.");
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regression guard for the HEL-613 rework: the skill editor must load the
|
||||
* SKILL.md BODY only — never the YAML frontmatter — so saving cannot wrap
|
||||
* the file in a second frontmatter layer (each edit used to nest one more
|
||||
* `---\nname: …` block into the content: the old strip regex anchored on
|
||||
* `\A`, a Python-ism JS treats as a literal, so it never matched).
|
||||
*
|
||||
* Layers:
|
||||
* 1. behavioural — stripSkillFrontmatter() must strip the exact block
|
||||
* shape the backend's render_skill_md writes, split at the right
|
||||
* boundary, be idempotent (the no-doubling invariant), and pass
|
||||
* plain bodies through unchanged.
|
||||
* 2. round-trip — strip → re-render (frontmatter + body) → strip again
|
||||
* yields the identical body: editing twice adds zero layers.
|
||||
* 3. static — the editor page must use the shared helper; the inline
|
||||
* `\A`-anchored regex cannot creep back in.
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-skill-frontmatter.mjs`).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
|
||||
let failed = false;
|
||||
function check(name, cond, detail = "") {
|
||||
console.log(
|
||||
`${cond ? "ok " : "FAIL "} ${name}${cond ? "" : ` :: ${detail}`}`,
|
||||
);
|
||||
if (!cond) failed = true;
|
||||
}
|
||||
|
||||
// Mirror of the backend's render_skill_md output shape
|
||||
// (calliope-backend/src/calliope/agent/skills_store.py).
|
||||
const rendered = [
|
||||
"---",
|
||||
"name: cyberpunk-style-kit",
|
||||
'description: "赛博朋克风格词库"',
|
||||
"version: 1.0.1",
|
||||
"metadata:",
|
||||
" author: user",
|
||||
" tags: [neon, rain]",
|
||||
" type: template",
|
||||
"---",
|
||||
"",
|
||||
"# 赛博朋克风格词库",
|
||||
"neon, rain, hologram",
|
||||
].join("\n");
|
||||
|
||||
const { stripSkillFrontmatter } = await import(
|
||||
pathToFileURL(join(ROOT, "src/lib/skillFrontmatter.ts"))
|
||||
);
|
||||
|
||||
// 1. Editor load: body only, no frontmatter.
|
||||
const body = stripSkillFrontmatter(rendered);
|
||||
check(
|
||||
"edit load strips the frontmatter block",
|
||||
!body.includes("name: cyberpunk-style-kit") &&
|
||||
!body.trimStart().startsWith("---"),
|
||||
JSON.stringify(body.slice(0, 40)),
|
||||
);
|
||||
check(
|
||||
"edit load keeps the body",
|
||||
body.includes("# 赛博朋克风格词库") && body.includes("neon, rain, hologram"),
|
||||
JSON.stringify(body),
|
||||
);
|
||||
|
||||
// 2. Strip splits at the exact boundary: frontmatter + body reassembles
|
||||
// the file byte-identically (proves no body text was eaten).
|
||||
const fm = rendered.slice(0, rendered.length - body.length);
|
||||
check(
|
||||
"strip boundary is exact",
|
||||
fm + body === rendered && fm.trimEnd().endsWith("---"),
|
||||
"frontmatter/body split drifted",
|
||||
);
|
||||
|
||||
// 3. No-doubling invariant: idempotent strip + round-trip stability.
|
||||
check(
|
||||
"strip is idempotent",
|
||||
stripSkillFrontmatter(body) === body,
|
||||
"second strip changed the body",
|
||||
);
|
||||
const savedAgain = fm + body; // render_skill_md re-wraps the same frontmatter
|
||||
check(
|
||||
"second edit-save-reload adds no layer",
|
||||
stripSkillFrontmatter(savedAgain) === body,
|
||||
"round-trip grew the content",
|
||||
);
|
||||
|
||||
// 4. Body without frontmatter passes through unchanged.
|
||||
const plain = "# Just a body\nwith text\n";
|
||||
check(
|
||||
"plain body unchanged",
|
||||
stripSkillFrontmatter(plain) === plain,
|
||||
JSON.stringify(stripSkillFrontmatter(plain)),
|
||||
);
|
||||
|
||||
// 5. Static: the page uses the shared helper; no `\A` regex remains.
|
||||
const page = readFileSync(
|
||||
join(ROOT, "src/routes/agents/+page.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
check(
|
||||
"page imports the shared strip helper",
|
||||
page.includes("stripSkillFrontmatter"),
|
||||
"helper import missing",
|
||||
);
|
||||
check(
|
||||
"no \\A-anchored strip regex left behind",
|
||||
!/\\A---/.test(page),
|
||||
"inline \\A regex present",
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
console.error("skill frontmatter checks FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("skill frontmatter checks passed.");
|
||||
@@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Calliope Lab</title>
|
||||
<title>影工坊</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
|
||||
+1046
-574
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,8 @@ 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;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,345 @@
|
||||
<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 { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽): empty = 1.5px dashed
|
||||
* border + "click or drop", drag-hover = amber dashed, filled = solid
|
||||
* border + thumbnail + mono name + ✕, error = red border + red reason.
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Extra hint under the label, e.g. 可从生图结果送入. */
|
||||
hint?: string;
|
||||
/** 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';
|
||||
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,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
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);
|
||||
$effect(() => {
|
||||
void value;
|
||||
broken = false;
|
||||
});
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
if (kind === 'audio') return 'audio/*';
|
||||
return 'image/*';
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
// strip uuid8- prefix the upload endpoint writes
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
|
||||
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}</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 kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} 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="mono name" title={baseName(value)}>{baseName(value)}</span>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
onclick={onClear}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</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">{t('studio.slotPick')}</span>
|
||||
{/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);
|
||||
}
|
||||
.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: 11px;
|
||||
}
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
}
|
||||
.slot-thumb {
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.media-slot.wide .slot-thumb img,
|
||||
.media-slot.wide .slot-thumb video {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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: 10.5px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
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>
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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() });
|
||||
|
||||
@@ -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',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1157,596 @@ 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.wfImported': 'Workflow importiert',
|
||||
'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.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.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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1041,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 +1159,595 @@ 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.wfImported': 'Workflow imported',
|
||||
'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.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.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',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -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',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1157,596 @@ 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.wfImported': 'Flujo importado',
|
||||
'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.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.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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1157,596 @@ 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.wfImported': 'Workflow importé',
|
||||
'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.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.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',
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ja: Dict = {
|
||||
'nav.projects': 'プロジェクト',
|
||||
'nav.buildScene': 'シーン構築',
|
||||
'nav.settings': '設定',
|
||||
'nav.agents': 'エージェント',
|
||||
'nav.models': 'モデル設定',
|
||||
'nav.home': 'ホーム',
|
||||
'nav.primary': 'メイン',
|
||||
'nav.projectStages': 'プロジェクトの段階',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1157,596 @@ 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.wfImported': 'ワークフローを取り込みました',
|
||||
'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.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.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': '制作フロー',
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ko: Dict = {
|
||||
'nav.projects': '프로젝트',
|
||||
'nav.buildScene': '장면 구성',
|
||||
'nav.settings': '설정',
|
||||
'nav.agents': '에이전트',
|
||||
'nav.models': '모델 설정',
|
||||
'nav.home': '홈',
|
||||
'nav.primary': '기본',
|
||||
'nav.projectStages': '프로젝트 단계',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1157,596 @@ 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.wfImported': '워크플로를 가져왔습니다',
|
||||
'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.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.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': '제작 흐름',
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export const zh: Dict = {
|
||||
'nav.projects': '项目',
|
||||
'nav.buildScene': '搭建场景',
|
||||
'nav.settings': '设置',
|
||||
'nav.agents': '智能体',
|
||||
'nav.models': '模型设置',
|
||||
'nav.home': '首页',
|
||||
'nav.primary': '主导航',
|
||||
'nav.projectStages': '项目阶段',
|
||||
@@ -962,6 +964,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 +1018,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 +1031,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 +1041,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 +1159,593 @@ 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.wfImported': '工作流已导入',
|
||||
'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.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.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': '创作流程',
|
||||
};
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Strip a SKILL.md YAML frontmatter block, leaving only the body the
|
||||
* skill editor edits.
|
||||
*
|
||||
* The block is `---` … `---` at the START of the file. The anchor is `^`
|
||||
* (string start) — `\A` is a Python-ism that JS silently treats as a
|
||||
* literal, which made the strip a no-op and let every save re-wrap the
|
||||
* whole file in a new frontmatter layer (HEL-613 rework).
|
||||
*/
|
||||
const FRONTMATTER_RE = /^---\s*\n[\s\S]*?\n---\s*\n?/;
|
||||
|
||||
export function stripSkillFrontmatter(text: string): string {
|
||||
const m = text.match(FRONTMATTER_RE);
|
||||
return m ? text.slice(m[0].length) : text;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* ≥1440px is the full-width breakpoint of the spec. There the add form
|
||||
* of 模型设置 / ComfyUI 连接 stays open in the right column (a6 / a7);
|
||||
* below it the form is a drawer that only opens on demand.
|
||||
*/
|
||||
export function isWideLayout(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(min-width: 1440px)').matches;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
|
||||
// The root route is the A「片场暗房」项目总览 (HEL-608) — no redirect.
|
||||
export function load() {
|
||||
redirect(302, '/agents');
|
||||
return {};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+283
-70
@@ -3,32 +3,52 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0c;
|
||||
--bg-surface: #141418;
|
||||
--bg-elevated: #1e1e24;
|
||||
--border: #2a2a32;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--accent-glow: rgba(139, 92, 246, 0.25);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #3b82f6;
|
||||
/* ------------------------------------------------------------------
|
||||
* A「片场暗房」design tokens — spec-v2 §2 (HEL-603 confirmed).
|
||||
* Single source of truth for the whole app: do not hardcode colors in
|
||||
* pages or components, and do not override these with ad-hoc CSS.
|
||||
* ------------------------------------------------------------------ */
|
||||
--bg: #0d0e11;
|
||||
--surface: #14161b;
|
||||
--surface-2: #1b1e25;
|
||||
--surface-3: #22262f;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--border-hover: rgba(255, 255, 255, 0.24);
|
||||
--text: #edeef0;
|
||||
--text-2: #9ba1ab;
|
||||
--text-3: #5c626c;
|
||||
--accent: #e8a33d;
|
||||
--accent-hover: #f1b155; /* +8% brightness (§5 主按钮) */
|
||||
--accent-contrast: #16130a;
|
||||
--accent-dim: rgba(232, 163, 61, 0.14);
|
||||
--accent-glow: rgba(232, 163, 61, 0.35);
|
||||
--accent-focus: rgba(232, 163, 61, 0.6);
|
||||
--success: #3fb97f;
|
||||
--success-dim: rgba(63, 185, 127, 0.14);
|
||||
--info: #5b8def;
|
||||
--info-dim: rgba(91, 141, 239, 0.14);
|
||||
--error: #e5484d;
|
||||
--error-dim: rgba(229, 72, 77, 0.14);
|
||||
--error-border: rgba(229, 72, 77, 0.4);
|
||||
--cloud: #b98be0;
|
||||
--cloud-dim: rgba(185, 139, 224, 0.14);
|
||||
|
||||
/* Omni Composer pill tokens */
|
||||
--pill-bg: var(--bg-elevated);
|
||||
--pill-border: var(--border);
|
||||
--pill-radius: 9999px;
|
||||
--pill-height: 36px;
|
||||
--pill-padding: 0 14px;
|
||||
--pill-font-size: 13px;
|
||||
/* Avatar five-color set (§2.1) — five agents only */
|
||||
--avatar-1: linear-gradient(135deg, #e8a33d, #b96a1b);
|
||||
--avatar-2: linear-gradient(135deg, #5b8def, #3a5fa9);
|
||||
--avatar-3: linear-gradient(135deg, #3fb97f, #2a7a54);
|
||||
--avatar-4: linear-gradient(135deg, #b98be0, #7a4fa9);
|
||||
--avatar-5: linear-gradient(135deg, #d98e6a, #a95535);
|
||||
|
||||
--radius-sm: 6px;
|
||||
/* Typography (§2.2) */
|
||||
--font-ui: 'Noto Sans SC', 'Noto Sans CJK SC', sans-serif;
|
||||
--font-a-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
|
||||
|
||||
/* Radius / spacing (§2.3) */
|
||||
--radius-sm: 7px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-lg: 10px;
|
||||
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
@@ -36,78 +56,271 @@
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
|
||||
--font-display: 'Outfit', system-ui, sans-serif;
|
||||
--font-body: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
/* Legacy token names used by shared primitives — aliases of the A set,
|
||||
so every component renders in the same darkroom palette. */
|
||||
--bg-primary: var(--bg);
|
||||
--bg-surface: var(--surface);
|
||||
--bg-elevated: var(--surface-2);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text-2);
|
||||
--text-muted: var(--text-3);
|
||||
--warning: var(--accent);
|
||||
--font-display: var(--font-ui);
|
||||
--font-body: var(--font-ui);
|
||||
--font-mono: var(--font-a-mono);
|
||||
|
||||
/* Omni Composer pill tokens */
|
||||
--pill-bg: var(--surface-2);
|
||||
--pill-border: var(--border-strong);
|
||||
--pill-radius: 9999px;
|
||||
--pill-height: 36px;
|
||||
--pill-padding: 0 14px;
|
||||
--pill-font-size: 13px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
.a-darkroom {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
code,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
.mono,
|
||||
.kh {
|
||||
font-family: var(--font-a-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Shared A controls (§5 组件状态全表). The only definitions of these
|
||||
* looks — `ui/Button.svelte` renders `.a-btn`, pages use the classes
|
||||
* directly for links / selects. Page styles may only tweak layout
|
||||
* (width / margin), never re-declare colors.
|
||||
* ------------------------------------------------------------------ */
|
||||
.a-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
color 150ms,
|
||||
transform 100ms;
|
||||
}
|
||||
.a-btn.sm {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.a-btn.xs {
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.a-btn.block {
|
||||
width: 100%;
|
||||
}
|
||||
.a-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.a-btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.a-btn.secondary {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.secondary:hover:not(:disabled) {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
.a-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.a-btn.ghost:hover:not(:disabled) {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.danger {
|
||||
background: transparent;
|
||||
border-color: var(--error-border);
|
||||
color: var(--error);
|
||||
}
|
||||
.a-btn.danger:hover:not(:disabled) {
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.a-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.a-btn:disabled,
|
||||
.a-btn[aria-disabled='true'] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Top-bar status capsule (counts / save state / connection). */
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-chip.accent {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.status-chip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
.status-chip-dot.ok {
|
||||
background: var(--success);
|
||||
}
|
||||
.status-chip-dot.fail {
|
||||
background: var(--error);
|
||||
}
|
||||
.status-chip-dot.idle {
|
||||
background: var(--text-3);
|
||||
}
|
||||
|
||||
/* Card / panel surface (§2.3: 10px radius, 1px border, no shadow). */
|
||||
.a-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px 22px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.a-panel-title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.a-panel-lead {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.a-select,
|
||||
.a-input,
|
||||
.a-textarea,
|
||||
.field-input,
|
||||
.field-textarea,
|
||||
.field-select {
|
||||
box-sizing: border-box;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.a-select {
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.a-input,
|
||||
.field-input,
|
||||
.field-select {
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
.a-textarea,
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.a-select:hover,
|
||||
.a-input:hover,
|
||||
.a-textarea:hover,
|
||||
.field-input:hover,
|
||||
.field-textarea:hover,
|
||||
.field-select:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
.a-select:focus,
|
||||
.a-input:focus,
|
||||
.a-textarea:focus,
|
||||
.field-input:focus,
|
||||
.field-textarea:focus,
|
||||
.field-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-focus);
|
||||
}
|
||||
.a-input.invalid,
|
||||
.a-textarea.invalid,
|
||||
.field-input.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.a-select option.unavailable {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: var(--space-md);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.field-input,
|
||||
.field-textarea,
|
||||
.field-select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-textarea {
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-input:focus,
|
||||
.field-textarea:focus,
|
||||
.field-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.field-error {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Dark theme scrollbars (Firefox + Chromium) */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f3f46 transparent;
|
||||
scrollbar-color: var(--surface-3) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@@ -120,14 +333,14 @@ code,
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
background: var(--surface-3);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
background: var(--text-3);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
@@ -1,2 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
|
||||
</script>
|
||||
|
||||
<StudioGenerate kind="image" />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -1,2 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
return { id: params.id };
|
||||
redirect(308, `/workbench/${params.id}`);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const prerender = false;
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
return {};
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import {
|
||||
assetUrl,
|
||||
jobsApi,
|
||||
playgroundApi,
|
||||
projects,
|
||||
workflows,
|
||||
type Job,
|
||||
} from '$lib/api';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { handleJobEvent, progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { isStandaloneJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
|
||||
/**
|
||||
* 生成任务页 (spec-v2 §3 nav item, HEL-612): one table summing both
|
||||
* task classes — image and video — from standalone windows, the
|
||||
* creation flow, and Playground, with retry / cancel / delete.
|
||||
*/
|
||||
const client = useQueryClient();
|
||||
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
const projectsQuery = createQuery({ queryKey: ['projects'], queryFn: projects.list });
|
||||
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
if (ev.type.startsWith('job.') || ev.type === 'events.resync') {
|
||||
void client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
void client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
}
|
||||
});
|
||||
return off;
|
||||
});
|
||||
|
||||
type Filter = 'all' | 'image' | 'video';
|
||||
let filter = $state<Filter>('all');
|
||||
|
||||
const genJobs = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'),
|
||||
);
|
||||
const shown = $derived(filter === 'all' ? genJobs : genJobs.filter((j) => j.kind === filter));
|
||||
const activeCount = $derived(genJobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
|
||||
function wfName(job: Job): string {
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
|
||||
t('studio.jobName', { id: job.id })
|
||||
);
|
||||
}
|
||||
|
||||
function projectOf(job: Job): string {
|
||||
if (isStandaloneJob(job)) return '';
|
||||
const title = ($projectsQuery.data ?? []).find((p) => p.id === job.project_id)?.title;
|
||||
return title ? `《${title}》` : '';
|
||||
}
|
||||
|
||||
function timeOf(job: Job): string {
|
||||
const ts = parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function stInfo(job: Job): { cls: string; label: string; mono?: boolean } {
|
||||
switch (job.status) {
|
||||
case 'done':
|
||||
return { cls: 'ok', label: t('tasks.done') };
|
||||
case 'running':
|
||||
return { cls: 'run', label: `${Math.round(progressFor(job.id)?.progress ?? 3)}%`, mono: true };
|
||||
case 'pending':
|
||||
return { cls: 'queue', label: t('tasks.queued') };
|
||||
default:
|
||||
return { cls: 'fail', label: t('tasks.failed') };
|
||||
}
|
||||
}
|
||||
|
||||
let busyId = $state<number | null>(null);
|
||||
async function retry(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
toast.success(t('studio.retryQueuedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.retryFailedToast', { detail: err instanceof Error ? err.message : String(err) }));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
toast.info(t('studio.cancelToast'));
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let deleteJobId = $state<number | null>(null);
|
||||
const deleteTarget = $derived(genJobs.find((j) => j.id === deleteJobId) ?? null);
|
||||
async function doDelete() {
|
||||
if (!deleteJobId) return;
|
||||
try {
|
||||
await playgroundApi.deleteJob(deleteJobId);
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
deleteJobId = null;
|
||||
deleteOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="tasks" title={t('tasks.title')} subtitle={t('tasks.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip">{t('tasks.count', { n: genJobs.length, m: activeCount })}</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="tasks-page">
|
||||
<div class="seg" role="radiogroup" aria-label={t('tasks.title')}>
|
||||
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')]] as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as Filter)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !shown.length}
|
||||
<div class="empty-card">
|
||||
<Icon name="queue" size={22} />
|
||||
<p>{t('tasks.empty')}</p>
|
||||
<a class="a-btn primary" href="/image">{t('tasks.goGenerate')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="task-table">
|
||||
{#each shown as job (job.id)}
|
||||
{@const st = stInfo(job)}
|
||||
<div class="trow" class:failed={job.status === 'failed'}>
|
||||
<span class="tkind {job.kind}">
|
||||
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
</span>
|
||||
<div class="tthumb">
|
||||
{#if job.output_paths?.[0]}
|
||||
{#if job.kind === 'image'}
|
||||
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
|
||||
{:else}
|
||||
<video src={assetUrl(job.output_paths[0])} preload="metadata" muted></video>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tmain">
|
||||
<div class="tname" title={wfName(job)}>{wfName(job)}</div>
|
||||
<div class="tmeta mono">
|
||||
{#if projectOf(job)}{projectOf(job)} · {/if}{jobSourceLabel(job)} · {timeOf(job)}
|
||||
{#if (job.output_paths?.length ?? 0) > 0}
|
||||
· {t('tasks.outputs', { n: job.output_paths.length })}
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.status === 'failed' && job.error}
|
||||
<div class="terror">{job.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tout">
|
||||
{#if job.output_paths?.length}
|
||||
<div class="tout-thumbs">
|
||||
{#each job.output_paths.slice(0, 3) as p (p)}
|
||||
<a href={assetUrl(p)} target="_blank" rel="noreferrer" title={p.split(/[/\\]/).pop()}>
|
||||
{#if job.kind === 'image'}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<video src={assetUrl(p)} preload="metadata" muted></video>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="tst {st.cls}" class:mono={st.mono}>{st.label}</span>
|
||||
<span class="tactions">
|
||||
{#if job.status === 'failed'}
|
||||
<button type="button" class="link-accent" disabled={busyId === job.id} onclick={() => retry(job)}>
|
||||
{t('tasks.retry')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button type="button" class="link-danger" disabled={busyId === job.id} onclick={() => cancel(job)}>
|
||||
{t('tasks.cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if isStandaloneJob(job)}
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('tasks.delete')}
|
||||
onclick={() => {
|
||||
deleteJobId = job.id;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('tasks.delTitle')}
|
||||
message={t('tasks.delConfirm')}
|
||||
confirmLabel={t('tasks.delete')}
|
||||
danger
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => {
|
||||
deleteJobId = null;
|
||||
deleteOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.tasks-page {
|
||||
padding: 20px 28px 40px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
.seg-item {
|
||||
padding: 7px 18px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg-item.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 56px 24px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.empty-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.task-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.trow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
min-height: 56px;
|
||||
}
|
||||
.trow.failed {
|
||||
border-color: rgba(229, 72, 77, 0.35);
|
||||
}
|
||||
|
||||
.tkind {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tkind.image {
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
}
|
||||
.tkind.video {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.tthumb {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tthumb img,
|
||||
.tthumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tmain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tname {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tmeta {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.terror {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tout {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tout-thumbs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tout-thumbs a {
|
||||
width: 44px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
.tout-thumbs img,
|
||||
.tout-thumbs video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tst {
|
||||
width: 52px;
|
||||
text-align: right;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tst.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.tst.run {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tst.queue {
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.tst.fail {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tactions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.link-accent {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-accent:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link-accent:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.link-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.link-danger:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.link-danger:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.tasks-page {
|
||||
padding: 14px 16px 32px;
|
||||
}
|
||||
.tout {
|
||||
display: none;
|
||||
}
|
||||
.trow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tactions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
|
||||
</script>
|
||||
|
||||
<StudioGenerate kind="video" />
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { projects, type Project } from '$lib/api';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 创作工作台 entry: pick a project to open its five-stage flow. All
|
||||
* numbers come from real project stats (stage progress + live jobs).
|
||||
*/
|
||||
const STAGES = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
|
||||
|
||||
const projectsQuery = createQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
});
|
||||
|
||||
const items = $derived(($projectsQuery.data ?? []).slice());
|
||||
|
||||
function stageStates(p: Project): Record<string, string> {
|
||||
return p.stats?.stage_status ?? {};
|
||||
}
|
||||
|
||||
function confirmedCount(p: Project): number {
|
||||
return p.stats?.stage_confirmed ?? 0;
|
||||
}
|
||||
|
||||
function activeJobs(p: Project): number {
|
||||
return p.stats?.active_jobs ?? 0;
|
||||
}
|
||||
|
||||
function stageClass(state: string | undefined): string {
|
||||
if (state === 'confirmed') return 'st-confirmed';
|
||||
if (state === 'running' || state === 'awaiting_confirm') return 'st-draft';
|
||||
return 'st-todo';
|
||||
}
|
||||
|
||||
function open(p: Project) {
|
||||
goto(`/workbench/${p.id}`);
|
||||
}
|
||||
|
||||
function firstOpenStage(p: Project): string {
|
||||
const states = stageStates(p);
|
||||
for (const key of STAGES) {
|
||||
if (states[key] !== 'confirmed') return key;
|
||||
}
|
||||
return 'shot';
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="workbench" title={t('workbench.title')} subtitle={t('workbench.pickerSubtitle')}>
|
||||
<div class="picker">
|
||||
{#if $projectsQuery.isLoading}
|
||||
<div class="skeleton-col">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton height="72px" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<EmptyState title={t('workbench.pickerEmpty')} body={t('workbench.pickerEmptyBody')}>
|
||||
{#snippet action()}
|
||||
<a class="entry-btn primary" href="/">{t('workbench.pickerGoHome')}</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<ul class="project-list" data-testid="workbench-project-list">
|
||||
{#each items as p (p.id)}
|
||||
<li>
|
||||
<button type="button" class="row" onclick={() => open(p)}>
|
||||
<span class="row-title">{p.title}</span>
|
||||
<span class="row-stages" aria-label={t('workbench.stageProgress')}>
|
||||
{#each STAGES as key (key)}
|
||||
{@const state = stageStates(p)[key]}
|
||||
<span class="seg {stageClass(state)}" title={t(`workbench.stage.${key}`)}></span>
|
||||
{/each}
|
||||
<span class="seg-count mono">{confirmedCount(p)}/5</span>
|
||||
</span>
|
||||
<span class="row-meta">
|
||||
{#if activeJobs(p) > 0}
|
||||
<span class="jobs-chip">{t('workbench.activeJobs', { n: activeJobs(p) })}</span>
|
||||
{/if}
|
||||
<span class="go"><Icon name="chevron-right" size={15} /></span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="foot-hint">{t('workbench.pickerFootHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.skeleton-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
padding: 16px 18px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 150ms;
|
||||
}
|
||||
.row:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-stages {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.seg {
|
||||
width: 18px;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg.st-draft {
|
||||
background: var(--accent);
|
||||
}
|
||||
.seg.st-confirmed {
|
||||
background: var(--success);
|
||||
}
|
||||
.seg-count {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.jobs-chip {
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.go {
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.foot-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entry-btn.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9px 18px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.entry-btn.primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.picker {
|
||||
padding: 16px 14px 32px;
|
||||
}
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
Reference in New Issue
Block a user