Compare commits

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

Co-authored-by: multica-agent <github@multica.ai>
2026-09-27 20:49:59 +08:00
multica-builder 786b349752 feat: default UI language to zh and add LLM connection test
- First-open default language is now Simplified Chinese (i18n.svelte.ts);
  stored language choice still wins.
- New POST /api/settings/llm-test endpoint probes an OpenAI-compatible
  endpoint with a 1-token chat completion; falls back to a saved profile's
  secret so keys never travel back to the browser.
- Settings LLM cards gain a connection-test button with readable
  success/failure feedback (auth / timeout / connect / HTTP detail),
  localized in all seven dictionaries.
- Backend tests cover success, saved-secret reuse, 401, connection
  refused, and input validation against a local fake OpenAI server.
2026-09-27 20:04:46 +08:00
41 changed files with 9035 additions and 62 deletions
@@ -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}
@@ -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),
)
+188 -1
View File
@@ -59,7 +59,15 @@ DEFAULT_AGENT_HARDENING_PROMPT = """You are operating under additional operator-
# Agent roles that can be pinned to a specific LLM profile. Values live in
# Settings.agent_llm_assignments (role -> profile id or None). A missing or
# None entry means "use the Active LLM".
AGENT_LLM_ROLES: tuple[str, ...] = ("main", "planner", "story", "script", "assets", "video")
AGENT_LLM_ROLES: tuple[str, ...] = (
"main",
"planner",
"story",
"style",
"script",
"assets",
"video",
)
def is_ephemeral_path(path: Path | str | None) -> bool:
@@ -124,6 +132,13 @@ class Settings(BaseSettings):
comfyui_base_url: str = "http://127.0.0.1:8188"
# Comfy is HTTP-only (upload / prompt / history / view). No local input/output dirs.
# Named ComfyUI endpoints (multi-connection, HEL-611). `comfyui_base_url`
# above keeps mirroring the ACTIVE endpoint so legacy readers (worker
# fallback, tests) keep working — same pattern as llm_* vs llm_profiles.
# Each endpoint: {id, name, base_url, auth_type: none|bearer|header,
# auth_token, header_name, notes}. auth_token stays server-side only.
comfyui_endpoints: list[dict[str, Any]] = Field(default_factory=list)
comfyui_active_id: str | None = None
queue_concurrency: int = 1
queue_poll_interval_sec: float = 2.0
@@ -309,6 +324,167 @@ class Settings(BaseSettings):
break
self.apply_active_llm()
# ------------------------------------------------------------------
# ComfyUI multi-connection endpoints (HEL-611). Mirrors the LLM profile
# pattern: list + active id + secret-preserving replace + masked public
# view. `comfyui_base_url` always mirrors the active endpoint so legacy
# single-address readers keep working.
# ------------------------------------------------------------------
@staticmethod
def _normalize_comfy_endpoint(raw: dict[str, Any]) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
base_url = str(raw.get("base_url") or "").strip().rstrip("/")
if not base_url:
return None
auth_type = str(raw.get("auth_type") or "none")
if auth_type not in ("none", "bearer", "header"):
auth_type = "none"
token = raw.get("auth_token")
if not isinstance(token, str) or not token.strip():
token = None
header_name = str(raw.get("header_name") or "").strip() or None
return {
"id": str(raw.get("id") or uuid.uuid4()),
"name": str(raw.get("name") or "").strip() or base_url,
"base_url": base_url,
"auth_type": auth_type,
"auth_token": token,
"header_name": header_name,
"notes": str(raw.get("notes") or "").strip() or None,
}
def ensure_comfyui_endpoints(self) -> bool:
"""Guarantee at least one endpoint and base_url mirroring the active.
Returns True when the list was created or repaired (caller persists).
Existing configs with only comfyui_base_url migrate into one endpoint.
"""
migrated = False
raw_list = self.comfyui_endpoints if isinstance(self.comfyui_endpoints, list) else []
normalized: list[dict[str, Any]] = []
for item in raw_list:
ep = self._normalize_comfy_endpoint(item)
if ep is not None:
normalized.append(ep)
if not normalized:
legacy = (self.comfyui_base_url or "http://127.0.0.1:8188").strip().rstrip("/")
pid = str(uuid.uuid4())
self.comfyui_endpoints = [
{
"id": pid,
"name": "本机 ComfyUI" if self._is_local_url(legacy) else legacy,
"base_url": legacy,
"auth_type": "none",
"auth_token": None,
"header_name": None,
"notes": None,
}
]
self.comfyui_active_id = pid
migrated = True
else:
self.comfyui_endpoints = normalized
ids = {ep["id"] for ep in normalized}
if self.comfyui_active_id not in ids:
self.comfyui_active_id = normalized[0]["id"]
migrated = True
self.apply_active_comfyui()
return migrated
@staticmethod
def _is_local_url(url: str) -> bool:
host = url.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
return host in ("localhost", "127.0.0.1", "::1")
def apply_active_comfyui(self) -> None:
"""Mirror the active endpoint onto comfyui_base_url (legacy field)."""
endpoints = self.comfyui_endpoints if isinstance(self.comfyui_endpoints, list) else []
endpoint = next(
(ep for ep in endpoints if ep.get("id") == self.comfyui_active_id), None
)
if endpoint is None and endpoints:
endpoint = endpoints[0]
self.comfyui_active_id = str(endpoint.get("id") or "")
if endpoint is None:
return
if endpoint.get("base_url"):
self.comfyui_base_url = str(endpoint["base_url"])
def replace_comfyui_endpoints(self, incoming: list[dict[str, Any]]) -> None:
"""Replace the endpoint list, preserving tokens unless a new one is sent."""
existing = {
str(ep["id"]): ep
for ep in (self.comfyui_endpoints or [])
if isinstance(ep, dict) and ep.get("id")
}
new_list: list[dict[str, Any]] = []
for raw in incoming:
if not isinstance(raw, dict):
continue
pid = str(raw["id"]) if raw.get("id") else str(uuid.uuid4())
old = existing.get(pid, {})
merged = {**old, **raw, "id": pid}
# Blank token on edit = keep the stored one (never echoed back).
incoming_token = raw.get("auth_token")
if not (isinstance(incoming_token, str) and incoming_token.strip()):
merged["auth_token"] = old.get("auth_token")
ep = self._normalize_comfy_endpoint(merged)
if ep is not None:
new_list.append(ep)
if not new_list:
raise ValueError("至少需要一个 ComfyUI 连接")
self.comfyui_endpoints = new_list
ids = {ep["id"] for ep in new_list}
if self.comfyui_active_id not in ids:
self.comfyui_active_id = new_list[0]["id"]
self.apply_active_comfyui()
def sync_legacy_comfyui_into_active(self) -> None:
"""Write a legacy comfyui_base_url PATCH onto the active endpoint."""
base_url = (self.comfyui_base_url or "").strip().rstrip("/")
self.ensure_comfyui_endpoints()
for ep in self.comfyui_endpoints:
if ep.get("id") == self.comfyui_active_id:
if base_url:
ep["base_url"] = base_url
if not str(ep.get("name") or "").strip():
ep["name"] = base_url
break
self.apply_active_comfyui()
def resolve_comfyui_endpoint(self, endpoint_id: str | None = None) -> dict[str, Any] | None:
"""Full endpoint dict (token included) for an id; None id or a dangling
id falls back to the active endpoint. Server-side use only."""
self.ensure_comfyui_endpoints()
endpoints = self.comfyui_endpoints
if endpoint_id:
for ep in endpoints:
if ep.get("id") == endpoint_id:
return ep
return next((ep for ep in endpoints if ep.get("id") == self.comfyui_active_id), None) or (
endpoints[0] if endpoints else None
)
def _public_comfyui_endpoints(self) -> list[dict[str, Any]]:
self.ensure_comfyui_endpoints()
out: list[dict[str, Any]] = []
for ep in self.comfyui_endpoints:
token = ep.get("auth_token") if isinstance(ep.get("auth_token"), str) else None
out.append(
{
"id": ep["id"],
"name": ep["name"],
"base_url": ep["base_url"],
"auth_type": ep.get("auth_type") or "none",
"header_name": ep.get("header_name"),
"notes": ep.get("notes"),
"has_token": bool(token),
"token_tail": (token or "")[-4:] or None,
}
)
return out
def _public_llm_profiles(self) -> list[dict[str, Any]]:
self.ensure_llm_profiles()
return [
@@ -318,6 +494,9 @@ class Settings(BaseSettings):
"base_url": p["base_url"],
"model": p["model"],
"api_key": bool(p.get("api_key")),
# Last 4 characters only — enough to recognise a saved key in
# the UI without ever exposing it (GitHub/Stripe convention).
"api_key_tail": (p.get("api_key") or "")[-4:] or None,
}
for p in self.llm_profiles
]
@@ -339,6 +518,8 @@ class Settings(BaseSettings):
"llm_active_id": self.llm_active_id,
"agent_llm_assignments": dict(self.agent_llm_assignments or {}),
"comfyui_base_url": self.comfyui_base_url,
"comfyui_endpoints": self._public_comfyui_endpoints(),
"comfyui_active_id": self.comfyui_active_id,
"queue_concurrency": self.queue_concurrency,
"queue_poll_interval_sec": self.queue_poll_interval_sec,
"queue_poll_timeout_sec": self.queue_poll_timeout_sec,
@@ -397,6 +578,10 @@ class Settings(BaseSettings):
self.dry_run = bool(data.get("dry_run", False))
self.ensure_storage_dirs()
profiles_migrated = self.ensure_llm_profiles()
comfy_migrated = self.ensure_comfyui_endpoints()
if comfy_migrated:
# Legacy single-address config: persist the migrated endpoint list.
self.save_config_file()
# Auto-heal poisoned config (temp paths / quoted dirs / legacy comfy dirs) back to disk
needs_rewrite = is_ephemeral_path(normalize_path(data.get("data_dir"))) or any(
@@ -434,6 +619,8 @@ class Settings(BaseSettings):
"llm_active_id": self.llm_active_id,
"agent_llm_assignments": dict(self.agent_llm_assignments or {}),
"comfyui_base_url": self.comfyui_base_url,
"comfyui_endpoints": self.comfyui_endpoints,
"comfyui_active_id": self.comfyui_active_id,
"queue_concurrency": self.queue_concurrency,
"queue_poll_interval_sec": self.queue_poll_interval_sec,
"queue_poll_timeout_sec": self.queue_poll_timeout_sec,
+5
View File
@@ -319,6 +319,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")
+2
View File
@@ -20,6 +20,7 @@ from calliope.routers import (
agent,
assets,
canvas,
comfyui as comfy,
events,
jobs,
library,
@@ -135,6 +136,7 @@ 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"])
@@ -200,6 +200,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 +210,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):
+31 -1
View File
@@ -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
+35 -3
View File
@@ -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],
@@ -0,0 +1,360 @@
"""ComfyUI multi-connection endpoints: CRUD + server-side connectivity test.
HEL-611 / HEL-601 §3. All probes run from the machine hosting this
workbench (the user's rule: connectivity is judged by the SERVER, not the
browser). Tokens are stored server-side only and never echoed back.
"""
from __future__ import annotations
import ipaddress
import socket
import time
from typing import Any
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from calliope.config import settings
router = APIRouter()
_TEST_TIMEOUT_SEC = 5.0
_TEST_TIMEOUT_RANGE = (2.0, 15.0)
_AUTH_TYPES = ("none", "bearer", "header")
# SSRF guard (HEL-601 §3.4): http/https only; link-local (incl. cloud
# metadata 169.254.169.254) and unspecified/multicast/reserved targets are
# rejected. Loopback is EXPLICITLY allowed — a local ComfyUI is a
# legitimate target for this desktop tool.
_BLOCKED_NETWORKS = [
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("224.0.0.0/4"),
ipaddress.ip_network("240.0.0.0/4"),
ipaddress.ip_network("255.255.255.255/32"),
ipaddress.ip_network("fe80::/10"),
ipaddress.ip_network("ff00::/8"),
]
class EndpointIn(BaseModel):
id: str | None = None
name: str | None = None
base_url: str
auth_type: str = "none"
# Blank/None on edit keeps the stored token (it is never echoed back).
auth_token: str | None = None
header_name: str | None = None
notes: str | None = None
class EndpointPatch(BaseModel):
name: str | None = None
base_url: str | None = None
auth_type: str | None = None
auth_token: str | None = None
header_name: str | None = None
notes: str | None = None
class EndpointTestIn(BaseModel):
endpoint_id: str | None = None
# Raw candidate values (unsaved form). Stored values fill the blanks
# when endpoint_id is given — the token never needs a round trip.
base_url: str | None = None
auth_type: str | None = None
auth_token: str | None = None
header_name: str | None = None
timeout_sec: float | None = Field(
None, ge=_TEST_TIMEOUT_RANGE[0], le=_TEST_TIMEOUT_RANGE[1]
)
def _parsed_url(base_url: str) -> tuple[str, str]:
url = (base_url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="API 地址不能为空")
if "://" not in url:
url = f"http://{url}"
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
return url.rstrip("/"), parsed.hostname
def _assert_not_ssrf(hostname: str) -> None:
"""Reject link-local / metadata / reserved targets (loopback allowed)."""
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
addr = None
candidates: list[str] = [str(addr)] if addr else _resolve_host(hostname)
for cand in candidates:
try:
ip = ipaddress.ip_address(cand)
except ValueError:
continue
if ip.is_loopback:
continue
for net in _BLOCKED_NETWORKS:
if ip in net:
raise HTTPException(
status_code=400,
detail="该地址位于被禁止的网段(链路本地/元数据/保留地址),不允许探测",
)
def _resolve_host(hostname: str) -> list[str]:
try:
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
except OSError:
return []
return sorted({info[4][0] for info in infos})
def _public_payload() -> dict[str, Any]:
data = settings.to_public_dict()
return {"endpoints": data["comfyui_endpoints"], "active_id": data["comfyui_active_id"]}
def _stored_endpoint(endpoint_id: str) -> dict[str, Any]:
settings.ensure_comfyui_endpoints()
for ep in settings.comfyui_endpoints:
if ep.get("id") == endpoint_id:
return ep
raise HTTPException(status_code=404, detail="连接不存在")
@router.get("/endpoints")
async def list_endpoints() -> dict[str, Any]:
return _public_payload()
@router.post("/endpoints")
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
base_url, _host = _parsed_url(payload.base_url)
incoming = [ep for ep in settings.comfyui_endpoints or []]
incoming.append(payload.model_dump() | {"base_url": base_url})
try:
settings.replace_comfyui_endpoints(incoming)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings.save_config_file()
return _public_payload()
@router.patch("/endpoints/{endpoint_id}")
async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str, Any]:
_stored_endpoint(endpoint_id)
base_url = payload.base_url
if base_url is not None:
base_url, _host = _parsed_url(base_url)
patch = payload.model_dump(exclude_unset=True)
if base_url is not None:
patch["base_url"] = base_url
incoming = [
{**ep, **patch} if ep.get("id") == endpoint_id else ep
for ep in settings.comfyui_endpoints
]
try:
settings.replace_comfyui_endpoints(incoming)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings.save_config_file()
return _public_payload()
@router.delete("/endpoints/{endpoint_id}")
async def delete_endpoint(endpoint_id: str) -> dict[str, Any]:
_stored_endpoint(endpoint_id)
remaining = [ep for ep in settings.comfyui_endpoints if ep.get("id") != endpoint_id]
if not remaining:
raise HTTPException(status_code=400, detail="至少保留一个连接;如需更换请先编辑现有连接")
settings.replace_comfyui_endpoints(remaining)
settings.save_config_file()
return _public_payload()
@router.post("/endpoints/{endpoint_id}/activate")
async def activate_endpoint(endpoint_id: str) -> dict[str, Any]:
_stored_endpoint(endpoint_id)
settings.comfyui_active_id = endpoint_id
settings.apply_active_comfyui()
settings.save_config_file()
return _public_payload()
def _classify_connect_error(exc: httpx.ConnectError) -> tuple[str, str]:
"""Map a connect failure onto (code, Chinese message).
httpx collapses socket errors to "All connection attempts failed" on
the outer exception — the distinguishing errno lives in the __cause__
chain, so classify over the whole chain.
"""
parts: list[str] = [f"{exc.__class__.__name__}: {exc}"]
node = exc.__cause__ or exc.__context__
seen = {id(exc)}
while node is not None and id(node) not in seen:
seen.add(id(node))
parts.append(f"{node.__class__.__name__}: {node}")
node = node.__cause__ or node.__context__
text = " | ".join(parts)
lowered = text.lower()
if (
"name or service not known" in lowered
or "getaddrinfo" in lowered
or "temporary failure in resolution" in lowered
or "nodename nor servname" in lowered
):
return "dns", "域名解析失败:请检查地址拼写或 DNS"
if (
"connection refused" in lowered
or "errno 111" in lowered
or "errno 10061" in lowered
or "winerror 10061" in lowered
):
return "refused", "连接被拒绝:目标端口没有服务在监听"
if "ssl" in lowered or "certificate" in lowered:
return "tls", "TLS/证书错误:检查 https 配置或改用 http"
if "unreachable" in lowered or "no route to host" in lowered:
return "refused", "网络不可达:本机无法路由到该地址"
return "connect", f"无法建立连接:{str(exc)[:300]}"
@router.post("/endpoints/test")
async def test_endpoint(payload: EndpointTestIn) -> dict[str, Any]:
"""Server-side ComfyUI probe: GET /system_stats with error classification."""
from calliope.comfyui.endpoints import build_auth_headers
base_url = (payload.base_url or "").strip()
auth_type = (payload.auth_type or "").strip() or None
auth_token = (payload.auth_token or "").strip() or None
header_name = (payload.header_name or "").strip() or None
if payload.endpoint_id:
stored = _stored_endpoint(payload.endpoint_id)
if not base_url:
base_url = str(stored.get("base_url") or "")
if auth_type is None:
auth_type = str(stored.get("auth_type") or "none")
if auth_token is None:
token = stored.get("auth_token")
auth_token = token if isinstance(token, str) and token.strip() else None
if header_name is None:
header_name = stored.get("header_name")
if not base_url:
raise HTTPException(status_code=400, detail="base_url 和 endpoint_id 至少提供一项")
url, hostname = _parsed_url(base_url)
_assert_not_ssrf(hostname)
headers = build_auth_headers(
{
"auth_type": auth_type or "none",
"auth_token": auth_token,
"header_name": header_name,
}
)
timeout_sec = (
payload.timeout_sec if payload.timeout_sec is not None else _TEST_TIMEOUT_SEC
)
timeout_sec = min(max(timeout_sec, _TEST_TIMEOUT_RANGE[0]), _TEST_TIMEOUT_RANGE[1])
started = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=timeout_sec, follow_redirects=True) as client:
resp = await client.get(f"{url}/system_stats", headers=headers)
except httpx.TimeoutException:
return {
"ok": False,
"error": "timeout",
"message": f"超时:{timeout_sec:.0f} 秒内没有响应",
"timeout_sec": timeout_sec,
}
except httpx.ConnectError as exc:
code, message = _classify_connect_error(exc)
return {"ok": False, "error": code, "message": message, "detail": str(exc)[:300]}
except httpx.HTTPError as exc:
return {
"ok": False,
"error": "connect",
"message": "连接失败",
"detail": (str(exc) or exc.__class__.__name__)[:300],
}
elapsed_ms = int((time.perf_counter() - started) * 1000)
if resp.status_code in (401, 403):
return {
"ok": False,
"error": "auth",
"message": f"鉴权失败(HTTP {resp.status_code}):请检查访问令牌或鉴权方式",
}
if resp.status_code == 404:
# Reverse proxies sometimes strip /system_stats — probe "/" so the
# user learns "reachable, but not confirmed ComfyUI" (HEL-601 risk ①).
reachable = False
try:
async with httpx.AsyncClient(timeout=timeout_sec, follow_redirects=True) as client:
root = await client.get(f"{url}/", headers=headers)
reachable = root.status_code < 500
except httpx.HTTPError:
reachable = False
if reachable:
return {
"ok": False,
"error": "not_comfyui",
"message": "HTTP 可达但未确认是 ComfyUI(/system_stats 不存在)",
"elapsed_ms": elapsed_ms,
}
return {
"ok": False,
"error": "not_comfyui",
"message": "未找到 /system_stats,且根路径不可用",
}
if resp.status_code >= 500:
return {
"ok": False,
"error": "server",
"message": f"目标服务返回错误(HTTP {resp.status_code})",
"elapsed_ms": elapsed_ms,
}
if resp.status_code >= 400:
return {
"ok": False,
"error": "http",
"message": f"请求被拒绝(HTTP {resp.status_code})",
"detail": resp.text[:200].strip(),
}
try:
data = resp.json()
except ValueError:
return {
"ok": False,
"error": "not_comfyui",
"message": "地址连通,但响应不是 ComfyUI(/system_stats 返回非 JSON)",
"elapsed_ms": elapsed_ms,
}
system = data.get("system") if isinstance(data, dict) else None
version = None
if isinstance(system, dict):
version = system.get("comfyui_version")
if not version:
return {
"ok": False,
"error": "not_comfyui",
"message": "地址连通,但响应不像 ComfyUI(/system_stats 缺少版本字段)",
"elapsed_ms": elapsed_ms,
}
device_name = None
devices = data.get("devices") if isinstance(data, dict) else None
if isinstance(devices, list) and devices and isinstance(devices[0], dict):
device_name = devices[0].get("name") or devices[0].get("type")
return {
"ok": True,
"elapsed_ms": elapsed_ms,
"comfyui_version": str(version),
**({"device_name": str(device_name)} if device_name else {}),
}
+12 -2
View File
@@ -6,7 +6,6 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query
from calliope.agent.video_agent import enqueue_video_jobs, preview_clip_prompt
from calliope.comfyui.client import ComfyUIClient
from calliope.config import settings
from calliope.db import get_db
from calliope.events.bus import event_bus
@@ -191,7 +190,18 @@ async def cancel_job(job_id: int) -> dict[str, Any]:
# Export runs local ffmpeg, not Comfy — kill the process instead.
kill_running(job_id)
else:
client = ComfyUIClient()
# Interrupt on the endpoint this job actually runs against (payload
# snapshot; falls back to the active connection).
from calliope.comfyui.endpoints import client_for
try:
snapshot = (json.loads(job.get("payload_json") or "{}") or {}).get("endpoint")
except json.JSONDecodeError:
snapshot = None
endpoint = None
if isinstance(snapshot, dict) and snapshot.get("id"):
endpoint = settings.resolve_comfyui_endpoint(str(snapshot["id"]))
client = client_for(endpoint)
try:
await client.interrupt()
finally:
@@ -26,6 +26,9 @@ PLAYGROUND_TITLE = "Playground Scratch"
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
class PlaygroundAttach(BaseModel):
@@ -339,7 +342,11 @@ 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": "playground",
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
},
)
await event_bus.publish(
"job.created",
@@ -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}
@@ -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,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
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* Regression guard for the "crypto.randomUUID is not a function" crash.
*
* `crypto.randomUUID()` only exists in secure contexts (HTTPS, localhost,
* 127.0.0.1). On a LAN HTTP page — e.g. http://192.168.200.15:8080 — the
* browser exposes `crypto` WITHOUT `randomUUID`, so any direct call throws
* and model saving dies. `crypto.getRandomValues()` has no secure-context
* requirement, so the shared helper can always mint a proper v4 UUID.
*
* Two layers of protection, mirroring check-storage-ssr.mjs:
* 1. behavioural — lib/ids.ts must produce unique, well-formed v4 UUIDs
* under every crypto shape a browser can present, including the exact
* insecure-context shape that produced the original crash.
* 2. static — direct `crypto.randomUUID(` calls are banned outside
* lib/ids.ts, so the crash cannot be reintroduced by copying the old
* pattern into a new page.
*
* Run with `npm test` (or `node scripts/check-random-uuid.mjs`).
*/
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const SRC = join(ROOT, "src");
const HELPER = join(SRC, "lib", "ids.ts");
let failures = 0;
function fail(message) {
failures += 1;
console.error(`FAIL ${message}`);
}
function ok(message) {
console.log(`ok ${message}`);
}
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
/** Capture the platform crypto before any test replaces it. */
const REAL_CRYPTO = globalThis.crypto;
/** Replace the global under test. Node's own binding may be getter-only. */
function setGlobalCrypto(value) {
Object.defineProperty(globalThis, "crypto", {
value,
configurable: true,
writable: true,
});
}
const { randomId } = await import(pathToFileURL(HELPER).href);
// ---- 1. behavioural ----------------------------------------------------
// The exact shape a LAN HTTP browser presents: crypto WITHOUT randomUUID.
// This is the configuration that produced the original crash.
const insecureContextCrypto = {
getRandomValues: REAL_CRYPTO.getRandomValues.bind(REAL_CRYPTO),
};
setGlobalCrypto(insecureContextCrypto);
try {
const ids = new Set(Array.from({ length: 1000 }, randomId));
if (ids.size !== 1000)
fail(`insecure context: duplicate ids (${1000 - ids.size} collisions)`);
else if (![...ids].every((id) => UUID_V4.test(id))) {
fail("insecure context: ids are not all well-formed v4 UUIDs");
} else {
ok("insecure-context crypto (getRandomValues only) yields unique v4 UUIDs");
}
} catch (err) {
fail(`insecure-context crypto threw: ${err.message}`);
}
// crypto present but BOTH sources missing (exotic embed / ancient runtime).
setGlobalCrypto({});
try {
const ids = new Set(Array.from({ length: 1000 }, randomId));
if (ids.size !== 1000)
fail(`method-less crypto: duplicate ids (${1000 - ids.size} collisions)`);
else if (![...ids].every((id) => id.length >= 16)) {
fail("method-less crypto: ids too short to be collision-resistant");
} else {
ok(
"method-less crypto degrades to unique best-effort ids without throwing",
);
}
} catch (err) {
fail(`method-less crypto threw: ${err.message}`);
}
// crypto entirely absent.
setGlobalCrypto(undefined);
try {
const ids = new Set(Array.from({ length: 1000 }, randomId));
if (ids.size !== 1000)
fail(`absent crypto: duplicate ids (${1000 - ids.size} collisions)`);
else ok("absent crypto degrades to unique best-effort ids without throwing");
} catch (err) {
fail(`absent crypto threw: ${err.message}`);
}
// A fully-featured crypto (secure context / Node default) must still be used
// directly — guards against a helper that ignores randomUUID.
setGlobalCrypto(REAL_CRYPTO);
try {
const probe = { used: false };
setGlobalCrypto({
randomUUID: () => {
probe.used = true;
return REAL_CRYPTO.randomUUID();
},
getRandomValues: REAL_CRYPTO.getRandomValues.bind(REAL_CRYPTO),
});
const id = randomId();
if (!probe.used) fail("native randomUUID available but not preferred");
else if (!UUID_V4.test(id)) fail(`secure-context id malformed: ${id}`);
else ok("native randomUUID preferred when available");
} catch (err) {
fail(`secure-context crypto threw: ${err.message}`);
} finally {
setGlobalCrypto(REAL_CRYPTO);
}
// ---- 2. static ---------------------------------------------------------
const UNSAFE = /crypto\.randomUUID\s*\(/;
const EXTENSIONS = [".ts", ".js", ".svelte"];
function walk(dir) {
const found = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") continue;
found.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
found.push(full);
}
}
return found;
}
const offenders = [];
for (const file of walk(SRC)) {
if (file === HELPER) continue; // the one place allowed to call the native API
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (UNSAFE.test(line))
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
}
if (offenders.length) {
fail(
`direct \`crypto.randomUUID()\` call outside lib/ids.ts — use randomId() instead (insecure contexts crash on it):\n ${offenders.join("\n ")}`,
);
} else {
ok("no direct `crypto.randomUUID()` calls in src/");
}
// ---- result ------------------------------------------------------------
if (failures) {
console.error(`\n${failures} randomId/insecure-context check(s) failed.`);
process.exit(1);
}
console.log("\nrandomId insecure-context checks passed.");
+1 -1
View File
@@ -8,7 +8,7 @@
<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%
+79
View File
@@ -81,6 +81,19 @@ export interface LlmProfile {
base_url: string;
model: string;
api_key: boolean;
/** Last 4 chars of the stored key, for masked display only. */
api_key_tail?: string | null;
}
export interface ComfyEndpoint {
id: string;
name: string;
base_url: string;
auth_type: 'none' | 'bearer' | 'header';
header_name: string | null;
notes: string | null;
has_token: boolean;
token_tail?: string | null;
}
export interface Settings {
@@ -96,6 +109,8 @@ export interface Settings {
llm_profiles: LlmProfile[];
llm_active_id: string | null;
comfyui_base_url: string;
comfyui_endpoints: ComfyEndpoint[];
comfyui_active_id: string | null;
queue_concurrency: number;
queue_poll_interval_sec: number;
queue_poll_timeout_sec: number;
@@ -267,6 +282,68 @@ export const settings = {
get: () => api<Settings>('/api/settings'),
update: (payload: Record<string, unknown>) =>
api<Settings>('/api/settings', { method: 'POST', body: JSON.stringify(payload) }),
testLlm: (payload: {
profile_id?: string;
base_url?: string;
model?: string;
api_key?: string;
timeout_sec?: number;
}) =>
api<{
ok: boolean;
elapsed_ms?: number;
error?: string;
detail?: string;
timeout_sec?: number;
}>('/api/settings/llm-test', {
method: 'POST',
body: JSON.stringify(payload),
}),
};
export const comfy = {
listEndpoints: () =>
api<{ endpoints: ComfyEndpoint[]; active_id: string | null }>('/api/comfyui/endpoints'),
createEndpoint: (payload: Partial<ComfyEndpoint> & { base_url: string; auth_token?: string }) =>
api<{ endpoints: ComfyEndpoint[]; active_id: string | null }>('/api/comfyui/endpoints', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateEndpoint: (id: string, payload: Partial<ComfyEndpoint> & { auth_token?: string }) =>
api<{ endpoints: ComfyEndpoint[]; active_id: string | null }>(`/api/comfyui/endpoints/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteEndpoint: (id: string) =>
api<{ endpoints: ComfyEndpoint[]; active_id: string | null }>(`/api/comfyui/endpoints/${id}`, {
method: 'DELETE',
}),
activateEndpoint: (id: string) =>
api<{ endpoints: ComfyEndpoint[]; active_id: string | null }>(
`/api/comfyui/endpoints/${id}/activate`,
{ method: 'POST' },
),
testEndpoint: (payload: {
endpoint_id?: string;
base_url?: string;
auth_type?: string;
auth_token?: string;
header_name?: string;
timeout_sec?: number;
}) =>
api<{
ok: boolean;
elapsed_ms?: number;
comfyui_version?: string;
device_name?: string;
error?: string;
message?: string;
detail?: string;
timeout_sec?: number;
}>('/api/comfyui/endpoints/test', {
method: 'POST',
body: JSON.stringify(payload),
}),
};
export const workflows = {
@@ -288,6 +365,7 @@ export const workflows = {
workflow_json: Record<string, unknown>;
description?: string;
prompt_profile?: string;
endpoint_id?: string | null;
}) => api<Workflow>('/api/workflows', { method: 'POST', body: JSON.stringify(payload) }),
update: (
id: number,
@@ -297,6 +375,7 @@ export const workflows = {
is_enabled: boolean;
description: string;
prompt_profile: string;
endpoint_id: string | null;
}>,
) => api<Workflow>(`/api/workflows/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
delete: (id: number) => api<{ ok: boolean }>(`/api/workflows/${id}`, { method: 'DELETE' }),
+2
View File
@@ -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>
@@ -0,0 +1,469 @@
<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,
},
);
// Spec §2.1 allows the five avatar gradients as cover placeholders.
const PLACEHOLDER_GRADIENTS = [
'linear-gradient(135deg, #e8a33d, #b96a1b)',
'linear-gradient(135deg, #5b8def, #3a5fa9)',
'linear-gradient(135deg, #3fb97f, #2a7a54)',
'linear-gradient(135deg, #b98be0, #7a4fa9)',
'linear-gradient(135deg, #d98e6a, #a95535)',
];
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
const coverUrl = $derived(assetUrl(project.cover_path));
let coverFailed = $state(false);
const STATUS_CLASS: Record<string, string> = {
draft: 'st-draft',
in_progress: 'st-progress',
completed: 'st-done',
};
const STATUS_KEY: Record<string, string> = {
draft: 'home.statusDraft',
in_progress: 'home.statusProgress',
completed: 'home.statusDone',
};
const statusClass = $derived(STATUS_CLASS[project.status] ?? 'st-draft');
const statusLabel = $derived(
project.status in STATUS_KEY ? t(STATUS_KEY[project.status]) : project.status,
);
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(`/project/${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 {statusClass}">{statusLabel}</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="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;
backdrop-filter: blur(4px);
}
.st-draft {
color: var(--text-2);
background: rgba(27, 30, 37, 0.85);
border: 1px solid var(--border-strong);
}
.st-progress {
color: var(--accent);
background: var(--accent-dim);
}
.st-done {
color: var(--success);
background: var(--success-dim);
}
.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;
}
.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);
}
/* restyle reused ui/Button + form primitives inside the darkroom scope */
.a-card :global(.btn-primary) {
background: var(--accent);
color: var(--accent-contrast);
}
.a-card :global(.field-input) {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text);
}
</style>
@@ -0,0 +1,467 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
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 } 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: '/canvas' },
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', wip: true },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', wip: true },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', wip: true },
{ 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,
});
// The ACTIVE ComfyUI connection from settings (HEL-611). The dot stays
// neutral — a passing test is only claimed on the connections page,
// where it comes from a real server-side probe.
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;
}
});
</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"><Icon name="film" size={17} /></span>
<span class="brand-name">{t('shell.brand')}</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>
</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={t('shell.comfyManage')}>
<span class="dot" aria-hidden="true"></span>
<span class="comfy-text">
{#if comfyUrl}
{t('shell.comfyAt', { host: comfyUrl })}
{:else}
{t('shell.comfyUnset')}
{/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: linear-gradient(135deg, #e8a33d, #b96a1b);
color: #16130a;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-name {
font-size: 15.5px;
font-weight: 700;
letter-spacing: 0.02em;
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;
}
.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);
}
/* Neutral until a real server-side probe says otherwise — the
connections page (a6) owns the tested/failed states (HEL-611). */
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
}
.comfy-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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-name {
display: none;
}
.nav {
width: 100%;
}
.nav-item {
justify-content: center;
padding: 0;
}
.nav-label {
display: none;
}
.wip-pill {
display: none;
}
/* 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>
+1 -1
View File
@@ -25,7 +25,7 @@ function initialLanguage(): Language {
// language this build no longer ships must not blank the UI. This runs on
// the server too, where storage is absent and we render the default.
const stored = storageGet(STORAGE_KEY);
return isLanguage(stored) ? stored : 'en';
return isLanguage(stored) ? stored : 'zh';
}
export const language = $state<{ current: Language }>({ current: initialLanguage() });
+237
View File
@@ -34,6 +34,8 @@ export const de: Dict = {
'nav.projects': 'Projekte',
'nav.buildScene': 'Szene bauen',
'nav.settings': 'Einstellungen',
'nav.agents': 'Agenten',
'nav.models': 'Modelle',
'nav.home': 'Start',
'nav.primary': 'Primär',
'nav.projectStages': 'Projektphasen',
@@ -995,6 +997,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',
@@ -1128,4 +1136,233 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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': 'Die eigenständige Auftragsseite ist in Entwicklung; die vollständige Historie liegt derzeit in jedem Projekt.',
'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.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.',
};
+236
View File
@@ -32,6 +32,8 @@ export const en = {
'nav.projects': 'Projects',
'nav.buildScene': 'Build Scene',
'nav.settings': 'Settings',
'nav.agents': 'Agents',
'nav.models': 'Models',
'nav.home': 'Home',
'nav.primary': 'Primary',
'nav.projectStages': 'Project stages',
@@ -995,6 +997,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',
@@ -1012,6 +1020,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 +1138,232 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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': 'The standalone Jobs page is under development; for now full job history lives inside each project.',
'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.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.',
};
export type Dict = typeof en;
+237
View File
@@ -34,6 +34,8 @@ export const es: Dict = {
'nav.projects': 'Proyectos',
'nav.buildScene': 'Componer escena',
'nav.settings': 'Ajustes',
'nav.agents': 'Agentes',
'nav.models': 'Modelos',
'nav.home': 'Inicio',
'nav.primary': 'Principal',
'nav.projectStages': 'Etapas del proyecto',
@@ -995,6 +997,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',
@@ -1128,4 +1136,233 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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': 'La página independiente de tareas está en desarrollo; el historial completo vive por ahora en cada proyecto.',
'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.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.',
};
+237
View File
@@ -34,6 +34,8 @@ export const fr: Dict = {
'nav.projects': 'Projets',
'nav.buildScene': 'Composer la scène',
'nav.settings': 'Paramètres',
'nav.agents': 'Agents',
'nav.models': 'Modèles',
'nav.home': 'Accueil',
'nav.primary': 'Principal',
'nav.projectStages': 'Étapes du projet',
@@ -995,6 +997,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.',
@@ -1128,4 +1136,233 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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': 'La page Tâches autonome est en développement ; l\'historique complet reste pour l\'instant dans chaque projet.',
'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.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.',
};
+237
View File
@@ -34,6 +34,8 @@ export const ja: Dict = {
'nav.projects': 'プロジェクト',
'nav.buildScene': 'シーン構築',
'nav.settings': '設定',
'nav.agents': 'エージェント',
'nav.models': 'モデル設定',
'nav.home': 'ホーム',
'nav.primary': 'メイン',
'nav.projectStages': 'プロジェクトの段階',
@@ -995,6 +997,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': '最大ステップ数',
@@ -1128,4 +1136,233 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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.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 から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
};
+237
View File
@@ -34,6 +34,8 @@ export const ko: Dict = {
'nav.projects': '프로젝트',
'nav.buildScene': '장면 구성',
'nav.settings': '설정',
'nav.agents': '에이전트',
'nav.models': '모델 설정',
'nav.home': '홈',
'nav.primary': '기본',
'nav.projectStages': '프로젝트 단계',
@@ -995,6 +997,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': '최대 단계',
@@ -1128,4 +1136,233 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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.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에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
};
+236
View File
@@ -34,6 +34,8 @@ export const zh: Dict = {
'nav.projects': '项目',
'nav.buildScene': '搭建场景',
'nav.settings': '设置',
'nav.agents': '智能体',
'nav.models': '模型设置',
'nav.home': '首页',
'nav.primary': '主导航',
'nav.projectStages': '项目阶段',
@@ -995,6 +997,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': '最大步数',
@@ -1012,6 +1020,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 +1138,230 @@ 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.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.comfyAt': 'ComfyUI · {host}',
'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.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 导出后在此导入,通用导入通道已就绪。',
};
+31
View File
@@ -0,0 +1,31 @@
/**
* ID generation that survives insecure contexts.
*
* `crypto.randomUUID()` is only exposed on secure contexts (HTTPS or
* localhost/127.0.0.1). On a LAN HTTP deployment (e.g.
* `http://192.168.200.15:8080`) the method is missing and direct calls crash
* with "crypto.randomUUID is not a function". `crypto.getRandomValues()` has
* no secure-context requirement, so the fallback builds a real RFC 4122 v4
* UUID from it; only when `crypto` is absent entirely do we degrade to a
* best-effort Math.random id.
*/
export function randomId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
if (c && typeof c.getRandomValues === "function")
return uuidV4(c.getRandomValues.bind(c));
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random()
.toString(36)
.slice(2)}${Math.random().toString(36).slice(2)}`;
}
/** RFC 4122 version 4 UUID from a crypto-strength byte source. */
function uuidV4(getRandomValues: (array: Uint8Array) => Uint8Array): string {
const bytes = getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
"",
);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
@@ -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 {
+975 -3
View File
@@ -1,5 +1,977 @@
<script lang="ts">
// The root route redirects to /agents in +page.ts. This empty component only
// exists to satisfy the leaf-route requirement; it is never rendered because
// load() throws redirect first. Kept as a defensive fallback.
// A「片场暗房」项目总览 — the confirmed a1 layout (HEL-608).
// Everything on this page is driven by real project / job records; when
// there is nothing yet, honest Chinese empty states are shown instead of
// the sample progress rendered in the mockup.
import { goto } from '$app/navigation';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { jobsApi, projects, type Project } from '$lib/api';
import { agentDeepLink } from '$lib/agentTasks';
import AppShell from '$lib/components/shell/AppShell.svelte';
import AProjectCard from '$lib/components/home/AProjectCard.svelte';
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';
const client = useQueryClient();
const projectsQuery = createQuery({
queryKey: ['projects'],
queryFn: projects.list,
});
const jobsQuery = createQuery({
queryKey: ['jobs', 'recent'],
queryFn: () => jobsApi.list(),
refetchInterval: 8000,
});
const allProjects = $derived($projectsQuery.data ?? []);
const inProgressCount = $derived(allProjects.filter((p) => p.status === 'in_progress').length);
// ------------------------------------------------------------------
// Entry flows — both lead to already-working Calliope flows:
// 从创意开始 → create project → project stage workspace (/project/:id)
// 导入剧本 → create project with the script stored → hand off to the
// project-linked script agent (/agents?project=…&task=script)
// ------------------------------------------------------------------
type EntryMode = 'none' | 'idea' | 'script';
let entryMode = $state<EntryMode>('none');
const GENRES = [
'Adventure / Mystery',
'Drama',
'Sci-Fi',
'Fantasy',
'Horror',
'Romance',
'Thriller',
];
const TONES = [
'Cinematic, atmospheric',
'Dark, tense',
'Whimsical, warm',
'Gritty, realistic',
'Epic, sweeping',
];
const DURATIONS = ['30 seconds', '1 minute', '2 minutes', '5 minutes', '10 minutes'];
let ideaForm = $state({
title: '',
idea: '',
genre: GENRES[0],
tone: TONES[0],
duration: DURATIONS[2],
});
let scriptForm = $state({ title: '', script: '' });
let showErrors = $state(false);
function openIdea() {
entryMode = 'idea';
showErrors = false;
ideaForm = {
title: '',
idea: '',
genre: GENRES[0],
tone: TONES[0],
duration: DURATIONS[2],
};
}
function openScript() {
entryMode = 'script';
showErrors = false;
scriptForm = { title: '', script: '' };
}
function closeEntry() {
entryMode = 'none';
showErrors = false;
}
const ideaErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!ideaForm.title.trim()) errs.title = t('home.errTitle');
return errs;
});
const scriptErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!scriptForm.title.trim()) errs.title = t('home.errTitle');
if (!scriptForm.script.trim()) errs.script = t('home.errScript');
return errs;
});
function onIdeaCreated(p: Project) {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projects.created', { title: p.title }));
goto(`/project/${p.id}`);
}
function onScriptCreated(p: Project) {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projects.created', { title: p.title }));
goto(agentDeepLink(p.id, 'script'));
}
const ideaMutation = createMutation({
mutationFn: () =>
projects.create({
title: ideaForm.title.trim(),
idea: ideaForm.idea.trim() || undefined,
genre: ideaForm.genre || undefined,
tone: ideaForm.tone || undefined,
target_duration: ideaForm.duration || undefined,
}),
onSuccess: onIdeaCreated,
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
},
});
const scriptMutation = createMutation({
mutationFn: () =>
projects.create({
title: scriptForm.title.trim(),
idea: scriptForm.script.trim(),
}),
onSuccess: onScriptCreated,
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
},
});
function submitIdea(e: Event) {
e.preventDefault();
showErrors = true;
if (Object.keys(ideaErrors).length || $ideaMutation.isPending) return;
$ideaMutation.mutate();
}
function submitScript(e: Event) {
e.preventDefault();
showErrors = true;
if (Object.keys(scriptErrors).length || $scriptMutation.isPending) return;
$scriptMutation.mutate();
}
// ------------------------------------------------------------------
// Right task area — real queue records only (spec §5 four states).
// ------------------------------------------------------------------
const recentJobs = $derived(($jobsQuery.data ?? []).slice(0, 10));
const activeJobCount = $derived(
($jobsQuery.data ?? []).filter((j) => j.status === 'pending' || j.status === 'running').length,
);
const projectTitles = $derived.by(() => {
const map = new Map<number, string>();
for (const p of allProjects) map.set(p.id, p.title);
return map;
});
const retryMutation = createMutation({
mutationFn: (id: number) => jobsApi.retry(id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['jobs'] });
toast.success(t('home.jobRequeued'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('home.jobRetryFailed'));
},
});
const KIND_KEY: Record<string, string> = {
image: 'home.kindImage',
video: 'home.kindVideo',
export: 'home.kindExport',
};
function jobStatusLabel(status: string): string {
if (status === 'done') return t('home.jobDone');
if (status === 'running') return t('home.jobRunning');
if (status === 'pending') return t('home.jobQueued');
if (status === 'failed') return t('home.jobFailed');
return status;
}
function formatJobTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// English values are the backend contract; display labels come from the
// dictionary — same mapping as the legacy projects page.
function genreKey(value: string): string {
const map: Record<string, string> = {
'Adventure / Mystery': 'adventure',
Drama: 'drama',
'Sci-Fi': 'scifi',
Fantasy: 'fantasy',
Horror: 'horror',
Romance: 'romance',
Thriller: 'thriller',
};
return map[value] ?? 'adventure';
}
function toneKey(value: string): string {
const map: Record<string, string> = {
'Cinematic, atmospheric': 'cinematic',
'Dark, tense': 'dark',
'Whimsical, warm': 'whimsical',
'Gritty, realistic': 'gritty',
'Epic, sweeping': 'epic',
};
return map[value] ?? 'cinematic';
}
function durationKey(value: string): string {
const map: Record<string, string> = {
'30 seconds': '30',
'1 minute': '1m',
'2 minutes': '2m',
'5 minutes': '5m',
'10 minutes': '10',
};
return map[value] ?? '2m';
}
</script>
<AppShell active="projects" title={t('home.title')} subtitle={t('home.subtitle')}>
{#snippet actions()}
<span class="chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
>
<button
class="entry-btn"
class:primary={entryMode === 'none'}
class:secondary={entryMode !== 'none'}
onclick={openIdea}
>
<Icon name="sparkle" size={14} />
{t('home.startIdea')}
</button>
{/snippet}
<div class="home">
<section class="main-col">
{#if entryMode === 'none'}
<div class="entries">
<article class="entry-card">
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
<h2>{t('home.entryIdeaTitle')}</h2>
<p>{t('home.entryIdeaBody')}</p>
<button type="button" class="entry-btn secondary full" onclick={openIdea}>
{t('home.startIdea')}
</button>
</article>
<article class="entry-card">
<span class="entry-icon"><Icon name="script" size={18} /></span>
<h2>{t('home.entryScriptTitle')}</h2>
<p>{t('home.entryScriptBody')}</p>
<button type="button" class="entry-btn secondary full" onclick={openScript}>
{t('home.importScript')}
</button>
</article>
</div>
{:else if entryMode === 'idea'}
<form class="entry-form" onsubmit={submitIdea}>
<div class="form-head">
<h2>{t('home.entryIdeaTitle')}</h2>
<button
type="button"
class="icon-btn"
aria-label={t('projects.close')}
onclick={closeEntry}
>
<Icon name="close" size={16} />
</button>
</div>
<label class="f-field">
<span class="f-label">{t('projects.titleField')}<em>*</em></span>
<input
class="f-input"
class:invalid={showErrors && ideaErrors.title}
bind:value={ideaForm.title}
placeholder={t('projects.titlePlaceholder')}
/>
{#if showErrors && ideaErrors.title}<span class="f-error">{ideaErrors.title}</span>{/if}
</label>
<label class="f-field">
<span class="f-label">{t('projects.ideaField')}</span>
<textarea
class="f-input textarea"
bind:value={ideaForm.idea}
placeholder={t('home.ideaPlaceholder')}
rows={4}
></textarea>
<span class="f-hint">{t('projects.ideaHint')}</span>
</label>
<div class="f-grid">
<label class="f-field">
<span class="f-label">{t('projects.genre')}</span>
<select class="f-input" bind:value={ideaForm.genre}>
{#each GENRES as v (v)}<option value={v}>{t('genres.' + genreKey(v))}</option
>{/each}
</select>
</label>
<label class="f-field">
<span class="f-label">{t('projects.tone')}</span>
<select class="f-input" bind:value={ideaForm.tone}>
{#each TONES as v (v)}<option value={v}>{t('tones.' + toneKey(v))}</option>{/each}
</select>
</label>
<label class="f-field">
<span class="f-label">{t('projects.duration')}</span>
<select class="f-input" bind:value={ideaForm.duration}>
{#each DURATIONS as v (v)}<option value={v}
>{t('durations.' + durationKey(v))}</option
>{/each}
</select>
</label>
</div>
<div class="form-actions">
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
<Button variant="primary" type="submit" loading={$ideaMutation.isPending}>
{t('projects.create')}
</Button>
</div>
</form>
{:else}
<form class="entry-form" onsubmit={submitScript}>
<div class="form-head">
<h2>{t('home.entryScriptTitle')}</h2>
<button
type="button"
class="icon-btn"
aria-label={t('projects.close')}
onclick={closeEntry}
>
<Icon name="close" size={16} />
</button>
</div>
<label class="f-field">
<span class="f-label">{t('projects.titleField')}<em>*</em></span>
<input
class="f-input"
class:invalid={showErrors && scriptErrors.title}
bind:value={scriptForm.title}
placeholder={t('projects.titlePlaceholder')}
/>
{#if showErrors && scriptErrors.title}<span class="f-error">{scriptErrors.title}</span
>{/if}
</label>
<label class="f-field">
<span class="f-label">{t('home.scriptField')}<em>*</em></span>
<textarea
class="f-input textarea"
class:invalid={showErrors && scriptErrors.script}
bind:value={scriptForm.script}
placeholder={t('home.scriptPlaceholder')}
rows={9}
></textarea>
{#if showErrors && scriptErrors.script}<span class="f-error">{scriptErrors.script}</span
>{/if}
<span class="f-hint">{t('home.scriptHint')}</span>
</label>
<div class="form-actions">
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
<Button variant="primary" type="submit" loading={$scriptMutation.isPending}>
{t('home.importScript')}
</Button>
</div>
</form>
{/if}
<div class="projects-head">
<h2>{t('home.myProjects')}</h2>
{#if allProjects.length > 0}
<span class="projects-hint">{t('home.myProjectsHint')}</span>
{/if}
</div>
{#if $projectsQuery.isLoading}
<div class="cards" aria-busy="true">
{#each [1, 2, 3] as n (n)}
<div class="skel-card">
<Skeleton height="132px" />
<div class="skel-body">
<Skeleton width="55%" height="15px" />
<Skeleton width="90%" />
<Skeleton width="40%" height="11px" />
</div>
</div>
{/each}
</div>
{:else if $projectsQuery.isError}
<div class="panel-empty error">
<Icon name="alert" size={18} />
<p>{t('projects.loadError')}</p>
<Button variant="secondary" onclick={() => $projectsQuery.refetch()}>
<Icon name="retry" size={14} />
{t('common.retry')}
</Button>
</div>
{:else if allProjects.length === 0}
<div class="panel-empty">
<Icon name="folder" size={20} />
<p>{t('home.emptyProjects')}</p>
</div>
{:else}
<div class="cards">
{#each allProjects as project (project.id)}
<AProjectCard {project} />
{/each}
</div>
{/if}
</section>
<aside class="tasks">
<div class="tasks-head">
<h2>{t('home.tasksTitle')}</h2>
<span class="chip" class:amber={activeJobCount > 0}>
{t('home.activeJobs', { n: activeJobCount })}
</span>
</div>
{#if $jobsQuery.isLoading}
<div class="tasks-loading">{t('common.loading')}…</div>
{:else if recentJobs.length === 0}
<div class="tasks-empty">
<Icon name="queue" size={20} />
<p>{t('home.emptyJobs')}</p>
<p class="tasks-empty-sub">{t('home.emptyJobsSub')}</p>
</div>
{:else}
<ul class="job-list">
{#each recentJobs as job (job.id)}
{@const kindKey = KIND_KEY[job.kind] ?? null}
<li class="job-row" class:failed={job.status === 'failed'}>
<span class="job-main">
{#if kindKey}<span class="tag">{t(kindKey)}</span>{/if}
<span
class="job-name"
title={projectTitles.get(job.project_id) ?? `#${job.project_id}`}
>
{projectTitles.get(job.project_id) ??
t('home.jobProject', { id: job.project_id })}
</span>
</span>
<span class="job-side">
{#if job.status === 'failed' && job.error && job.error !== 'cancelled'}
<span class="job-status mono failed-text" title={job.error}
>{jobStatusLabel(job.status)}</span
>
<button
type="button"
class="retry-link"
onclick={() => $retryMutation.mutate(job.id)}
>
{t('common.retry')}
</button>
{:else}
<span
class="job-status mono {job.status === 'done'
? 'done-text'
: job.status === 'running'
? 'running-text'
: 'queued-text'}"
>
{#if job.status === 'running'}<span class="dot breathe" aria-hidden="true"
></span>{/if}
{jobStatusLabel(job.status)}
</span>
<span class="job-time mono">{formatJobTime(job.created_at)}</span>
{/if}
</span>
</li>
{/each}
</ul>
{/if}
<p class="tasks-note">{t('home.tasksNote')}</p>
</aside>
</div>
</AppShell>
<style>
.home {
display: grid;
grid-template-columns: minmax(0, 1fr) 344px;
gap: 24px;
padding: 24px 28px 40px;
max-width: 1560px;
width: 100%;
box-sizing: border-box;
margin: 0 auto;
align-items: start;
}
.main-col {
min-width: 0;
display: flex;
flex-direction: column;
gap: 22px;
}
/* ---------------- topbar chip + buttons ---------------- */
.chip {
padding: 5px 12px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
font-size: 11.5px;
white-space: nowrap;
}
.chip.amber {
background: var(--accent-dim);
color: var(--accent);
}
.entry-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
padding: 0 16px;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
transform 100ms;
}
.entry-btn.primary {
background: var(--accent);
color: var(--accent-contrast);
}
.entry-btn.primary:hover {
background: var(--accent-hover);
}
.entry-btn.secondary {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text);
}
.entry-btn.secondary:hover {
border-color: rgba(255, 255, 255, 0.24);
}
.entry-btn:active {
transform: scale(0.98);
}
.entry-btn.full {
width: 100%;
margin-top: auto;
}
/* ---------------- entry cards / forms (a1 双入口) ---------------- */
.entries {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.entry-card {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px 20px;
min-height: 168px;
}
.entry-icon {
width: 38px;
height: 38px;
border-radius: 9px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.entry-card h2 {
margin: 4px 0 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.entry-card p {
margin: 0;
font-size: 12.5px;
line-height: 1.65;
color: var(--text-2);
}
.entry-form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px 20px;
}
.form-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.form-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-3);
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.f-field {
display: block;
margin-bottom: 13px;
}
.f-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 6px;
}
.f-label em {
color: var(--error);
font-style: normal;
}
.f-input {
width: 100%;
box-sizing: border-box;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 7px;
padding: 9px 11px;
color: var(--text);
font-family: inherit;
font-size: 13px;
}
.f-input.textarea {
resize: vertical;
min-height: 88px;
line-height: 1.6;
}
.f-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.f-input.invalid {
border-color: rgba(229, 72, 77, 0.65);
}
.f-hint {
display: block;
margin-top: 5px;
font-size: 11px;
color: var(--text-3);
}
.f-error {
display: block;
margin-top: 5px;
font-size: 11.5px;
color: var(--error);
}
.f-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.f-grid .f-field {
margin-bottom: 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* restyle reused ui/Button inside the darkroom scope (§5) */
.home :global(.btn-primary) {
background: var(--accent);
color: var(--accent-contrast);
}
.home :global(.btn-primary:hover:not(:disabled)) {
background: var(--accent-hover);
}
.home :global(.btn-secondary) {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text);
}
.home :global(.btn-secondary:hover:not(:disabled)) {
background: var(--surface-3);
}
.home :global(.btn-ghost) {
color: var(--text-2);
}
.home :global(.btn-ghost:hover:not(:disabled)) {
background: var(--surface-3);
color: var(--text);
}
/* ---------------- project cards ---------------- */
.projects-head {
display: flex;
align-items: baseline;
gap: 12px;
}
.projects-head h2 {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.projects-hint {
font-size: 11.5px;
color: var(--text-3);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.skel-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.skel-card :global(.skeleton) {
border-radius: 0;
}
.skel-body {
display: flex;
flex-direction: column;
gap: 9px;
padding: 14px 16px;
}
.panel-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 36px 20px;
border: 1.5px dashed var(--border-strong);
border-radius: 10px;
color: var(--text-3);
text-align: center;
}
.panel-empty p {
margin: 0;
font-size: 12.5px;
line-height: 1.65;
color: var(--text-2);
}
.panel-empty.error p {
color: var(--error);
}
/* ---------------- right task area ---------------- */
.tasks {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 18px 14px;
position: sticky;
top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.tasks-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.tasks-head h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.tasks-loading {
color: var(--text-3);
font-size: 12px;
padding: 12px 0;
}
.tasks-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 22px 10px 16px;
color: var(--text-3);
text-align: center;
}
.tasks-empty p {
margin: 0;
font-size: 12px;
line-height: 1.65;
color: var(--text-2);
}
.tasks-empty-sub {
font-size: 11px;
color: var(--text-3);
}
.job-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.job-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
min-height: 44px;
}
.job-row:last-child {
border-bottom: none;
}
.job-main {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tag {
padding: 1px 8px;
border-radius: 999px;
background: var(--info-dim);
color: var(--info);
font-size: 10.5px;
font-weight: 600;
flex-shrink: 0;
}
.job-name {
font-size: 12.5px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-side {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.job-status {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
}
.done-text {
color: var(--success);
}
.running-text {
color: var(--accent);
}
.queued-text {
color: var(--text-3);
}
.failed-text {
color: var(--error);
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
}
.dot.breathe {
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.job-time {
font-size: 10.5px;
color: var(--text-3);
}
.retry-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--error);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.tasks-note {
margin: 0;
padding-top: 10px;
border-top: 1px solid var(--border);
font-size: 10.5px;
line-height: 1.6;
color: var(--text-3);
}
/* ---------------- responsive ---------------- */
@media (max-width: 1439px) {
.home {
grid-template-columns: minmax(0, 1fr) 320px;
padding: 20px 22px 36px;
}
}
@media (max-width: 1023px) {
.home {
grid-template-columns: minmax(0, 1fr);
padding: 16px 16px 32px;
}
.tasks {
position: static;
}
.entries {
grid-template-columns: minmax(0, 1fr);
}
.f-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+2 -3
View File
@@ -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 {};
}
+56
View File
@@ -49,6 +49,62 @@ body {
min-height: 100vh;
}
/* ------------------------------------------------------------------
* A「片场暗房」scoped design tokens — spec-v2 §2 (HEL-603 confirmed).
* Adopted incrementally by new A-style pages via the `.a-darkroom`
* wrapper; graduates to :root when the full A shell (216px nav) lands.
* Values are the single source of truth for A pages — do not hardcode
* colors inside pages, and do not override these with ad-hoc CSS.
* ------------------------------------------------------------------ */
.a-darkroom {
--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);
--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);
--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);
--cloud: #b98be0;
--cloud-dim: rgba(185, 139, 224, 0.14);
/* Typography (§2.2) */
--font-ui: 'Noto Sans SC', 'Noto Sans CJK SC', var(--font-body);
--font-a-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
/* 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);
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 13.5px;
line-height: 1.6;
}
.a-darkroom .mono,
.a-darkroom code,
.a-darkroom .kh {
font-family: var(--font-a-mono);
font-variant-numeric: tabular-nums;
}
h1,
h2,
h3,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
+133 -34
View File
@@ -11,6 +11,7 @@
import Icon from '$lib/components/ui/Icon.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import { settings, type LlmProfile, type Settings } from '$lib/api';
import { randomId } from '$lib/ids';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
@@ -33,6 +34,7 @@
let draft = $state<Record<string, unknown>>({});
let apiKeyDrafts = $state<Record<string, string>>({});
let llmTestState = $state<Record<string, { pending: boolean; ok?: boolean; message: string }>>({});
// Dirty tracking: any staged field (draft map or a typed API key) counts.
const dirtyKeys = $derived([
@@ -325,7 +327,7 @@
function addProfile(s: Settings) {
ensureLlmDraft(s);
const id = crypto.randomUUID();
const id = randomId();
draft.llm_profiles = [
...(draft.llm_profiles as LlmProfile[]),
{
@@ -349,6 +351,53 @@
const nextKeys = { ...apiKeyDrafts };
delete nextKeys[id];
apiKeyDrafts = nextKeys;
const nextTests = { ...llmTestState };
delete nextTests[id];
llmTestState = nextTests;
}
function llmTestFailureText(res: { error?: string; detail?: string }): string {
let detail = res.detail ?? res.error ?? '';
if (res.error === 'auth') detail = t('settings.llmTestAuth', { detail });
else if (res.error === 'timeout') detail = t('settings.llmTestTimeout');
else if (res.error === 'connect') detail = t('settings.llmTestConnect', { detail });
return t('settings.llmTestFail', { detail });
}
async function testLlmConnection(s: Settings, profile: LlmProfile) {
ensureLlmDraft(s);
const typedKey = apiKeyDrafts[profile.id];
const savedOnServer = s.llm_profiles?.some((p) => p.id === profile.id) ?? false;
llmTestState = { ...llmTestState, [profile.id]: { pending: true, message: '' } };
try {
const res = await settings.testLlm({
...(typedKey || !savedOnServer ? {} : { profile_id: profile.id }),
base_url: profile.base_url.trim(),
model: profile.model.trim(),
...(typedKey ? { api_key: typedKey } : {}),
});
llmTestState = {
...llmTestState,
[profile.id]: res.ok
? {
pending: false,
ok: true,
message: t('settings.llmTestOk', { ms: res.elapsed_ms ?? '?' }),
}
: { pending: false, ok: false, message: llmTestFailureText(res) },
};
} catch (err) {
llmTestState = {
...llmTestState,
[profile.id]: {
pending: false,
ok: false,
message: t('settings.llmTestFail', {
detail: err instanceof Error ? err.message : String(err),
}),
},
};
}
}
</script>
@@ -389,16 +438,25 @@
{#each workingProfiles(s) as profile (profile.id)}
{@const active = workingActiveId(s) === profile.id}
<article class="llm-card" class:active>
<header class="llm-card-head">
<label class="llm-active">
<input
type="radio"
name="llm-active"
checked={active}
onchange={() => setActiveProfile(s, profile.id)}
/>
<span>{active ? t('settings.active') : t('settings.useThis')}</span>
</label>
<header class="llm-card-head">
<label class="llm-active">
<input
type="radio"
name="llm-active"
checked={active}
onchange={() => setActiveProfile(s, profile.id)}
/>
<span>{active ? t('settings.active') : t('settings.useThis')}</span>
</label>
<div class="llm-card-actions">
<Button
variant="secondary"
size="sm"
loading={llmTestState[profile.id]?.pending}
onclick={() => testLlmConnection(s, profile)}
>
{t('settings.testLlm')}
</Button>
{#if workingProfiles(s).length > 1}
<Button
variant="ghost"
@@ -410,7 +468,17 @@
{t('settings.remove')}
</Button>
{/if}
</header>
</div>
</header>
{#if llmTestState[profile.id] && !llmTestState[profile.id].pending && llmTestState[profile.id].message}
<p
class="test-result"
class:ok={llmTestState[profile.id].ok}
class:err={llmTestState[profile.id].ok === false}
>
{llmTestState[profile.id].message}
</p>
{/if}
<label class="field">
<span class="field-label">{t('settings.name')}</span>
<input
@@ -474,28 +542,25 @@
{/each}
</div>
</section>
{:else if tab === 'comfy'}
<section class="panel">
<h1>{t('settings.comfySection')}</h1>
<p class="lead">{t('settings.comfyLead')}</p>
<label class="field">
<span class="field-label">{t('settings.baseUrl')}</span>
<input
class="field-input"
value={String(fieldValue('comfyui_base_url', s.comfyui_base_url))}
oninput={(e) => (draft.comfyui_base_url = e.currentTarget.value)}
/>
<p class="field-hint">{t('settings.comfyHint')}</p>
</label>
<label class="check">
<input
type="checkbox"
checked={dryRunChecked(s)}
onchange={(e) => (draft.dry_run = e.currentTarget.checked)}
/>
{t('settings.dryRunLabel')}
</label>
</section>
{:else if tab === 'comfy'}
<section class="panel">
<h1>{t('settings.comfySection')}</h1>
<p class="lead">{t('settings.comfyLead')}</p>
<div class="comfy-redirect">
<p class="field-hint">{t('settings.comfyMultiHint')}</p>
<a class="comfy-link" href="/connections">
{t('settings.comfyGoto')}
</a>
</div>
<label class="check">
<input
type="checkbox"
checked={dryRunChecked(s)}
onchange={(e) => (draft.dry_run = e.currentTarget.checked)}
/>
{t('settings.dryRunLabel')}
</label>
</section>
{:else if tab === 'queue'}
<section class="panel">
<h1>{t('settings.queueSection')}</h1>
@@ -822,6 +887,27 @@
.check input {
width: auto;
}
.comfy-redirect {
border: 1px dashed var(--border);
border-radius: var(--radius-md, 8px);
padding: 14px 16px;
margin-bottom: var(--space-lg, 20px);
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.comfy-link {
color: var(--accent, #e8a33d);
font-size: 13.5px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid currentColor;
padding-bottom: 1px;
}
.comfy-link:hover {
opacity: 0.85;
}
.preset-chips {
display: flex;
gap: 8px;
@@ -950,6 +1036,19 @@
gap: 12px;
margin-bottom: var(--space-sm);
}
.llm-card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.test-result {
margin: 0 0 var(--space-sm);
font-size: 12px;
color: var(--error);
}
.test-result.ok {
color: var(--success);
}
.llm-active {
display: inline-flex;
align-items: center;