Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cf9188955 | ||
|
|
25d1e05a30 | ||
|
|
4945d29e52 | ||
|
|
5d909aca3b | ||
|
|
786b349752 |
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -38,12 +38,13 @@ class QueueManager:
|
||||
) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload or {})),
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
@@ -51,6 +52,35 @@ class QueueManager:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _stamp_endpoint(
|
||||
conn, workflow_id: int | None, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Snapshot the target ComfyUI connection into the job payload (HEL-611).
|
||||
|
||||
Every generation path (flow video/assets, playground, agent tools)
|
||||
funnels through here, so they all share one execution route.
|
||||
Priority: an explicit payload ``endpoint_id`` → the workflow's
|
||||
bound connection → the active connection. The snapshot carries
|
||||
id/name/base_url only — the token is resolved server-side by
|
||||
the worker at run time.
|
||||
"""
|
||||
from calliope.comfyui.endpoints import endpoint_snapshot
|
||||
|
||||
if payload.get("endpoint"):
|
||||
return payload
|
||||
endpoint_id = payload.get("endpoint_id")
|
||||
if not endpoint_id and workflow_id:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
endpoint_id = row["endpoint_id"] if row else None
|
||||
except Exception:
|
||||
endpoint_id = None # pre-migration DB: column may be missing
|
||||
payload["endpoint"] = endpoint_snapshot(endpoint_id)
|
||||
return payload
|
||||
|
||||
def claim_next(self) -> dict[str, Any] | None:
|
||||
if self.paused:
|
||||
return None
|
||||
|
||||
@@ -151,7 +151,8 @@ class QueueWorker:
|
||||
# and dry-run writes an mp4 placeholder (not the default PNG).
|
||||
return await run_export(job, payload, event_bus, dry_run=use_dry)
|
||||
|
||||
client = ComfyUIClient(config.settings.comfyui_base_url)
|
||||
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
if use_dry:
|
||||
return await self._dry_run(job, payload)
|
||||
@@ -159,8 +160,8 @@ class QueueWorker:
|
||||
healthy = await client.health()
|
||||
if not healthy:
|
||||
raise RuntimeError(
|
||||
f"ComfyUI unreachable at {config.settings.comfyui_base_url}. "
|
||||
"Start ComfyUI, or enable Dry-run in Settings only for placeholder testing."
|
||||
f"ComfyUI 不可达({endpoint_label})。请在「ComfyUI 连接」页测试并修正该连接,"
|
||||
"或在设置中开启 Dry-run 做占位测试。"
|
||||
)
|
||||
|
||||
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
|
||||
@@ -208,6 +209,37 @@ class QueueWorker:
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_endpoint(payload: dict[str, Any]) -> tuple[str, dict[str, str], str]:
|
||||
"""Pick the ComfyUI address for THIS job (HEL-611).
|
||||
|
||||
Priority: the payload's endpoint snapshot id, still present in
|
||||
config (fresh URL + token win) → the snapshot's URL when the id was
|
||||
deleted (token lost — history still explains the address) → the
|
||||
active connection (snapshots from before multi-connection, or jobs
|
||||
enqueued without one).
|
||||
"""
|
||||
from calliope.comfyui.endpoints import build_auth_headers
|
||||
|
||||
snapshot = payload.get("endpoint") or {}
|
||||
if isinstance(snapshot, dict) and snapshot.get("id"):
|
||||
endpoint = config.settings.resolve_comfyui_endpoint(str(snapshot["id"]))
|
||||
if endpoint is not None and endpoint.get("id") == snapshot.get("id"):
|
||||
base_url = str(endpoint.get("base_url") or "")
|
||||
label = f"{endpoint.get('name') or '连接'} · {base_url}"
|
||||
return base_url, build_auth_headers(endpoint), label
|
||||
if isinstance(snapshot, dict) and snapshot.get("base_url"):
|
||||
# Dangling id: run against the snapshotted address, no token.
|
||||
base_url = str(snapshot["base_url"])
|
||||
label = f"{snapshot.get('name') or '已删除连接'} · {base_url}"
|
||||
return base_url, {}, label
|
||||
endpoint = config.settings.resolve_comfyui_endpoint(None)
|
||||
if endpoint is None:
|
||||
raise RuntimeError("没有可用的 ComfyUI 连接:请到「ComfyUI 连接」页添加。")
|
||||
base_url = str(endpoint.get("base_url") or "")
|
||||
label = f"{endpoint.get('name') or '默认连接'} · {base_url}"
|
||||
return base_url, build_auth_headers(endpoint), label
|
||||
|
||||
async def _resolve_continue_source(
|
||||
self,
|
||||
job: dict[str, Any],
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""ComfyUI multi-connection endpoints: CRUD + server-side connectivity test.
|
||||
|
||||
HEL-611 / HEL-601 §3. All probes run from the machine hosting this
|
||||
workbench (the user's rule: connectivity is judged by the SERVER, not the
|
||||
browser). Tokens are stored server-side only and never echoed back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from calliope.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_TEST_TIMEOUT_SEC = 5.0
|
||||
_TEST_TIMEOUT_RANGE = (2.0, 15.0)
|
||||
|
||||
_AUTH_TYPES = ("none", "bearer", "header")
|
||||
|
||||
# SSRF guard (HEL-601 §3.4): http/https only; link-local (incl. cloud
|
||||
# metadata 169.254.169.254) and unspecified/multicast/reserved targets are
|
||||
# rejected. Loopback is EXPLICITLY allowed — a local ComfyUI is a
|
||||
# legitimate target for this desktop tool.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("224.0.0.0/4"),
|
||||
ipaddress.ip_network("240.0.0.0/4"),
|
||||
ipaddress.ip_network("255.255.255.255/32"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
ipaddress.ip_network("ff00::/8"),
|
||||
]
|
||||
|
||||
|
||||
class EndpointIn(BaseModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
base_url: str
|
||||
auth_type: str = "none"
|
||||
# Blank/None on edit keeps the stored token (it is never echoed back).
|
||||
auth_token: str | None = None
|
||||
header_name: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class EndpointPatch(BaseModel):
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
auth_type: str | None = None
|
||||
auth_token: str | None = None
|
||||
header_name: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class EndpointTestIn(BaseModel):
|
||||
endpoint_id: str | None = None
|
||||
# Raw candidate values (unsaved form). Stored values fill the blanks
|
||||
# when endpoint_id is given — the token never needs a round trip.
|
||||
base_url: str | None = None
|
||||
auth_type: str | None = None
|
||||
auth_token: str | None = None
|
||||
header_name: str | None = None
|
||||
timeout_sec: float | None = Field(
|
||||
None, ge=_TEST_TIMEOUT_RANGE[0], le=_TEST_TIMEOUT_RANGE[1]
|
||||
)
|
||||
|
||||
|
||||
def _parsed_url(base_url: str) -> tuple[str, str]:
|
||||
url = (base_url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="API 地址不能为空")
|
||||
if "://" not in url:
|
||||
url = f"http://{url}"
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
return url.rstrip("/"), parsed.hostname
|
||||
|
||||
|
||||
def _assert_not_ssrf(hostname: str) -> None:
|
||||
"""Reject link-local / metadata / reserved targets (loopback allowed)."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
addr = None
|
||||
candidates: list[str] = [str(addr)] if addr else _resolve_host(hostname)
|
||||
for cand in candidates:
|
||||
try:
|
||||
ip = ipaddress.ip_address(cand)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.is_loopback:
|
||||
continue
|
||||
for net in _BLOCKED_NETWORKS:
|
||||
if ip in net:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="该地址位于被禁止的网段(链路本地/元数据/保留地址),不允许探测",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_host(hostname: str) -> list[str]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
|
||||
except OSError:
|
||||
return []
|
||||
return sorted({info[4][0] for info in infos})
|
||||
|
||||
|
||||
def _public_payload() -> dict[str, Any]:
|
||||
data = settings.to_public_dict()
|
||||
return {"endpoints": data["comfyui_endpoints"], "active_id": data["comfyui_active_id"]}
|
||||
|
||||
|
||||
def _stored_endpoint(endpoint_id: str) -> dict[str, Any]:
|
||||
settings.ensure_comfyui_endpoints()
|
||||
for ep in settings.comfyui_endpoints:
|
||||
if ep.get("id") == endpoint_id:
|
||||
return ep
|
||||
raise HTTPException(status_code=404, detail="连接不存在")
|
||||
|
||||
|
||||
@router.get("/endpoints")
|
||||
async def list_endpoints() -> dict[str, Any]:
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.post("/endpoints")
|
||||
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
|
||||
base_url, _host = _parsed_url(payload.base_url)
|
||||
incoming = [ep for ep in settings.comfyui_endpoints or []]
|
||||
incoming.append(payload.model_dump() | {"base_url": base_url})
|
||||
try:
|
||||
settings.replace_comfyui_endpoints(incoming)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.patch("/endpoints/{endpoint_id}")
|
||||
async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str, Any]:
|
||||
_stored_endpoint(endpoint_id)
|
||||
base_url = payload.base_url
|
||||
if base_url is not None:
|
||||
base_url, _host = _parsed_url(base_url)
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if base_url is not None:
|
||||
patch["base_url"] = base_url
|
||||
incoming = [
|
||||
{**ep, **patch} if ep.get("id") == endpoint_id else ep
|
||||
for ep in settings.comfyui_endpoints
|
||||
]
|
||||
try:
|
||||
settings.replace_comfyui_endpoints(incoming)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.delete("/endpoints/{endpoint_id}")
|
||||
async def delete_endpoint(endpoint_id: str) -> dict[str, Any]:
|
||||
_stored_endpoint(endpoint_id)
|
||||
remaining = [ep for ep in settings.comfyui_endpoints if ep.get("id") != endpoint_id]
|
||||
if not remaining:
|
||||
raise HTTPException(status_code=400, detail="至少保留一个连接;如需更换请先编辑现有连接")
|
||||
settings.replace_comfyui_endpoints(remaining)
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
@router.post("/endpoints/{endpoint_id}/activate")
|
||||
async def activate_endpoint(endpoint_id: str) -> dict[str, Any]:
|
||||
_stored_endpoint(endpoint_id)
|
||||
settings.comfyui_active_id = endpoint_id
|
||||
settings.apply_active_comfyui()
|
||||
settings.save_config_file()
|
||||
return _public_payload()
|
||||
|
||||
|
||||
def _classify_connect_error(exc: httpx.ConnectError) -> tuple[str, str]:
|
||||
"""Map a connect failure onto (code, Chinese message).
|
||||
|
||||
httpx collapses socket errors to "All connection attempts failed" on
|
||||
the outer exception — the distinguishing errno lives in the __cause__
|
||||
chain, so classify over the whole chain.
|
||||
"""
|
||||
parts: list[str] = [f"{exc.__class__.__name__}: {exc}"]
|
||||
node = exc.__cause__ or exc.__context__
|
||||
seen = {id(exc)}
|
||||
while node is not None and id(node) not in seen:
|
||||
seen.add(id(node))
|
||||
parts.append(f"{node.__class__.__name__}: {node}")
|
||||
node = node.__cause__ or node.__context__
|
||||
text = " | ".join(parts)
|
||||
lowered = text.lower()
|
||||
if (
|
||||
"name or service not known" in lowered
|
||||
or "getaddrinfo" in lowered
|
||||
or "temporary failure in resolution" in lowered
|
||||
or "nodename nor servname" in lowered
|
||||
):
|
||||
return "dns", "域名解析失败:请检查地址拼写或 DNS"
|
||||
if (
|
||||
"connection refused" in lowered
|
||||
or "errno 111" in lowered
|
||||
or "errno 10061" in lowered
|
||||
or "winerror 10061" in lowered
|
||||
):
|
||||
return "refused", "连接被拒绝:目标端口没有服务在监听"
|
||||
if "ssl" in lowered or "certificate" in lowered:
|
||||
return "tls", "TLS/证书错误:检查 https 配置或改用 http"
|
||||
if "unreachable" in lowered or "no route to host" in lowered:
|
||||
return "refused", "网络不可达:本机无法路由到该地址"
|
||||
return "connect", f"无法建立连接:{str(exc)[:300]}"
|
||||
|
||||
|
||||
@router.post("/endpoints/test")
|
||||
async def test_endpoint(payload: EndpointTestIn) -> dict[str, Any]:
|
||||
"""Server-side ComfyUI probe: GET /system_stats with error classification."""
|
||||
from calliope.comfyui.endpoints import build_auth_headers
|
||||
|
||||
base_url = (payload.base_url or "").strip()
|
||||
auth_type = (payload.auth_type or "").strip() or None
|
||||
auth_token = (payload.auth_token or "").strip() or None
|
||||
header_name = (payload.header_name or "").strip() or None
|
||||
if payload.endpoint_id:
|
||||
stored = _stored_endpoint(payload.endpoint_id)
|
||||
if not base_url:
|
||||
base_url = str(stored.get("base_url") or "")
|
||||
if auth_type is None:
|
||||
auth_type = str(stored.get("auth_type") or "none")
|
||||
if auth_token is None:
|
||||
token = stored.get("auth_token")
|
||||
auth_token = token if isinstance(token, str) and token.strip() else None
|
||||
if header_name is None:
|
||||
header_name = stored.get("header_name")
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail="base_url 和 endpoint_id 至少提供一项")
|
||||
|
||||
url, hostname = _parsed_url(base_url)
|
||||
_assert_not_ssrf(hostname)
|
||||
|
||||
headers = build_auth_headers(
|
||||
{
|
||||
"auth_type": auth_type or "none",
|
||||
"auth_token": auth_token,
|
||||
"header_name": header_name,
|
||||
}
|
||||
)
|
||||
timeout_sec = (
|
||||
payload.timeout_sec if payload.timeout_sec is not None else _TEST_TIMEOUT_SEC
|
||||
)
|
||||
timeout_sec = min(max(timeout_sec, _TEST_TIMEOUT_RANGE[0]), _TEST_TIMEOUT_RANGE[1])
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_sec, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{url}/system_stats", headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "timeout",
|
||||
"message": f"超时:{timeout_sec:.0f} 秒内没有响应",
|
||||
"timeout_sec": timeout_sec,
|
||||
}
|
||||
except httpx.ConnectError as exc:
|
||||
code, message = _classify_connect_error(exc)
|
||||
return {"ok": False, "error": code, "message": message, "detail": str(exc)[:300]}
|
||||
except httpx.HTTPError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "connect",
|
||||
"message": "连接失败",
|
||||
"detail": (str(exc) or exc.__class__.__name__)[:300],
|
||||
}
|
||||
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "auth",
|
||||
"message": f"鉴权失败(HTTP {resp.status_code}):请检查访问令牌或鉴权方式",
|
||||
}
|
||||
if resp.status_code == 404:
|
||||
# Reverse proxies sometimes strip /system_stats — probe "/" so the
|
||||
# user learns "reachable, but not confirmed ComfyUI" (HEL-601 risk ①).
|
||||
reachable = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout_sec, follow_redirects=True) as client:
|
||||
root = await client.get(f"{url}/", headers=headers)
|
||||
reachable = root.status_code < 500
|
||||
except httpx.HTTPError:
|
||||
reachable = False
|
||||
if reachable:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "HTTP 可达但未确认是 ComfyUI(/system_stats 不存在)",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "未找到 /system_stats,且根路径不可用",
|
||||
}
|
||||
if resp.status_code >= 500:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "server",
|
||||
"message": f"目标服务返回错误(HTTP {resp.status_code})",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "http",
|
||||
"message": f"请求被拒绝(HTTP {resp.status_code})",
|
||||
"detail": resp.text[:200].strip(),
|
||||
}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "地址连通,但响应不是 ComfyUI(/system_stats 返回非 JSON)",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
system = data.get("system") if isinstance(data, dict) else None
|
||||
version = None
|
||||
if isinstance(system, dict):
|
||||
version = system.get("comfyui_version")
|
||||
if not version:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "not_comfyui",
|
||||
"message": "地址连通,但响应不像 ComfyUI(/system_stats 缺少版本字段)",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
device_name = None
|
||||
devices = data.get("devices") if isinstance(data, dict) else None
|
||||
if isinstance(devices, list) and devices and isinstance(devices[0], dict):
|
||||
device_name = devices[0].get("name") or devices[0].get("type")
|
||||
return {
|
||||
"ok": True,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"comfyui_version": str(version),
|
||||
**({"device_name": str(device_name)} if device_name else {}),
|
||||
}
|
||||
@@ -6,7 +6,6 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from calliope.agent.video_agent import enqueue_video_jobs, preview_clip_prompt
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
@@ -191,7 +190,18 @@ async def cancel_job(job_id: int) -> dict[str, Any]:
|
||||
# Export runs local ffmpeg, not Comfy — kill the process instead.
|
||||
kill_running(job_id)
|
||||
else:
|
||||
client = ComfyUIClient()
|
||||
# Interrupt on the endpoint this job actually runs against (payload
|
||||
# snapshot; falls back to the active connection).
|
||||
from calliope.comfyui.endpoints import client_for
|
||||
|
||||
try:
|
||||
snapshot = (json.loads(job.get("payload_json") or "{}") or {}).get("endpoint")
|
||||
except json.JSONDecodeError:
|
||||
snapshot = None
|
||||
endpoint = None
|
||||
if isinstance(snapshot, dict) and snapshot.get("id"):
|
||||
endpoint = settings.resolve_comfyui_endpoint(str(snapshot["id"]))
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
await client.interrupt()
|
||||
finally:
|
||||
|
||||
@@ -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
|
||||
@@ -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 ."
|
||||
},
|
||||
|
||||
@@ -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.");
|
||||
@@ -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%
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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>
|
||||
@@ -25,7 +25,7 @@ function initialLanguage(): Language {
|
||||
// language this build no longer ships must not blank the UI. This runs on
|
||||
// the server too, where storage is absent and we render the default.
|
||||
const stored = storageGet(STORAGE_KEY);
|
||||
return isLanguage(stored) ? stored : 'en';
|
||||
return isLanguage(stored) ? stored : 'zh';
|
||||
}
|
||||
|
||||
export const language = $state<{ current: Language }>({ current: initialLanguage() });
|
||||
|
||||
@@ -34,6 +34,8 @@ export const de: Dict = {
|
||||
'nav.projects': 'Projekte',
|
||||
'nav.buildScene': 'Szene bauen',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.agents': 'Agenten',
|
||||
'nav.models': 'Modelle',
|
||||
'nav.home': 'Start',
|
||||
'nav.primary': 'Primär',
|
||||
'nav.projectStages': 'Projektphasen',
|
||||
@@ -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.',
|
||||
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
};
|
||||
|
||||
@@ -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 から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
|
||||
|
||||
};
|
||||
|
||||
@@ -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에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
|
||||
|
||||
};
|
||||
|
||||
@@ -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 导出后在此导入,通用导入通道已就绪。',
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user