Compare commits

...
Author SHA1 Message Date
leeferandmultica-agent 54880e900b fix: restore library entrance and document video caption exception
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:44:33 +08:00
leeferandmultica-agent 4c849e9ef9 docs: explain 29 scoped lint exceptions for HEL-762 batch 4
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 04:14:43 +08:00
leeferandmultica-agent 46fd5bbc86 fix: use Svelte reactive collections at isolated boundaries (HEL-760)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:51:16 +08:00
leeferandmultica-agent fc6616b118 fix: preserve resource link semantics with external rel (HEL-758)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:32:35 +08:00
leeferandmultica-agent 22f82fbed6 docs: record HEL-756 batch 1 implementation and validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:07:08 +08:00
leeferandmultica-agent 67eb822efe fix(navigation): keep tool destinations in a module constant
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 03:02:47 +08:00
leeferandmultica-agent 99574c2226 fix(state): preserve readonly runes and remove inert export flag
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 88965fa70d fix(lists): key 48 loops with verified identity or positional semantics
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:22 +08:00
leeferandmultica-agent 9d54b9d8dd fix(navigation): resolve the 74 approved application destinations
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:57:12 +08:00
leeferandmultica-agent a666f6bd12 docs: record HEL-753 joint type validation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent b02c50fdf7 fix(shot): type mirror and posture boundaries without assertions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent 7e9a5be19d fix(shot): declare real mannequin joints and numeric accessors
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:39:47 +08:00
leeferandmultica-agent e49b8b0072 docs(HEL-752): record validation and unresolved behavioral gates
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:25:13 +08:00
leeferandmultica-agent ddc02fbe4c chore(HEL-752): retain unchanged SCSS package registry entry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:21:00 +08:00
leeferandmultica-agent d076978a4a refactor(HEL-752): remove unused breakpoint bookkeeping
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:20:23 +08:00
leeferandmultica-agent 4e200cc8ac refactor(HEL-752): type menu and blend contracts and document local exceptions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:18:32 +08:00
leeferandmultica-agent bfcf542e59 refactor(HEL-752): remove unused locals while preserving evaluation and props
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:16:48 +08:00
leeferandmultica-agent 3d1c4ca5af fix(HEL-752): use Svelte-aware const checks and preserve legacy rules
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:15:16 +08:00
leeferandmultica-agent 24129438e8 chore(HEL-750): configure ESLint 9 and document remaining lint blockers
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 02:02:58 +08:00
leeferandmultica-agent 155d914df3 style(HEL-750): apply existing Prettier formatting to 61 files
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:57:34 +08:00
leeferandmultica-agent 64c1116bca fix(HEL-730): preserve shared pins and sample icon geometry
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:51:42 +08:00
leeferandmultica-agent 03bebec94a fix(HEL-730): align media header and connection add card
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:26:21 +08:00
leeferandmultica-agent 0f4d6a8961 fix(HEL-730): use approved standard button tokens
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:14:59 +08:00
leeferandmultica-agent 06c9bd5000 fix(HEL-730): match compact workflow filter sizing
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:12:34 +08:00
leeferandmultica-agent acb6beca1c fix(HEL-730): align measured control sizes and typography
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:05:33 +08:00
leeferandmultica-agent a6b04e5c91 feat(HEL-730): reshape management pages and preserve workflow tools
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 01:01:58 +08:00
leeferandmultica-agent 634965f5f5 HEL-743: restore search shortcut and full-width responsive desk header
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 23:26:41 +08:00
leeferandmultica-agent e62499c517 HEL-741: match runnable toggle to sample and record final regression
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 22:47:22 +08:00
leeferandmultica-agent b6b3a9fed3 HEL-741: restore workflow filters, header and result actions to sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 22:44:37 +08:00
leeferandmultica-agent 7bf63d0587 HEL-740: document existing AGit review update
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:56:46 +08:00
leeferandmultica-agent 5cb75d5718 HEL-740: restore approved creative desk dock and anchored controls
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:54:09 +08:00
leeferandmultica-agent 2304ab1538 feat(HEL-729): unify the Nocturne creative desk and isolate history
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 21:07:19 +08:00
leeferandmultica-agent 8e84e4c21f feat(HEL-728): establish approved themes and compatible studio navigation
Co-authored-by: multica-agent <github@multica.ai>
2026-10-21 20:13:03 +08:00
leeferandmultica-agent 35fd07af24 HEL-665: implement v5 creation hierarchy and project-bound agent drafts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-15 00:33:26 +08:00
leeferandmultica-agent 012a946f85 fix(prototype): align scene aspect label with sample media
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 23:45:34 +08:00
leeferandmultica-agent 875dfcb41a design(HEL-665): rebuild creation hierarchy and interactive desktop prototype
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 23:42:32 +08:00
leeferandmultica-agent 780ac52ffc fix(agents): preserve copy fallback and guard new session submission
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 22:15:48 +08:00
leeferandmultica-agent d4e199f3c8 HEL-665: implement approved reference creative and agent interfaces
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 22:03:32 +08:00
leeferandmultica-agent ae9e34f6ab HEL-665: prioritize media inputs and keep short-window result actions visible
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:20:48 +08:00
leeferandmultica-agent 16933b1dfc HEL-665: switch results without overlapping video players
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:14:48 +08:00
leeferandmultica-agent 07f6475b1a HEL-665: rebuild ComfyUI creative journey with visible controls and isolated drafts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-14 00:04:19 +08:00
leeferandmultica-agent ac1c0f98a6 HEL-665: preserve creative inputs and isolate cloud catalogue keyboard selection
Co-authored-by: multica-agent <github@multica.ai>
2026-10-12 00:54:27 +08:00
leeferandmultica-agent aaddd3dcaf HEL-665: implement approved Refract Studio across real desktop workbench
Co-authored-by: multica-agent <github@multica.ai>
2026-10-12 00:43:14 +08:00
总工 2b3bbf9d03 HEL-691: deploy S6 visual sweep (feat/hel-685-visual-sweep @ bc24370) 2026-10-11 18:41:38 +08:00
施工员andmultica-agent bc24370233 feat(style): 全站无彩灰阶收尾——清理 legacy 别名与硬编码色漂移 (HEL-685 S6)
- app.css 删除 8 个无引用 legacy 别名,新增 --id-1..5 身份灰阶实色阶
- 画布去紫罗兰二级 palette(tokens.css 删除,改用全站 token)
- 画布/镜头台/智能体/omni/视频组件硬编码色全部归位 token
- agentPalette 彩色身份色改为显影灰阶 --id 阶;修复 3 处相纸白底白字不可读
- WorkflowLibrary 试运行抽屉焦点还原(异步禁用触发键丢焦)修复
- e2e: fpages/import 色值断言对齐现行 token;新增 hel685 走查脚本
  (1440/1024/390 截图 + 控制台错误 + 减动效 + 键盘焦点 + 对比度探针);
  pagination 空库自举;fpages 增加逐页控制台错误检查

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 17:26:12 +08:00
总工 2f267a751f HEL-690: deploy S5 project flowback (feat/hel-684-project-attach @ 0841f5a) 2026-10-11 15:34:07 +08:00
施工员andmultica-agent 0841f5a296 feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 15:05:57 +08:00
271 changed files with 56743 additions and 33670 deletions
@@ -97,7 +97,7 @@ class MemoryCreate(BaseModel):
class MessageCreate(BaseModel):
content: str = ""
mentions: list[MessageMention] = Field(default_factory=list, max_length=1)
mentions: list[MessageMention] = Field(default_factory=list, max_length=5)
attachments: list[MessageAttachment] = Field(default_factory=list, max_length=8)
answer_to: int | None = None # question/asked seq this message answers (card click)
@@ -113,8 +113,10 @@ class MessageCreate(BaseModel):
@field_validator("mentions")
@classmethod
def _one_workflow(cls, v: list[MessageMention]) -> list[MessageMention]:
if len(v) > 1:
if sum(m.type == "workflow" for m in v) > 1:
raise ValueError("Only one @workflow mention per message")
if sum(m.type == "skill" for m in v) > 4:
raise ValueError("At most four skill mentions per message")
return v
@@ -1,4 +1,5 @@
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
from __future__ import annotations
import json
@@ -31,6 +32,23 @@ PLAYGROUND_TITLE = "Playground Scratch"
# page can label origins ("独立生图" vs canvas Playground) without guessing.
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
# HEL-684: a desk visit opened from a five-stage workbench row may carry
# that row as optional context. Only real, server-verified project/stage/
# source info is accepted — the source row must still exist in the stage
# content, and the desk kind must match what that stage renders.
WorkbenchStage = Literal["assets", "shot"]
WORKBENCH_KIND_BY_STAGE: dict[str, str] = {"assets": "image", "shot": "video"}
WORKBENCH_ROW_KEY_BY_STAGE: dict[str, str] = {"assets": "assets", "shot": "shots"}
class PlaygroundWorkbenchContext(BaseModel):
project_id: int
stage: WorkbenchStage
source_kind: Literal["asset", "shot"]
source_id: str = Field(min_length=1)
# Display-only echo of the DeskContext label (e.g. 镜头 S1).
label: str | None = None
class PlaygroundGenerate(BaseModel):
workflow_id: int
@@ -39,6 +57,13 @@ class PlaygroundGenerate(BaseModel):
# then the active one (resolved in queue_manager.enqueue).
endpoint_id: str | None = None
source: PlaygroundSource = "playground"
# Optional real workbench source — validated, then stamped on the job
# payload so a finished take can be flowed back (attach-workbench).
context: PlaygroundWorkbenchContext | None = None
class PlaygroundAttachWorkbench(BaseModel):
job_id: int
class PlaygroundAttach(BaseModel):
@@ -83,6 +108,72 @@ def ensure_playground_project(conn) -> int:
return int(cur.lastrowid)
# ─────────────────────────────────────────────────────────────────────────
# HEL-684: workbench source context — validate against real rows, never
# guess. Both generate-time (record on the job payload) and attach-time
# (flow a finished take back) go through the same checks.
# ─────────────────────────────────────────────────────────────────────────
def _workbench_target_row(conn, ctx: PlaygroundWorkbenchContext) -> dict[str, Any] | None:
"""The real asset/shot row this context points at, or None."""
stage = conn.execute(
"SELECT content_json FROM project_stages WHERE project_id = ? AND stage_key = ?",
(ctx.project_id, ctx.stage),
).fetchone()
if not stage or not stage["content_json"]:
return None
try:
content = json.loads(stage["content_json"])
except (json.JSONDecodeError, TypeError):
return None
rows = content.get(WORKBENCH_ROW_KEY_BY_STAGE[ctx.stage])
if not isinstance(rows, list):
return None
for row in rows:
if isinstance(row, dict) and str(row.get("id") or "") == ctx.source_id:
return row
return None
def _validate_workbench_context(conn, ctx: PlaygroundWorkbenchContext, kind: str) -> dict[str, Any]:
"""Reject anything that is not a real, current project/stage/source.
Returns the payload stamp for the job. Raises on any mismatch so no
guessed or stale source is ever recorded.
"""
expected_kind = WORKBENCH_KIND_BY_STAGE[ctx.stage]
expected_source_kind = "asset" if ctx.stage == "assets" else "shot"
if ctx.source_kind != expected_source_kind or kind != expected_kind:
raise HTTPException(
status_code=422,
detail=(
f"来源上下文与生成类型不符:「{ctx.stage}」阶段需要{expected_kind}生成。"
"可关闭来源提示后独立生成。"
),
)
project = conn.execute(
"SELECT id, status FROM projects WHERE id = ?", (ctx.project_id,)
).fetchone()
if not project:
raise HTTPException(status_code=404, detail="来源项目不存在,无法记录回流上下文")
if project["status"] == PLAYGROUND_STATUS:
raise HTTPException(status_code=400, detail="不能以 playground 隐藏项目作为回流来源")
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成)。可关闭来源提示后独立生成。"
),
)
stamp: dict[str, Any] = ctx.model_dump()
if not (stamp.get("label") or "").strip():
stamp["label"] = None
return stamp
def _assert_path_in_assets(path: str) -> Path:
assets_root = config.settings.assets_dir.resolve()
target = Path(path).resolve()
@@ -195,6 +286,17 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
# HEL-684: which takes have already been flowed back to a workbench
# row (per source project) — the desk shows an honest 已回流 state
# that survives reloads instead of guessing from client memory.
links_by_project: dict[int, dict[int, dict[str, Any]]] = {}
rows = conn.execute(
"SELECT DISTINCT project_id FROM jobs"
" WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in rows:
proj_id = int(r["project_id"])
links_by_project[proj_id] = _workbench_link_rows(conn, proj_id)
finally:
conn.close()
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
@@ -204,7 +306,16 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
for j in queue_manager.list_jobs(pid, None, limit)
if str(j.get("origin") or "user") != "workflow_test"
]
return [_job_public(j) for j in jobs]
out = []
for j in jobs:
pub = _job_public(j)
stamp = (pub.get("payload") or {}).get("workbench")
if isinstance(stamp, dict) and isinstance(stamp.get("project_id"), int):
link = links_by_project.get(int(stamp["project_id"]), {}).get(int(pub["id"]))
if link and link.get("target_id") == stamp.get("source_id"):
pub["workbench_attach"] = link
out.append(pub)
return out
@router.delete("/jobs/{job_id}")
@@ -239,6 +350,21 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_clear_entity_refs(conn, [p for p in paths if isinstance(p, str)])
# HEL-684: the output files are about to vanish — drop the workbench
# link rows created from this take so no project row points at
# missing media (mirrors the entity-ref clearing above).
removed_links: list[int] = []
link_rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE payload_json LIKE '%\"from_playground_job\"%'"
).fetchall()
for r in link_rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("from_playground_job") == job_id:
conn.execute("DELETE FROM jobs WHERE id = ?", (int(r["id"]),))
removed_links.append(int(r["id"]))
conn.commit()
finally:
conn.close()
@@ -261,6 +387,7 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
"job_id": job_id,
"deleted_files": deleted_files,
"missing_files": missing_files,
"removed_workbench_links": removed_links,
}
@@ -365,6 +492,14 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
# HEL-684: an optional workbench source is only recorded when it is
# real and current — the job itself still runs on the playground
# scratch project so the desk feed, SSE and tasks stay unchanged.
workbench_stamp = (
_validate_workbench_context(conn, payload.context, kind)
if payload.context is not None
else None
)
finally:
conn.close()
@@ -385,6 +520,7 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
"input_values": input_values,
"source": payload.source,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
**({"workbench": workbench_stamp} if workbench_stamp else {}),
},
)
await event_bus.publish(
@@ -495,3 +631,167 @@ async def attach_to_project(payload: PlaygroundAttach) -> dict[str, Any]:
return out
finally:
conn.close()
def _workbench_link_rows(conn, project_id: int) -> dict[int, dict[str, Any]]:
"""playground job id → its flow-back link row on this project.
Link rows are done jobs with payload.source='workbench' and a
from_playground_job stamp — the exact shape _workbench_job_outputs
already enriches the workbench rows from, so a flowed-back take shows
up on its source row through the existing display path.
"""
out: dict[int, dict[str, Any]] = {}
rows = conn.execute(
"SELECT id, payload_json FROM jobs WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
for r in rows:
try:
p = json.loads(r["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
if p.get("source") != "workbench":
continue
src = p.get("from_playground_job")
if isinstance(src, int):
out[src] = {
"job_id": int(r["id"]),
"stage": p.get("stage_key"),
"target_id": p.get("target_id"),
}
return out
@router.post("/attach-workbench")
async def attach_workbench(payload: PlaygroundAttachWorkbench) -> dict[str, Any]:
"""Flow a finished desk take back to its workbench source row (HEL-684).
The source is whatever real context the generate request carried —
nothing is guessed here. Re-attaching is idempotent: the existing link
row is returned, no duplicate is created, nothing is overwritten.
"""
conn = get_db(config.settings.db_path)
try:
pid = ensure_playground_project(conn)
job = queue_manager.get_job(payload.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if int(job["project_id"]) != int(pid):
raise HTTPException(status_code=403, detail="Not a playground job")
try:
job_payload = json.loads(job.get("payload_json") or "{}")
except (json.JSONDecodeError, TypeError):
job_payload = {}
stamp = job_payload.get("workbench")
if not isinstance(stamp, dict) or not stamp.get("source_id"):
raise HTTPException(
status_code=400, detail="该记录没有项目来源上下文,无法回流(独立生成不回流)"
)
if job["status"] != "done":
raise HTTPException(status_code=400, detail="生成完成后才能回流")
outputs = json.loads(job.get("output_paths_json") or "[]")
if not isinstance(outputs, list) or not outputs:
raise HTTPException(status_code=400, detail="该记录没有产物文件,无法回流")
try:
ctx = PlaygroundWorkbenchContext(
project_id=int(stamp["project_id"]),
stage=stamp.get("stage"),
source_kind=stamp.get("source_kind"),
source_id=str(stamp["source_id"]),
label=stamp.get("label"),
)
except Exception as exc: # pydantic ValidationError / missing keys
raise HTTPException(
status_code=400, detail="该记录的项目来源上下文无效,无法回流"
) from exc
# Re-validate against the live stage content — the row may have been
# regenerated since the take was created; refuse rather than mis-write.
if _workbench_target_row(conn, ctx) is None:
raise HTTPException(
status_code=409,
detail=(
"来源"
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
"(阶段内容可能已重新生成),未回流。"
),
)
links = _workbench_link_rows(conn, ctx.project_id)
existing = links.get(int(job["id"]))
if existing and existing.get("target_id") == ctx.source_id:
return {
"ok": True,
"already_attached": True,
"job_id": int(job["id"]),
"attached_job_id": existing["job_id"],
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
link_payload = {
"source": "workbench",
"stage_key": ctx.stage,
"target_id": ctx.source_id,
"from_playground_job": int(job["id"]),
"input_values": job_payload.get("input_values") or {},
}
if ctx.label:
link_payload["label"] = ctx.label
cur = conn.execute(
"""
INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,
output_paths_json, created_at, started_at, completed_at, origin)
VALUES (?, ?, ?, 'done', ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'user')
""",
(
ctx.project_id,
job["kind"],
job["workflow_id"],
json.dumps(link_payload, ensure_ascii=False),
json.dumps([str(p) for p in outputs if isinstance(p, str)]),
),
)
conn.execute(
"UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(ctx.project_id,),
)
conn.commit()
link_id = int(cur.lastrowid)
finally:
conn.close()
await event_bus.publish(
"job.completed",
{
"job_id": link_id,
"kind": job["kind"],
"outputs": outputs,
"project_id": ctx.project_id,
"source": "workbench",
"message": "生成台结果已回流到项目",
},
)
await event_bus.publish(
"asset.ready",
{
"job_id": link_id,
"path": outputs[0] if isinstance(outputs[0], str) else str(outputs[0]),
"project_id": ctx.project_id,
"target": "workbench",
"source": "playground_attach",
},
)
return {
"ok": True,
"already_attached": False,
"job_id": int(job["id"]),
"attached_job_id": link_id,
"project_id": ctx.project_id,
"stage": ctx.stage,
"target_id": ctx.source_id,
}
@@ -45,6 +45,10 @@ class StagePatch(BaseModel):
chosen_style_id: str | None = None
class StageNotePatch(BaseModel):
text: str = Field(max_length=200_000)
class StageRunRequest(BaseModel):
instruction: str | None = Field(default=None, max_length=8000)
@@ -292,10 +296,12 @@ def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
content = json.loads(row["content_json"]) if row["content_json"] else None
return {
"stage_key": row["stage_key"],
"status": row["status"],
"content": json.loads(row["content_json"]) if row["content_json"] else None,
"content": content,
"assistant_note": (content or {}).get("assistant_note", ""),
"confirmed_content": (
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
),
@@ -571,6 +577,10 @@ async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction
conn = get_db(db)
try:
latest_stage = _load_stage(conn, project_id, stage_key)
previous = json.loads(latest_stage["content_json"]) if latest_stage["content_json"] else {}
if previous.get("assistant_note"):
content["assistant_note"] = previous["assistant_note"]
conn.execute(
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
"WHERE id = ?",
@@ -681,6 +691,9 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
content = NORMALIZERS[stage_key](payload.content)
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
if old_content.get("assistant_note"):
content["assistant_note"] = old_content["assistant_note"]
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
# Editing a confirmed stage produces a new draft awaiting re-confirm;
@@ -715,6 +728,32 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
conn.close()
@router.patch("/projects/{project_id}/stages/{stage_key}/note")
async def patch_stage_note(project_id: int, stage_key: str, payload: StageNotePatch) -> dict[str, Any]:
"""Keep free-text advice separate from validated stage artifacts and snapshots."""
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再保存建议。")
content = json.loads(stage["content_json"]) if stage["content_json"] else {}
if payload.text.strip():
content["assistant_note"] = payload.text.strip()
else:
content.pop("assistant_note", None)
conn.execute(
"UPDATE project_stages SET content_json=?, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id=? AND stage_key=?",
(json.dumps(content, ensure_ascii=False) if content else None, project_id, stage_key),
)
conn.execute("UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (project_id,))
conn.commit()
return {"ok": True, "stage": _stage_public(_load_stage(conn, project_id, stage_key))}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/{stage_key}/run")
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
instruction = (payload.instruction if payload else None) or None
@@ -777,7 +816,10 @@ async def confirm_stage(
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
if not stage["content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
content = json.loads(stage["content_json"])
try:
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
chosen = payload.chosen_style_id if payload else None
if stage_key == "style":
directions = content.get("directions") or []
@@ -815,11 +857,15 @@ async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
stage = _stage_or_404(conn, project_id, stage_key)
if not stage["confirmed_content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
content = json.loads(stage["confirmed_content_json"])
current = json.loads(stage["content_json"]) if stage["content_json"] else {}
if current.get("assistant_note"):
content["assistant_note"] = current["assistant_note"]
conn.execute(
"UPDATE project_stages SET content_json=confirmed_content_json, "
"UPDATE project_stages SET content_json=?, "
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
@@ -0,0 +1,36 @@
"""Workflow uniqueness is independent of the selected task skills."""
import pytest
from pydantic import ValidationError
from calliope.routers.agent import MessageCreate
def workflow(n=1):
return {"type": "workflow", "id": n, "name": f"workflow-{n}", "kind": "image"}
def skill(n=1):
return {"type": "skill", "name": f"skill-{n}"}
def test_workflow_and_skills_can_be_sent_together():
message = MessageCreate(content="Create an image", mentions=[workflow(), *[skill(i) for i in range(4)]])
assert len(message.mentions) == 5
assert message.mentions[0].id == 1
def test_legacy_single_workflow_is_accepted():
assert MessageCreate(mentions=[workflow()]).mentions[0].type == "workflow"
def test_skills_without_workflow_are_accepted():
assert len(MessageCreate(mentions=[skill(i) for i in range(4)]).mentions) == 4
def test_second_workflow_is_still_rejected():
with pytest.raises(ValidationError, match="Only one @workflow"):
MessageCreate(mentions=[workflow(), skill(), workflow(2)])
def test_fifth_skill_is_rejected():
with pytest.raises(ValidationError, match="At most four skill"):
MessageCreate(mentions=[skill(i) for i in range(5)])
@@ -0,0 +1,350 @@
"""HEL-684 — playground takes flow back to their workbench source row.
Covers the acceptance list: real-context-only passthrough (no guessing,
stale rows rejected), explicit flow-back with idempotency (repeated
operations create no duplicates), cancelled/independent paths never
writing the project, and playground job deletion clearing the link.
"""
from __future__ import annotations
import tempfile
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from calliope.db import migrate_db
from calliope.main import create_app
SAMPLE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveImage",
"inputs": {"images": ["1", 0]},
"_meta": {"title": "Save (Output:image)"},
},
}
SAMPLE_VIDEO_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "hello"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "SaveVideo",
"inputs": {"video": ["1", 0]},
"_meta": {"title": "Save (Output:video)"},
},
}
@pytest.fixture
def client(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
import asyncio
import calliope.config as config_module
s = config_module.settings
prev = {
"data_dir": s.data_dir,
"assets_dir": s.assets_dir,
"dry_run": s.dry_run,
}
s.data_dir = Path(tmpdir)
s.assets_dir = Path(tmpdir) / "assets"
s.assets_dir.mkdir(parents=True, exist_ok=True)
s.dry_run = True
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
asyncio.run(migrate_db(s.db_path))
app = create_app()
try:
with TestClient(app) as c:
yield c
finally:
for k, v in prev.items():
setattr(s, k, v)
def _wait_done(client: TestClient, job_id: int) -> dict:
for _ in range(60):
jobs = client.get("/api/playground/jobs").json()
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
if done and done.get("output_paths"):
return done
time.sleep(0.25)
raise AssertionError("playground job did not finish in dry-run")
def _seed_assets_stage(client: TestClient) -> dict:
proj = client.post("/api/projects", json={"title": "Reel"}).json()
# Real stage rows the way the workbench itself creates them.
patch = client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-1",
"kind": "character",
"name": "林夏",
"description": "",
"prompt": "a stoic courier",
"negative_prompt": "",
}
]
}
},
)
assert patch.status_code == 200, patch.text
return proj
def _context(proj: dict, **over: object) -> dict:
base = {
"project_id": proj["id"],
"stage": "assets",
"source_kind": "asset",
"source_id": "asset-1",
"label": "角色「林夏」",
}
base.update(over)
return base
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
r = client.post(
"/api/workflows",
json={
"name": name,
"kind": "video" if "SaveVideo" in str(workflow) else "image",
"workflow_json": workflow,
},
)
assert r.status_code == 200, r.text
return r.json()["id"]
# ── context passthrough ────────────────────────────────────────────────
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {"1": "a cat"},
"source": "studio-image",
"context": _context(proj),
},
)
assert r.status_code == 200, r.text
job = r.json()["job"]
assert job["payload"]["workbench"]["project_id"] == proj["id"]
assert job["payload"]["workbench"]["source_id"] == "asset-1"
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
# The job itself rides the hidden playground project — independent
# desk behavior (feed, SSE, tasks) is untouched.
scratch = client.get("/api/playground/project").json()
assert job["project_id"] == scratch["id"]
def test_generate_without_context_writes_nothing_to_any_project(client):
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
_seed_assets_stage(client)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
)
assert r.status_code == 200
assert "workbench" not in r.json()["job"]["payload"]
def test_generate_rejects_fake_or_stale_context(client):
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
# Unknown project
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
)
assert r.status_code == 404
# Source row that does not exist in the stage content
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
)
assert r.status_code == 409
# Kind mismatch: assets stage wants an image workflow
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={"workflow_id": video_wf, "context": _context(proj)},
)
assert r.status_code == 422
# The playground scratch project is not a valid source
scratch = client.get("/api/playground/project").json()
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
)
assert r.status_code == 400
# ── flow back ──────────────────────────────────────────────────────────
def test_attach_workbench_links_take_to_source_row(client):
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
# Not done yet → refused, nothing written.
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert early.status_code == 400
done = _wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 200, r.text
body = r.json()
assert body["already_attached"] is False
assert body["project_id"] == proj["id"]
assert body["target_id"] == "asset-1"
# The workbench overview now shows the artifact on the source row,
# through the exact enrichment path in-flow generation uses.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] == body["attached_job_id"]
assert row["job"]["status"] == "done"
assert row["job"]["output"] == done["output_paths"][0]
# Repeated flow-back: idempotent, no duplicate row.
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert again.status_code == 200
assert again.json()["already_attached"] is True
assert again.json()["attached_job_id"] == body["attached_job_id"]
# The desk jobs list reports the honest 已回流 marker.
jobs = client.get("/api/playground/jobs").json()
listed = next(j for j in jobs if j["id"] == job["id"])
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
def test_attach_workbench_requires_real_context(client):
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
).json()["job"]
_wait_done(client, job["id"])
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 400
assert "独立" in r.json()["detail"]
def test_attach_workbench_refuses_regenerated_source(client):
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
# Stage content regenerated: the old row id is gone.
client.patch(
f"/api/workbench/projects/{proj['id']}/stages/assets",
json={
"content": {
"assets": [
{
"id": "asset-2",
"kind": "prop",
"name": "Blaster",
"description": "",
"prompt": "a blaster",
"negative_prompt": "",
}
]
}
},
)
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
assert r.status_code == 409
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
assert all("job" not in a for a in assets_stage["content"]["assets"])
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
"""A later in-flow generation for the same row wins the display, as
the enrichment keeps the newest workbench job per target."""
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
_wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
flow = client.post(
f"/api/workbench/projects/{proj['id']}/generate",
json={
"kind": "image",
"stage_key": "assets",
"target_id": "asset-1",
"prompt": "in-flow take",
},
)
assert flow.status_code == 200, flow.text
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert row["job"]["id"] != link["attached_job_id"]
def test_delete_playground_job_removes_workbench_link(client):
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
proj = _seed_assets_stage(client)
job = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
).json()["job"]
done = _wait_done(client, job["id"])
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
dead = client.delete(f"/api/playground/jobs/{job['id']}")
assert dead.status_code == 200
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
# The link row is gone: the project row no longer points at deleted files.
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
assert not Path(done["output_paths"][0]).exists()
+33
View File
@@ -197,6 +197,39 @@ def _confirm(client, pid: int, key: str, **extra):
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
def test_assistant_notes_keep_confirmation_and_survive_artifact_edits(client):
pid = _make_project(client)
note_url = f"/api/workbench/projects/{pid}/stages/style/note"
r = client.patch(note_url, json={"text": "建议先比较冷暖两个方向。"})
assert r.status_code == 200
assert r.json()["stage"]["status"] == "todo"
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
assert _confirm(client, pid, "style").status_code == 400
# An ordinary structured save must keep the separate advice.
r = client.patch(f"/api/workbench/projects/{pid}/stages/style", json={"content": json.loads(STYLE_REPLY)})
assert r.status_code == 200
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
# Notes on confirmed content don't change its status or confirmed snapshot.
client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
before = _stage(_overview(client, pid), "script")
r = client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "下一版建议加强人物动机。"})
assert r.status_code == 200
after = r.json()["stage"]
assert after["status"] == before["status"] == "confirmed"
assert after["confirmed_content"] == before["confirmed_content"]
assert after["draft_edited"] == before["draft_edited"]
assert _confirm(client, pid, "script").status_code == 200
confirmed = _stage(_overview(client, pid), "script")
assert "assistant_note" not in confirmed["confirmed_content"]
client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "保留确认后的新建议。"})
restored = client.post(f"/api/workbench/projects/{pid}/stages/script/restore").json()["stage"]
assert restored["assistant_note"] == "保留确认后的新建议。"
assert restored["confirmed_content"] == confirmed["confirmed_content"]
assert client.patch(note_url, json={"text": ""}).json()["stage"]["assistant_note"] == ""
assert client.patch(note_url, json={"text": "x" * 200_001}).status_code == 422
assert client.patch(f"/api/workbench/projects/{pid}/stages/unknown/note", json={"text": "x"}).status_code == 404
# ─────────────────────────────────────────────────────────────────────────
+49
View File
@@ -0,0 +1,49 @@
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import svelte from "eslint-plugin-svelte";
export default [
// SvelteKit sync and adapter-static output; these are not source files.
{ ignores: [".svelte-kit/**", "build/**"] },
js.configs.recommended,
...svelte.configs["flat/recommended"],
// v3 removed these from recommended; retain the previous checks explicitly.
{
rules: {
"svelte/valid-compile": "error",
"svelte/no-dynamic-slot-name": "error",
},
},
{
files: ["**/*.{ts,svelte}"],
plugins: { "@typescript-eslint": tsPlugin },
rules: {
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
...tsPlugin.configs.recommended.rules,
},
},
{
files: ["**/*.ts"],
ignores: ["**/*.svelte.ts"],
languageOptions: { parser: tsParser },
},
{
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { parserOptions: { parser: tsParser } },
rules: {
"prefer-const": "off",
"svelte/prefer-const": "error",
},
},
{
files: ["src/**/*.{js,ts,svelte}"],
ignores: ["src/hooks.server.ts"],
languageOptions: { globals: globals.browser },
},
{
files: ["*.{js,ts}", "scripts/**/*.mjs", "src/hooks.server.ts"],
languageOptions: { globals: globals.node },
},
];
+4511 -4256
View File
File diff suppressed because it is too large Load Diff
+39 -37
View File
@@ -1,39 +1,41 @@
{
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "^2.46.0",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
"name": "calliope-web",
"version": "1.5.8",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.13.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.20.0",
"eslint-plugin-svelte": "3.23.1",
"postcss": "^8.5.0",
"prettier": "^3.5.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.20.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.0",
"vite": "^6.1.0"
},
"dependencies": {
"@tanstack/svelte-query": "^5.66.0",
"@types/three": "0.185.0",
"@xyflow/svelte": "^1.6.5",
"mannequin-js": "5.2.3",
"three": "0.185.1"
},
"type": "module"
}
+105 -94
View File
@@ -16,51 +16,53 @@
*
* Run with `npm test` (or `node scripts/check-storage-ssr.mjs`).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFileSync, readdirSync, statSync } 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', 'storage.ts');
const ROOT = join(HERE, "..");
const SRC = join(ROOT, "src");
const HELPER = join(SRC, "lib", "storage.ts");
let failures = 0;
function fail(message) {
failures += 1;
console.error(`FAIL ${message}`);
failures += 1;
console.error(`FAIL ${message}`);
}
function ok(message) {
console.log(`ok ${message}`);
console.log(`ok ${message}`);
}
/** Replace the global under test. Node's own binding may be getter-only. */
function setGlobalStorage(value) {
Object.defineProperty(globalThis, 'localStorage', {
value,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value,
configurable: true,
writable: true,
});
}
/** A Storage that behaves like a browser's. */
function fakeStorage(initial = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => void map.set(k, String(v)),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HELPER).href);
const { storageGet, storageSet, storageRemove } = await import(
pathToFileURL(HELPER).href
);
// ---- 1. behavioural ----------------------------------------------------
@@ -68,124 +70,133 @@ const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HEL
// This is the configuration that produced the original crash.
setGlobalStorage({});
try {
if (storageGet('calliope-lang') !== null) fail('method-less localStorage: expected null');
else if (storageSet('calliope-lang', 'ja') !== undefined) fail('method-less localStorage: setItem should no-op');
else {
storageRemove('calliope-lang');
ok('method-less localStorage (Node SSR shape) degrades to null without throwing');
}
if (storageGet("calliope-lang") !== null)
fail("method-less localStorage: expected null");
else if (storageSet("calliope-lang", "ja") !== undefined)
fail("method-less localStorage: setItem should no-op");
else {
storageRemove("calliope-lang");
ok(
"method-less localStorage (Node SSR shape) degrades to null without throwing",
);
}
} catch (err) {
fail(`method-less localStorage threw: ${err.message}`);
fail(`method-less localStorage threw: ${err.message}`);
}
// Absent entirely (the common server case).
setGlobalStorage(undefined);
try {
if (storageGet('k') !== null) fail('undefined localStorage: expected null');
else ok('absent localStorage degrades to null');
if (storageGet("k") !== null) fail("undefined localStorage: expected null");
else ok("absent localStorage degrades to null");
} catch (err) {
fail(`undefined localStorage threw: ${err.message}`);
fail(`undefined localStorage threw: ${err.message}`);
}
// Access itself can throw (sandboxed frame / blocked third-party storage).
setGlobalStorage(
new Proxy(
{},
{
get() {
throw new Error('SecurityError');
},
},
),
new Proxy(
{},
{
get() {
throw new Error("SecurityError");
},
},
),
);
try {
if (storageGet('k') !== null) fail('throwing localStorage: expected null');
else ok('throwing localStorage access degrades to null');
if (storageGet("k") !== null) fail("throwing localStorage: expected null");
else ok("throwing localStorage access degrades to null");
} catch (err) {
fail(`throwing localStorage access threw: ${err.message}`);
fail(`throwing localStorage access threw: ${err.message}`);
}
// Quota / private-mode errors on write must not escape.
setGlobalStorage({
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError');
},
removeItem: () => {},
getItem: () => null,
setItem: () => {
throw new Error("QuotaExceededError");
},
removeItem: () => {},
});
try {
storageSet('k', 'v');
ok('setItem failure is swallowed');
storageSet("k", "v");
ok("setItem failure is swallowed");
} catch (err) {
fail(`setItem failure escaped: ${err.message}`);
fail(`setItem failure escaped: ${err.message}`);
}
// A real browser Storage must still work — guards against a helper that just
// returns null unconditionally to satisfy the checks above.
setGlobalStorage(fakeStorage());
try {
storageSet('calliope-lang', 'ko');
const read = storageGet('calliope-lang');
if (read !== 'ko') fail(`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`);
else {
storageRemove('calliope-lang');
if (storageGet('calliope-lang') !== null) fail('working storage remove: expected null');
else ok('working storage round-trips and removes');
}
storageSet("calliope-lang", "ko");
const read = storageGet("calliope-lang");
if (read !== "ko")
fail(
`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`,
);
else {
storageRemove("calliope-lang");
if (storageGet("calliope-lang") !== null)
fail("working storage remove: expected null");
else ok("working storage round-trips and removes");
}
} catch (err) {
fail(`working storage threw: ${err.message}`);
fail(`working storage threw: ${err.message}`);
}
// ---- 2. static ---------------------------------------------------------
const UNSAFE = /typeof\s+localStorage/;
const EXTENSIONS = ['.ts', '.js', '.svelte'];
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 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;
}
function exists(p) {
try {
return statSync(p).isFile();
} catch {
return false;
}
try {
return statSync(p).isFile();
} catch {
return false;
}
}
const offenders = [];
for (const file of walk(SRC)) {
if (file === HELPER) continue; // the one place allowed to probe the global
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 (file === HELPER) continue; // the one place allowed to probe the global
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(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join('\n ')}`,
);
fail(
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join("\n ")}`,
);
} else {
ok('no unsafe `typeof localStorage` guards in src/');
ok("no unsafe `typeof localStorage` guards in src/");
}
if (!exists(HELPER)) fail('src/lib/storage.ts is missing');
if (!exists(HELPER)) fail("src/lib/storage.ts is missing");
// ---- result ------------------------------------------------------------
if (failures) {
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
console.error(`\n${failures} storage/SSR check(s) failed.`);
process.exit(1);
}
console.log('\nstorage SSR checks passed.');
console.log("\nstorage SSR checks passed.");
+29 -18
View File
@@ -1,19 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<script>
(function () {
var saved;
try {
saved = localStorage.getItem("aigc-theme");
} catch (_) {}
document.documentElement.dataset.theme =
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
})();
</script>
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+6 -6
View File
@@ -1,9 +1,9 @@
import type { HandleClientError } from '@sveltejs/kit';
import { t } from '$lib/i18n.svelte';
import type { HandleClientError } from "@sveltejs/kit";
import { t } from "$lib/i18n.svelte";
export const handleError: HandleClientError = ({ error }) => {
console.error(error);
return {
message: t('global.somethingWentWrong'),
};
console.error(error);
return {
message: t("global.somethingWentWrong"),
};
};
+2 -2
View File
@@ -1,4 +1,4 @@
export async function handle({ event, resolve }) {
const response = await resolve(event);
return response;
const response = await resolve(event);
return response;
}
+17
View File
@@ -0,0 +1,17 @@
import type { Action } from "svelte/action";
/** A local honeycomb highlight follows the pointer, independent of selected state. */
export const spotlight: Action<HTMLElement> = (node) => {
const move = (event: PointerEvent) => {
if (
document.documentElement.dataset.reduce === "1" ||
event.pointerType === "touch"
)
return;
const rect = node.getBoundingClientRect();
node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
};
node.addEventListener("pointermove", move);
return { destroy: () => node.removeEventListener("pointermove", move) };
};
+51 -42
View File
@@ -2,50 +2,50 @@
* Shared types and helpers for the Agent composer (@workflow mentions + attachments).
*/
export type WorkflowKind = 'image' | 'video';
export type AttachmentKind = 'image' | 'video' | 'audio' | 'document';
export type WorkflowKind = "image" | "video";
export type AttachmentKind = "image" | "video" | "audio" | "document";
export interface WorkflowMention {
type: 'workflow';
id: number;
name: string;
kind: WorkflowKind;
type: "workflow";
id: number;
name: string;
kind: WorkflowKind;
}
export interface AgentAttachment {
path: string;
name: string;
kind: AttachmentKind;
path: string;
name: string;
kind: AttachmentKind;
}
export interface AgentComposerPayload {
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
content: string;
mentions: (WorkflowMention | SkillMention)[];
attachments: AgentAttachment[];
/** question/asked seq this message answers (question-card click). */
answer_to?: number;
}
export interface WorkflowOption {
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
id: number;
name: string;
kind: WorkflowKind;
description?: string | null;
is_enabled?: boolean;
}
export interface SkillOption {
name: string;
description: string;
tags: string[];
name: string;
description: string;
tags: string[];
}
/** A `/skill` command inserted as a chip. Serialized into the message's
* mentions payload so the backend can project it into the LLM context. */
export interface SkillMention {
type: 'skill';
name: string;
description: string;
type: "skill";
name: string;
description: string;
}
export const MAX_WORKFLOW_MENTIONS = 1;
@@ -53,27 +53,36 @@ export const MAX_WORKFLOW_MENTIONS = 1;
export const MENTION_LIMIT = 12;
/** Case-insensitive name filter of enabled workflows, capped for the typeahead. */
export function filterWorkflows(workflows: WorkflowOption[], query: string): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
export function filterWorkflows(
workflows: WorkflowOption[],
query: string,
): WorkflowOption[] {
const q = query.trim().toLowerCase();
const enabled = workflows.filter((w) => w.is_enabled !== false);
const matched = q
? enabled.filter((w) => w.name.toLowerCase().includes(q))
: enabled;
return matched.slice(0, MENTION_LIMIT);
}
/** Case-insensitive filter of skills for the `/` typeahead (name + description). */
export function filterSkills(skills: SkillOption[], query: string): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
export function filterSkills(
skills: SkillOption[],
query: string,
): SkillOption[] {
const q = query.trim().toLowerCase();
const matched = q
? skills.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
)
: skills;
return matched.slice(0, MENTION_LIMIT);
}
export function payloadIsEmpty(p: AgentComposerPayload): boolean {
return !p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0;
return (
!p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0
);
}
+13 -11
View File
@@ -7,28 +7,30 @@
* buttons stay dumb and the wording never drifts out of sync.
*/
export type AgentTaskKind = 'story' | 'script';
export type AgentTaskKind = "story" | "script";
/** The pre-filled composer prompt for each task. */
export const AGENT_TASK_PROMPTS: Record<AgentTaskKind, string> = {
story:
'Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.',
script:
'Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.',
story:
"Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.",
script:
"Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.",
};
/** i18n key for the auto-created session's sidebar title. */
export const AGENT_TASK_TITLE_KEYS: Record<AgentTaskKind, string> = {
story: 'agent.task.story',
script: 'agent.task.script',
story: "agent.task.story",
script: "agent.task.script",
};
export function isAgentTaskKind(value: string | null | undefined): value is AgentTaskKind {
return value === 'story' || value === 'script';
export function isAgentTaskKind(
value: string | null | undefined,
): value is AgentTaskKind {
return value === "story" || value === "script";
}
/** Build the /agents deep-link URL for a project-scoped task. */
export function agentDeepLink(projectId: number, task: AgentTaskKind): string {
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
const params = new URLSearchParams({ project: String(projectId), task });
return `/agents?${params.toString()}`;
}
+32
View File
@@ -696,6 +696,15 @@ export const playgroundApi = {
endpoint_id?: string | null;
/** Origin recorded on the job payload (tasks page labels). */
source?: "playground" | "studio-image" | "studio-video";
/** Optional real workbench source (HEL-684) — validated server-side and
* stamped on the job so finished takes can flow back to their row. */
context?: {
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
};
}) =>
api<{ ok: boolean; job: Job }>("/api/playground/generate", {
method: "POST",
@@ -713,7 +722,24 @@ export const playgroundApi = {
job_id: number;
deleted_files: string[];
missing_files: string[];
removed_workbench_links?: number[];
}>(`/api/playground/jobs/${jobId}`, { method: "DELETE" }),
/** Flow a finished take back to the workbench row it came from (HEL-684).
* The server derives the target from the job's own recorded context —
* idempotent, never overwrites, refuses stale source rows. */
attachWorkbench: (jobId: number) =>
api<{
ok: boolean;
already_attached: boolean;
job_id: number;
attached_job_id: number;
project_id: number;
stage: "assets" | "shot";
target_id: string;
}>("/api/playground/attach-workbench", {
method: "POST",
body: JSON.stringify({ job_id: jobId }),
}),
attach: (payload: {
path: string;
project_id: number;
@@ -808,6 +834,7 @@ export interface VideoShot {
}
export interface WorkbenchStage {
assistant_note?: string;
stage_key: WorkbenchStageKey;
status: WorkbenchStageStatus;
content: Record<string, unknown> | null;
@@ -831,6 +858,11 @@ export interface WorkbenchOverview {
}
export const workbenchApi = {
saveNote: (projectId: number, stageKey: WorkbenchStageKey, text: string) =>
api<{ ok: boolean; stage: WorkbenchStage }>(
`/api/workbench/projects/${projectId}/stages/${stageKey}/note`,
{ method: "PATCH", body: JSON.stringify({ text }) },
),
overview: (projectId: number) =>
api<WorkbenchOverview>(`/api/workbench/projects/${projectId}`),
patchStage: (
+41 -31
View File
@@ -1,50 +1,60 @@
/** Shared shape for Omni / video ref pickers. */
export type AssetGroup = 'character' | 'location' | 'item' | 'upload' | 'clip' | 'shot';
export type AssetGroup =
"character" | "location" | "item" | "upload" | "clip" | "shot";
export interface AssetOption {
label: string;
path: string;
kind?: 'image' | 'video' | 'audio';
group?: AssetGroup;
label: string;
path: string;
kind?: "image" | "video" | "audio";
group?: AssetGroup;
}
const GROUP_FROM_SUFFIX: Array<{ re: RegExp; group: AssetGroup }> = [
{ re: /\s·\s*sheet$/i, group: 'character' },
{ re: /\s·\s*environment$/i, group: 'location' },
{ re: /\s·\s*item$/i, group: 'item' },
{ re: /\s·\s*upload$/i, group: 'upload' },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: 'shot' },
{ re: /^clip\s*#/i, group: 'clip' },
{ re: /\s·\s*sheet$/i, group: "character" },
{ re: /\s·\s*environment$/i, group: "location" },
{ re: /\s·\s*item$/i, group: "item" },
{ re: /\s·\s*upload$/i, group: "upload" },
{ re: /\s·\s*blockout(\s+clip)?$/i, group: "shot" },
{ re: /^clip\s*#/i, group: "clip" },
];
export function assetGroup(opt: AssetOption): AssetGroup {
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return 'upload';
if (opt.group) return opt.group;
for (const { re, group } of GROUP_FROM_SUFFIX) {
if (re.test(opt.label)) return group;
}
return "upload";
}
/** Strip the " · sheet" style suffix so the tab already names the type. */
export function assetDisplayName(opt: AssetOption): string {
return opt.label.replace(/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i, '').trim() || opt.label;
return (
opt.label
.replace(
/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i,
"",
)
.trim() || opt.label
);
}
export const ASSET_GROUP_TABS: Array<{ id: AssetGroup; label: string }> = [
{ id: 'character', label: 'Characters' },
{ id: 'location', label: 'Environments' },
{ id: 'item', label: 'Misc. Items' },
{ id: 'shot', label: 'From Build Scene' },
{ id: 'clip', label: 'Clips' },
{ id: 'upload', label: 'Uploads' },
{ id: "character", label: "Characters" },
{ id: "location", label: "Environments" },
{ id: "item", label: "Misc. Items" },
{ id: "shot", label: "From Build Scene" },
{ id: "clip", label: "Clips" },
{ id: "upload", label: "Uploads" },
];
export function tabsForMediaKind(kind: string | undefined): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === 'video'
? ['clip', 'upload']
: kind === 'audio'
? ['upload']
: ['character', 'location', 'item', 'shot', 'upload'];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
export function tabsForMediaKind(
kind: string | undefined,
): Array<{ id: AssetGroup; label: string }> {
const ids: AssetGroup[] =
kind === "video"
? ["clip", "upload"]
: kind === "audio"
? ["upload"]
: ["character", "location", "item", "shot", "upload"];
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
}
+11 -14
View File
@@ -47,14 +47,12 @@
</header>
<div class="media">
{#if videoSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
onclick={openPreview}
title={t('canvas.openPreview')}
>
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
@@ -64,7 +62,6 @@
></video>
</button>
{:else if mediaSrc}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<button
type="button"
class="media-btn"
@@ -98,8 +95,8 @@
position: relative;
}
.artifact-node.running {
border-color: rgba(125, 211, 252, 0.6);
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
border-color: var(--run);
box-shadow: 0 0 18px var(--run-soft);
}
:global(.svelte-flow__node.selected) .artifact-node {
border-color: var(--accent);
@@ -117,9 +114,9 @@
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #bae6fd;
background: rgba(12, 74, 110, 0.55);
border: 1px solid rgba(125, 211, 252, 0.35);
color: var(--text-2);
background: var(--bg-4);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 6px;
flex-shrink: 0;
@@ -141,12 +138,12 @@
flex-shrink: 0;
}
.running-badge {
color: #bae6fd;
background: rgba(12, 74, 110, 0.6);
color: var(--run);
background: var(--run-soft);
}
.failed-badge {
color: #fecaca;
background: rgba(127, 29, 29, 0.5);
color: var(--err);
background: var(--err-soft);
}
.play-badge {
font-size: 9px;
@@ -205,9 +202,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
@@ -76,7 +76,6 @@
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
{:else if videoSrc}
<!-- No poster available: #t=0.1 first-frame thumbnail. -->
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="media-el"
src={videoSrc}
@@ -108,7 +107,7 @@
transition: border-color 0.15s;
}
.entity-node:hover {
border-color: #3a3a44;
border-color: var(--line-2);
}
/* Svelte Flow marks the selected node wrapper with .selected */
:global(.svelte-flow__node.selected) .entity-node {
@@ -176,9 +175,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
-13
View File
@@ -1,13 +0,0 @@
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
The global --accent stays untouched for v1 pages. */
.canvas-root {
--accent: #4f46a5;
--accent-hover: #6157c9;
--accent-glow: rgba(99, 102, 241, 0.3);
--edge: #7dd3fc;
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
--panel: #17171f;
--panel-border: #2a2a35;
--fg: #f4f4f5;
--fg-dim: #a1a1aa;
}
+102 -96
View File
@@ -12,24 +12,25 @@
* control → compact pills (resolution, duration, seed)
* advanced → hidden inside an "Advanced" popover
*/
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
export type InputZone = 'composer' | 'media' | 'control' | 'advanced' | 'hidden';
export type InputZone =
"composer" | "media" | "control" | "advanced" | "hidden";
export type OmniWidget =
| 'promptTextarea'
| 'negativeTextarea'
| 'uploadTile'
| 'assetTile'
| 'resolutionPill'
| 'numberPill'
| 'textPill';
| "promptTextarea"
| "negativeTextarea"
| "uploadTile"
| "assetTile"
| "resolutionPill"
| "numberPill"
| "textPill";
export interface ClassifiedInput {
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
input: ComfyDynamicInput;
zone: InputZone;
widget: OmniWidget;
}
/**
@@ -39,49 +40,49 @@ export interface ClassifiedInput {
* zone. Unknown roles fall to `advanced` so no field is ever hidden or lost.
*/
export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
const role = normalizeInputRole(inp.role);
const role = normalizeInputRole(inp.role);
// --- Composer ---
if (role === 'prompt' || role === 'positive') {
return { input: inp, zone: 'composer', widget: 'promptTextarea' };
}
if (role === 'negative') {
return { input: inp, zone: 'composer', widget: 'negativeTextarea' };
}
// --- Composer ---
if (role === "prompt" || role === "positive") {
return { input: inp, zone: "composer", widget: "promptTextarea" };
}
if (role === "negative") {
return { input: inp, zone: "composer", widget: "negativeTextarea" };
}
// --- Media tray ---
if (
role === 'character' ||
role === 'location' ||
role === 'image' ||
role === 'video' ||
role === 'audio'
) {
const isMediaKind =
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'audio' ||
inp.kind === 'video';
return {
input: inp,
zone: 'media',
widget: isMediaKind ? 'uploadTile' : 'assetTile',
};
}
// --- Media tray ---
if (
role === "character" ||
role === "location" ||
role === "image" ||
role === "video" ||
role === "audio"
) {
const isMediaKind =
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "audio" ||
inp.kind === "video";
return {
input: inp,
zone: "media",
widget: isMediaKind ? "uploadTile" : "assetTile",
};
}
// --- Control bar ---
if (role === 'width' || role === 'height') {
return { input: inp, zone: 'control', widget: 'resolutionPill' };
}
if (role === 'duration' || role === 'seed') {
return { input: inp, zone: 'control', widget: 'numberPill' };
}
// --- Control bar ---
if (role === "width" || role === "height") {
return { input: inp, zone: "control", widget: "resolutionPill" };
}
if (role === "duration" || role === "seed") {
return { input: inp, zone: "control", widget: "numberPill" };
}
// --- Advanced popover ---
if (inp.kind === 'number') {
return { input: inp, zone: 'advanced', widget: 'numberPill' };
}
return { input: inp, zone: 'advanced', widget: 'textPill' };
// --- Advanced popover ---
if (inp.kind === "number") {
return { input: inp, zone: "advanced", widget: "numberPill" };
}
return { input: inp, zone: "advanced", widget: "textPill" };
}
/**
@@ -92,57 +93,62 @@ export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
* nodeIds on change.
*/
export function classifyAll(inputs: ComfyDynamicInput[]): {
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
prompt: ClassifiedInput | null;
negative: ClassifiedInput | null;
media: ClassifiedInput[];
control: ClassifiedInput[];
advanced: ClassifiedInput[];
/** Non-null when both width AND height are present → merge into one pill. */
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
} {
const classified = inputs.map(classifyInput);
const classified = inputs.map(classifyInput);
const prompt = classified.find((c) => c.widget === 'promptTextarea') ?? null;
const negative = classified.find((c) => c.widget === 'negativeTextarea') ?? null;
const media = classified.filter((c) => c.zone === 'media');
const advanced = classified.filter((c) => c.zone === 'advanced');
const prompt = classified.find((c) => c.widget === "promptTextarea") ?? null;
const negative =
classified.find((c) => c.widget === "negativeTextarea") ?? null;
const media = classified.filter((c) => c.zone === "media");
const advanced = classified.filter((c) => c.zone === "advanced");
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === 'control' && c.widget !== 'resolutionPill',
);
const resInputs = classified.filter((c) => c.widget === 'resolutionPill');
// Separate resolution from other control inputs
const controlNonRes = classified.filter(
(c) => c.zone === "control" && c.widget !== "resolutionPill",
);
const resInputs = classified.filter((c) => c.widget === "resolutionPill");
// Detect width + height pair
const wInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'width');
const hInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'height');
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// Detect width + height pair
const wInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "width",
);
const hInput = resInputs.find(
(c) => normalizeInputRole(c.input.role) === "height",
);
const resolutionPair =
wInput && hInput ? { width: wInput, height: hInput } : null;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
// If only one of width/height exists, treat it as a standalone control number pill
const standaloneRes = resolutionPair ? [] : resInputs;
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
return {
prompt,
negative,
media,
control: [...controlNonRes, ...standaloneRes],
advanced,
resolutionPair,
};
}
// ── Resolution presets ────────────────────────────────────────────────────────
export interface ResolutionPreset {
label: string;
width: number;
height: number;
label: string;
width: number;
height: number;
}
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
{ label: '720p', width: 1280, height: 720 },
{ label: '1080p', width: 1920, height: 1080 },
{ label: "720p", width: 1280, height: 720 },
{ label: "1080p", width: 1920, height: 1080 },
];
/**
@@ -150,12 +156,12 @@ export const RESOLUTION_PRESETS: ResolutionPreset[] = [
* (or return null for custom / unset values).
*/
export function resolutionLabel(
width: number | string | undefined,
height: number | string | undefined,
width: number | string | undefined,
height: number | string | undefined,
): string | null {
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
const w = Number(width);
const h = Number(height);
if (!w || !h) return null;
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
return match?.label ?? `${w}×${h}`;
}
+163 -113
View File
@@ -1,142 +1,192 @@
import type { ComfyDynamicInput, ComfyDynamicOutput, WorkflowNode } from './types';
import type {
ComfyDynamicInput,
ComfyDynamicOutput,
WorkflowNode,
} from "./types";
const TEXT_AREA = new Set([
'CLIPTextEncode',
'Note',
'ShowText',
'ImpactWildcardProcessor',
'PrimitiveStringMultiline',
'PrimitiveString',
"CLIPTextEncode",
"Note",
"ShowText",
"ImpactWildcardProcessor",
"PrimitiveStringMultiline",
"PrimitiveString",
]);
const NUMBER = new Set([
"INT",
"FLOAT",
"PrimitiveInt",
"PrimitiveFloat",
"KSampler",
"KSamplerAdvanced",
]);
const IMAGE = new Set([
"LoadImage",
"LoadImageMask",
"ImageLoader",
"ETN_LoadImageBase64",
]);
const IMAGE_URL = new Set(["Load Image From Url (mtb)"]);
const AUDIO = new Set(["LoadAudio", "VHS_LoadAudio"]);
const VIDEO = new Set(["LoadVideo", "VHS_LoadVideo", "VHS_LoadVideoPath"]);
const VIDEO_OUT = new Set([
"VHS_VideoCombine",
"SaveVideo",
"VideoOutput",
"AnimateDiffCombine",
]);
const IMAGE_OUT = new Set([
"SaveImage",
"PreviewImage",
"SaveImageWebsocket",
"ETN_SendImageWebSocket",
]);
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
const IMAGE = new Set(['LoadImage', 'LoadImageMask', 'ImageLoader', 'ETN_LoadImageBase64']);
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
const VIDEO_OUT = new Set(['VHS_VideoCombine', 'SaveVideo', 'VideoOutput', 'AnimateDiffCombine']);
const IMAGE_OUT = new Set(['SaveImage', 'PreviewImage', 'SaveImageWebsocket', 'ETN_SendImageWebSocket']);
/** Matches (Input), (Input:prompt), (Output:image), … */
const TITLE_TAG_RE = /\((Input|Output)(?::([a-zA-Z0-9_-]+))?\)/i;
const INPUT_ROLE_ALIASES: Record<string, string[]> = {
prompt: ['prompt', 'positive'],
negative: ['negative', 'neg'],
width: ['width', 'w'],
height: ['height', 'h'],
character: ['character', 'char', 'portrait', 'sheet', 'face', 'ref'],
location: ['location', 'loc', 'environment', 'env', 'background', 'scene'],
image: ['image', 'img'],
video: ['video', 'vid'],
audio: ['audio', 'sound', 'sfx'],
seed: ['seed'],
duration: ['duration', 'dur', 'length', 'seconds'],
prompt: ["prompt", "positive"],
negative: ["negative", "neg"],
width: ["width", "w"],
height: ["height", "h"],
character: ["character", "char", "portrait", "sheet", "face", "ref"],
location: ["location", "loc", "environment", "env", "background", "scene"],
image: ["image", "img"],
video: ["video", "vid"],
audio: ["audio", "sound", "sfx"],
seed: ["seed"],
duration: ["duration", "dur", "length", "seconds"],
};
const OUTPUT_ROLE_ALIASES: Record<string, string[]> = {
image: ['image', 'img'],
video: ['video', 'vid'],
image: ["image", "img"],
video: ["video", "vid"],
};
export function parseTitleTag(title: string): {
kind: 'input' | 'output' | null;
role: string | null;
label: string;
kind: "input" | "output" | null;
role: string | null;
label: string;
} {
if (!title) return { kind: null, role: null, label: '' };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as 'input' | 'output';
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, '').trim();
const label = stripped || role || kind;
return { kind, role, label };
if (!title) return { kind: null, role: null, label: "" };
const match = TITLE_TAG_RE.exec(title);
if (!match) return { kind: null, role: null, label: title.trim() };
const kind = match[1].toLowerCase() as "input" | "output";
const role = match[2] ? match[2].toLowerCase() : null;
const stripped = title.replace(TITLE_TAG_RE, "").trim();
const label = stripped || role || kind;
return { kind, role, label };
}
export function normalizeInputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeInputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
export function normalizeOutputRole(role: string | null | undefined): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
export function normalizeOutputRole(
role: string | null | undefined,
): string | null {
if (!role) return null;
const r = role.toLowerCase().trim();
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
if (r === canonical || aliases.includes(r)) return canonical;
}
return r;
}
function classToInputKind(classType: string): ComfyDynamicInput['kind'] {
if (IMAGE.has(classType)) return 'image';
if (IMAGE_URL.has(classType)) return 'image_url';
if (AUDIO.has(classType)) return 'audio';
if (VIDEO.has(classType)) return 'video';
if (NUMBER.has(classType)) return 'number';
if (TEXT_AREA.has(classType)) return 'textarea';
const lower = classType.toLowerCase();
if (lower.includes('video')) return 'video';
if (lower.includes('audio')) return 'audio';
if (lower.includes('image') || lower.includes('load')) return 'image';
if (lower.includes('int') || lower.includes('float') || lower.includes('seed')) return 'number';
if (lower.includes('text') || lower.includes('clip') || lower.includes('prompt')) return 'textarea';
return 'text';
function classToInputKind(classType: string): ComfyDynamicInput["kind"] {
if (IMAGE.has(classType)) return "image";
if (IMAGE_URL.has(classType)) return "image_url";
if (AUDIO.has(classType)) return "audio";
if (VIDEO.has(classType)) return "video";
if (NUMBER.has(classType)) return "number";
if (TEXT_AREA.has(classType)) return "textarea";
const lower = classType.toLowerCase();
if (lower.includes("video")) return "video";
if (lower.includes("audio")) return "audio";
if (lower.includes("image") || lower.includes("load")) return "image";
if (
lower.includes("int") ||
lower.includes("float") ||
lower.includes("seed")
)
return "number";
if (
lower.includes("text") ||
lower.includes("clip") ||
lower.includes("prompt")
)
return "textarea";
return "text";
}
function extractDefault(node: WorkflowNode): string | number | undefined {
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type)) return undefined;
if (typeof inputs.text === 'string') return inputs.text;
if (typeof inputs.value === 'string' || typeof inputs.value === 'number') return inputs.value;
if (typeof inputs.int === 'number') return inputs.int;
if (typeof inputs.float === 'number') return inputs.float;
return undefined;
const { inputs, class_type } = node;
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type))
return undefined;
if (typeof inputs.text === "string") return inputs.text;
if (typeof inputs.value === "string" || typeof inputs.value === "number")
return inputs.value;
if (typeof inputs.int === "number") return inputs.int;
if (typeof inputs.float === "number") return inputs.float;
return undefined;
}
export function parseDynamicInputs(workflow: Record<string, WorkflowNode>): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'input') continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
export function parseDynamicInputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicInput[] {
const results: ComfyDynamicInput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "input") continue;
results.push({
nodeId,
label: label || node.class_type,
role: normalizeInputRole(role),
kind: classToInputKind(node.class_type),
defaultValue: extractDefault(node),
required: true,
});
}
return results;
}
export function parseDynamicOutputs(workflow: Record<string, WorkflowNode>): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? '';
const { kind, role, label } = parseTitleTag(title);
if (kind !== 'output') continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput['kind'] = 'other';
if (canon === 'video' || VIDEO_OUT.has(node.class_type) || node.class_type.toLowerCase().includes('video'))
outKind = 'video';
else if (
canon === 'image' ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes('image')
)
outKind = 'image';
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
export function parseDynamicOutputs(
workflow: Record<string, WorkflowNode>,
): ComfyDynamicOutput[] {
const results: ComfyDynamicOutput[] = [];
for (const [nodeId, node] of Object.entries(workflow)) {
const title = node._meta?.title ?? "";
const { kind, role, label } = parseTitleTag(title);
if (kind !== "output") continue;
const canon = normalizeOutputRole(role);
let outKind: ComfyDynamicOutput["kind"] = "other";
if (
canon === "video" ||
VIDEO_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("video")
)
outKind = "video";
else if (
canon === "image" ||
IMAGE_OUT.has(node.class_type) ||
node.class_type.toLowerCase().includes("image")
)
outKind = "image";
results.push({
nodeId,
label: label || node.class_type,
role: canon,
kind: outKind,
});
}
return results;
}
+36 -32
View File
@@ -1,51 +1,55 @@
import type { ComfyDynamicInput } from './types';
import { normalizeInputRole } from './parser';
import type { ComfyDynamicInput } from "./types";
import { normalizeInputRole } from "./parser";
const PROMPT_ROLES = new Set(['prompt', 'positive']);
const PROMPT_ROLES = new Set(["prompt", "positive"]);
function roleOf(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
/** Positive prompt input — prefers (Input:prompt), legacy label fallback. */
export function isPromptLikeInput(inp: ComfyDynamicInput): boolean {
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== 'text' && inp.kind !== 'textarea') return false;
const label = (inp.label || '').toLowerCase();
if (label.includes('negative')) return false;
return (
label.includes('prompt') ||
label.includes('positive') ||
label.includes('text') ||
inp.kind === 'textarea'
);
const role = roleOf(inp);
if (role && PROMPT_ROLES.has(role)) return true;
if (role) return false; // other roles are never "the" prompt
if (inp.kind !== "text" && inp.kind !== "textarea") return false;
const label = (inp.label || "").toLowerCase();
if (label.includes("negative")) return false;
return (
label.includes("prompt") ||
label.includes("positive") ||
label.includes("text") ||
inp.kind === "textarea"
);
}
/** Drop prompt-role fields from shared workflow forms (entity Image prompt owns those). */
export function withoutPromptInputs(inputs: ComfyDynamicInput[] | undefined | null): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
export function withoutPromptInputs(
inputs: ComfyDynamicInput[] | undefined | null,
): ComfyDynamicInput[] {
if (!inputs?.length) return [];
return inputs.filter((inp) => !isPromptLikeInput(inp));
}
/** True if this workflow schema has a prompt-role (or legacy prompt-like) (Input). */
export function workflowHasPromptInput(inputs: ComfyDynamicInput[] | undefined | null): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
export function workflowHasPromptInput(
inputs: ComfyDynamicInput[] | undefined | null,
): boolean {
if (!inputs?.length) return false;
return inputs.some(isPromptLikeInput);
}
/** Strip blank strings so empty fields cannot wipe smart-fill. */
export function compactInputValues(
values: Record<string, unknown> | undefined | null,
values: Record<string, unknown> | undefined | null,
): Record<string, unknown> {
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string' && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
if (!values) return {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === "string" && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
}
+26 -23
View File
@@ -3,39 +3,42 @@
* ratios and rounding, so the size shown is the size ComfyUI renders.
*/
const RATIOS: Record<string, [number, number]> = {
'1:1 (Square)': [1, 1],
'2:3 (Portrait Photo)': [2, 3],
'3:2 (Photo)': [3, 2],
'3:4 (Portrait Standard)': [3, 4],
'4:3 (Standard)': [4, 3],
'9:16 (Portrait Widescreen)': [9, 16],
'16:9 (Widescreen)': [16, 9],
'21:9 (Ultrawide)': [21, 9],
"1:1 (Square)": [1, 1],
"2:3 (Portrait Photo)": [2, 3],
"3:2 (Photo)": [3, 2],
"3:4 (Portrait Standard)": [3, 4],
"4:3 (Standard)": [4, 3],
"9:16 (Portrait Widescreen)": [9, 16],
"16:9 (Widescreen)": [16, 9],
"21:9 (Ultrawide)": [21, 9],
};
/** Python's round(): halves go to the even neighbour. */
function roundHalfEven(x: number): number {
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
}
export function resolutionSelectorSize(
aspectRatio: string,
megapixels: number,
multiple: number,
aspectRatio: string,
megapixels: number,
multiple: number,
): { width: number; height: number } | null {
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return {
width: roundHalfEven((w * scale) / m) * m,
height: roundHalfEven((h * scale) / m) * m,
};
}
/** "16:9 (Widescreen)" → "16:9". */
export function ratioShort(option: string): string {
return option.split(' ')[0] ?? option;
return option.split(" ")[0] ?? option;
}
+288 -271
View File
@@ -1,178 +1,184 @@
export type ComfyInputKind =
| 'text'
| 'textarea'
| 'number'
| 'select'
| 'image'
| 'image_url'
| 'audio'
| 'video'
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| 'image_list'
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| 'video_list'
| 'audio_list';
export type ComfyOutputKind = 'image' | 'video' | 'other';
| "text"
| "textarea"
| "number"
| "select"
| "image"
| "image_url"
| "audio"
| "video"
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| "image_list"
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
| "video_list"
| "audio_list";
export type ComfyOutputKind = "image" | "video" | "other";
/** Structural finding on an imported workflow; `error` blocks generation. */
export interface WorkflowWarning {
level: 'error' | 'warning';
code: string;
node_id: string;
message: string;
level: "error" | "warning";
code: string;
node_id: string;
message: string;
}
export interface ComfyDynamicInput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
role?: string | null;
kind: ComfyInputKind;
defaultValue?: string | number;
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
media_hint?: string;
}
export interface ComfyDynamicOutput {
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: 'image' | 'video' | null;
nodeId: string;
label: string;
/** Role from title tag, e.g. (Output:image). */
role?: string | null;
/** Null while an unknown custom node awaits the user's media-type pick. */
kind: ComfyOutputKind | null;
/** Untagged detection: node class + which rule typed it. */
class_type?: string;
evidence?: "registry" | "class_name_match" | "unknown_node";
/** Form default for unknown candidates — never auto-applied. */
suggested_kind?: "image" | "video" | null;
}
/** One entry of a workflow's confirmed output mapping (HEL-638). */
export interface WorkflowOutputMapEntry {
nodeId: string;
kind: 'image' | 'video';
label?: string;
class_type?: string;
nodeId: string;
kind: "image" | "video";
label?: string;
class_type?: string;
}
/** The five workflow usages the studio distinguishes (HEL-636). */
export type WorkflowUsage =
| 'text2image'
| 'image2image'
| 'text2video'
| 'image2video'
| 'ref2video';
"text2image" | "image2image" | "text2video" | "image2video" | "ref2video";
export const WORKFLOW_USAGES: WorkflowUsage[] = [
'text2image',
'image2image',
'text2video',
'image2video',
'ref2video',
"text2image",
"image2image",
"text2video",
"image2video",
"ref2video",
];
/** Which usages each generate page serves. */
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
image: ['text2image', 'image2image'],
video: ['text2video', 'image2video', 'ref2video'],
export const USAGE_BY_KIND: Record<"image" | "video", WorkflowUsage[]> = {
image: ["text2image", "image2image"],
video: ["text2video", "image2video", "ref2video"],
};
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
export interface PreflightFinding {
level: 'error' | 'warn' | 'info' | 'ok';
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
level: "error" | "warn" | "info" | "ok";
code: string;
node_id?: string;
node_title?: string | null;
message: string;
hint?: string | null;
}
/** Result of checking a workflow against a target connection's /object_info. */
export interface WorkflowConnectionCheck {
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
endpoint_id: string | null;
endpoint_name: string | null;
base_url?: string | null;
/** False when any error-level finding exists (or the server was unreachable). */
ok: boolean;
/** False → 无法核查 (server down): honest, non-blocking. */
reachable: boolean;
findings: PreflightFinding[];
message?: string | null;
}
/** Slim live record of a workflow's latest test run (HEL-643). */
export interface WorkflowLastTest {
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
id: number;
status: string;
phase: string | null;
error: string | null;
created_at: string;
completed_at: string | null;
output_count: number;
}
/** Result of POST /api/workflows/analyze (import confirm step 1). */
export interface WorkflowAnalyzeResult {
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
inputs: ComfyDynamicInput[];
outputs: ComfyDynamicOutput[];
suggested_profile?: string;
warnings?: WorkflowWarning[];
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
needs_mapping: boolean;
usage_problem: string | null;
usage_evidence: {
outputs: ComfyDynamicOutput[];
image_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
video_slots: Array<{
nodeId: string;
label: string;
kind: string;
role: string | null;
}>;
summary: string[];
};
usable_usage: WorkflowUsage[];
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
connection_check?: WorkflowConnectionCheck;
}
export interface WorkflowNode {
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
class_type: string;
inputs: Record<string, unknown>;
_meta?: { title?: string };
}
export interface Workflow {
id: number;
name: string;
kind: 'image' | 'video';
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: 'auto' | 'user' | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: 'auto' | 'user' | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
id: number;
name: string;
kind: "image" | "video";
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
usage_source?: "auto" | "user" | null;
workflow_json: Record<string, WorkflowNode>;
input_schema: ComfyDynamicInput[];
output_schema: ComfyDynamicOutput[];
description: string | null;
prompt_profile: string;
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
output_map?: WorkflowOutputMapEntry[] | null;
output_map_source?: "auto" | "user" | null;
/** True when the graph has unknown output nodes needing designation. */
needs_mapping?: boolean;
/** Latest test-run record, queried live (HEL-643); null = never tested. */
last_test?: WorkflowLastTest | null;
}
/**
@@ -180,188 +186,199 @@ export interface Workflow {
* point is browsing 150+ workflows without downloading workflow_json.
*/
export interface WorkflowMediaIO {
in: string[];
out: string[];
in: string[];
out: string[];
}
export interface WorkflowMeta {
id: number;
name: string;
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_source: 'auto' | 'user' | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: 'auto' | 'user' | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
id: number;
name: string;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_source: "auto" | "user" | null;
is_enabled: boolean;
category: string | null;
model_family: string | null;
media_io: WorkflowMediaIO;
tags: string[];
favorite: boolean;
last_used_at: string | null;
description: string | null;
source: string | null;
source_path: string | null;
prompt_profile: string;
created_at: string;
endpoint_id: string | null;
output_map_source: "auto" | "user" | null;
/** Cheap pending check: usage or output mapping not settled yet. */
needs_mapping: boolean;
}
/** One UI-format file submitted to the batch convert/import pipeline. */
export interface WorkflowUIFileInput {
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
name?: string;
source_path?: string;
workflow_json: Record<string, unknown>;
usage?: WorkflowUsage;
description?: string;
tags?: string[];
}
export interface WorkflowConvertIssue {
node_id: string;
code: string;
level: 'error' | 'warning';
message: string;
node_id: string;
code: string;
level: "error" | "warning";
message: string;
}
export interface WorkflowConvertSuggested {
kind: 'image' | 'video';
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
kind: "image" | "video";
usage: WorkflowUsage | null;
usage_ambiguous: boolean;
category: string | null;
category_label: string | null;
model_family: string | null;
media_in: string[];
media_out: string[];
description: string | null;
}
export interface WorkflowPreflightFinding {
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
level: string;
code: string;
node_id?: string;
node_title?: string;
message: string;
hint?: string;
}
export interface WorkflowConvertFileReport {
name: string;
source_path: string | null;
status: 'ok' | 'warn' | 'fail';
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
name: string;
source_path: string | null;
status: "ok" | "warn" | "fail";
needs_confirmation: boolean;
issues: WorkflowConvertIssue[];
importable: boolean;
skip_reason?: string | null;
suggested?: WorkflowConvertSuggested;
preflight?: {
endpoint_id: string | null;
ok: boolean;
findings: WorkflowPreflightFinding[];
};
}
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
export interface WorkflowConvertReport {
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
total: number;
ok: number;
warn: number;
fail: number;
needs_confirmation: number;
importable: number;
files: WorkflowConvertFileReport[];
}
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
result?: 'imported' | 'skipped' | 'failed';
id?: number;
existing_id?: number;
result?: "imported" | "skipped" | "failed";
id?: number;
existing_id?: number;
}
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
export interface WorkflowImportResult {
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
files: WorkflowImportFileReport[];
total: number;
imported: number;
skipped: number;
imported_ids: number[];
skipped_files: {
name: string;
source_path: string | null;
reason: string | null;
}[];
files: WorkflowImportFileReport[];
}
export interface Job {
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
id: number;
project_id: number;
scene_id: number | null;
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
clip_id: number | null;
kind: string;
workflow_id: number | null;
status: string;
payload: Record<string, unknown>;
output_paths: string[];
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
origin?: string;
/** HEL-684: present when this take has been flowed back to its
* workbench source row (link job id on the source project). */
workbench_attach?: {
job_id: number;
stage: string;
target_id: string;
};
}
export interface SceneVideoSettings {
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
input_values?: Record<string, string | number>;
/** 'auto' | 'upload' | target scene id as string. */
clip_source?: string;
form_workflow_id?: number;
prompt_draft?: string;
prompt_draft_meta?: { based_on?: string; saved_at?: string };
[key: string]: unknown;
}
export interface Scene {
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
id: number;
project_id: number;
order_index: number;
heading: string | null;
action: string | null;
dialog: string | null;
duration_sec: number | null;
workflow_id: number | null;
env_image_path: string | null;
location_id: number | null;
video_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
character_ids: number[];
characters: Array<{
id: number;
name: string;
role: string | null;
portrait_path: string | null;
sheet_path: string | null;
}>;
clips: Clip[];
}
/** Shot unit: one scene expands into 1+ clips; each renders one ~5-10s video. */
export interface Clip {
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
id: number;
scene_id: number;
project_id: number;
order_index: number;
description: string | null;
shot_size: string | null;
dialog_lines_covered: number[] | null;
duration_sec: number | null;
workflow_id: number | null;
clip_path: string | null;
chain_from_prev?: number | boolean | null;
video_settings?: SceneVideoSettings | null;
/** '#3.2' — scene 3, clip 2 (display label). */
label?: string;
}
+74 -70
View File
@@ -8,92 +8,96 @@
* - upload progress per nodeId
* - metadata (name, kind) for uploaded files
*/
import { playgroundApi, type UploadKind } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { playgroundApi, type UploadKind } from "$lib/api";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
export interface UploadState {
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
/** nodeId → uploading filename (present while upload is in-flight) */
uploading: Record<string, string>;
/** path → { name, kind } for previously uploaded files */
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
}
export function createUploadManager() {
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>({});
let uploading = $state<Record<string, string>>({});
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>(
{},
);
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
return {
get uploading() {
return uploading;
},
get uploadedMeta() {
return uploadedMeta;
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
isUploading(nodeId: string): boolean {
return !!uploading[nodeId];
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf('.')).toLowerCase();
if (['.mp4', '.webm', '.mov', '.mkv'].includes(ext))
return { name: base, kind: 'video' };
if (['.mp3', '.wav', '.flac', '.ogg', '.m4a'].includes(ext))
return { name: base, kind: 'audio' };
return { name: base, kind: 'image' };
},
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
const meta = uploadedMeta[path];
if (meta) return meta;
const base = path.split(/[/\\]/).pop() ?? path;
const ext = base.slice(base.lastIndexOf(".")).toLowerCase();
if ([".mp4", ".webm", ".mov", ".mkv"].includes(ext))
return { name: base, kind: "video" };
if ([".mp3", ".wav", ".flac", ".ogg", ".m4a"].includes(ext))
return { name: base, kind: "audio" };
return { name: base, kind: "image" };
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Upload a file and return the server path.
* Manages uploading state and metadata internally.
* Throws on failure (caller handles toast if needed).
*/
async upload(nodeId: string, file: File): Promise<string> {
uploading = { ...uploading, [nodeId]: file.name };
try {
const res = await playgroundApi.upload(file);
uploadedMeta = {
...uploadedMeta,
[res.path]: { name: res.name, kind: res.kind },
};
return res.path;
} finally {
const next = { ...uploading };
delete next[nodeId];
uploading = next;
}
},
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('toast.uploadFailed'));
return null;
}
},
};
/**
* Convenience: upload + toast on error, return path or null.
*/
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
try {
return await this.upload(nodeId, file);
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("toast.uploadFailed"),
);
return null;
}
},
};
}
export type UploadManager = ReturnType<typeof createUploadManager>;
export function truncateMiddle(name: string, max = 32): string {
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
if (name.length <= max) return name;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
}
export function acceptForKind(kind: string): string {
if (kind === 'audio') return 'audio/*';
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
if (kind === 'document') return '.txt,.md,.docx';
return 'image/*';
if (kind === "audio") return "audio/*";
if (kind === "video") return "video/*,.mp4,.webm,.mov,.mkv";
if (kind === "document") return ".txt,.md,.docx";
return "image/*";
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { t } from '$lib/i18n.svelte';
@@ -15,7 +16,7 @@ interface Props {
<header class="header">
<div class="header-left">
<a class="logo" href="/" title={t('shell.brand')}>
<a class="logo" href={resolve('/')} title={t('shell.brand')}>
{t('shell.brand')}
</a>
{#if crumb}
@@ -27,13 +28,13 @@ interface Props {
</div>
{/if}
<nav class="top-nav" aria-label={t('nav.primary')}>
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href="/">{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>
<a class="nav-link" class:active={active === 'canvas'} href={resolve('/canvas')}>{t('nav.canvas')}</a>
<a class="nav-link" class:active={active === 'projects'} href={resolve('/')}>{t('nav.projects')}</a>
<a class="nav-link" class:active={active === 'build-scene'} href={resolve('/build-scene')}>{t('nav.buildScene')}</a>
<a class="nav-link" class:active={active === 'library'} href={resolve('/library')}>{t('nav.library')}</a>
<a class="nav-link" class:active={active === 'agents'} href={resolve('/agents')}>{t('nav.agents')}</a>
<a class="nav-link" class:active={active === 'models'} href={resolve('/models')}>{t('nav.models')}</a>
<a class="nav-link" class:active={active === 'settings'} href={resolve('/settings')}>{t('nav.settings')}</a>
</nav>
</div>
<div class="header-right">
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createMutation, createQuery } from '@tanstack/svelte-query';
import {
playgroundApi,
@@ -137,9 +138,6 @@
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
const stageLink = $derived(
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
);
</script>
<div class="attach">
@@ -147,7 +145,7 @@
<div class="attached" role="status">
<span class="attached-check" aria-hidden="true"><Icon name="check" size={14} /></span>
<span class="attached-text">{t('attach.addedTo')} <strong>{attached.title}</strong></span>
<a class="attached-link" href={stageLink}>{t('attach.viewInProject')}</a>
<a class="attached-link" href={resolve(`/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}`)}>{t('attach.viewInProject')}</a>
<button
class="attached-dismiss"
type="button"
@@ -173,7 +171,7 @@
<span class="field-label">{t('attach.projectField')}</span>
<select class="field-select" bind:value={projectId}>
<option value="">{t('attach.selectProject')}</option>
{#each projectList as p}
{#each projectList as p (p.id)}
<option value={p.id}>{p.title}</option>
{/each}
</select>
@@ -194,7 +192,7 @@
<span class="field-label">{t('attach.characterField')}</span>
<select class="field-select" bind:value={characterId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectCharacter')}</option>
{#each characters as c}
{#each characters as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
@@ -207,7 +205,7 @@
<span class="field-label">{t('attach.locationField')}</span>
<select class="field-select" bind:value={locationId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectLocation')}</option>
{#each locations as loc}
{#each locations as loc (loc.id)}
<option value={loc.id}>{loc.name}</option>
{/each}
</select>
@@ -232,7 +230,7 @@
<span class="field-label">{t('attach.sceneField')}</span>
<select class="field-select" bind:value={sceneId} disabled={projectId === '' || loadingTargets}>
<option value="">{t('attach.selectScene')}</option>
{#each scenes as s}
{#each scenes as s (s.id)}
<option value={s.id}>#{s.order_index} {s.heading || t('attach.scene')}</option>
{/each}
</select>
@@ -229,6 +229,7 @@
{#if inputs.length === 0}
<p class="muted">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- This fixed key comes only from the seven bundled translation dictionaries, without interpolation or remote overrides. Retain the existing HTML-capable translation contract. -->
{@html t('cdf.noInputs')}
</p>
{:else}
@@ -381,7 +382,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -422,7 +424,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -453,7 +456,8 @@
}}
>
<option value="">{t('cdf.chooseAsset')}</option>
{#each assetOptions as opt}
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
{#each assetOptions as opt, i (i)}
<option value={opt.path}>{opt.label}</option>
{/each}
</select>
@@ -522,7 +526,7 @@
}
.role {
font-weight: 600;
color: var(--accent, #a78bfa);
color: var(--accent);
font-size: 11px;
margin-left: 6px;
font-family: ui-monospace, monospace;
@@ -567,7 +571,7 @@
input.invalid:focus-visible,
textarea.invalid:focus-visible,
select.invalid:focus-visible {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
box-shadow: 0 0 0 3px var(--err-soft);
}
.error-msg {
font-size: 11px;
@@ -607,7 +611,7 @@
}
.clear:hover {
color: var(--error);
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.clear:focus-visible {
outline: 2px solid var(--accent);
@@ -30,7 +30,7 @@
position: absolute;
inset: 0;
z-index: 30;
background: rgba(18, 18, 24, 0.78);
background: var(--overlay);
backdrop-filter: blur(2px);
border-radius: var(--radius-md);
display: flex;
@@ -96,8 +96,8 @@
<button
class="tool"
type="button"
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
onclick={() => (zoomed = !zoomed)}
>
<Icon name="zoom-in" size={17} />
@@ -108,7 +108,9 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
class="tool"
type="button"
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
title={frameBusy
? t('lastFrame.exporting')
: `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={(e) => {
@@ -127,6 +129,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
class="tool"
href={src}
download={downloadName}
rel="external"
aria-label={t('lightbox.downloadMedia')}
title={t('lightbox.download')}
onclick={(e) => e.stopPropagation()}
@@ -179,9 +182,8 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
{alt}
kind="video"
label={t('lightbox.videoUnavailable')}
autoplay
controls
preload="auto"
preload="metadata"
/>
</div>
{:else}
@@ -207,7 +209,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
{:else}
{t('lightbox.clickToZoom')} · {t('lightbox.closeHint')} ·
{/if}
<a href={src} target="_blank" rel="noopener" onclick={(e) => e.stopPropagation()}
<a href={src} target="_blank" rel="noopener external" onclick={(e) => e.stopPropagation()}
>{t('lightbox.openOriginal')}</a
>
</p>
@@ -237,7 +239,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
}
.counter {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
color: color-mix(in srgb, var(--on-media) 65%, transparent);
margin-right: 4px;
font-variant-numeric: tabular-nums;
}
@@ -249,13 +251,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
background: color-mix(in srgb, var(--on-media) 12%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.tool:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.tool:focus-visible {
outline: 2px solid var(--accent);
@@ -272,13 +274,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 44px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: color-mix(in srgb, var(--on-media) 10%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.nav:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.nav:focus-visible {
outline: 2px solid var(--accent);
@@ -334,16 +336,16 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
.caption {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
max-width: min(96vw, 720px);
text-align: center;
}
.hint {
margin: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
color: color-mix(in srgb, var(--on-media) 55%, transparent);
}
.hint a {
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
}
</style>
@@ -1,37 +1,56 @@
<script lang="ts">
import { language, setLanguage, type Language } from '$lib/i18n.svelte';
import { t } from '$lib/i18n.svelte';
import { language, setLanguage, type Language } from "$lib/i18n.svelte";
import { t } from "$lib/i18n.svelte";
let { segmented = false }: { segmented?: boolean } = $props();
const languages: [Language, string][] = [
["zh", "中文"],
["en", "English"],
["ja", "日本語"],
["ko", "한국어"],
["de", "Deutsch"],
["es", "Español"],
["fr", "Français"],
];
</script>
<select
class="lang-switcher"
value={language.current}
aria-label={t('common.language')}
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="zh">中文</option>
</select>
{#if segmented}<div class="mg-options" aria-label={t("common.language")}>
{#each languages as [code, label] (code)}<button
class="mg-option"
class:on={language.current === code}
aria-pressed={language.current === code}
onclick={() => setLanguage(code)}>{label}</button
>{/each}
</div>{:else}
<select
class="lang-switcher"
value={language.current}
aria-label={t("common.language")}
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="zh">中文</option>
</select>
{/if}
<style>
.lang-switcher {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
min-height: 34px;
}
.lang-switcher:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
.lang-switcher {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
min-height: 34px;
}
.lang-switcher:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
@@ -18,7 +18,7 @@
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
import { normalizeInputRole } from '$lib/comfy/parser';
import type { AssetOption } from '$lib/assetPicker';
import { assetUrl } from '$lib/api';
import '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import PillSelect from './omni/PillSelect.svelte';
@@ -504,9 +504,9 @@
height: 36px;
padding: 0 20px;
border-radius: 9999px;
background: var(--success, #22c55e);
background: var(--ok);
border: none;
color: #000;
color: var(--accent-ink);
font-size: 14px;
font-weight: 700;
font-family: var(--font-body);
@@ -95,10 +95,10 @@
rgba(255, 255, 255, 0.03) 6px,
rgba(255, 255, 255, 0.03) 12px
),
var(--bg-elevated, #16161d);
border: 1px dashed var(--border, #2a2a35);
var(--bg-elevated);
border: 1px dashed var(--border);
border-radius: inherit;
color: var(--text-muted, #8b8b9a);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -48,7 +48,7 @@
onchanged,
}: Props = $props();
const client = useQueryClient();
useQueryClient();
let endpointId = $state('');
let values = $state<Record<string, string | number>>({});
@@ -146,7 +146,7 @@
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
});
$effect(() => {
stageLog.length;
void stageLog.length;
if (logBox) logBox.scrollTop = logBox.scrollHeight;
});
@@ -447,7 +447,7 @@
href={assetUrl(path)}
download
target="_blank"
rel="noreferrer"
rel="noreferrer external"
title={t('wft.download')}
>
<SafeMedia
@@ -132,7 +132,7 @@
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
}
.artifact.failed {
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.meta {
display: flex;
@@ -249,12 +249,12 @@
width: 44px;
height: 44px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
background: var(--media-scrim);
box-shadow: 0 0 0 1px var(--on-media-line);
}
.play-badge :global(svg) {
position: absolute;
color: #fff;
color: var(--on-media);
fill: currentColor;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
margin-left: 2px;
@@ -61,6 +61,7 @@
/** Later tool results (wait_for_jobs) often have the finished status + paths. */
const trailJobsById = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Each derivation rebuilds this tool-result lookup from messages; only the completed Map escapes, with no later mutation.
const map = new Map<number, ArtifactJob>();
for (const row of messages) {
if (row.role !== 'tool' || !row.tool_result || typeof row.tool_result !== 'object') continue;
@@ -76,6 +76,7 @@ let {
void draftNonce;
if (draftNonce === 0) return;
if (!editorEl) return;
// eslint-disable-next-line svelte/no-dom-manipulating -- Template owns the empty editor shell; its user-editable subtree is imperative. Restore draft text before caret placement/focus and serialization.
editorEl.textContent = draft;
placeCaretAtEnd(editorEl);
editorEl.focus();
@@ -161,6 +162,7 @@ let {
attachments: [...attachments],
};
if (payloadIsEmpty(payload)) return;
// eslint-disable-next-line svelte/no-dom-manipulating -- Template does not render editor children; after serializing text/chips, clear the user-owned subtree before onSend, preserving submit/reset behavior.
if (editorEl) editorEl.innerHTML = '';
attachments = [];
mentionOpen = false;
@@ -291,8 +293,10 @@ let {
chip.after(space);
placeCaretAfter(space);
} else {
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor owns its children: fallback appends a noneditable workflow chip whose dataset serialize reads; keep template shell and caret logic intact.
editorEl.appendChild(makeChip(wf));
const space = document.createTextNode('\u00a0');
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor-owned trailing space provides a text caret after the workflow chip; placeCaretAfter uses this exact node.
editorEl.appendChild(space);
placeCaretAfter(space);
}
@@ -327,8 +331,10 @@ let {
chip.after(space);
placeCaretAfter(space);
} else {
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor owns its children: fallback appends a noneditable skill chip whose dataset serialize reads; keep template shell and caret logic intact.
editorEl.appendChild(makeSkillChip(skill));
const space = document.createTextNode('\u00a0');
// eslint-disable-next-line svelte/no-dom-manipulating -- Editor-owned trailing space provides a text caret after the skill chip; placeCaretAfter uses this exact node.
editorEl.appendChild(space);
placeCaretAfter(space);
}
@@ -668,8 +674,8 @@ let {
justify-content: center;
border: none;
border-radius: 999px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
background: var(--media-scrim);
color: var(--on-media);
cursor: pointer;
padding: 0;
}
@@ -772,7 +778,7 @@ let {
border-radius: var(--radius-md);
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
color: var(--accent-ink);
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -785,7 +791,7 @@ let {
.send.stop {
background: transparent;
color: var(--error);
border-color: rgba(239, 68, 68, 0.5);
border-color: var(--err-line);
}
.send.steer {
color: var(--accent);
@@ -28,6 +28,7 @@
const sandbox = $derived(sessions.filter((s) => s.project_id == null));
const grouped = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local grouping accumulator is consumed as an array in this derivation; sessions drive recomputation, not Map writes.
const map = new Map<number, { project: AgentSession['project']; items: AgentSession[] }>();
for (const s of sessions) {
if (s.project_id == null) continue;
@@ -431,6 +432,6 @@
}
.del:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.12);
background: var(--err-soft);
}
</style>
@@ -60,7 +60,7 @@
overflow: hidden;
}
.tool-card.error {
border-color: rgba(239, 68, 68, 0.45);
border-color: var(--err-line);
}
.head {
display: flex;
@@ -1,16 +1,9 @@
<script lang="ts">
<script lang="ts" generics="Item extends { id?: number; name: string; kind?: string; description?: string | null }">
import { t } from '$lib/i18n.svelte';
interface MenuItem {
id?: number;
name: string;
kind?: string;
description?: string | null;
}
interface Props {
open: boolean;
items: MenuItem[];
items: Item[];
activeIndex: number;
/** Caret / `@` viewport box — menu opens above this (composer is bottom-docked). */
anchor: { top: number; bottom: number; left: number };
@@ -18,7 +11,7 @@
lockReason?: string | null;
/** Skill mode: rows show `/name` + a description subtitle, no kind chip. */
skillMode?: boolean;
onSelect: (item: any) => void;
onSelect: (item: Item) => void;
onHover: (index: number) => void;
}
@@ -62,6 +55,7 @@
if (!open || !menuEl) return;
document.body.appendChild(menuEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates menu contents; this effect moves the root to body and owns portal teardown on close/unmount. Remove that moved root to avoid residue.
menuEl?.remove();
};
});
@@ -1,67 +1,74 @@
/**
* Agent identity palette: a stable display name + accent color per agent.
* Agent identity palette: a stable display name + identity step per agent.
*
* Identity colors answer "who is speaking" only (avatar / name / bubble
* tint) — they never express task status (spec v2 §3.6). The curated set
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
* no tailwind rainbow hues leak into the fusion palette; the default
* main-loop agent is neutral `--text-2` slate.
* Identity answers "who is speaking" only (avatar dot / name / bubble tint)
* — it never expresses task status. HEL-685 无彩化:全部改为 app.css 的
* 显影灰阶实色阶 --id-1..5(与全站 --avatar-1..5 同一身份语言),
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
export const DEFAULT_AGENT_NAME = "Calliope";
// Fixed identity map (keyed by the raw agent_name the backend emits).
// Step numbers mirror the avatar five-set used across the app
// (models/agents/home project cards).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; colors mirror the spec §2.1 avatar five-set.
剧本创作: { label: '剧本创作', color: '#e8a33d' },
视觉设计: { label: '视觉设计', color: '#5b8def' },
分镜设计: { label: '分镜设计', color: '#3fb97f' },
素材资产: { label: '素材资产', color: '#b98be0' },
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
planner: { label: "Planner", color: "var(--id-2)" },
"story-agent": { label: "Story Agent", color: "var(--id-3)" },
"script-agent": { label: "Script Agent", color: "var(--id-1)" },
"assets-agent": { label: "Assets Agent", color: "var(--id-4)" },
"video-agent": { label: "Video Agent", color: "var(--id-5)" },
"research-agent": { label: "Research Agent", color: "var(--id-4)" },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: "剧本创作", color: "var(--id-1)" },
视觉设计: { label: "视觉设计", color: "var(--id-2)" },
分镜设计: { label: "分镜设计", color: "var(--id-3)" },
素材资产: { label: "素材资产", color: "var(--id-4)" },
镜头脚本: { label: "镜头脚本", color: "var(--id-5)" },
};
// Fallback palette for unknown names (stable hash → deterministic color),
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
// Fallback palette for unknown names (stable hash → deterministic step),
// spanning the same five grayscale identity steps.
const FALLBACK_COLORS = [
"var(--id-1)",
"var(--id-2)",
"var(--id-3)",
"var(--id-4)",
"var(--id-5)",
];
function hashString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return h;
}
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = '#aab2c5';
/** Neutral step (--id-4) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = "var(--id-4)";
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
if (!key) return DEFAULT_AGENT_NAME;
return IDENTITIES[key]?.label ?? prettyName(name!);
const key = (name || "").trim().toLowerCase();
if (!key) return DEFAULT_AGENT_NAME;
return IDENTITIES[key]?.label ?? prettyName(name!);
}
export function agentColor(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
if (!key) return DEFAULT_AGENT_COLOR;
const identity = IDENTITIES[key];
if (identity) return identity.color;
return FALLBACK_COLORS[hashString(key) % FALLBACK_COLORS.length];
const key = (name || "").trim().toLowerCase();
if (!key) return DEFAULT_AGENT_COLOR;
const identity = IDENTITIES[key];
if (identity) return identity.color;
return FALLBACK_COLORS[hashString(key) % FALLBACK_COLORS.length];
}
function prettyName(raw: string): string {
// "story-agent" → "Story Agent", "assets-agent" → "Assets Agent"
const title = raw
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return title || DEFAULT_AGENT_NAME;
// "story-agent" → "Story Agent", "assets-agent" → "Assets Agent"
const title = raw
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
return title || DEFAULT_AGENT_NAME;
}
@@ -0,0 +1,831 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { untrack, type Snippet } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import {
clearOutputMissing,
markOutputMissing,
missingOutputsOf,
} from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
import { usageLabel } from '../generate/schema';
import {
elapsedSec,
fmtClock,
fmtDur,
inputsOf,
isVideoOutput,
promptOf,
specOf,
} from '../generate/takes';
/**
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
* stage with its spec, inputs and workflow; 参数 hosts the full form.
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
* fullscreen — `overlay` / `open` drive the last two.
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
* ↑↓/J/K 即时无动画), video playback is always explicit,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
main?: boolean;
comparePath?: string | null;
tab: 'result' | 'params';
job: Job | null;
wf: Workflow | undefined;
nowMs: number;
open: boolean;
/** Collapsed or off-screen — nothing inside may take focus. */
inactive: boolean;
/** Current workflow can take an image output (送入当前图片槽). */
canSendToSlot: boolean;
/** Animate this result change (pointer pick) — 150ms cross-fade. */
crossfade: boolean;
/** The stage <video> element, for explicit playback controls. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
canAttach?: boolean;
attachBusy?: boolean;
onAttach?: () => void;
}
let {
main = false,
comparePath = null,
tab = $bindable(),
job,
wf,
nowMs,
canSendToSlot,
videoEl = $bindable(null),
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
canAttach = false,
attachBusy = false,
onAttach,
}: Props = $props();
let detailsOpen = $state(false);
let deleteOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
lightbox = $state(false);
$effect(() => {
void job?.id;
detailsOpen = false;
deleteOpen = false;
compare = false;
lightbox = false;
});
$effect.pre(() => {
void job?.id;
outIndex = 0;
});
const st = $derived(job ? jobStatusInfo(job) : null);
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
// cannot be fetched — the failed case stays red.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const outputs = $derived(
job && st?.tone === 'ok'
? job.output_paths.filter((p) => !gonePaths.includes(p))
: [],
);
const output = $derived(
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const compareSource = $derived(
inputs.find((i) => i.kind === 'image')?.path ?? comparePath,
);
const spec = $derived(job ? specOf(job, wf) : []);
const endpoint = $derived(
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as
string | undefined) ?? '',
);
const progress = $derived.by(() => {
if (!job || job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
let dims = $state('');
let naturalWidth = $state(1),
naturalHeight = $state(1);
let stageWidth = $state(0),
stageHeight = $state(0);
const fitted = $derived(
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
);
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
naturalWidth = v.videoWidth;
naturalHeight = v.videoHeight;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
let paused = $state(true);
$effect.pre(() => {
void output;
untrack(() => {
videoEl?.pause();
paused = true;
});
});
function togglePlayback() {
if (!videoEl) return;
if (videoEl.paused) videoEl.play().catch(() => {});
else videoEl.pause();
}
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
let frameBusy = $state(false);
async function exportTail() {
const src = assetUrl(output);
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script>
<div class="artboard" aria-label="生成结果">
<button
class="artboard-toolbar"
aria-label="作品详情与操作"
onclick={() => (detailsOpen = true)}
>
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
</button>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div
class="stage-media"
bind:clientWidth={stageWidth}
bind:clientHeight={stageHeight}
>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onkeydown={(e) => {
if (e.code === 'Space' && e.target === e.currentTarget) {
e.preventDefault();
togglePlayback();
}
}}
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}>▶</button
>{/if}
</div>
{:else if output}
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{#if main && compare && compareSource}<div
class="comparison-layer"
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
>
<img src={assetUrl(compareSource)} alt="对比参考画面" />
</div>
<div
class="comparison-divider"
style:left={`${compareAt}%`}
></div>{/if}
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub"
>{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span
>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}>
<i></i>
</div>
{/if}
</div>
{/if}
</div>
{/key}{/if}
{#if outputs.length > 1}<div
class="output-picker"
role="group"
aria-label="本次生成的作品"
>
<!-- Output paths may repeat; the button number and outIndex refer to this position. -->
{#each outputs as p, i (i)}<button
aria-pressed={output === p}
onclick={() => (outIndex = i)}
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
>{/each}
</div>{/if}
</div>
<footer class="artboard-footer">
<button
class="canvas-btn"
disabled={!output}
onclick={() =>
video
? videoEl?.requestFullscreen().catch(() => {})
: (lightbox = true)}
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
>
<a
class="canvas-btn"
href={output ? assetUrl(output) : undefined}
aria-disabled={!output}
download
rel="external"
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!job || !output}
onclick={() => job && onReuse(job)}
><Icon name="retry" size={14} />复用参数</button
>
<button
class="canvas-btn"
disabled={!output || video || !canSendToSlot}
title={canSendToSlot
? '放入当前工作流图片输入'
: '当前工作流没有图片输入'}
onclick={() => output && onSendToSlot(output)}
><Icon name="image" size={14} />作为参考图</button
>
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="film" size={14} />送去生视频</button
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!canAttach || attachBusy}
title={canAttach
? '保存到来源项目'
: '通用选择项目与阶段将在后续批次提供'}
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
>
<a class="canvas-btn" href={resolve('/library')}
><Icon name="assets" size={14} />素材库</a
>
<button
class="canvas-btn delete-output"
aria-label="删除作品记录"
title="删除作品记录"
disabled={!job || job.status === 'pending' || job.status === 'running'}
onclick={() => (deleteOpen = true)}
><Icon name="close" size={14} /></button
>
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if output && !video && compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => {
compare = !compare;
detailsOpen = false;
}}>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
<button
class="a-btn secondary sm"
onclick={() => {
onReuse(job);
detailsOpen = false;
}}>复用到输入区</button
>
{#if output && !video && canSendToSlot}<button
class="a-btn secondary sm"
onclick={() => {
onSendToSlot(output);
detailsOpen = false;
}}>送入当前图片槽</button
>{/if}
{#if video && output}<button
class="a-btn secondary sm"
disabled={frameBusy}
onclick={exportTail}>{frameBusy ? '正在导出…' : '导出尾帧'}</button
>{/if}
{#if st.tone === 'fail' || fileGone}<button
class="a-btn secondary sm"
onclick={() => onRetry(job)}>用相同参数重做</button
>{/if}
{#if job.status !== 'pending' && job.status !== 'running'}<button
class="a-btn danger sm"
onclick={() => {
deleteOpen = true;
detailsOpen = false;
}}>移除记录</button
>{/if}
</div>
{#if st.tone === 'fail' || fileGone}<p class="error-note" role="alert">
{fileGone ? t('jobst.missingDetail') : st.detail}
</p>{/if}
<div class="detail-meta">
<!-- Display-only spec rows have no local state; preserve their existing positional reuse. -->
{#each spec as s, i (i)}<span>{s.label} · {s.value}</span
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
</div>
{#if inputs.length || prompt}
<div class="section-title">{t('desk.inputs')}</div>
{#if inputs.length}
<div class="inputs">
{#each inputs as inp (inp.label + inp.path)}
{#if inp.kind === 'image'}
<img
class="mini"
src={assetUrl(inp.path)}
alt={inp.label}
title={inp.label}
loading="lazy"
/>
{:else}
<span class="mini glyph" title={inp.label}
><Icon
name={inp.kind === 'video' ? 'film' : 'music'}
size={14}
/></span
>
{/if}
{/each}
</div>
{/if}
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{/if}
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>
{wf?.name ?? t('studio.jobName', { id: job.id })}
{#if wfDeleted}<span class="a-tag wf-gone"
>{t('studio.wfDeletedTag')}</span
>{/if}
</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
{/if}
<dt>{t('desk.kvCreated')}</dt>
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
{#if job.comfy_prompt_id}
<dt>{t('desk.kvPromptId')}</dt>
<dd class="mono">{job.comfy_prompt_id}</dd>
{/if}
</dl>
{/if}
</Modal>
<Modal bind:open={deleteOpen} title="删除作品记录" size="md">
<p>
确认移除这条生成记录及生成文件?关联项目中的回流记录也会移除。确认后有 5
秒可撤销,提交后无法恢复。
</p>
{#snippet footer()}<button
class="a-btn secondary"
onclick={() => (deleteOpen = false)}>取消</button
>
<button
class="a-btn danger"
onclick={() => {
if (job) onDelete(job);
deleteOpen = false;
}}>确认移除</button
>{/snippet}
</Modal>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
onClose={() => (lightbox = false)}
/>{/if}
<style>
.artboard {
position: relative;
display: grid;
grid-template-rows: 0 minmax(0, 1fr) 40px;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
overflow: hidden;
}
.artboard-toolbar,
.artboard-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 12px;
min-width: 0;
}
.artboard-toolbar {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
padding: 2px 6px;
max-width: calc(100% - 20px);
border: 0;
cursor: pointer;
text-align: left;
}
.result-title {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
font-size: 11px;
color: var(--text-2);
}
.result-title small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.canvas-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
box-sizing: border-box;
background: var(--bg-2);
color: var(--text-2);
border: 1px solid var(--line);
border-radius: var(--desk-action-radius);
padding: 0 9px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.action-separator {
width: 1px;
height: 18px;
flex-shrink: 0;
margin: 0 4px;
background: var(--line-2);
}
.delete-output {
width: 26px;
padding: 0;
justify-content: center;
color: var(--err);
background: transparent;
border-color: transparent;
}
.canvas-btn:disabled,
.canvas-btn[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
.canvas-btn:hover {
color: var(--text);
background: var(--bg-3);
}
.canvas-btn[aria-pressed='true'],
.play-button {
color: var(--accent);
border-color: var(--accent-line);
}
.stage {
grid-row: 2;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.stage-media {
position: absolute;
inset: 0;
padding: 0;
display: grid;
place-items: center;
min-height: 0;
}
.print {
position: relative;
display: grid;
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
border: 0;
padding: 0;
background: none;
box-shadow: none;
}
.big-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--on-media-line);
color: var(--on-media);
background: var(--media-scrim);
font-size: 26px;
cursor: pointer;
}
.print img,
.print video {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
min-height: 0;
min-width: 0;
object-fit: contain;
display: block;
}
.print :global(.a-fix) {
display: none;
}
.stage-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
font-size: 14px;
color: var(--text-2);
}
.stage-empty .sub {
color: var(--text-3);
font-size: 11px;
text-align: center;
max-width: 480px;
overflow-wrap: anywhere;
}
.stage-bar {
width: 200px;
}
.comparison-layer {
position: absolute;
inset: 0;
background: var(--bg-0);
pointer-events: none;
}
.comparison-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent);
pointer-events: none;
}
.artboard-footer {
grid-row: 3;
justify-content: center;
gap: 6px;
padding: 0;
font-size: 10px;
color: var(--text-3);
}
.result-spec {
margin-left: auto;
}
.compare-slider {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.compare-slider input {
width: 120px;
accent-color: var(--accent);
}
.output-picker {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
max-width: 90%;
overflow: auto;
background: var(--media-scrim);
border: 1px solid var(--line);
padding: 4px;
border-radius: 8px;
}
.output-picker button {
background: none;
border: 0;
color: var(--text-3);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.output-picker button[aria-pressed='true'] {
background: var(--accent-soft);
color: var(--accent);
}
.detail-actions,
.detail-meta,
.inputs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.detail-meta {
color: var(--text-3);
font-size: 11px;
}
.section-title {
font-size: 12px;
color: var(--text-3);
margin: 20px 0 10px;
}
.mini {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
background: var(--surface);
}
.mini.glyph {
display: grid;
place-items: center;
}
.prompt {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 16px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg-2);
max-height: 260px;
overflow: auto;
}
.kv {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
font-size: 12px;
gap: 8px;
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
overflow-wrap: anywhere;
}
.error-note {
color: var(--warn);
}
@media (max-width: 1100px) {
.result-title small {
display: none;
}
.stage-media {
padding: 8px 12px;
}
}
</style>
@@ -0,0 +1,631 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { workflows, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
import { usageLabel } from '$lib/components/generate/schema';
import { spotlight } from '$lib/actions/spotlight';
import { toast } from '$lib/toast';
import Icon from '$lib/components/ui/Icon.svelte';
let {
kind,
initialUsage = 'all',
metas,
selectedId,
pendingCount,
onPick,
onKind,
onDetails,
disabled = false,
historyCollapsed = false,
onToggleHistory,
}: {
disabled?: boolean;
historyCollapsed?: boolean;
onToggleHistory: () => void;
kind: 'image' | 'video';
initialUsage?: string;
metas: WorkflowMeta[];
selectedId: number | null;
pendingCount: number;
onPick: (id: number) => void;
onKind: (kind: 'image' | 'video') => void;
onDetails: () => void;
} = $props();
let searchInput: HTMLInputElement;
let searchOrigin: HTMLElement | null = null;
function searchKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
if (
[
...document.querySelectorAll('.shell-pop, .modal, [role="dialog"]'),
].some((el) => el.getClientRects().length)
)
return;
if (e.key === 'Escape' && e.target === searchInput) {
e.preventDefault();
const origin = searchOrigin?.isConnected
? searchOrigin
: searchInput.closest('aside')?.querySelector('button');
if (origin instanceof HTMLElement) origin.focus();
else searchInput.blur();
return;
}
const el = e.target instanceof HTMLElement ? e.target : null;
if (
e.key !== '/' ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
el?.isContentEditable ||
el?.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
) ||
!searchInput?.getClientRects().length
)
return;
e.preventDefault();
searchInput.focus();
}
const client = useQueryClient();
let search = $state(''),
usage = $state(untrack(() => initialUsage)),
family = $state('all'),
favOnly = $state(false),
runnableOnly = $state(true),
limit = $state(48),
pins = $state<number[]>([]);
onMount(() => {
try {
pins = JSON.parse(
localStorage.getItem('calliope.reference.workflowPins') || '[]',
);
} catch {
pins = [];
}
});
const families = $derived([
...new Set(metas.map((m) => m.model_family).filter(Boolean)),
]);
const usages = $derived([
...new Set(metas.map((m) => m.usage).filter(Boolean)),
]);
const filtered = $derived(
metas
.filter(
(m) =>
(!runnableOnly || runnable(m)) &&
(!favOnly || m.favorite) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
[
m.name,
m.description,
m.source_path,
m.usage ? usageLabel(m.usage) : null,
m.model_family,
...(m.tags ?? []),
]
.join(' ')
.toLowerCase()
.includes(search.trim().toLowerCase()),
)
.sort(
(a, b) =>
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
Number(b.favorite) - Number(a.favorite) ||
(a.model_family ?? '本地工作流').localeCompare(
b.model_family ?? '本地工作流',
'zh',
) ||
a.name.localeCompare(b.name, 'zh'),
),
);
$effect(() => {
void search;
void usage;
void family;
void favOnly;
limit = 48;
});
$effect(() => {
const index = filtered.findIndex((m) => m.id === selectedId);
if (index >= limit) limit = index + 24;
});
function runnable(m: WorkflowMeta) {
return isWorkflowRunnable(m);
}
function choose(m: WorkflowMeta) {
if (runnable(m)) onPick(m.id);
else void goto(resolve(`/workflows?manage=1&wf=${m.id}`));
}
function pin(id: number) {
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
try {
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
} catch {
/* Pins remain usable this visit. */
}
}
const favorite = createMutation({
mutationFn: (m: WorkflowMeta) =>
workflows.update(m.id, { favorite: !m.favorite }),
onSuccess: () => client.invalidateQueries({ queryKey: ['workflows-meta'] }),
onError: (e) => toast.error(apiErrorMessage(e)),
});
</script>
<svelte:window onkeydown={searchKeydown} />
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<strong>创作台</strong><button
class="history-toggle a-btn ghost sm"
onclick={onToggleHistory}
aria-label={historyCollapsed ? '展开历史栏' : '收起历史栏'}
aria-expanded={!historyCollapsed}>◫</button
>
</header>
<div class="rail-tools">
<div class="kind-tabs" role="tablist" aria-label="创作类型">
<button
role="tab"
aria-selected={kind === 'image'}
{disabled}
onclick={() => onKind('image')}>图片</button
>
<button
role="tab"
aria-selected={kind === 'video'}
{disabled}
onclick={() => onKind('video')}>视频</button
>
</div>
<div class="rail-search">
<Icon name="search" size={14} />
<input
type="search"
bind:this={searchInput}
onfocus={(e) => {
searchOrigin =
e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
}}
bind:value={search}
aria-label="搜索工作流"
placeholder="搜用途、模型或文件名"
/>
<kbd>/</kbd>
</div>
<div class="filters" role="group" aria-label="筛选工作流用途">
<button aria-pressed={usage === 'all'} onclick={() => (usage = 'all')}
>全部</button
>
{#each usages as u (u)}<button
aria-pressed={usage === u}
onclick={() => (usage = u!)}>{usageLabel(u!)}</button
>{/each}
<button aria-pressed={favOnly} onclick={() => (favOnly = !favOnly)}
>☆ 收藏</button
>
</div>
{#if families.length}<div
class="filters"
role="group"
aria-label="筛选模型家族"
>
<button aria-pressed={family === 'all'} onclick={() => (family = 'all')}
>全部模型</button
>
{#each families as f (f)}<button
aria-pressed={family === f}
onclick={() => (family = f!)}>{f}</button
>{/each}
</div>{/if}
<div class="list-meta">
<span title="目录接口不提供仓库文件总数"
>{filtered.length} 个 · 仓库文件数未知</span
>
<label class="runnable-filter"
><input type="checkbox" bind:checked={runnableOnly} />仅可运行</label
>
</div>
</div>
<div class="rail-list">
{#each filtered.slice(0, limit) as m, index (m.id)}
{#if index === 0 || pins.includes(m.id) !== pins.includes(filtered[index - 1].id) || (!pins.includes(m.id) && m.model_family !== filtered[index - 1].model_family)}<div
class="family-label"
>
{pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'}
</div>{/if}
<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
>
<button
class="workflow-pick"
{disabled}
onclick={() => choose(m)}
aria-pressed={selectedId === m.id}
title={m.name}
><i class:video={kind === 'video'}
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
><span
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
>待配置 · 映射 / 测试 →</small
>{/if}<small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
></button
>
<div class="row-actions">
<button
class:pressed={pins.includes(m.id)}
aria-pressed={pins.includes(m.id)}
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
onclick={() => pin(m.id)}>⌖</button
><button
aria-pressed={!!m.favorite}
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
disabled={$favorite.isPending}
onclick={() => $favorite.mutate(m)}>{m.favorite ? '★' : '☆'}</button
>
</div>
</div>{/each}
{#if !filtered.length}<p class="empty">
没有符合条件的工作流。可以清空搜索或更改筛选。
</p>{/if}
{#if filtered.length > limit}<button
class="more"
onclick={() => (limit += 48)}
>再显示 {Math.min(filtered.length - limit, 48)} 个</button
>{/if}
</div>
<footer>
{#if pendingCount}<a href={resolve('/workflows?manage=1')}
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
><a href={resolve('/models')}>云端模型与接口 ↗</a>
</footer>
</aside>
<style>
.workflow-rail {
box-sizing: border-box;
width: var(--list);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.workflow-rail > header {
padding: 12px 12px 0;
height: var(--desk-rail-title-height);
box-sizing: border-box;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
header strong {
font-size: 14px;
font-weight: 600;
display: block;
}
.rail-tools {
padding: var(--desk-rail-gap) 12px;
border-bottom: 1px solid var(--line);
}
.kind-tabs {
display: flex;
gap: 2px;
padding: 3px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: var(--desk-seg-radius);
background: var(--well);
}
.kind-tabs button {
flex: 1;
height: 28px;
padding: 0 12px;
font-size: 12.5px;
border: 0;
border-radius: var(--desk-seg-selected-radius);
background: transparent;
color: var(--tx-2);
cursor: pointer;
}
.kind-tabs [aria-selected='true'] {
background: var(--bg-3);
box-shadow: var(--sh-1);
color: var(--tx-1);
}
.kind-tabs button:disabled {
cursor: wait;
}
.rail-search {
display: flex;
align-items: center;
gap: 8px;
margin-top: var(--desk-rail-gap);
height: var(--desk-search-height);
box-sizing: border-box;
font-size: var(--desk-search-size);
padding: 0 10px;
background: var(--well);
border: 1px solid var(--line-2);
border-radius: var(--desk-search-radius);
color: var(--text);
}
.rail-search input {
min-width: 0;
flex: 1;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
outline: none;
}
.rail-search kbd {
font: var(--desk-search-key-font);
padding: var(--desk-search-key-padding);
border-radius: var(--desk-search-key-radius);
border: 1px solid var(--line-2);
color: var(--tx-3);
background: var(--bg-1);
}
.rail-search:focus-within {
border-color: var(--scene);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.filters button {
height: var(--desk-filter-height);
padding: 0 9px;
font-size: var(--desk-filter-size);
border-radius: var(--desk-filter-radius);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--tx-2);
cursor: pointer;
}
.filters button[aria-pressed='true'] {
color: var(--tx-1);
border-color: color-mix(in srgb, var(--scene) 60%, transparent);
background: color-mix(in srgb, var(--scene) 13%, var(--bg-2));
}
.runnable-filter {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
cursor: pointer;
}
.runnable-filter input {
margin: 0;
appearance: none;
width: var(--desk-switch-width);
height: var(--desk-switch-height);
border: 0;
border-radius: var(--r-pill);
background: var(--bg-4);
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.runnable-filter input::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: var(--desk-switch-knob);
height: var(--desk-switch-knob);
border-radius: 50%;
background: var(--tx-2);
transition: transform var(--t-base);
}
.runnable-filter input:checked {
background: color-mix(in srgb, var(--scene) 55%, var(--bg-4));
}
.runnable-filter input:checked::after {
transform: translateX(11px);
background: var(--on-media);
}
.pending {
color: var(--warn);
}
.list-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 8px 2px 0;
font-size: var(--desk-filter-size);
color: var(--tx-3);
}
.family-label {
font-size: 11px;
color: var(--tx-3);
padding: 8px;
}
.rail-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0 8px 8px;
scrollbar-gutter: stable;
}
.workflow-row {
border: 1px solid transparent;
position: relative;
display: flex;
align-items: center;
border-radius: 10px;
margin: 2px 0;
}
.workflow-row.selected {
border-color: color-mix(in srgb, var(--scene) 35%, transparent);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--scene) 12%, var(--bg-3)),
var(--bg-3)
);
}
.workflow-row.selected::before {
content: '';
width: 3px;
height: 22px;
position: absolute;
left: 0;
background: var(--scene);
border-radius: 3px;
}
.workflow-pick {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 8px;
min-height: 55px;
width: 100%;
min-width: 0;
text-align: left;
border: 0;
background: none;
color: var(--text);
cursor: pointer;
}
.workflow-pick i {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 9px;
border: 1px solid var(--line-2);
color: var(--scene);
background: var(--bg-3);
font-size: 16px;
font-style: normal;
}
.workflow-pick > span {
min-width: 0;
flex: 1;
}
.workflow-pick b {
display: block;
font-size: 12px;
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.workflow-pick small {
display: block;
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-3);
}
.row-actions {
display: flex;
gap: 2px;
padding-right: 4px;
flex-shrink: 0;
}
.row-actions button {
opacity: 0;
padding: 2px;
height: 24px;
width: 20px;
border: 0;
color: var(--text-3);
background: none;
font-size: 12px;
cursor: pointer;
}
.row-actions button[aria-pressed='true'] {
color: var(--warn);
opacity: 1;
}
.workflow-row:hover .row-actions button,
.workflow-row:focus-within .row-actions button {
opacity: 1;
}
.workflow-row:hover {
background-color: var(--bg-2);
}
.empty {
color: var(--text-3);
font-size: 11px;
padding: 12px;
}
.more {
width: 100%;
padding: 8px;
background: none;
border: 1px solid var(--line);
color: var(--text-3);
border-radius: 6px;
font-size: 11px;
cursor: pointer;
}
footer {
padding: 12px;
display: grid;
gap: 6px;
border-top: 1px solid var(--line);
}
footer a,
.manage {
font-size: 11px;
color: var(--text-3);
text-decoration: none;
}
.manage {
text-align: left;
display: flex;
align-items: center;
gap: 7px;
background: none;
border: 0;
padding: 7px 8px;
cursor: pointer;
}
.manage small {
display: none;
}
.manage b {
margin-left: auto;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
@media (max-width: 1100px) {
.workflow-rail {
width: var(--list);
}
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts">
import { assetUrl } from '$lib/api';
import { jobStatusInfo } from '$lib/jobStatus';
import { isVideoOutput } from '../generate/takes';
import type { Job } from '$lib/comfy/types';
let {
jobs,
selectedId,
onSelect,
onExpand,
collapsed = false,
}: {
collapsed?: boolean;
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onExpand: () => void;
} = $props();
const sorted = $derived([...jobs].sort((a, b) => b.id - a.id));
</script>
<aside class="history-rail" class:collapsed aria-label="生成历史">
<header>历史</header>
{#if !collapsed}
<div class="history-list">
{#each sorted as job (job.id)}
{@const status = jobStatusInfo(job)}
<button
class="history-take"
class:selected={selectedId === job.id}
onclick={() => onSelect(job)}
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
aria-pressed={selectedId === job.id}
>
<div class="thumb">
{#if job.output_paths[0] && status.tone === 'ok'}
{#if isVideoOutput(job, job.output_paths[0])}<span
class="video-mark">▷</span
><video
src={assetUrl(job.output_paths[0])}
muted
preload="metadata"
aria-label="视频缩略图"
></video>
{:else}<img
src={assetUrl(job.output_paths[0])}
alt=""
loading="lazy"
/>{/if}
{:else}<span class:failed={status.tone === 'fail'}
>{status.label}</span
>{/if}
</div>
<small>#{job.id}</small>
</button>
{/each}
{#if !sorted.length}<p>生成后<br />记录留在这里</p>{/if}
</div>
<footer><button onclick={onExpand}>全部记录</button></footer>
{/if}
</aside>
<style>
.history-rail {
box-sizing: border-box;
width: var(--hist);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--line);
background: var(--bg-1);
}
.history-rail.collapsed {
width: 0;
border: 0;
overflow: hidden;
visibility: hidden;
}
header {
height: var(--head);
display: grid;
place-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
button {
font: inherit;
font-size: 11px;
background: none;
color: var(--tx-2);
border: 0;
cursor: pointer;
}
.history-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 6px;
}
.history-take {
display: block;
width: 100%;
padding: 3px;
margin-bottom: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
}
.history-take.selected {
border-color: var(--scene);
background: color-mix(in srgb, var(--scene) 10%, transparent);
}
.thumb {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
background: var(--bg-0);
border-radius: var(--r-xs);
overflow: hidden;
font-size: 10px;
}
img,
video {
width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
inset: 0;
}
.video-mark {
position: absolute;
z-index: 1;
color: var(--on-media);
font-size: 24px;
text-shadow: 0 1px 6px var(--media-scrim);
}
.failed {
color: var(--err);
}
small {
font-family: var(--mono);
font-size: 10px;
color: var(--tx-3);
}
p {
text-align: center;
font-size: 10px;
color: var(--tx-3);
line-height: 1.7;
}
footer {
padding: 10px 2px;
text-align: center;
border-top: 1px solid var(--line);
}
</style>
@@ -0,0 +1,150 @@
<script lang="ts">
import { assetUrl } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { missingOutputsOf, markOutputMissing } from '$lib/missingOutputs.svelte';
let {
jobs,
selectedId,
onSelect,
onHistory,
nameOf,
}: {
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onHistory: () => void;
nameOf: (job: Job) => string;
} = $props();
const sorted = $derived(
[...jobs].sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at)),
);
</script>
<section class="result-rail" aria-label="生成结果记录">
<header>
<strong>生成记录 <small>{jobs.length}</small></strong><button
class="a-btn ghost sm"
onclick={onHistory}>全部记录与任务 ↗</button
>
</header>
<div class="rail-items">
{#each sorted as job (job.id)}
{@const path = job.output_paths.find((p) => !missingOutputsOf(job).includes(p))}
{@const status = jobStatusInfo(job)}
<button
class="result-item"
class:selected={job.id === selectedId}
aria-pressed={job.id === selectedId}
aria-label="查看第 {job.id} 次生成,{status.label}"
title="{nameOf(job)} · {job.created_at} · {status.label}"
onclick={() => onSelect(job)}
>
<span class="thumb"
>{#if path && job.kind === 'image'}<img
src={assetUrl(path)}
alt=""
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<span>{job.kind === 'video' ? '▶' : '✳'}</span>{/if}</span
>
<span class="record-label">{nameOf(job)}</span>
<span class="record-label"
>第 {job.id} 次 · {path
? status.label
: job.status === 'done'
? '文件缺失'
: status.label}</span
>
</button>
{:else}<p>生成后,结果会保存在这里。</p>{/each}
</div>
</section>
<style>
.result-rail {
border-top: 1px solid var(--line);
padding-top: 8px;
min-width: 0;
flex-shrink: 0;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
gap: 10px;
}
strong {
font-size: 12px;
}
small {
color: var(--text-3);
margin-left: 5px;
font-weight: 400;
}
.rail-items {
display: flex;
gap: 8px;
overflow-x: auto;
overscroll-behavior: contain;
padding-bottom: 5px;
}
.result-item {
width: 122px;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
padding: 3px;
background: var(--surface);
cursor: pointer;
color: var(--text);
}
.result-item.selected {
border: 2px solid var(--accent);
padding: 2px;
background: var(--accent-soft);
}
.thumb {
display: grid;
place-items: center;
height: 48px;
background: var(--bg);
border-radius: 5px;
color: var(--accent);
font-size: 20px;
}
img {
width: 100%;
height: 48px;
object-fit: cover;
border-radius: 5px;
}
.record-label {
display: block;
font-size: 10px;
padding: 5px 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
font-size: 12px;
color: var(--text-3);
margin: 4px 0;
}
@media (max-height: 650px) {
.rail-items {
display: none;
}
.thumb,
img {
height: 32px;
}
.result-item {
width: 110px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -18,11 +18,8 @@
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local counting accumulator is consumed synchronously into mode rows; metas/kind drive the derivation.
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
@@ -1,18 +1,13 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { tick } from 'svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { workflows, settings, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import LibraryFilters from './LibraryFilters.svelte';
import LibraryRow from './LibraryRow.svelte';
/**
* 工作流挑选台 (HEL-683, HEL-674 §二):中部可搜索筛选、按模型家族
* 分组的挑选列。数据来自 S2 轻量元数据(view=meta,不含完整 JSON);
* 云端来源只做诚实占位(未接入,不可点击使用)。键盘:↑↓/J/K 游走、
* Enter 使用、Esc 关闭。长列表增量渲染 + content-visibility。
*/
import InputRequirementBadge from './InputRequirementBadge.svelte';
import { toast } from '$lib/toast';
interface Props {
open: boolean;
metas: WorkflowMeta[];
@@ -22,467 +17,534 @@
endpoints: Array<{ id: string; name: string }>;
onPick: (id: number) => void;
onClose: () => void;
catalogue?: boolean;
localOnly?: boolean;
onPreview?: (id: number) => void;
}
let { open, metas, pendingCount, selectedId, activeId = $bindable(null), endpoints, onPick, onClose }: Props =
$props();
let search = $state('');
let source = $state<'local' | 'cloud'>('local');
let favOnly = $state(false);
let visibleCount = $state(48);
let searchEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLElement | null>(null);
const CATEGORY_LABELS: Record<string, string> = {
basic: 'wfCat.basic',
audio_tts: 'wfCat.audioTts',
music: 'wfCat.music',
image: 'wfCat.image',
video: 'wfCat.video',
digital_human: 'wfCat.digitalHuman',
motion_transfer: 'wfCat.motionTransfer',
let {
open,
metas,
pendingCount,
selectedId,
activeId = $bindable(null),
onPick,
onClose,
catalogue = false,
localOnly = false,
onPreview,
}: Props = $props();
const client = useQueryClient();
const modelsQuery = createQuery({
queryKey: ['settings'],
queryFn: settings.get,
});
let search = $state(''),
category = $state('all'),
family = $state('all'),
source = $state('local'),
usage = $state('all'),
favOnly = $state(false),
recentOnly = $state(false),
visibleCount = $state(48);
let searchEl: HTMLInputElement;
let listEl: HTMLDivElement;
const labels: Record<string, string> = {
basic: '基础工具',
audio_tts: '语音',
music: '音乐',
image: '图片',
video: '视频',
digital_human: '数字人',
motion_transfer: '动作',
};
function categoryLabel(key: string | null | undefined): string {
if (!key) return '';
const k = CATEGORY_LABELS[key];
return k ? t(k) : key;
}
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
return metas.filter((m) => {
if (favOnly && !m.favorite) return false;
if (!q) return true;
const hay = [
m.name,
m.description ?? '',
(m.tags ?? []).join(' '),
m.model_family ?? '',
m.usage ? usageLabel(m.usage) : '',
categoryLabel(m.category),
]
.join(' ')
.toLowerCase();
return hay.includes(q);
});
});
interface Group {
key: string;
label: string;
rows: WorkflowMeta[];
}
const groups = $derived.by(() => {
const byFamily = new Map<string, WorkflowMeta[]>();
for (const m of filtered) {
const key = m.model_family ?? '';
const list = byFamily.get(key) ?? [];
list.push(m);
byFamily.set(key, list);
}
const out: Group[] = [];
for (const [key, rows] of byFamily) {
rows.sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
const la = a.last_used_at ?? '';
const lb = b.last_used_at ?? '';
if (la !== lb) return lb.localeCompare(la);
return a.name.localeCompare(b.name, 'zh');
});
out.push({ key, label: key || t('desk.bayNoFamily'), rows });
}
out.sort((a, b) =>
a.key === '' ? 1 : b.key === '' ? -1 : a.label.localeCompare(b.label, 'zh'),
);
return out;
});
const flat = $derived(groups.flatMap((g) => g.rows));
/** Flattened rows cut at the incremental window, per group. */
const windowed = $derived.by(() => {
let left = visibleCount;
return groups.map((g) => {
const take = Math.max(0, Math.min(g.rows.length, left));
left -= take;
return { ...g, visible: g.rows.slice(0, take) };
});
});
// Any filter change restarts the incremental window.
const categories = $derived([
...new Set(metas.map((m) => m.category).filter((k): k is string => !!k)),
]);
const families = $derived(
[...new Set(metas.map((m) => m.model_family).filter((k): k is string => !!k))].sort(),
);
const filtered = $derived(
metas
.filter(
(m) =>
(category === 'all' || m.category === category) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
(!favOnly || m.favorite) &&
(!recentOnly || m.last_used_at) &&
(!search.trim() ||
[
m.name,
m.description,
...(m.tags ?? []),
m.model_family,
m.usage ? usageLabel(m.usage) : '',
]
.join(' ')
.toLowerCase()
.includes(search.toLowerCase().trim())),
)
.sort((a, b) =>
recentOnly
? (b.last_used_at ?? '').localeCompare(a.last_used_at ?? '')
: Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, 'zh'),
),
);
const visible = $derived(filtered.slice(0, visibleCount));
const cloudModels = $derived(
($modelsQuery.data?.llm_profiles ?? []).filter(
(p) => !search || [p.name, p.model].join(' ').toLowerCase().includes(search.toLowerCase()),
),
);
$effect(() => {
void search;
void category;
void family;
void usage;
void favOnly;
void recentOnly;
visibleCount = 48;
listEl?.scrollTo({ top: 0 });
});
// Keep the active row real: follow the selection, clamp on filter changes.
$effect(() => {
void flat;
if (activeId != null && flat.some((m) => m.id === activeId)) return;
activeId = flat.find((m) => m.id === selectedId)?.id ?? flat[0]?.id ?? null;
});
// Opening the bay: the selected workflow's row must be inside the
// incremental window and in view — otherwise the highlight is invisible.
$effect(() => {
if (!open) return;
const target = activeId ?? selectedId;
const idx = flat.findIndex((m) => m.id === target);
if (idx < 0) return;
const idx = filtered.findIndex((m) => m.id === selectedId);
if (idx >= visibleCount) visibleCount = idx + 48;
const id = setTimeout(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${target}"]`)
?.scrollIntoView({ block: 'center' });
}, 0);
return () => clearTimeout(id);
void tick().then(() => searchEl?.focus());
});
const activeMeta = $derived(metas.find((m) => m.id === activeId) ?? null);
const resultHint = $derived(
source === 'cloud' ? '' : t('desk.bayCount', { n: filtered.length, total: metas.length }),
);
// Autofocus the search whenever the bay opens.
$effect(() => {
if (!open) return;
const id = setTimeout(() => searchEl?.focus(), 0);
return () => clearTimeout(id);
if (!filtered.some((m) => m.id === activeId)) activeId = filtered[0]?.id ?? null;
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((m) => m.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
i = Math.max(0, Math.min(flat.length - 1, i));
const next = flat[i];
activeId = next.id;
// Keyboard roving may step past the incremental window — extend it so
// the row exists, then reveal it.
if (i >= visibleCount) visibleCount = i + 24;
void tick().then(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${next.id}"]`)
?.scrollIntoView({ block: 'nearest' });
});
}
function pickActive() {
if (activeMeta) onPick(activeMeta.id);
}
export function focusSearch() {
searchEl?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
const inSearch = e.target === searchEl;
if (e.key === 'ArrowDown' || (!inSearch && e.key === 'j')) {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp' || (!inSearch && e.key === 'k')) {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
pickActive();
async function favorite(m: WorkflowMeta) {
try {
await workflows.update(m.id, { favorite: !m.favorite });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
} catch (e) {
toast.error(apiErrorMessage(e));
}
}
function onScroll() {
const el = listEl;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) {
if (visibleCount < flat.length) visibleCount += 48;
function pick(m: WorkflowMeta) {
if (onPreview) onPreview(m.id);
else if (isWorkflowRunnable(m)) onPick(m.id);
}
function move(delta: number) {
const i = Math.max(
0,
Math.min(filtered.length - 1, filtered.findIndex((m) => m.id === activeId) + delta),
);
activeId = filtered[i]?.id ?? null;
if (i >= visibleCount) visibleCount = i + 24;
void tick().then(() =>
listEl?.querySelector(`[data-wf-row="${activeId}"]`)?.scrollIntoView({ block: 'nearest' }),
);
}
function key(e: KeyboardEvent) {
if (e.defaultPrevented) return;
if (e.key === 'Escape' && !catalogue) {
e.preventDefault();
onClose();
} else if (source !== 'local') {
return;
} else if (e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Enter' && e.target === searchEl) {
e.preventDefault();
const m = filtered.find((m) => m.id === activeId);
if (m) pick(m);
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bay-backdrop" onclick={onClose} aria-hidden="true"></div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section
class="bay"
class:open
aria-label={t('desk.pickWorkflow')}
inert={!open}
onkeydown={onKeydown}
>
<header class="bay-head">
<h2 class="bay-title">
<Icon name="playground" size={15} />
<span>{t('desk.pickWorkflow')}</span>
</h2>
<button type="button" class="a-btn ghost sm close" onclick={onClose} title={t('desk.keyClose')}>
<Icon name="close" size={14} />
<span class="lbl">{t('desk.keyClose')}</span>
</button>
</header>
<div class="bay-filters">
<LibraryFilters
bind:searchEl
{search}
{source}
{favOnly}
{resultHint}
onSetSearch={(v) => (search = v)}
onSetSource={(s) => (source = s)}
onToggleFav={() => (favOnly = !favOnly)}
/>
</div>
{#if source === 'cloud'}
<div class="cloud-note" role="note">
<Icon name="alert" size={16} />
<p class="cloud-title">{t('desk.bayCloudTitle')}</p>
<p class="cloud-body">{t('desk.bayCloudBody')}</p>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="catalogue" inert={!open} onkeydown={key}>
<aside class="categories">
<p>按创作任务寻找</p>
<button
class:active={category === 'all' && !favOnly && !recentOnly}
onclick={() => {
category = 'all';
favOnly = false;
recentOnly = false;
}}><span>全部工具</span><small>{metas.length}</small></button
>
{#each categories as cat (cat)}<button
class:active={category === cat}
onclick={() => {
category = cat;
favOnly = false;
recentOnly = false;
}}
><span>{labels[cat] ?? cat}</span><small
>{metas.filter((m) => m.category === cat).length}</small
></button
>{/each}
<div class="divider"></div>
<button
class:active={favOnly}
onclick={() => {
favOnly = !favOnly;
recentOnly = false;
}}><span>☆ 我的收藏</span><small>{metas.filter((m) => m.favorite).length}</small></button
><button
class:active={recentOnly}
onclick={() => {
recentOnly = !recentOnly;
favOnly = false;
}}><span>◷ 最近使用</span></button
>
</aside>
<section class="catalogue-main">
<label class="search"
><span>⌕</span><input
type="search"
bind:this={searchEl}
bind:value={search}
placeholder="搜索工作流、模型或用途…"
aria-label="搜索工作流"
/></label
>
<div class="filters">
<button class:active={source === 'local'} onclick={() => (source = 'local')}
>本地 ComfyUI</button
>{#if !localOnly}<button class:active={source === 'cloud'} onclick={() => (source = 'cloud')}
>云端与智能体</button
>{/if}{#if source === 'local'}<select aria-label="筛选创作任务" bind:value={usage}
><option value="all">所有创作任务</option>{#each [...new Set(metas
.map((m) => m.usage)
.filter(Boolean))] as u (u)}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f (f)}<option value={f}>{f}</option
>{/each}</select
>{/if}<span>{source === 'local' ? filtered.length : cloudModels.length} 个结果</span>
</div>
{:else if !metas.length}
<div class="bay-empty">
<p>{t('desk.noWorkflows')}</p>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bay-list" bind:this={listEl} role="listbox" aria-label={t('desk.pickWorkflow')} onscroll={onScroll}>
{#each windowed as g (g.key)}
{#if g.visible.length}
<div class="group-label" aria-hidden="true">{g.label} · {g.rows.length}</div>
{#each g.visible as m (m.id)}
<div data-wf-row={m.id}>
<LibraryRow
meta={m}
current={m.id === selectedId}
active={m.id === activeId}
{endpoints}
onPick={() => onPick(m.id)}
onActive={() => (activeId = m.id)}
/>
</div>
{/each}
{/if}
<div class="results" bind:this={listEl}>
{#if source === 'cloud'}
<div class="cloud-context">
<h3>让专业搭档加入创作。</h3>
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
<a href={resolve('/canvas')} class="a-btn primary">打开创作画布 ↗</a>
</div>
{#each cloudModels as profile (profile.id)}<article class="workflow-card">
<div class="family">{profile.model}<span>已配置模型</span></div>
<h3>{profile.name}</h3>
<a href={resolve('/agents')} class="tool-link">交给智能体创作 ↗</a>
</article>{:else}<p class="empty">
还没有匹配的模型。<a href={resolve('/models')}>配置模型 ↗</a>
</p>{/each}
{:else}
<p class="bay-none">{t('desk.wfNoMatch')}</p>
{/each}
{#if visibleCount < flat.length}
<button type="button" class="more a-btn ghost sm" onclick={() => (visibleCount += 48)}>
{t('desk.bayMore', { n: Math.min(48, flat.length - visibleCount) })}
</button>
{#each visible as m (m.id)}<article
class="workflow-card"
class:selected={m.id === selectedId}
class:roving={m.id === activeId}
data-wf-row={m.id}
>
<button
class="workflow-choice"
onclick={() => pick(m)}
onfocus={() => (activeId = m.id)}
disabled={!onPreview && (!m.is_enabled || m.needs_mapping || !m.usage)}
aria-label="{onPreview ? '查看' : '使用'} {m.name}"
aria-current={m.id === selectedId ? 'true' : undefined}
>
<div class="family">
{m.model_family ?? 'COMFYUI'}<span>本地</span>
</div>
<h3 title={m.name}>{m.name}</h3>
{#if m.description}<p class="description">{m.description}</p>{/if}
<div class="card-meta">
<span>{m.usage ? usageLabel(m.usage) : '待映射'}</span><InputRequirementBadge
io={m.media_io}
compact
/>
</div>
</button>
<button
class="favorite"
class:saved={m.favorite}
aria-label="{m.favorite ? '取消收藏' : '收藏'} {m.name}"
aria-pressed={m.favorite}
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
>
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
href={resolve('/workflows?manage=1')}
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
>{/if}
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
{#if visibleCount < filtered.length}<button
class="more a-btn secondary"
onclick={() => (visibleCount += 48)}
>再看 {Math.min(48, filtered.length - visibleCount)} 个工具</button
>{/if}
{/if}
</div>
<footer class="bay-foot">
{#if pendingCount > 0}
<a class="pending-link" href="/connections">
{t('desk.bayPending', { n: pendingCount })}
<Icon name="chevron-right" size={12} />
</a>
{/if}
{#if activeMeta}
<span class="active-name" title={activeMeta.name}>{activeMeta.name}</span>
{/if}
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</footer>
{/if}
</section>
{#if pendingCount > 0}<p class="pending">
另有 {pendingCount} 个工作流待映射。<a href={resolve('/workflows?manage=1')}>去整理 ↗</a>
</p>{/if}
</section>
</div>
<style>
.bay {
display: flex;
flex-direction: column;
.description {
font-size: 11px;
color: var(--text-3);
line-height: 1.5;
margin: 0 0 10px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 1100px) {
.catalogue {
grid-template-columns: 112px minmax(0, 1fr);
}
.catalogue-main {
padding-left: 12px;
}
.results {
grid-template-columns: 1fr;
}
}
.catalogue {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
height: 100%;
min-height: 0;
min-width: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-4) var(--sp-3);
overflow: hidden;
}
.bay-head {
.categories {
padding: 16px 15px 20px 0;
border-right: 1px solid var(--line);
overflow: auto;
}
.categories p {
font-size: 9px;
letter-spacing: 1px;
color: var(--text-3);
margin: 0 9px 15px;
}
.categories button {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-2) var(--sp-4);
width: 100%;
border: 0;
background: none;
border-radius: 7px;
text-align: left;
font-size: 11px;
padding: 11px 10px;
color: var(--text-2);
cursor: pointer;
}
.bay-title {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
.categories button.active {
background: #e8eafe;
color: var(--accent);
}
.bay-title :global(svg) {
.categories small {
font-size: 9px;
color: var(--text-3);
}
.close {
margin-left: auto;
.divider {
border-top: 1px solid var(--line);
margin: 20px 8px 12px;
}
.close .lbl {
display: none;
}
.bay-filters {
padding: 0 var(--sp-1) var(--sp-3);
}
.bay-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
.catalogue-main {
padding: 16px 0 0 22px;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-1);
min-height: 0;
min-width: 0;
}
.group-label {
position: sticky;
top: 0;
z-index: 2;
padding: var(--sp-3) var(--sp-3) var(--sp-1);
background: linear-gradient(var(--bg-2) 82%, transparent);
.search {
display: flex;
gap: 9px;
align-items: center;
background: var(--bg);
border: 1px solid var(--line);
padding: 9px 13px;
border-radius: 8px;
}
.search input {
border: 0;
outline: none;
background: transparent;
width: 100%;
font-size: 12px;
color: var(--text);
}
.search:focus-within {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.filters {
display: flex;
gap: 7px;
align-items: center;
margin: 16px 0 21px;
flex-wrap: wrap;
}
.filters button,
.filters select {
font-size: 10px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 20px;
background: var(--surface);
color: var(--text-3);
cursor: pointer;
}
.filters button.active {
color: var(--surface);
background: var(--text);
border-color: var(--text);
}
.filters span {
font-size: 9px;
color: var(--text-3);
margin-left: auto;
}
.filters select {
max-width: 140px;
}
.results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
overflow: auto;
min-height: 0;
align-content: start;
padding: 2px 5px 15px 2px;
}
.workflow-card {
border: 1px solid var(--line);
border-radius: 11px;
padding: 14px;
position: relative;
background: var(--surface);
min-width: 0;
content-visibility: auto;
contain-intrinsic-size: auto 132px;
}
.workflow-card.selected {
border-color: var(--accent);
background: #f1f3ff;
}
.workflow-choice {
display: block;
text-align: left;
width: 100%;
padding: 0 0 9px;
border: 0;
background: none;
color: var(--text);
cursor: pointer;
}
.workflow-choice:disabled {
opacity: 0.65;
cursor: default;
}
.family {
font: 9px var(--mono);
letter-spacing: 0.5px;
display: flex;
justify-content: space-between;
margin-bottom: 12px;
color: var(--text-3);
}
.family span {
font: 8px var(--font);
padding: 2px 5px;
border: 1px solid var(--line);
border-radius: 4px;
}
h3 {
font-size: 11px;
line-height: 1.7;
margin: 0 0 9px;
font-weight: 550;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.card-meta {
display: flex;
gap: 5px;
flex-wrap: wrap;
font-size: 9px;
color: var(--text-3);
padding-right: 14px;
}
.favorite {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 18px;
border: 0;
background: none;
color: #838a78;
cursor: pointer;
}
.favorite.saved {
color: var(--accent);
}
.tool-link {
font-size: 10px;
color: var(--accent);
text-decoration: none;
}
.cloud-context {
grid-column: 1/-1;
background: #e9e7df;
padding: 25px;
border-radius: 12px;
}
.cloud-context h3 {
font-size: 19px;
}
.cloud-context p {
font-size: 11px;
color: var(--text-2);
line-height: 1.8;
}
.cloud-context .a-btn {
font-size: 11px;
}
.pending {
font-size: 10px;
color: var(--text-3);
padding-top: 12px;
border-top: 1px solid var(--line);
margin: 0;
}
.empty {
grid-column: 1/-1;
padding: 45px;
text-align: center;
font-size: 12px;
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.04em;
}
.more {
align-self: center;
margin: var(--sp-3) 0;
grid-column: 1/-1;
justify-self: center;
font-size: 11px;
}
.bay-none,
.bay-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
text-align: center;
}
.bay-empty {
display: grid;
justify-content: center;
gap: var(--sp-3);
}
.bay-foot {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-2) 0;
border-top: 1px solid var(--line);
font-size: var(--fs-mini);
color: var(--text-3);
}
.pending-link {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
}
.pending-link:hover {
color: var(--text);
}
.active-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-2);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
.cloud-note {
display: grid;
justify-items: start;
gap: var(--sp-3);
margin: var(--sp-4) var(--sp-2);
padding: var(--sp-6);
border: 1px dashed var(--line-2);
border-radius: var(--r-lg);
color: var(--text-2);
}
.cloud-title {
margin: 0;
color: var(--text);
font-weight: 600;
}
.cloud-body {
margin: 0;
font-size: var(--fs-aux);
line-height: 1.6;
}
/* 窄/中屏:挑选台改为全屏浮层(计划中的最简可用降级)。 */
.bay-backdrop {
display: none;
}
@media (max-width: 1179px) {
.bay {
position: fixed;
left: 0;
right: 0;
top: var(--topbar-h);
bottom: 0;
z-index: 60;
background: var(--bg);
box-shadow: var(--shadow-float);
transform: translateY(12px);
opacity: 0;
pointer-events: none;
transition:
transform var(--d-drawer) var(--ease-drawer),
opacity var(--d-state) ease;
@media (hover: hover) {
.workflow-card:hover {
border-color: var(--accent-line);
}
.bay.open {
transform: none;
opacity: 1;
pointer-events: auto;
}
.bay-backdrop {
display: block;
position: fixed;
inset: var(--topbar-h) 0 0 0;
z-index: 59;
background: var(--overlay);
}
.close .lbl {
display: inline;
}
.keys {
display: none;
}
:global(:root[data-reduce='1']) .bay {
transition: none;
}
}
@media (max-width: 760px) {
.bay {
top: 0;
padding: var(--sp-4) var(--sp-4) calc(var(--tabbar-h) + env(safe-area-inset-bottom));
}
.bay-backdrop {
inset: 0;
.categories button:hover {
background: var(--bg-3);
}
}
</style>
@@ -0,0 +1,17 @@
// Same-tab fallback for browsers that deny session storage. No user data leaves the browser.
const drafts = new Map<string, string>();
export function readDeskDraft(key: string): string | null {
try {
return sessionStorage.getItem(key) ?? drafts.get(key) ?? null;
} catch {
return drafts.get(key) ?? null;
}
}
export function writeDeskDraft(key: string, value: string) {
drafts.set(key, value);
try {
sessionStorage.setItem(key, value);
} catch {
/* In-memory switching still works. */
}
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,14 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { backHref, type DeskContext } from './context';
/**
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
* Says where from, and plainly that takes stay in the desk feed — the
* desk's generate call has no shot/asset link to fill back.
* Says where from. With a full source identity (HEL-684) results can
* be flowed back per take from the result card; otherwise the old
* note stands — results stay in the desk feed, nothing is written back.
*/
interface Props {
ctx: DeskContext;
@@ -17,6 +19,7 @@
let { ctx, projectTitle, onDismiss }: Props = $props();
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
const canFlowBack = $derived(!!ctx.sourceKind && !!ctx.sourceId);
</script>
<div class="context-bar" role="status">
@@ -24,8 +27,10 @@
<span class="from">
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label}&nbsp;· {ctx.label}{/if}
</span>
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
<span class="note">
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
</span>
<a class="a-btn ghost xs back" href={resolve(backHref(ctx))}>{t('desk.ctxBack', { stage: stageName })}</a>
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
<Icon name="close" size={12} />
</button>
@@ -1,12 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { untrack, type Snippet } from 'svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import {
clearOutputMissing,
markOutputMissing,
missingOutputsOf,
} from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
import { usageLabel } from './schema';
@@ -18,11 +23,13 @@
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
* fullscreen — `overlay` / `open` drive the last two.
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
* ↑↓/J/K 即时无动画), video playback is always explicit,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
main?: boolean;
comparePath?: string | null;
tab: 'result' | 'params';
job: Job | null;
wf: Workflow | undefined;
@@ -34,19 +41,20 @@
canSendToSlot: boolean;
/** Animate this result change (pointer pick) — 150ms cross-fade. */
crossfade: boolean;
/** Bump to auto-play the stage video (pointer pick / completion). */
playSignal: number;
/** The stage <video> element, for the desk's space-bar toggle. */
/** The stage <video> element, for explicit playback controls. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
let {
main = false,
comparePath = null,
tab = $bindable(),
job,
wf,
@@ -54,18 +62,25 @@
open,
inactive,
canSendToSlot,
crossfade,
playSignal,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
}: Props = $props();
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
lightbox = $state(false);
$effect(() => {
void job?.id;
compare = false;
lightbox = false;
});
$effect.pre(() => {
void job?.id;
outIndex = 0;
@@ -76,16 +91,20 @@
// cannot be fetched — the failed case stays red.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
const outputs = $derived(
job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : [],
);
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const compareSource = $derived(inputs.find((i) => i.kind === 'image')?.path ?? comparePath);
const spec = $derived(job ? specOf(job, wf) : []);
const endpoint = $derived(
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ??
'',
);
const progress = $derived.by(() => {
if (!job || job.status !== 'running') return 0;
@@ -108,25 +127,18 @@
dims = `${v.videoWidth}×${v.videoHeight}`;
}
// 完成或点选后自动播放(静音);键盘切换不触发。
$effect(() => {
void playSignal;
if (inactive || tab !== 'result' || !videoEl) return;
videoEl.muted = true;
videoEl.play().catch(() => {});
let paused = $state(true);
$effect.pre(() => {
void output;
untrack(() => {
videoEl?.pause();
paused = true;
});
});
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
function xfade(_node: Element) {
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
return { duration: ms, css: (t: number) => `opacity: ${t}` };
function togglePlayback() {
if (!videoEl) return;
if (videoEl.paused) videoEl.play().catch(() => {});
else videoEl.pause();
}
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
@@ -144,14 +156,23 @@
}
</script>
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
<aside class="inspector" class:main class:open inert={inactive} aria-label={t('desk.inspector')}>
<div class="tabs">
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}
>{t('desk.tabResult')}</button
>
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}
>{t('desk.tabParams')}</button
>
</div>
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
<button
type="button"
class="a-btn ghost sm close"
aria-label={t('desk.closeInspector')}
onclick={onClose}
>
<Icon name="close" size={14} />
</button>
</div>
@@ -160,11 +181,17 @@
{#if tab === 'params'}
{@render params()}
{:else if !job || !st}
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
<div class="a-stage stage">
<div class="stage-empty">
<span class="empty-symbol">✳</span><strong>给想象留一个位置。</strong><span class="sub"
>在右侧写下描述,或选择一次已有创作。</span
>
</div>
</div>
{:else}
<div class="a-stage stage">
{#key mediaKey}
<div class="stage-media" in:xfade out:xfade>
<div class="stage-media">
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
@@ -173,6 +200,10 @@
bind:this={videoEl}
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
@@ -194,6 +225,13 @@
}}
onerror={() => output && markOutputMissing(output)}
/>
{#if main && compare && compareSource}<div
class="comparison-layer"
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
>
<img src={assetUrl(compareSource)} alt="对比参考画面" />
</div>
<div class="comparison-divider" style:left={`${compareAt}%`}></div>{/if}
</div>
{:else if fileGone}
<div class="stage-empty gone">
@@ -205,7 +243,9 @@
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
<div class="a-progress stage-bar" style:--p={progress / 100}>
<i></i>
</div>
{/if}
</div>
{/if}
@@ -213,6 +253,19 @@
{/key}
</div>
{#if output && video}<div class="playback-bar">
<button
class="a-btn primary sm"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放视频' : 'Ⅱ 暂停视频'}</button
>
<span>视频默认暂停,由你控制播放。</span>
<button
class="a-btn secondary sm"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}>全屏查看 ⤢</button
>
</div>{/if}
{#if allOutputs.length > 1}
<div class="strip" role="group" aria-label={t('desk.outputs')}>
{#each allOutputs as p, i (p)}
@@ -241,23 +294,49 @@
<span>{s.label} <b class="mono">{s.value}</b></span>
{/if}
{/each}
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
<span
>{t('desk.elapsed')}
<b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span
>
{#if endpoint}<span>{endpoint}</span>{/if}
</div>
{#if main && compare && output && !video}<div class="compare-controls">
<span>{inputs.some((i) => i.kind === 'image') ? '参考图片' : '上一版'}</span><input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/><span>当前结果</span>
</div>{/if}
<div class="actions">
{#if main && output && !video}<button
class="a-btn secondary sm"
onclick={() => (lightbox = true)}>放大查看 ⤢</button
>{#if compareSource && compareSource !== output}<button
class="a-btn secondary sm"
aria-pressed={compare}
onclick={() => (compare = !compare)}>{compare ? '结束对比' : '对比画面'} ◫</button
>{/if}{/if}
{#if fileGone && !outputs.length}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
>{t('desk.redo')}</button
>
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
>{t('desk.removeRecord')}</button
>
{:else}
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}
>{t('desk.reuseToComposer')}</button
>
{#if output && !video && canSendToSlot}
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
{t('desk.sendToSlot')}
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
<a class="a-btn secondary sm" href={assetUrl(output)} download rel="external">{t('desk.download')}</a>
{/if}
{#if output && video}
<button
@@ -272,10 +351,18 @@
</button>
{/if}
{#if st.tone === 'fail'}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
>{t('common.retry')}</button
>
{/if}
{#if job.status === 'pending' || job.status === 'running'}<button
class="a-btn secondary sm"
onclick={() => onCancel(job)}>取消这次生成</button
>{/if}
{#if job.status !== 'pending' && job.status !== 'running'}
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
>{t('studio.deleteBtn')}</button
>
{/if}
{/if}
</div>
@@ -286,44 +373,61 @@
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
{/if}
{#if inputs.length || prompt}
<div class="section-title">{t('desk.inputs')}</div>
{#if inputs.length}
<div class="inputs">
{#each inputs as inp (inp.label + inp.path)}
{#if inp.kind === 'image'}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{:else}
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
{/if}
{/each}
</div>
<details class="result-details">
<summary>创作详情与参数</summary>
{#if inputs.length || prompt}
<div class="section-title">{t('desk.inputs')}</div>
{#if inputs.length}
<div class="inputs">
{#each inputs as inp (inp.label + inp.path)}
{#if inp.kind === 'image'}
<img
class="mini"
src={assetUrl(inp.path)}
alt={inp.label}
title={inp.label}
loading="lazy"
/>
{:else}
<span class="mini glyph" title={inp.label}
><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span
>
{/if}
{/each}
</div>
{/if}
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{/if}
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{/if}
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>
{wf?.name ?? t('studio.jobName', { id: job.id })}
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
{/if}
<dt>{t('desk.kvCreated')}</dt>
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
{#if job.comfy_prompt_id}
<dt>{t('desk.kvPromptId')}</dt>
<dd class="mono">{job.comfy_prompt_id}</dd>
{/if}
</dl>
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>
{wf?.name ?? t('studio.jobName', { id: job.id })}
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
{/if}
<dt>{t('desk.kvCreated')}</dt>
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
{#if job.comfy_prompt_id}
<dt>{t('desk.kvPromptId')}</dt>
<dd class="mono">{job.comfy_prompt_id}</dd>
{/if}
</dl>
</details>
{/if}
</div>
</aside>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
onClose={() => (lightbox = false)}
/>{/if}
<style>
.inspector {
display: flex;
@@ -578,4 +682,206 @@
padding: var(--sp-5);
}
}
.inspector.main {
position: static;
inset: auto;
transform: none;
opacity: 1;
pointer-events: auto;
width: 100%;
height: 100%;
min-height: 0;
max-width: none;
border: 0;
background: transparent;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
}
.main .tabs {
display: none;
}
.main .body {
padding: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.main .stage {
aspect-ratio: auto;
width: 100%;
min-height: 110px;
flex: 1;
height: auto;
max-height: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: #eae9e3;
}
.main .stage-media {
position: relative;
inset: auto;
padding: 0;
height: 100%;
width: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.main .print {
width: 100%;
height: 100%;
min-height: 0;
max-height: 100%;
max-width: 100%;
padding: 0;
border: 0;
background: none;
display: grid;
grid-template-rows: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
}
.main .print img,
.main .print video {
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 0;
}
.main .meta,
.main .actions,
.main .playback-bar,
.main .strip,
.main .note,
.main .result-details {
flex-shrink: 0;
margin: 0;
}
.main .meta {
font-size: 10px;
gap: 8px;
line-height: 1.5;
max-height: 30px;
overflow: auto;
}
.main .actions {
gap: 6px;
flex-wrap: wrap;
}
.main .result-details {
font-size: 11px;
max-height: 160px;
overflow: auto;
}
.playback-bar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 4px 0;
}
.playback-bar span {
flex: 1;
font-size: 11px;
color: var(--text-3);
}
@media (max-height: 650px) {
.playback-bar span {
display: none;
}
.main .meta {
max-height: 16px;
}
.main .actions :global(.a-btn) {
font-size: 11px;
padding: 4px 8px;
}
}
.main .a-fix {
display: none;
}
.main .stage-empty {
display: flex;
gap: 10px;
flex-direction: column;
align-items: center;
color: var(--text-3);
font-size: 12px;
}
.empty-symbol {
color: var(--accent);
font-size: 55px;
font-weight: 300;
line-height: 1.4;
}
.stage-empty strong {
font-size: 16px;
font-weight: 500;
color: var(--text);
}
.main .meta {
font-size: 9px;
gap: 12px;
margin-top: 10px;
}
.main .actions {
margin-top: 12px;
}
.result-details {
font-size: 11px;
color: var(--text-3);
margin-top: 12px;
}
.result-details summary {
cursor: pointer;
}
.result-details .prompt {
margin-top: 12px;
}
.comparison-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.comparison-layer img {
width: 100%;
height: 100%;
object-fit: contain;
background: #eae9e3;
}
.comparison-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--surface);
box-shadow: 0 0 8px #292a2d40;
pointer-events: none;
}
.compare-controls {
display: flex;
align-items: center;
gap: 15px;
margin-top: 10px;
font-size: 10px;
color: var(--text-3);
}
.compare-controls input {
flex: 1;
accent-color: var(--accent);
}
</style>
@@ -0,0 +1,155 @@
<script lang="ts">
import { tick } from 'svelte';
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { validateForm, classifySchema, type FormValues } from './schema';
let {
inputs,
values,
anchor,
onClose,
onApply,
}: {
inputs: ComfyDynamicInput[];
values: FormValues;
anchor: HTMLElement | null;
onClose: () => void;
onApply: (values: FormValues) => void;
} = $props();
let draft = $state<FormValues>({});
let showErrors = $state(false);
let panel = $state<HTMLDivElement | null>(null);
let x = $state(0),
y = $state(0);
const errors = $derived(
validateForm(inputs, classifySchema(inputs, 'image'), draft),
);
$effect(() => {
if (!anchor || !inputs.length) return;
const source = anchor;
draft = Object.fromEntries(
inputs.map((i) => [i.nodeId, values[i.nodeId] ?? i.defaultValue ?? '']),
);
showErrors = false;
const rect = source.getBoundingClientRect();
x = Math.max(12, Math.min(rect.left, innerWidth - 312));
y = Math.max(12, innerHeight - rect.top + 8);
void tick().then(() =>
panel
?.querySelector<HTMLElement>('input,select,textarea,button')
?.focus(),
);
const closeOutside = (e: PointerEvent) => {
if (
!panel?.contains(e.target as Node) &&
!source.contains(e.target as Node)
)
onClose();
};
const keys = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
if (e.key === 'Tab' && panel) {
const all = [
...panel.querySelectorAll<HTMLElement>(
'input,select,textarea,button',
),
].filter(
(e) => !e.hasAttribute('disabled') && e.getClientRects().length,
);
if (e.shiftKey && document.activeElement === all[0]) {
e.preventDefault();
all.at(-1)?.focus();
} else if (!e.shiftKey && document.activeElement === all.at(-1)) {
e.preventDefault();
all[0]?.focus();
}
}
};
document.addEventListener('pointerdown', closeOutside);
document.addEventListener('keydown', keys, true);
window.addEventListener('resize', onClose);
return () => {
document.removeEventListener('pointerdown', closeOutside);
document.removeEventListener('keydown', keys, true);
window.removeEventListener('resize', onClose);
if (source.isConnected) source.focus();
};
});
function save() {
if (Object.keys(errors).length) {
showErrors = true;
return;
}
onApply(draft);
onClose();
}
</script>
{#if inputs.length && anchor}
<div
bind:this={panel}
class="parameter-popover"
role="dialog"
aria-label={inputs.map((i) => i.label).join(' / ')}
style:left={`${x}px`}
style:bottom={`${y}px`}
>
<header>{inputs.map((i) => i.label).join(' / ')}</header>
<ComfyDynamicForm
{inputs}
values={draft as Record<string, string | number>}
quiet
{errors}
{showErrors}
onChange={(v) => (draft = { ...draft, ...v })}
/>
<footer>
<button class="a-btn ghost sm" onclick={onClose}>取消</button><button
class="a-btn secondary sm"
onclick={save}>应用参数</button
>
</footer>
</div>
{/if}
<style>
.parameter-popover {
position: fixed;
z-index: 800;
width: 300px;
box-sizing: border-box;
max-height: calc(100dvh - 36px);
overflow: auto;
padding: 16px;
background: var(--float-bg);
border: 1px solid var(--line-2);
border-radius: 12px;
box-shadow: var(--shadow-float);
transform-origin: bottom left;
animation: appear var(--d-pop) var(--ease-out);
}
header {
font-size: 12px;
color: var(--text-2);
margin-bottom: 14px;
}
footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
@keyframes appear {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
</style>
@@ -116,7 +116,9 @@
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
export function dropFiles(files: File[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Per-drop allocation buffer, synchronously consumed into groupAdd calls; no collection state survives this invocation.
const pending = new Map<string, File[]>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Per-drop reservation set prevents assigning two files to one slot before async upload completes; never used as render state.
const taken = new Set<string>();
let unplaced = 0;
for (const f of files) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
@@ -8,7 +9,15 @@
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
import {
elapsedSec,
fmtClock,
fmtDur,
inputsOf,
isVideoOutput,
promptOf,
workbenchCtxOf,
} from './takes';
/**
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
@@ -20,6 +29,9 @@
* take whose files no longer load turns amber (文件缺失) with
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
* marked as such instead of pretending nothing changed.
* HEL-684: a take generated with a real workbench source offers an
* explicit 回流到源项目 action; once flowed back it shows 已回流 and
* a direct 返回项目 link instead.
*/
interface Props {
job: Job;
@@ -33,11 +45,14 @@
flash?: boolean;
/** One-shot failure shake. */
shake?: boolean;
/** Flow-back request in flight (HEL-684). */
attachBusy?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
onCancel: () => void;
onDelete: () => void;
onAttach?: () => void;
}
let {
@@ -49,11 +64,13 @@
enter = false,
flash = false,
shake = false,
attachBusy = false,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
onAttach,
}: Props = $props();
const st = $derived(jobStatusInfo(job));
@@ -80,6 +97,16 @@
});
const active = $derived(job.status === 'pending' || job.status === 'running');
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
// HEL-684: flow-back only for finished takes with files and a real,
// still-recorded workbench source; the attached marker comes from the
// server so it survives reloads.
const wbCtx = $derived(workbenchCtxOf(job));
const attached = $derived(
!!job.workbench_attach && !!wbCtx && job.workbench_attach.target_id === wbCtx.sourceId,
);
const canAttach = $derived(
!!onAttach && !!wbCtx && !attached && st.tone === 'ok' && !fileGone && !!output,
);
let portrait = $state(false);
let clipSec = $state<number | null>(null);
@@ -164,6 +191,25 @@
</button>
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
{:else}
{#if canAttach}
<button
type="button"
class="a-btn ghost xs attach"
disabled={attachBusy}
title={t('desk.attachHint', { label: wbCtx?.label ?? '' })}
onclick={() => onAttach?.()}
>
{attachBusy ? t('desk.attaching') : t('desk.attachToProject')}
</button>
{/if}
{#if attached && wbCtx}
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
{t('desk.attachedTag')}
</span>
<a class="a-btn ghost xs" href={resolve(`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`)}>
{t('desk.attachBack')}
</a>
{/if}
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
@@ -172,7 +218,7 @@
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
<a class="a-btn ghost xs" href={assetUrl(output)} download rel="external">{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
@@ -38,11 +38,14 @@
banner?: Snippet;
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
inspectorToggle?: Snippet;
/** Flow-back to the workbench source row in flight (HEL-684). */
attachBusyId?: number | null;
onSelect: (job: Job) => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onDelete: (job: Job) => void;
onAttach?: (job: Job) => void;
}
let {
@@ -59,11 +62,13 @@
orderedIds = $bindable(),
banner,
inspectorToggle,
attachBusyId = null,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
onAttach,
}: Props = $props();
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
@@ -120,6 +125,7 @@
// Entrance bookkeeping: only takes that appear AFTER the first fill
// (or come back from an undo) play the 220ms rise.
// ------------------------------------------------------------------
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Nonreactive entrance ledger read/written in the jobs effect and markEnter; keep bookkeeping outside effect dependencies, with animateIds driving classes.
const seenIds = new Set<number>();
const animateIds = new SvelteSet<number>();
let hydrated = false;
@@ -263,6 +269,8 @@
onRetry={() => onRetry(job)}
onCancel={() => onCancel(job)}
onDelete={() => onDelete(job)}
attachBusy={attachBusyId === job.id}
onAttach={onAttach ? () => onAttach(job) : undefined}
/>
</div>
{/each}
@@ -1,55 +1,126 @@
import type { WorkbenchStageKey } from '$lib/api';
import type { DeskKind } from './schema';
import type { WorkbenchStageKey } from "$lib/api";
import type { DeskKind } from "./schema";
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
* The desk only prefills from it; the playground generate API has no
* project/shot link, so results stay in the desk feed (no backfill).
* The desk prefills from it and, when the full source identity is present
* (sourceKind/sourceId), sends it along with the generate request so a
* finished take can be flowed back to its source row (HEL-684).
*/
export interface DeskContext {
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
/** Real source row identity — only present on links the workbench
* stamped with it; without it no flow-back is offered (nothing guessed). */
sourceKind?: "asset" | "shot";
sourceId?: string;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
/** The generate-request context payload shape (HEL-684). */
export interface DeskWorkbenchContext {
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
}
export function deskHref(ctx: DeskContext): string {
const q = new URLSearchParams({
from: 'workbench',
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set('prompt', ctx.prompt);
if (ctx.negative) q.set('negative', ctx.negative);
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
return `/${ctx.kind}?${q.toString()}`;
const STAGES: WorkbenchStageKey[] = [
"script",
"style",
"storyboard",
"assets",
"shot",
];
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
* shot → video). Anything else cannot display a flowed-back take. */
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
assets: "image",
shot: "video",
};
export function deskHref(ctx: DeskContext): `/${DeskKind}?${string}` {
const q = new URLSearchParams({
from: "workbench",
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set("prompt", ctx.prompt);
if (ctx.negative) q.set("negative", ctx.negative);
if (ctx.durationSec != null) q.set("duration", String(ctx.durationSec));
if (ctx.sourceKind && ctx.sourceId) {
q.set("source_kind", ctx.sourceKind);
q.set("source_id", ctx.sourceId);
}
return `/${ctx.kind}?${q.toString()}`;
}
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
const q = url.searchParams;
if (q.get('from') !== 'workbench') return null;
const projectId = Number(q.get('project'));
const stage = q.get('stage') as WorkbenchStageKey | null;
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
const duration = Number(q.get('duration'));
return {
kind,
projectId,
stage,
label: q.get('label') ?? '',
prompt: q.get('prompt') ?? undefined,
negative: q.get('negative') ?? undefined,
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
};
const q = url.searchParams;
if (q.get("from") !== "workbench") return null;
const projectId = Number(q.get("project"));
const stage = q.get("stage") as WorkbenchStageKey | null;
if (
!Number.isInteger(projectId) ||
projectId <= 0 ||
!stage ||
!STAGES.includes(stage)
)
return null;
const duration = Number(q.get("duration"));
const rawKind = q.get("source_kind");
const rawId = q.get("source_id") ?? "";
// Source identity is only kept when it is complete and self-consistent;
// old/hand-made links keep today's prefill-only behavior.
const expectedKind =
stage === "assets" ? "asset" : stage === "shot" ? "shot" : null;
const sourceKind = rawKind === "asset" || rawKind === "shot" ? rawKind : null;
const hasSource =
sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== "";
return {
kind,
projectId,
stage,
label: q.get("label") ?? "",
prompt: q.get("prompt") ?? undefined,
negative: q.get("negative") ?? undefined,
durationSec:
Number.isFinite(duration) && duration > 0 ? duration : undefined,
sourceKind: hasSource ? (sourceKind as "asset" | "shot") : undefined,
sourceId: hasSource ? rawId : undefined,
};
}
export function backHref(ctx: DeskContext): string {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
/**
* The context a generate request carries: only when the full, real source
* identity is present AND the desk kind matches what that stage renders.
*/
export function workbenchContextOf(
ctx: DeskContext | null,
): DeskWorkbenchContext | undefined {
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
if (ctx.stage !== "assets" && ctx.stage !== "shot") return undefined;
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
const out: DeskWorkbenchContext = {
project_id: ctx.projectId,
stage: ctx.stage,
source_kind: ctx.sourceKind,
source_id: ctx.sourceId,
};
if (ctx.label.trim()) out.label = ctx.label;
return out;
}
export function backHref(
ctx: DeskContext,
): `/workbench/${string}?stage=${string}` {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
}
+396 -281
View File
@@ -1,7 +1,11 @@
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
import { normalizeInputRole } from '$lib/comfy/parser';
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import type {
ComfyDynamicInput,
Workflow,
WorkflowUsage,
} from "$lib/comfy/types";
import { normalizeInputRole } from "$lib/comfy/parser";
import { ratioShort, resolutionSelectorSize } from "$lib/comfy/resolution";
import { t } from "$lib/i18n.svelte";
/**
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
@@ -9,366 +13,477 @@ import { t } from '$lib/i18n.svelte';
* never from model names. No wiring → no slot.
*/
export type DeskKind = 'image' | 'video';
export type DeskKind = "image" | "video";
export type FormValue = string | number | string[];
export type FormValues = Record<string, FormValue>;
export interface SlotDef {
inp: ComfyDynamicInput;
label: string;
mediaKind: 'image' | 'video' | 'audio';
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
inp: ComfyDynamicInput;
label: string;
mediaKind: "image" | "video" | "audio";
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
}
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
export interface BlockReason {
text: string;
tone: 'warn' | 'err';
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
text: string;
tone: "warn" | "err";
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
}
export interface DeskSchema {
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
}
export function inputRole(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
return normalizeInputRole(inp.role ?? null);
}
function hay(inp: ComfyDynamicInput): string {
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
return `${inp.role ?? ""} ${inp.label}`.toLowerCase();
}
export function isMedia(inp: ComfyDynamicInput): boolean {
return (
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'video' ||
inp.kind === 'audio' ||
inp.kind === 'image_list' ||
inp.kind === 'video_list' ||
inp.kind === 'audio_list'
);
return (
inp.kind === "image" ||
inp.kind === "image_url" ||
inp.kind === "video" ||
inp.kind === "audio" ||
inp.kind === "image_list" ||
inp.kind === "video_list" ||
inp.kind === "audio_list"
);
}
function isList(inp: ComfyDynamicInput): boolean {
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
return (
inp.kind === "image_list" ||
inp.kind === "video_list" ||
inp.kind === "audio_list"
);
}
function isMask(inp: ComfyDynamicInput): boolean {
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
return inputRole(inp) === "mask" || /mask|遮罩/.test(hay(inp));
}
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
// workflows still name frames only in the title.
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
return (
inputRole(inp) === "first_frame" ||
(!inp.auto && kind === "video" && /first|start|首帧/.test(hay(inp)))
);
}
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
return (
inputRole(inp) === "last_frame" ||
(!inp.auto && kind === "video" && /last|end|尾帧/.test(hay(inp)))
);
}
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
export function listKind(
entry: ComfyDynamicInput,
): "image" | "video" | "audio" {
if (entry.kind === "video_list") return "video";
if (entry.kind === "audio_list") return "audio";
return "image";
}
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === 'string' && v ? [v] : [];
export function listPaths(
values: FormValues,
entry: ComfyDynamicInput,
): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === "string" && v ? [v] : [];
}
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
export function classifySchema(
schema: ComfyDynamicInput[],
kind: DeskKind,
): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
const prompt = byRole('prompt');
const negative = byRole('negative');
const seed = byRole('seed');
const width = byRole('width');
const height = byRole('height');
const resolution = byRole('resolution');
const duration = byRole('duration');
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
const prompt = byRole("prompt");
const negative = byRole("negative");
const seed = byRole("seed");
const width = byRole("width");
const height = byRole("height");
const resolution = byRole("resolution");
const duration = byRole("duration");
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find(
(i) =>
i.kind === "select" && inputRole(i) === "aspect_ratio" && i.resolution,
) ?? null;
const megapixels = ratio
? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null)
: null;
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
);
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
if (inp.kind === 'video') {
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
} else if (inp.kind === 'audio') {
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
} else if (isMask(inp)) {
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t('studio.slotFirst'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedFirstFrame'),
});
} else if (isLastFrame(inp, kind)) {
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
} else if (inp === editInput) {
slots.push({
...base,
label: t('studio.slotInput'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedInputImage'),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t('studio.slotRef')
: t('studio.slotRefN', { n: refIdx });
slots.push({
...base,
label,
mediaKind: 'image',
fromResults: true,
missing: t('studio.errUpload', { label }),
});
}
}
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) =>
i.kind === "image" &&
!isMask(i) &&
!isFirstFrame(i, kind) &&
!isLastFrame(i, kind),
);
const editInput =
kind === "image" && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = {
inp,
missing: "",
firstFrame: false,
lastFrame: false,
mask: false,
};
if (inp.kind === "video") {
slots.push({
...base,
label: t("studio.slotRefVideo"),
mediaKind: "video",
fromResults: false,
});
} else if (inp.kind === "audio") {
slots.push({
...base,
label: t("studio.slotAudio"),
mediaKind: "audio",
fromResults: false,
});
} else if (isMask(inp)) {
slots.push({
...base,
mask: true,
label: t("studio.slotMask"),
mediaKind: "image",
fromResults: false,
});
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t("studio.slotFirst"),
mediaKind: "image",
fromResults: true,
missing: t("studio.errNeedFirstFrame"),
});
} else if (isLastFrame(inp, kind)) {
slots.push({
...base,
lastFrame: true,
label: t("studio.slotLast"),
mediaKind: "image",
fromResults: true,
});
} else if (inp === editInput) {
slots.push({
...base,
label: t("studio.slotInput"),
mediaKind: "image",
fromResults: true,
missing: t("studio.errNeedInputImage"),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t("studio.slotRef")
: t("studio.slotRefN", { n: refIdx });
slots.push({
...base,
label,
mediaKind: "image",
fromResults: true,
missing: t("studio.errUpload", { label }),
});
}
}
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter(
(i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId),
);
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
};
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === "minimax_h3_ref2va"),
};
}
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
}
return out;
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== "")
out[inp.nodeId] = inp.defaultValue;
}
return out;
}
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === '') return orig !== undefined && orig !== '';
if (orig === undefined || orig === '') return true;
return String(v) !== String(orig);
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === "") return orig !== undefined && orig !== "";
if (orig === undefined || orig === "") return true;
return String(v) !== String(orig);
}
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
export function changedParams(
ds: DeskSchema,
values: FormValues,
): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
}
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
mp,
ds.ratio.resolution.multiple,
);
export function outputSize(
ds: DeskSchema,
values: FormValues,
): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(
values[ds.megapixels?.nodeId ?? ""] ?? ds.megapixels?.defaultValue,
);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ""),
mp,
ds.ratio.resolution.multiple,
);
}
export function batchCount(ds: DeskSchema, values: FormValues): number {
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
return warns;
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t("wfMedia.h3TotalOver", { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0)
warns.push(t("wfMedia.h3AudioAlone"));
return warns;
}
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
const label = inp.label;
if (inp.kind === 'select') {
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
}
if (inp.kind !== 'number') return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
return null;
export function valueError(
inp: ComfyDynamicInput,
raw: string | number,
): string | null {
const label = inp.label;
if (inp.kind === "select") {
return inp.options?.length && !inp.options.includes(String(raw))
? t("studio.errOption", { label })
: null;
}
if (inp.kind !== "number") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t("studio.errNumber", { label });
if (inp.integer && !Number.isInteger(n))
return t("studio.errInteger", { label });
if (inp.min != null && n < inp.min)
return t("studio.errMin", { label, v: inp.min });
if (inp.max != null && n > inp.max)
return t("studio.errMax", { label, v: inp.max });
return null;
}
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
export function validateForm(
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
): Record<string, string> {
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? '');
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t('studio.errFirstLast');
if (!a) errs[first.inp.nodeId] = ' ';
if (!b) errs[last.inp.nodeId] = ' ';
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? '').trim()) {
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === 'image_list'
? t('studio.errNeedRef')
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? "");
if (!v.trim()) errs[ds.prompt.nodeId] = t("studio.errPrompt");
else if (v.length > 2000)
errs[ds.prompt.nodeId] = t("studio.errPromptLong");
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? "");
const b = String(values[last.inp.nodeId] ?? "");
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t("studio.errFirstLast");
if (!a) errs[first.inp.nodeId] = " ";
if (!b) errs[last.inp.nodeId] = " ";
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? "").trim()) {
errs[s.inp.nodeId] =
s.missing || t("studio.errUpload", { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === "image_list"
? t("studio.errNeedRef")
: t("studio.errNeedRefMedia", { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t("studio.errRefMax", { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? "").trim()) {
errs[inp.nodeId] = t("studio.errFill", { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (
raw === undefined ||
Array.isArray(raw) ||
String(raw).trim() === "" ||
errs[inp.nodeId]
)
continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
}
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
export function missingMedia(
ds: DeskSchema,
values: FormValues,
): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? "").trim())
out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1))
out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
}
export function usageLabel(u: WorkflowUsage): string {
return t(`usage.${u}`);
return t(`usage.${u}`);
}
/** Mode badge: the stored usage when we have one, else the wiring shape. */
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
return t('studio.modeImageEdit');
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
export function modeLabel(
wf: Workflow | null,
ds: DeskSchema,
kind: DeskKind,
): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === "image_list"))
return t("studio.modeRefToVideo");
if (ds.slots.some((s) => s.firstFrame)) return t("studio.modeImageToVideo");
if (
kind === "image" &&
ds.slots.some((s) => s.mediaKind === "image" && !s.mask)
)
return t("studio.modeImageEdit");
if (ds.slots.length) return ds.slots.map((s) => s.label).join(" · ");
return kind === "image"
? t("studio.modeTextToImage")
: t("studio.modeTextToVideo");
}
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
export function slotSummary(wf: Workflow): string {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
];
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t("desk.slotOne", { label: s.label })),
...ds.lists.map((g) =>
t("desk.slotRange", { label: g.label, min: g.min ?? 1, max: g.max ?? 9 }),
),
];
return parts.length ? parts.join(" · ") : t("desk.promptOnly");
}
/** Short chip text for one common parameter value. */
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
}
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
return v || inp.label;
export function chipValue(
inp: ComfyDynamicInput,
values: FormValues,
ds: DeskSchema,
): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? "";
const v = Array.isArray(raw) ? raw.join(",") : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size
? `${ratioShort(v)} · ${size.width}×${size.height}`
: ratioShort(v);
}
if (inp === ds.duration)
return v ? t("desk.chipSeconds", { v }) : t("studio.durationLabel");
if (inp === ds.seed)
return t("desk.chipSeed", {
v: v.length > 8 ? `${v.slice(0, 8)}…` : v || "—",
});
if (inp === ds.resolution) return t("desk.chipResolution", { v: v || "—" });
return v || inp.label;
}
@@ -1,120 +1,120 @@
import { SvelteSet } from 'svelte/reactivity';
import type { QueryClient } from '@tanstack/svelte-query';
import { apiErrorMessage, playgroundApi } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { showToast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import type { QueryClient } from "@tanstack/svelte-query";
import { apiErrorMessage, playgroundApi } from "$lib/api";
import type { Job } from "$lib/comfy/types";
import { showToast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
/**
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
export interface TakeDeleteHandlers {
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
}
export interface TakeDeleteQueue {
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
}
const UNDO_MS = 5000;
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
const pending = new SvelteSet<number>();
const entries = new Map<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
const pending = new SvelteSet<number>();
const entries = new SvelteMap<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
client.invalidateQueries({ queryKey: ['jobs'] }),
]);
}
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ["playground-jobs"] }),
client.invalidateQueries({ queryKey: ["jobs"] }),
]);
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
'error',
);
}
await invalidate();
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t("studio.deleteFailedToast", { detail: apiErrorMessage(err) }),
"error",
);
}
await invalidate();
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: 'DELETE',
keepalive: true,
});
}
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: "DELETE",
keepalive: true,
});
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener("pagehide", onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
const inBrowser = typeof window !== 'undefined';
if (inBrowser) window.addEventListener('pagehide', onPageHide);
const inBrowser = typeof window !== "undefined";
if (inBrowser) window.addEventListener("pagehide", onPageHide);
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), 'info', UNDO_MS, {
label: t('toast.undo'),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), "info", UNDO_MS, {
label: t("toast.undo"),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
}
+155 -90
View File
@@ -1,129 +1,194 @@
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { jobStatusInfo } from '$lib/jobStatus';
import { resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
import type { Job, Workflow } from "$lib/comfy/types";
import { parseDbTs } from "$lib/dbtime";
import { jobStatusInfo } from "$lib/jobStatus";
import { resolutionSelectorSize } from "$lib/comfy/resolution";
import { t } from "$lib/i18n.svelte";
import {
classifySchema,
inputRole,
isFirstFrame,
isLastFrame,
isMedia,
} from "./schema";
/** Read-only views of a finished / running take (one playground job). */
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
export type FeedFilter = "all" | "image" | "video" | "failed";
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
/** The workbench source a take was generated with, if any (HEL-684). */
export interface TakeWorkbenchCtx {
projectId: number;
stage: string;
sourceKind: "asset" | "shot";
sourceId: string;
label?: string;
}
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
}
}
return null;
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
const stamp = job.payload?.workbench;
if (!stamp || typeof stamp !== "object") return null;
const s = stamp as Record<string, unknown>;
const projectId = s.project_id;
const stage = typeof s.stage === "string" ? s.stage : "";
const sourceKind =
s.source_kind === "asset" || s.source_kind === "shot"
? s.source_kind
: null;
const sourceId = typeof s.source_id === "string" ? s.source_id : "";
if (typeof projectId !== "number" || !stage || !sourceKind || !sourceId)
return null;
const label =
typeof s.label === "string" && s.label.trim() ? s.label : undefined;
return {
projectId,
stage,
sourceKind,
sourceId,
label,
};
}
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
}
export function jobValueByRole(
job: Job,
wf: Workflow | undefined,
...roles: string[]
): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== "")
return String(v);
}
}
return null;
}
export function promptOf(job: Job, wf: Workflow | undefined): string {
return jobValueByRole(job, wf, 'prompt') ?? '';
return jobValueByRole(job, wf, "prompt") ?? "";
}
export interface TakeInput {
label: string;
path: string;
kind: 'image' | 'video' | 'audio';
label: string;
path: string;
kind: "image" | "video" | "audio";
}
/** Media the take was generated from, in workflow order. */
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
list.forEach((p, i) => {
if (typeof p !== 'string' || !p) return;
let label = inp.label;
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === "string" && v ? [v] : [];
const mk = inp.kind.startsWith("video")
? "video"
: inp.kind.startsWith("audio")
? "audio"
: "image";
list.forEach((p, i) => {
if (typeof p !== "string" || !p) return;
let label = inp.label;
if (inp.kind === "image_list") label = t("studio.slotRefN", { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t("studio.slotFirst");
else if (isLastFrame(inp, kind)) label = t("studio.slotLast");
else if (inp.kind === "image" && inputRole(inp) !== "mask")
label = t("studio.slotRefN", { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
}
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, 'width');
const h = jobValueByRole(job, wf, 'height');
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
ds.ratio.resolution.multiple,
);
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
}
}
const seed = jobValueByRole(job, wf, 'seed');
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
const dur = jobValueByRole(job, wf, 'duration');
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
return out;
export function specOf(
job: Job,
wf: Workflow | undefined,
): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, "width");
const h = jobValueByRole(job, wf, "height");
if (w && h) out.push({ label: t("desk.specSize"), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ""),
Number(
vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue,
),
ds.ratio.resolution.multiple,
);
if (size)
out.push({
label: t("desk.specSize"),
value: `${size.width}×${size.height}`,
});
}
}
const seed = jobValueByRole(job, wf, "seed");
if (seed) out.push({ label: t("studio.seedLabel"), value: seed });
const dur = jobValueByRole(job, wf, "duration");
if (dur)
out.push({
label: t("studio.durationLabel"),
value: t("desk.chipSeconds", { v: dur }),
});
return out;
}
export function isVideoOutput(job: Job, path: string): boolean {
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
return job.kind === "video" && !/\.(png|jpe?g|webp)$/i.test(path);
}
export function elapsedSec(job: Job, nowMs: number): number | null {
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
const from = job.started_at
? parseDbTs(job.started_at)
: parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
}
export function fmtDur(sec: number | null): string {
if (sec == null) return '—';
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
if (sec == null) return "—";
if (sec < 60) return t("desk.durSec", { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t("desk.durMin", { m, s: String(s).padStart(2, "0") });
}
export function fmtClock(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
/** 今天 / 昨天 / 9月28日 heading for the feed. */
export function dayLabel(ms: number, nowMs: number): string {
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t('desk.today');
if (diff === 1) return t('desk.yesterday');
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) =>
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t("desk.today");
if (diff === 1) return t("desk.yesterday");
return t("desk.dayDate", { m: d.getMonth() + 1, d: d.getDate() });
}
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
if (filter === 'all') return true;
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
return job.kind === filter;
if (filter === "all") return true;
if (filter === "failed") return jobStatusInfo(job).tone === "fail";
return job.kind === filter;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, projects, type Project } from '$lib/api';
@@ -35,7 +36,13 @@
);
// Five-stage creation progress (HEL-614) — real stage rows only.
const STAGE_SEGMENTS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
const STAGE_SEGMENTS = [
'script',
'style',
'storyboard',
'assets',
'shot',
] as const;
function segClass(key: string): string {
const state = stats.stage_status?.[key];
if (state === 'confirmed') return 'seg-confirmed';
@@ -46,18 +53,16 @@
// Spec §2.1 allows the five avatar gradients as cover placeholders.
const PLACEHOLDER_GRADIENTS = [
'var(--avatar-1)',
'var(--avatar-2)',
'var(--avatar-3)',
'var(--avatar-4)',
'var(--avatar-5)',
'#1f262c',
'#253036',
'#2d2333',
'#26372c',
'#302836',
];
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
const placeholderIconColor = $derived(
placeholderIdx <= 1 ? 'var(--accent-ink)' : 'var(--text)',
);
const placeholderIconColor = $derived('var(--text-2)');
const coverUrl = $derived(assetUrl(project.cover_path));
@@ -69,17 +74,28 @@
if (stats.stage_confirmed >= STAGE_SEGMENTS.length) {
return { cls: 'st-done', label: t('home.statusDone') };
}
const current = STAGE_SEGMENTS.find((k) => stats.stage_status?.[k] !== 'confirmed');
const current = STAGE_SEGMENTS.find(
(k) => stats.stage_status?.[k] !== 'confirmed',
);
const state = current ? stats.stage_status?.[current] : undefined;
const stageName = current ? t(`workbench.stage.${current}`) : '';
if (state === 'running') {
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
return {
cls: 'st-progress',
label: `${stageName} · ${t('workbench.state.running')}`,
};
}
if (state === 'awaiting_confirm') {
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
return {
cls: 'st-pending',
label: `${stageName} · ${t('workbench.state.draft')}`,
};
}
if (stats.stage_confirmed > 0) {
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
return {
cls: 'st-progress',
label: `${stageName} · ${t('home.statusProgress')}`,
};
}
return { cls: 'st-draft', label: t('home.statusDraft') };
});
@@ -137,7 +153,9 @@
toast.success(t('projectCard.renamedTo', { title: renameValue }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
toast.error(
err instanceof Error ? err.message : t('projectCard.renameFailed'),
);
},
});
@@ -157,7 +175,9 @@
toast.success(t('projectCard.deleted', { title: project.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
toast.error(
err instanceof Error ? err.message : t('projectCard.deleteFailed'),
);
},
});
</script>
@@ -167,7 +187,7 @@
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/workbench/${project.id}`)}
onclick={() => goto(resolve(`/workbench/${project.id}`))}
>
<span class="cover">
{#if coverUrl && !coverFailed}
@@ -185,7 +205,9 @@
style:color={placeholderIconColor}
aria-hidden="true"
>
<Icon name="film" size={22} />
<span class="cover-type"
>✳<small>STORY / {String(project.id).padStart(3, '0')}</small></span
>
</span>
{/if}
<span class="status-tag {cover.cls}">{cover.label}</span>
@@ -198,11 +220,14 @@
<span class="idea">{project.idea || t('home.noIdea')}</span>
<span class="stage-bar" aria-label={t('home.stageProgress')}>
{#each STAGE_SEGMENTS as key (key)}
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}></span>
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}
></span>
{/each}
<span class="seg-count mono">{stats.stage_confirmed}/5</span>
{#if stats.active_jobs > 0}
<span class="jobs-pill">{t('home.activeJobs', { n: stats.active_jobs })}</span>
<span class="jobs-pill"
>{t('home.activeJobs', { n: stats.active_jobs })}</span
>
{/if}
</span>
<span class="meta">
@@ -232,7 +257,12 @@
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={openRename}
>
<Icon name="edit" size={14} />
{t('common.rename')}
</button>
@@ -266,7 +296,9 @@
</label>
</form>
{#snippet footer()}
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
<Button variant="ghost" onclick={() => (renameOpen = false)}
>{t('common.cancel')}</Button
>
<Button
variant="primary"
disabled={!renameValue.trim() || $renameMutation.isPending}
@@ -534,9 +566,81 @@
}
.menu-item.danger:hover {
background: var(--error-dim);
} .a-card :global(.field-input) {
}
.a-card :global(.field-input) {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text);
}
.a-card {
background: transparent;
border: 0;
border-radius: 0;
overflow: visible;
}
.cover {
height: 180px;
border-radius: 12px;
}
.body {
padding: 12px 0 0;
}
.status-tag {
top: auto;
left: 12px;
right: auto;
bottom: 12px;
border: 0;
border-radius: 4px;
padding: 4px 7px;
background: #121218ed;
color: var(--text);
font-size: 9px;
}
.cover-type {
font-size: 56px;
font-weight: 300;
color: var(--text-3);
}
.cover-type small {
display: block;
font: 9px var(--mono);
letter-spacing: 1px;
}
.cover-img {
transition: transform 250ms var(--ease-out);
}
.stage-bar {
width: 100%;
}
.seg {
flex: 1;
height: 3px;
}
.meta-stats,
.meta-time {
font-size: 10px;
}
.idea {
font-size: 11px;
min-height: 0;
}
.menu-wrap {
left: auto;
right: 10px;
}
.menu-btn {
background: var(--surface);
}
.menu {
left: auto;
right: 0;
box-shadow: var(--shadow-float);
}
@media (hover: hover) {
.a-card:hover .cover-img {
transform: scale(1.035);
}
}
</style>
@@ -0,0 +1,24 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
let { kind = "all" }: { kind?: "all" | "image" | "video" } = $props();
const pending = [
{ kind: "image", name: "云端 · 聚合 API(GPT-Image-2 · Nano Banana Pro)" },
{ kind: "video", name: "云端 · 可灵" },
{ kind: "video", name: "云端 · 即梦 CLI" },
];
</script>
{#each pending.filter((item) => kind === "all" || item.kind === kind) as item (item.name)}<article
class="mg-card pending"
>
<div class="mg-row">
<span class="mg-dot"></span><b>{item.name}</b><span class="mg-grow"
></span><span class="mg-tag">未接入</span>
</div>
<div class="mg-mono">尚未接入</div>
<div class="mg-muted">后续接入批次 · 当前不可用于生成</div>
<div class="mg-row">
<span class="mg-tag">0 个工作流使用</span><span class="mg-grow"
></span><Button size="sm" disabled>等待接入</Button>
</div>
</article>{/each}
@@ -0,0 +1,646 @@
<script lang="ts">
import CloudModels from "./CloudModels.svelte";
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { fade } from "svelte/transition";
import Modal from "$lib/components/ui/Modal.svelte";
import Button from "$lib/components/ui/Button.svelte";
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { comfy, settings, workflows, type ComfyEndpoint } from "$lib/api";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
const client = useQueryClient();
// ------------------------------------------------------------------
// Data
// ------------------------------------------------------------------
const settingsQuery = createQuery({
queryKey: ["settings"],
queryFn: settings.get,
});
// HEL-682: the library browses metadata only (view=meta) — the heavy
// workflow_json payloads are fetched per-row inside WorkflowLibrary
// exactly when an action needs them.
const wfMetaQuery = createQuery({
queryKey: ["workflows", "meta"],
queryFn: workflows.listMeta,
});
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
const activeId = $derived(
$settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? "",
);
// ------------------------------------------------------------------
// Local test records — presentational only; the connectivity facts
// always come from the server-side probe.
// ------------------------------------------------------------------
type TestRec = {
ok: boolean;
ms?: number;
version?: string;
at: number;
reason?: string;
};
const TESTS_KEY = "calliope.connections.tests";
let tests = $state<Record<string, TestRec>>({});
let pendingTests = $state<Record<string, boolean>>({});
function loadTests() {
try {
const raw = localStorage.getItem(TESTS_KEY);
if (raw) tests = JSON.parse(raw) as Record<string, TestRec>;
} catch {
tests = {};
}
}
function saveTests(map: Record<string, TestRec>) {
tests = map;
try {
localStorage.setItem(TESTS_KEY, JSON.stringify(map));
} catch {
/* storage disabled — the page still works without persistence */
}
}
function recordTest(id: string, rec: TestRec) {
saveTests({ ...tests, [id]: rec });
}
// ------------------------------------------------------------------
// Connection form (a6: right column, add / edit)
// ------------------------------------------------------------------
type FormMode = "idle" | "add" | "edit";
let formMode = $state<FormMode>("idle");
let editingId = $state<string | null>(null);
let form = $state({
name: "",
baseUrl: "",
authType: "none" as "none" | "bearer" | "header",
token: "",
headerName: "",
notes: "",
});
let formTest = $state<{ ok: boolean; ms?: number; version?: string } | null>(
null,
);
let formTestFailure = $state<{ message: string; detail: string } | null>(
null,
);
let formPending = $state(false);
let showErrors = $state(false);
function resetForm() {
formMode = "idle";
editingId = null;
form = {
name: "",
baseUrl: "",
authType: "none",
token: "",
headerName: "",
notes: "",
};
formTest = null;
formTestFailure = null;
showErrors = false;
}
export function startAdd() {
resetForm();
formMode = "add";
}
function startEdit(ep: ComfyEndpoint) {
resetForm();
formMode = "edit";
editingId = ep.id;
form = {
name: ep.name,
baseUrl: ep.base_url,
authType: ep.auth_type,
token: "",
headerName: ep.header_name ?? "",
notes: ep.notes ?? "",
};
}
function touchFormConnectivity() {
formTest = null;
formTestFailure = null;
}
const urlValid = $derived.by(() => {
try {
const u = new URL(form.baseUrl.trim());
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
});
const formErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = t("conn.errName");
if (!urlValid) errs.baseUrl = t("conn.errUrl");
if (form.authType === "header" && !form.headerName.trim())
errs.headerName = t("conn.errHeaderName");
return errs;
});
const formValid = $derived(Object.keys(formErrors).length === 0);
const editingEndpoint = $derived.by(() => {
if (formMode !== "edit" || !editingId) return null;
return endpoints.find((ep) => ep.id === editingId) ?? null;
});
async function runTest() {
showErrors = true;
if (!formValid) return;
formPending = true;
formTest = null;
formTestFailure = null;
try {
const res = await comfy.testEndpoint({
...(formMode === "edit" && editingId ? { endpoint_id: editingId } : {}),
base_url: form.baseUrl.trim(),
auth_type: form.authType,
...(form.authType !== "none" && form.token.trim()
? { auth_token: form.token.trim() }
: {}),
...(form.authType === "header" && form.headerName.trim()
? { header_name: form.headerName.trim() }
: {}),
timeout_sec: 5,
});
if (res.ok) {
formTest = {
ok: true,
ms: res.elapsed_ms,
version: res.comfyui_version,
};
} else {
formTest = { ok: false };
formTestFailure = {
message: res.message ?? res.detail ?? res.error ?? "",
detail: res.detail ?? "",
};
}
} catch (err) {
formTest = { ok: false };
formTestFailure = {
message: err instanceof Error ? err.message : String(err),
detail: "",
};
} finally {
formPending = false;
}
}
const saveMutation = createMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {
name: form.name.trim(),
base_url: form.baseUrl.trim(),
auth_type: form.authType,
notes: form.notes.trim() || null,
};
if (form.authType !== "none" && form.token.trim()) {
payload["auth_token"] = form.token.trim();
}
if (form.authType === "header")
payload["header_name"] = form.headerName.trim();
if (formMode === "edit" && editingId) {
return comfy.updateEndpoint(editingId, payload);
}
return comfy.createEndpoint(
payload as Parameters<typeof comfy.createEndpoint>[0],
);
},
onSuccess: (res) => {
client.invalidateQueries({ queryKey: ["settings"] });
if (formTest?.ok && editingId) {
recordTest(editingId, {
ok: true,
ms: formTest.ms,
version: formTest.version,
at: Date.now(),
});
} else if (formTest?.ok && res.endpoints.length) {
// newest is the one we just created when adding
const created = res.endpoints.find(
(ep) => ep.base_url === form.baseUrl.trim(),
);
if (created)
recordTest(created.id, {
ok: true,
ms: formTest.ms,
version: formTest.version,
at: Date.now(),
});
}
toast.success(t("conn.saved"));
resetForm();
},
onError: (err) => {
toast.error(
t("conn.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitSave() {
showErrors = true;
if (!formValid || !formTest?.ok || $saveMutation.isPending) return;
$saveMutation.mutate();
}
// ------------------------------------------------------------------
// Card actions
// ------------------------------------------------------------------
async function testEndpoint(ep: ComfyEndpoint) {
pendingTests = { ...pendingTests, [ep.id]: true };
try {
const res = await comfy.testEndpoint({
endpoint_id: ep.id,
timeout_sec: 10,
});
if (res.ok) {
recordTest(ep.id, {
ok: true,
ms: res.elapsed_ms,
version: res.comfyui_version,
at: Date.now(),
});
} else {
recordTest(ep.id, {
ok: false,
at: Date.now(),
reason: res.message ?? res.detail ?? res.error ?? "unknown",
});
}
} catch (err) {
recordTest(ep.id, {
ok: false,
at: Date.now(),
reason: err instanceof Error ? err.message : String(err),
});
} finally {
const next = { ...pendingTests };
delete next[ep.id];
pendingTests = next;
}
}
let deleteOpen = $state(false);
let deleteTarget = $state<ComfyEndpoint | null>(null);
function requestDelete(ep: ComfyEndpoint) {
deleteTarget = ep;
deleteOpen = true;
}
function workflowsBound(id: string): number {
return ($wfMetaQuery.data ?? []).filter(
(wf) => wf.endpoint_id === id || (!wf.endpoint_id && id === activeId),
).length;
}
const deleteMessage = $derived.by(() => {
if (!deleteTarget) return "";
const n = workflowsBound(deleteTarget.id);
return n > 0
? t("conn.delConfirmUsed", { name: deleteTarget.name })
: t("conn.delConfirmFree", { name: deleteTarget.name });
});
const deleteMutation = createMutation({
mutationFn: () => {
if (!deleteTarget) throw new Error("no target");
return comfy.deleteEndpoint(deleteTarget.id);
},
onSuccess: () => {
if (deleteTarget) {
const next = { ...tests };
delete next[deleteTarget.id];
saveTests(next);
if (editingId === deleteTarget.id) resetForm();
}
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("conn.deleted"));
},
onError: (err) => {
toast.error(
t("conn.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
const defaultMutation = createMutation({
mutationFn: (id: string) => comfy.activateEndpoint(id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("conn.defaultSetToast"));
},
onError: (err) => {
toast.error(
t("conn.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function tokenTail(ep: ComfyEndpoint): string {
const tail = ep.token_tail ?? "";
return tail ? `****${tail}` : "";
}
loadTests();
$effect(() => {
// prune stale test records once the endpoint list is known
const data = $settingsQuery.data;
if (data) {
const keep = new Set(data.comfyui_endpoints.map((ep) => ep.id));
let changed = false;
const next: Record<string, TestRec> = {};
for (const [k, v] of Object.entries(tests)) {
if (keep.has(k)) next[k] = v;
else changed = true;
}
if (changed) saveTests(next);
}
});
// Probe every connection once per visit so the cards show real state
// instead of 「未测试」; results younger than a minute are reused.
const autoProbed = new SvelteSet<string>();
$effect(() => {
const list = endpoints;
untrack(() => {
const now = Date.now();
for (const ep of list) {
if (autoProbed.has(ep.id)) continue;
autoProbed.add(ep.id);
const rec = tests[ep.id];
if (!rec || now - rec.at > 60_000) void testEndpoint(ep);
}
});
});
</script>
{#if $settingsQuery.isLoading}<p class="mg-empty">
正在读取连接…
</p>{:else if $settingsQuery.isError}<p class="mg-empty" role="alert">
连接加载失败<Button onclick={() => $settingsQuery.refetch()}>重试</Button>
</p>{:else}
<div class="mg-cards">
{#each endpoints as ep (ep.id)}{@const rec = tests[ep.id]}
<article class="mg-card">
<div class="mg-row">
<span
class="mg-dot"
class:ok={rec?.ok === true}
class:err={rec?.ok === false}
></span><b>{ep.name}</b><span class="mg-grow"></span><span
class="mg-latency"
>{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"}</span
>
</div>
<div class="mg-mono">{ep.base_url}</div>
<div class="mg-muted">
{rec?.ok === false
? "连接失败 · " + (rec.reason ?? "原因未知")
: rec?.ok
? "连接测试通过"
: "尚未测试"} · 队列未知 · 显存未知
</div>
<div class="mg-row">
<span class="mg-tag">{workflowsBound(ep.id)} 个工作流使用</span><span
class="mg-grow"
></span><Button
size="sm"
loading={pendingTests[ep.id]}
onclick={() => testEndpoint(ep)}>测试连接</Button
><Button variant="ghost" size="sm" onclick={() => startEdit(ep)}
>编辑</Button
>
</div>
</article>
{/each}
<CloudModels />
<button class="mg-card pending mg-add" onclick={startAdd}
><Icon name="plus" size={22} />添加连接</button
>
</div>{/if}
<Modal
open={formMode !== "idle"}
title={formMode === "add" ? t("conn.formTitleAdd") : t("conn.formTitleEdit")}
dismissible={!formPending && !$saveMutation.isPending}
onclose={resetForm}
>
<aside
class="form-col"
class:open={formMode !== "idle"}
aria-label={t("conn.formTitleAdd")}
>
{#if formMode !== "idle"}
<div class="form-head">
<h2>
{formMode === "add"
? t("conn.formTitleAdd")
: t("conn.formTitleEdit")}
</h2>
</div>
<label class="f-field">
<span class="f-label">{t("conn.fName")}<em>*</em></span>
<input
class="f-input"
class:invalid={showErrors && formErrors.name}
value={form.name}
disabled={formPending}
oninput={(e) => (form.name = e.currentTarget.value)}
placeholder={t("conn.fNamePh")}
/>
{#if showErrors && formErrors.name}<span class="f-error"
>{formErrors.name}</span
>{/if}
</label>
<label class="f-field">
<span class="f-label">{t("conn.fBaseUrl")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.baseUrl}
value={form.baseUrl}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.baseUrl = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={t("conn.fBaseUrlPh")}
/>
{#if showErrors && formErrors.baseUrl}
<span class="f-error">{formErrors.baseUrl}</span>
{/if}
<span class="f-hint">{t("conn.baseUrlHelp")}</span>
</label>
<div class="f-field">
<span class="f-label">{t("conn.fAuth")}</span>
<div class="mg-seg" role="radiogroup" aria-label={t("conn.fAuth")}>
{#each [["none", t("conn.authNone")], ["bearer", t("conn.authBearer")], ["header", t("conn.authHeader")]] as [value, label] (value)}
<button
type="button"
class="seg-item"
class:on={form.authType === value}
aria-pressed={form.authType === value}
onclick={() => {
form.authType = value as typeof form.authType;
touchFormConnectivity();
}}
>
{label}
</button>
{/each}
</div>
<span class="f-hint">{t("conn.authHelp")}</span>
</div>
{#if form.authType !== "none"}
{#if form.authType === "header"}
<label class="f-field">
<span class="f-label">{t("conn.fHeaderName")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.headerName}
value={form.headerName}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.headerName = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={t("conn.fHeaderNamePh")}
/>
{#if showErrors && formErrors.headerName}
<span class="f-error">{formErrors.headerName}</span>
{/if}
</label>
{/if}
<label class="f-field">
<span class="f-label">
{t("conn.fToken")}
<span class="kh-inline mono">{t("conn.keyNoEcho")}</span>
</span>
<span class="key-wrap">
<input
class="f-input key-input"
type="password"
value={form.token}
disabled={formPending}
autocomplete="off"
oninput={(e) => {
form.token = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={formMode === "edit" && editingEndpoint?.has_token
? t("conn.tokenSaved")
: "…"}
/>
{#if formMode === "edit" && editingEndpoint?.has_token && tokenTail(editingEndpoint)}
<span class="kh mono">{tokenTail(editingEndpoint)}</span>
{/if}
</span>
<span class="f-hint">{t("conn.keyHelp")}</span>
</label>
{/if}
<label class="f-field">
<span class="f-label">{t("conn.fNotes")}</span>
<input
class="f-input"
value={form.notes}
disabled={formPending}
oninput={(e) => (form.notes = e.currentTarget.value)}
placeholder={t("conn.fNotesPh")}
/>
</label>
{#if formTest?.ok}
<div class="result-bar ok" transition:fade={{ duration: 250 }}>
{t("conn.testOk", {
ms: formTest.ms ?? "?",
v: formTest.version ?? "?",
})}
</div>
{:else if formTest?.ok === false && formTestFailure}
<div class="result-bar err" transition:fade={{ duration: 250 }}>
<p>{t("conn.testFail", { reason: formTestFailure.message })}</p>
{#if formTestFailure.detail}
<p class="mono detail">{formTestFailure.detail}</p>
{/if}
</div>
{/if}
<div class="form-actions">
<span class="need-test" class:passed={formTest?.ok}
>{t("conn.needTest")}</span
>
<Button
variant="secondary"
size="sm"
loading={formPending}
onclick={runTest}
>
{formPending ? t("conn.testing") : t("conn.testBtn")}
</Button>
<Button
variant="primary"
size="sm"
disabled={!formValid || !formTest?.ok}
loading={$saveMutation.isPending}
onclick={submitSave}
>
{t("conn.save")}
</Button>
</div>
{/if}
{#if formMode === "edit" && editingId}<div class="mg-row">
{#if editingId !== activeId}<Button
size="sm"
onclick={() => $defaultMutation.mutate(editingId!)}>设为默认</Button
>{/if}<Button
size="sm"
variant="danger"
onclick={() => {
const target = editingEndpoint;
if (target) requestDelete(target);
}}>删除</Button
>
</div>{/if}
</aside></Modal
>
<ConfirmDialog
bind:open={deleteOpen}
title={t("conn.delTitle")}
message={deleteMessage}
confirmLabel={t("conn.delete")}
danger
onconfirm={() => $deleteMutation.mutate()}
oncancel={() => (deleteTarget = null)}
/>
@@ -0,0 +1,54 @@
<script lang="ts">
let {
model,
muted = false,
large = false,
}: { model: string | null; muted?: boolean; large?: boolean } = $props();
const family = $derived.by(() => {
const value = (model ?? "").toLowerCase();
const known: [string, string, string][] = [
["qwen", "qwen", "Qwen"],
["krea", "krea", "Kr2"],
["flux", "flux", "FLX"],
["z-image", "zimg", "Z"],
["minimax", "h3", "H3"],
["wan", "wan", "Wan"],
["anima", "anima", "Ani"],
];
const hit = known.find(([prefix]) => value.includes(prefix));
return hit
? { color: `var(--family-${hit[1]})`, label: hit[2] }
: { color: "var(--tx-3)", label: model?.slice(0, 3) ?? "本地" };
});
</script>
<span
class="family-badge"
class:muted
class:large
style:--family-color={family.color}
aria-hidden="true">{family.label}</span
>
<style>
.family-badge {
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
flex: none;
font: 700 9px var(--mono);
color: var(--family-ink);
background: var(--family-color);
}
.muted {
filter: grayscale(1) brightness(0.55);
}
.large {
width: 36px;
height: 36px;
border-radius: 9px;
font-size: 11px;
}
</style>
@@ -0,0 +1,834 @@
<script lang="ts">
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { fade } from "svelte/transition";
import Modal from "$lib/components/ui/Modal.svelte";
import Button from "$lib/components/ui/Button.svelte";
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { settings, type LlmProfile, type Settings } from "$lib/api";
import { randomId } from "$lib/ids";
import { toast } from "$lib/toast";
import { t } from "$lib/i18n.svelte";
const client = useQueryClient();
const settingsQuery = createQuery({
queryKey: ["settings"],
queryFn: settings.get,
});
// A profile while being edited here: `api_key` is the public boolean,
// except for a freshly typed key waiting to be sent to the server.
type WorkingProfile = Omit<LlmProfile, "api_key"> & {
api_key: boolean | string;
};
// ------------------------------------------------------------------
// Local test-state persistence (last result / time / consecutive
// failures per profile). Purely presentational; connectivity facts
// always come from the server-side probe.
// ------------------------------------------------------------------
type TestRec = {
ok: boolean;
ms?: number;
at: number;
fails: number;
reason?: string;
};
const TESTS_KEY = "calliope.models.tests";
let tests = $state<Record<string, TestRec>>({});
let pendingTests = $state<Record<string, boolean>>({});
function loadTests() {
try {
const raw = localStorage.getItem(TESTS_KEY);
if (raw) tests = JSON.parse(raw) as Record<string, TestRec>;
} catch {
tests = {};
}
}
function saveTests(map: Record<string, TestRec>) {
tests = map;
try {
localStorage.setItem(TESTS_KEY, JSON.stringify(map));
} catch {
/* storage full / disabled — the page still works without persistence */
}
}
function recordTest(id: string, rec: TestRec) {
saveTests({ ...tests, [id]: rec });
}
function pruneTests(ids: string[]) {
const keep = new Set(ids);
const next: Record<string, TestRec> = {};
let changed = false;
for (const [k, v] of Object.entries(tests)) {
if (keep.has(k)) next[k] = v;
else changed = true;
}
if (changed) saveTests(next);
}
// ------------------------------------------------------------------
// Form state — add / edit custom LLM (spec §9.3)
// ------------------------------------------------------------------
type FormMode = "idle" | "add" | "edit";
let formMode = $state<FormMode>("idle");
let editingId = $state<string | null>(null);
let form = $state({
name: "",
baseUrl: "",
model: "",
apiKey: "",
timeout: 10,
});
let formTest = $state<{ ok: boolean; ms?: number } | null>(null);
let formTestFailure = $state<{ text: string; detail: string } | null>(null);
let formPending = $state(false);
let showErrors = $state(false);
const TIMEOUT_OPTIONS = [5, 10, 15, 30, 60];
function resetForm() {
formMode = "idle";
editingId = null;
form = { name: "", baseUrl: "", model: "", apiKey: "", timeout: 10 };
formTest = null;
formTestFailure = null;
showErrors = false;
}
export function startAdd() {
resetForm();
formMode = "add";
}
function startEdit(profile: LlmProfile) {
resetForm();
formMode = "edit";
editingId = profile.id;
form = {
name: profile.name,
baseUrl: profile.base_url,
model: profile.model,
apiKey: "",
timeout: 10,
};
}
// A connectivity-relevant edit invalidates the previous passing test.
function touchFormConnectivity() {
formTest = null;
formTestFailure = null;
}
const urlValid = $derived.by(() => {
try {
const u = new URL(form.baseUrl.trim());
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
});
const formErrors = $derived.by(() => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = t("models.errName");
if (!urlValid) errs.baseUrl = t("models.errUrl");
if (!form.model.trim()) errs.model = t("models.errModel");
// Key is required when creating; on edit an empty field keeps the
// stored key (masked tail placeholder covers the affordance).
if (formMode === "add" && !form.apiKey.trim())
errs.apiKey = t("models.errKey");
return errs;
});
const formValid = $derived(Object.keys(formErrors).length === 0);
const editingProfile = $derived.by(() => {
if (formMode !== "edit" || !editingId || !$settingsQuery.data) return null;
return (
$settingsQuery.data.llm_profiles.find((p) => p.id === editingId) ?? null
);
});
// ------------------------------------------------------------------
// Failure classification → spec §9.6 copy (primary sentence) with the
// server-provided detail as a mono secondary line where it adds info.
// ------------------------------------------------------------------
function classifyFailure(
res: { error?: string; detail?: string; timeout_sec?: number },
fallbackSec: number,
): { text: string; detail: string } {
const detail = res.detail ?? "";
switch (res.error) {
case "timeout":
return {
text: t("models.failTimeout", {
sec: res.timeout_sec ?? fallbackSec,
}),
detail,
};
case "auth":
return { text: t("models.failAuth"), detail };
case "not_found":
return { text: t("models.failNotFound"), detail };
case "http": {
if (detail.startsWith("HTTP 404"))
return { text: t("models.failNotOpenai"), detail };
const status = detail.split(":")[0].replace("HTTP ", "").trim();
return {
text: t("models.failHttp", { status: status || detail }),
detail,
};
}
case "connect":
default:
return { text: t("models.failConnect"), detail };
}
}
async function runTest() {
showErrors = true;
if (!urlValid || !form.model.trim()) return;
// On add the key is required before anything can be probed.
if (formMode === "add" && !form.apiKey.trim()) return;
formPending = true;
formTest = null;
formTestFailure = null;
try {
const res = await settings.testLlm({
...(formMode === "edit" && editingId ? { profile_id: editingId } : {}),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
...(form.apiKey.trim() ? { api_key: form.apiKey.trim() } : {}),
timeout_sec: form.timeout,
});
if (res.ok) {
formTest = { ok: true, ms: res.elapsed_ms };
} else {
formTest = { ok: false };
formTestFailure = classifyFailure(res, form.timeout);
}
} catch (err) {
formTest = { ok: false };
formTestFailure = {
text: t("models.failConnect"),
detail: err instanceof Error ? err.message : String(err),
};
} finally {
formPending = false;
}
}
function llmProfilesFromSettings(s: Settings): WorkingProfile[] {
if (s.llm_profiles?.length) return s.llm_profiles.map((p) => ({ ...p }));
return [
{
id: s.llm_active_id || "legacy",
name: s.llm_model || "LLM",
base_url: s.llm_base_url,
model: s.llm_model,
api_key: s.llm_api_key,
},
];
}
const saveMutation = createMutation({
mutationFn: async () => {
const s = $settingsQuery.data;
if (!s) throw new Error("settings unavailable");
const base = llmProfilesFromSettings(s);
const typedKey = form.apiKey.trim();
let list: WorkingProfile[];
if (formMode === "edit" && editingId) {
list = base.map((p) =>
p.id === editingId
? {
...p,
name: form.name.trim(),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
...(typedKey ? { api_key: typedKey } : {}),
}
: p,
);
} else {
list = [
...base,
{
id: randomId(),
name: form.name.trim(),
base_url: form.baseUrl.trim(),
model: form.model.trim(),
api_key: typedKey,
},
];
}
return settings.update({
llm_profiles: list.map((p) => ({
id: p.id,
name: p.name.trim(),
base_url: p.base_url.trim(),
model: p.model.trim(),
...(typeof p.api_key === "string" && p.api_key.trim()
? { api_key: p.api_key.trim() }
: {}),
})),
});
},
onSuccess: (saved) => {
client.invalidateQueries({ queryKey: ["settings"] });
// Carry the passing test over so the new card shows 已连接.
if (formTest?.ok) {
const id =
formMode === "edit" && editingId
? editingId
: (saved.llm_profiles.at(-1)?.id ?? "");
if (id)
recordTest(id, {
ok: true,
ms: formTest.ms,
at: Date.now(),
fails: 0,
});
}
toast.success(t("models.saved"));
resetForm();
},
onError: (err) => {
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitSave() {
showErrors = true;
if (!formValid || !formTest?.ok || $saveMutation.isPending) return;
$saveMutation.mutate();
}
// ------------------------------------------------------------------
// Card actions: per-profile test / delete / set default
// ------------------------------------------------------------------
async function testProfile(profile: LlmProfile) {
pendingTests = { ...pendingTests, [profile.id]: true };
try {
const res = await settings.testLlm({
profile_id: profile.id,
base_url: profile.base_url.trim(),
model: profile.model.trim(),
timeout_sec: 10,
});
const prev = tests[profile.id];
if (res.ok) {
recordTest(profile.id, {
ok: true,
ms: res.elapsed_ms,
at: Date.now(),
fails: 0,
});
} else {
const cls = classifyFailure(res, 10);
recordTest(profile.id, {
ok: false,
at: Date.now(),
fails: (prev?.fails ?? 0) + 1,
reason: cls.text + (cls.detail ? `(${cls.detail})` : ""),
});
}
} catch (err) {
const prev = tests[profile.id];
recordTest(profile.id, {
ok: false,
at: Date.now(),
fails: (prev?.fails ?? 0) + 1,
reason: err instanceof Error ? err.message : String(err),
});
} finally {
const next = { ...pendingTests };
delete next[profile.id];
pendingTests = next;
}
}
let deleteOpen = $state(false);
let deleteTarget = $state<LlmProfile | null>(null);
function requestDelete(profile: LlmProfile) {
deleteTarget = profile;
deleteOpen = true;
}
function agentsUsing(s: Settings, profileId: string): number {
return Object.values(s.agent_llm_assignments ?? {}).filter(
(v) => v === profileId,
).length;
}
const deleteMessage = $derived.by(() => {
const s = $settingsQuery.data;
if (!deleteTarget || !s) return "";
const n = agentsUsing(s, deleteTarget.id);
return n > 0
? t("models.delConfirmUsed", { n })
: t("models.delConfirmFree");
});
const deleteMutation = createMutation({
mutationFn: async () => {
const s = $settingsQuery.data;
const target = deleteTarget;
if (!s || !target) throw new Error("no target");
const list = llmProfilesFromSettings(s).filter((p) => p.id !== target.id);
return settings.update({
llm_profiles: list.map((p) => ({
id: p.id,
name: p.name.trim(),
base_url: p.base_url.trim(),
model: p.model.trim(),
})),
});
},
onSuccess: () => {
if (deleteTarget) {
const next = { ...tests };
delete next[deleteTarget.id];
saveTests(next);
if (editingId === deleteTarget.id) resetForm();
}
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("models.deleted"));
},
onError: (err) => {
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
const defaultMutation = createMutation({
mutationFn: (id: string) => settings.update({ llm_active_id: id }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("models.defaultSetToast"));
},
onError: (err) => {
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
// ------------------------------------------------------------------
// Agent default-model panel (spec §9.4). The five AIGC agents; 视觉设计
// binds to the `style` role added for the upcoming style agent.
// ------------------------------------------------------------------
const AGENTS: {
role: string;
labelKey: string;
color: string;
char: string;
dark?: boolean;
}[] = [
{
role: "story",
labelKey: "models.agentStory",
color: "var(--avatar-1)",
char: "剧",
dark: true,
},
{
role: "style",
labelKey: "models.agentStyle",
color: "var(--avatar-2)",
char: "视",
dark: true,
},
{
role: "script",
labelKey: "models.agentScript",
color: "var(--avatar-3)",
char: "分",
},
{
role: "assets",
labelKey: "models.agentAssets",
color: "var(--avatar-4)",
char: "素",
},
{
role: "video",
labelKey: "models.agentVideo",
color: "var(--avatar-5)",
char: "镜",
},
];
const assignMutation = createMutation({
mutationFn: (payload: Record<string, string | null>) =>
settings.update({ agent_llm_assignments: payload }),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.success(t("models.assignSaved"));
},
onError: (err) => {
client.invalidateQueries({ queryKey: ["settings"] });
toast.error(
t("models.saveFailed", {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function setAssignment(role: string, value: string) {
// Single-role delta only (HEL-619): replaying the whole cached
// snapshot races concurrent dropdown switches — an older snapshot
// could overwrite a newer choice. The server merges per role.
$assignMutation.mutate({ [role]: value === "" ? null : value });
}
function keyTail(profile: LlmProfile): string {
const tail = profile.api_key_tail ?? "";
return tail ? `sk-****${tail}` : "";
}
loadTests();
// Prune stale test records once the profile list is known.
$effect(() => {
const data = $settingsQuery.data;
if (data) pruneTests(data.llm_profiles.map((p) => p.id));
});
// Test each model once per visit (1-token probe) so the cards show real
// state instead of 「未测试」; results younger than 10 minutes are reused.
const autoProbed = new SvelteSet<string>();
$effect(() => {
const list = $settingsQuery.data?.llm_profiles ?? [];
untrack(() => {
const now = Date.now();
for (const p of list) {
if (autoProbed.has(p.id)) continue;
autoProbed.add(p.id);
const rec = tests[p.id];
if (!rec || now - rec.at > 600_000) void testProfile(p);
}
});
});
</script>
{#if $settingsQuery.isLoading}<p class="mg-empty">
正在读取模型…
</p>{:else if !$settingsQuery.data}<p class="mg-empty" role="alert">
模型加载失败<Button onclick={() => $settingsQuery.refetch()}>重试</Button>
</p>{:else}{@const s = $settingsQuery.data}{@const profiles = s.llm_profiles}
<div class="mg-cards">
{#each profiles as profile (profile.id)}{@const rec = tests[profile.id]}
<article class="mg-card">
<div class="mg-row">
<span
class="mg-dot"
class:ok={rec?.ok === true}
class:err={rec?.ok === false}
></span><b>{profile.name}</b><span class="mg-grow"></span><span
class="mg-latency"
>{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"}</span
>
</div>
<div class="mg-mono">{profile.base_url}</div>
<div class="mg-muted">{profile.model}</div>
<div class="mg-dim">
{rec?.ok === false
? (rec.reason ?? "连接失败")
: rec?.ok
? "连接测试通过"
: "尚未测试"}
</div>
<div class="mg-row">
<span class="mg-tag">{agentsUsing(s, profile.id)} 个智能体使用</span
><span class="mg-grow"></span><Button
size="sm"
loading={pendingTests[profile.id]}
onclick={() => testProfile(profile)}>测试连接</Button
><Button size="sm" variant="ghost" onclick={() => startEdit(profile)}
>编辑</Button
>
</div>
</article>{/each}<button class="mg-card pending mg-add" onclick={startAdd}
><Icon name="plus" size={22} />添加大语言模型</button
>
<div class="model-assignments">
<section class="mg-field">
<div class="mg-row">
<h2>{t("models.agentsTitle")}</h2>
<span class="panel-hint">{t("models.agentsHint")}</span>
</div>
<div class="mg-assignments">
{#each AGENTS as agent (agent.role)}
<label class="agent-cell">
<span
class="avatar"
style:background={agent.color}
class:dark={agent.dark}
>
{agent.char}
</span>
<span class="agent-name">{t(agent.labelKey)}</span>
<select
class="a-select"
value={s.agent_llm_assignments?.[agent.role] ?? ""}
onchange={(e) =>
setAssignment(agent.role, e.currentTarget.value)}
>
<option value="">{t("models.agentDefault")}</option>
{#each profiles as p (p.id)}
<option
value={p.id}
class:unavailable={tests[p.id]?.ok === false}
>
{p.name} — {p.model}{tests[p.id]?.ok === false
? t("models.unavailable")
: ""}
</option>
{/each}
</select>
</label>
{/each}
</div>
</section>
</div>
</div>{/if}
<Modal
open={formMode !== "idle"}
title={formMode === "edit"
? t("models.formTitleEdit")
: t("models.formTitleAdd")}
dismissible={!formPending}
onclose={resetForm}
size="md"
><aside
class="form-col"
class:open={formMode !== "idle"}
aria-label={t("models.formTitleAdd")}
>
{#if formMode === "idle"}
<div class="form-idle">
<Icon name="sparkle" size={20} />
<p>{t("models.idleFormHint")}</p>
</div>
{:else}
<div class="form-head">
<h2>
{formMode === "add"
? t("models.formTitleAdd")
: t("models.formTitleEdit")}
</h2>
<button
type="button"
class="icon-btn"
aria-label={t("models.formClose")}
onclick={resetForm}
>
<Icon name="close" size={16} />
</button>
</div>
<label class="f-field">
<span class="f-label">{t("models.fName")}<em>*</em></span>
<input
class="f-input"
class:invalid={showErrors && formErrors.name}
value={form.name}
disabled={formPending}
oninput={(e) => (form.name = e.currentTarget.value)}
placeholder={t("models.fNamePh")}
/>
{#if showErrors && formErrors.name}<span class="f-error"
>{formErrors.name}</span
>{/if}
</label>
<div class="f-field">
<span class="f-label">{t("models.fApiType")}</span>
<div class="seg" role="radiogroup" aria-label={t("models.fApiType")}>
<span class="seg-item selected" role="radio" aria-checked="true">
{t("models.apiOpenai")}
</span>
<span class="seg-item disabled" aria-disabled="true">
{t("models.apiAnthropic")}
</span>
<span class="seg-item disabled" aria-disabled="true"
>{t("models.apiOther")}</span
>
</div>
<span class="f-hint">{t("models.apiTypeHelp")}</span>
</div>
<label class="f-field">
<span class="f-label">{t("models.fBaseUrl")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.baseUrl}
value={form.baseUrl}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.baseUrl = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder="http://192.168.200.18:11434/v1"
/>
{#if showErrors && formErrors.baseUrl}
<span class="f-error">{formErrors.baseUrl}</span>
{/if}
<span class="f-hint">{t("models.baseUrlHelp")}</span>
</label>
<label class="f-field">
<span class="f-label">{t("models.fModel")}<em>*</em></span>
<input
class="f-input mono"
class:invalid={showErrors && formErrors.model}
value={form.model}
disabled={formPending}
spellcheck="false"
oninput={(e) => {
form.model = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder="deepseek-chat"
/>
{#if showErrors && formErrors.model}
<span class="f-error">{formErrors.model}</span>
{/if}
<span class="f-hint">{t("models.modelHelp")}</span>
</label>
<label class="f-field">
<span class="f-label">
{t("models.fApiKey")}<em>*</em>
<span class="kh-inline mono">{t("models.keyNoEcho")}</span>
</span>
<span class="key-wrap">
<input
class="f-input key-input"
type="password"
class:invalid={showErrors && formErrors.apiKey}
value={form.apiKey}
disabled={formPending}
autocomplete="off"
oninput={(e) => {
form.apiKey = e.currentTarget.value;
touchFormConnectivity();
}}
placeholder={formMode === "edit" && editingProfile?.api_key
? t("models.keyKeepHint")
: "sk-…"}
/>
{#if formMode === "edit" && editingProfile?.api_key && keyTail(editingProfile)}
<span class="kh mono">{keyTail(editingProfile)}</span>
{/if}
</span>
{#if showErrors && formErrors.apiKey}
<span class="f-error">{formErrors.apiKey}</span>
{/if}
<span class="f-hint">{t("models.keyHelp")}</span>
</label>
<label class="f-field">
<span class="f-label">{t("models.fTimeout")}</span>
<select
class="f-input"
value={form.timeout}
disabled={formPending}
onchange={(e) => {
form.timeout = Number(e.currentTarget.value);
touchFormConnectivity();
}}
>
{#each TIMEOUT_OPTIONS as sec (sec)}
<option value={sec}>{t("models.timeoutSec", { n: sec })}</option>
{/each}
</select>
</label>
{#if formTest?.ok}
<div class="result-bar ok" transition:fade={{ duration: 250 }}>
{t("models.testOk", {
ms: formTest.ms ?? "?",
model: form.model.trim(),
})}
</div>
{:else if formTest?.ok === false && formTestFailure}
<div class="result-bar err" transition:fade={{ duration: 250 }}>
<p>
{t("models.testFail", { reason: formTestFailure.text })}
</p>
{#if formTestFailure.detail}
<p class="mono detail">{formTestFailure.detail}</p>
{/if}
</div>
{/if}
<div class="form-actions">
<span class="need-test" class:passed={formTest?.ok}
>{t("models.needTest")}</span
>
<Button
variant="secondary"
size="sm"
loading={formPending}
onclick={runTest}
>
{formPending ? t("models.testing") : t("models.testBtn")}
</Button>
<Button
variant="primary"
size="sm"
disabled={!formValid || !formTest?.ok}
loading={$saveMutation.isPending}
onclick={submitSave}
>
{t("models.save")}
</Button>
</div>
{/if}
{#if formMode === "edit" && editingId}<div class="mg-row">
{#if editingId !== $settingsQuery.data?.llm_active_id}<Button
size="sm"
onclick={() => $defaultMutation.mutate(editingId!)}>设为默认</Button
>{/if}<Button
size="sm"
variant="danger"
onclick={() => {
const target = editingProfile;
if (target) requestDelete(target);
}}>删除</Button
>
</div>{/if}
</aside></Modal
>
<ConfirmDialog
bind:open={deleteOpen}
title={t("models.delTitle")}
message={deleteMessage}
confirmLabel={t("models.delete")}
danger
onconfirm={() => $deleteMutation.mutate()}
oncancel={() => (deleteTarget = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from "svelte";
import AppShell from "$lib/components/shell/AppShell.svelte";
import type { NavId } from "$lib/components/shell/nav";
import "./management.css";
let {
title,
active,
tools,
actions,
children,
scene = "manage",
}: {
scene?: "manage" | "gold";
title: string;
active: NavId;
tools?: Snippet;
actions?: Snippet;
children: Snippet;
} = $props();
</script>
<AppShell {title} {active} workspace>
{#snippet pageHeader()}
<header
class="management-header"
style:--scene={scene === "gold" ? "var(--gold)" : "var(--sc-manage)"}
>
<b>{title}</b>{@render tools?.()}<span class="mg-grow"
></span>{@render actions?.()}
</header>
{/snippet}
<section
class="management-page"
aria-label={title}
style:--scene={scene === "gold" ? "var(--gold)" : "var(--sc-manage)"}
>
{@render children()}
</section>
</AppShell>
@@ -0,0 +1,754 @@
/* HEL-726 approved management primitives. Geometry and colours use theme tokens. */
.management-header {
--icon-stroke: var(--manage-icon-stroke);
letter-spacing: var(--manage-header-letter-spacing);
box-sizing: border-box;
--button-weight: var(--manage-control-weight);
--button-primary-weight: var(--manage-control-weight);
--button-height: var(--manage-control-height);
--button-font-size: var(--manage-control-size);
--button-radius: var(--manage-control-radius);
--button-secondary-bg: var(--bg-3);
--button-sm-height: var(--manage-button-height);
--button-sm-padding: var(--manage-button-padding);
--button-secondary-radius: var(--manage-button-radius);
height: var(--head);
flex: none;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
background: var(--glass);
backdrop-filter: blur(10px);
position: relative;
z-index: 3;
min-width: 0;
color: var(--tx-1);
}
.management-header > b {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.management-page {
--icon-stroke: var(--manage-icon-stroke);
box-sizing: border-box;
--button-weight: var(--manage-control-weight);
--button-primary-weight: var(--manage-control-weight);
--button-height: var(--manage-control-height);
--button-font-size: var(--manage-control-size);
--button-radius: var(--manage-control-radius);
--button-secondary-bg: var(--bg-3);
animation: management-enter var(--t-slow) var(--ease-out);
--button-sm-height: var(--manage-button-height);
--button-sm-padding: var(--manage-button-padding);
--button-sm-radius: var(--manage-button-radius);
--button-primary-height: var(--manage-control-height);
--button-primary-padding: 12px;
--button-primary-radius: var(--manage-control-radius);
--button-secondary-radius: var(--manage-button-radius);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
color: var(--tx-1);
font-size: 13px;
overflow: hidden;
}
.mg-grow {
flex: 1;
min-width: 0;
}
.manage {
--scene: var(--sc-manage);
}
.mg-layout {
box-sizing: border-box;
flex: 1;
display: grid;
grid-template-columns: var(--manage-filter-width) minmax(0, 1fr) var(
--manage-detail-width
);
min-height: 0;
}
.mg-side {
box-sizing: border-box;
border-right: 1px solid var(--line);
background: var(--manage-panel-background);
padding: 12px 10px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.mg-side .mg-label {
font-size: 11px;
color: var(--tx-3);
padding: 10px 8px 4px;
letter-spacing: 0.5px;
}
.mg-filter {
border: 0;
background: none;
text-align: left;
flex: none;
font-family: inherit;
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border-radius: 8px;
font-size: 12.5px;
color: var(--tx-2);
cursor: pointer;
}
.mg-filter:hover {
background: rgb(var(--ink) / 0.04);
color: var(--tx-1);
}
.mg-filter.on {
background: var(--bg-3);
color: var(--tx-1);
}
.mg-filter .n {
margin-left: auto;
font: 500 11px var(--mono);
color: var(--tx-3);
}
.mg-main {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.mg-tools {
box-sizing: border-box;
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.mg-table {
flex: 1;
overflow: auto;
}
.mg-table table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.mg-table th {
position: sticky;
top: 0;
background: var(--bg-0);
z-index: 1;
text-align: left;
font-weight: 500;
color: var(--tx-3);
font-size: 11.5px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.mg-table td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.mg-table tr {
cursor: pointer;
transition: background var(--t-fast);
}
.mg-table tbody tr:hover {
background: rgb(var(--ink) / 0.025);
}
.mg-table tr.on {
background: color-mix(in srgb, var(--sc-manage) 10%, transparent);
}
.mg-table .nm b {
font-weight: 500;
display: block;
}
.mg-table .nm small {
font: 11px var(--mono);
color: var(--tx-3);
}
.mg-detail {
box-sizing: border-box;
min-width: 0;
border-left: 1px solid var(--line);
background: var(--manage-panel-background);
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mg-map {
display: grid;
grid-template-columns: 1fr 18px 1fr;
gap: 6px;
align-items: center;
font-size: 12px;
}
.mg-map .a {
padding: 6px 8px;
border-radius: 6px;
background: var(--bg-0);
border: 1px solid var(--line);
font-family: var(--mono);
font-size: 11px;
color: var(--tx-2);
}
.mg-map .b {
padding: 6px 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--sc-image) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--sc-image) 30%, transparent);
}
.mg-media {
flex: 1;
overflow: auto;
padding: 14px 16px;
columns: 5 200px;
column-gap: 10px;
}
.mg-media > article {
break-inside: avoid;
margin-bottom: 10px;
border-radius: var(--manage-media-radius);
overflow: hidden;
border: 1px solid var(--line);
position: relative;
}
.mg-media img {
display: block;
width: 100%;
}
.mg-cards {
flex: 1;
overflow: auto;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--manage-card-min), 1fr));
gap: 12px;
align-content: start;
grid-auto-rows: max-content;
}
.mg-card {
box-sizing: border-box;
min-width: 0;
border-radius: var(--manage-card-radius);
border: 1px solid var(--line-2);
background: var(--bg-2);
padding: var(--manage-card-padding);
display: flex;
flex-direction: column;
gap: 10px;
}
.mg-card .mg-row b {
font-size: 13.5px;
}
.mg-latency {
font: 600 11px var(--mono);
color: var(--ok);
}
.mg-filter small {
margin-left: auto;
font: 500 11px var(--mono);
color: var(--tx-3);
}
.mg-search {
height: 34px;
border-radius: var(--r-sm);
background: var(--well);
border: 1px solid var(--line-2);
padding: 0 10px;
display: flex;
align-items: center;
gap: 8px;
color: var(--tx-3);
min-width: 0;
box-sizing: border-box;
}
.mg-search input {
min-width: 0;
width: 100%;
background: transparent;
border: 0;
outline: 0;
color: var(--tx-1);
font: inherit;
font-size: 13px;
padding: 0;
}
.mg-search:focus-within {
border-color: var(--scene);
}
.mg-search kbd {
font: 10px var(--mono);
color: var(--tx-3);
}
.mg-seg {
box-sizing: border-box;
display: flex;
padding: 3px;
border-radius: var(--manage-segment-radius);
background: var(--well);
border: 1px solid var(--line);
gap: 2px;
}
.mg-seg button {
flex: 1;
white-space: nowrap;
height: 28px;
border: 0;
border-radius: var(--manage-segment-option-radius);
background: transparent;
color: var(--tx-2);
font: inherit;
font-size: 12.5px;
padding: 0;
cursor: pointer;
transition: all var(--t-base) var(--ease-out);
}
.mg-seg button.on {
background: var(--bg-3);
color: var(--tx-1);
box-shadow: var(--sh-1);
}
.mg-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.mg-muted {
color: var(--tx-2);
}
.mg-dim {
color: var(--tx-3);
font-size: 12px;
}
.mg-mono {
font: 11px/var(--lh-body) var(--mono);
color: var(--tx-3);
overflow-wrap: anywhere;
}
.mg-tag {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
height: 18px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid var(--line);
background: rgb(var(--ink) / 0.05);
font-size: 11px;
color: var(--tx-2);
}
.mg-tag.ok {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
background: color-mix(in srgb, var(--ok) 7%, transparent);
}
.mg-tag.warn {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
background: color-mix(in srgb, var(--warn) 7%, transparent);
}
.mg-tag.err {
color: var(--err);
border-color: color-mix(in srgb, var(--err) 30%, transparent);
background: color-mix(in srgb, var(--err) 7%, transparent);
}
.mg-field {
box-sizing: border-box;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--manage-field-radius);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.mg-field h3 {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--tx-2);
margin: 0;
}
.mg-detail h2 {
font-size: 14px;
margin: 0;
font-weight: 600;
}
.mg-name {
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mg-name b {
font-size: 12.5px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mg-name small {
font: 11px var(--mono);
color: var(--tx-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mg-empty {
padding: 40px 16px;
text-align: center;
color: var(--tx-3);
font-size: 12px;
}
.mg-card.pending {
border-style: dashed;
background: transparent;
}
.mg-add {
min-height: var(--manage-add-min-height);
display: grid;
place-items: center;
color: var(--tx-3);
cursor: pointer;
font: inherit;
}
.mg-dot {
height: 7px;
width: 7px;
border-radius: 50%;
background: var(--tx-3);
flex: none;
}
.mg-dot.ok {
background: var(--ok);
}
.mg-dot.err {
background: var(--err);
}
.mg-click {
border: 0;
background: none;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
width: 100%;
}
.management-page button:focus-visible,
.management-header button:focus-visible {
outline: 2px solid var(--scene, var(--info));
outline-offset: 2px;
}
/* Shared connection/model edit form, hosted by the existing focus-trapping Modal. */
.form-col {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.form-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-head h2 {
font-size: 14px;
margin: 0;
}
.f-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--tx-2);
}
.f-label {
font-weight: 500;
display: flex;
gap: 4px;
}
.f-label em {
color: var(--err);
}
.f-input {
height: 34px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--well);
padding: 0 10px;
color: var(--tx-1);
font: inherit;
width: 100%;
box-sizing: border-box;
}
textarea.f-input {
height: 70px;
padding: 8px 10px;
resize: vertical;
}
.f-input.invalid {
border-color: var(--err);
}
.f-input:focus {
outline: 2px solid var(--info);
outline-offset: 1px;
}
.f-error {
color: var(--err);
font-size: 11px;
}
.f-hint,
.need-test {
color: var(--tx-3);
font-size: 11px;
}
.need-test.passed {
color: var(--ok);
}
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.form-test-result,
.form-test-failure {
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
font-size: 12px;
}
.form-test-failure {
color: var(--err);
}
.form-col pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 11px var(--mono);
}
.mg-assignments {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.agent-cell {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
font-size: 12px;
}
.agent-cell .avatar {
display: none;
}
.model-assignments {
grid-column: 1/-1;
}
.wf-map-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
border: 1px dashed var(--line-2);
border-radius: var(--r-sm);
}
.wf-slot-row {
display: flex;
gap: 8px;
align-items: center;
}
.wf-out-check {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.wf-slot-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.wf-slot-id,
.wf-map-hint {
font-size: 11px;
color: var(--tx-3);
}
.wf-slot-row select {
width: 90px;
}
.result-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.result-bar.ok {
color: var(--ok);
}
.result-bar.err {
color: var(--err);
}
.result-bar .detail {
font-size: 11px;
overflow-wrap: anywhere;
}
.key-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.kh,
.kh-inline {
font: 11px var(--mono);
color: var(--tx-3);
}
.mg-table .mg-row {
flex-wrap: nowrap;
}
.mg-field p {
margin: 0;
}
.mg-card > .mg-row {
flex-wrap: nowrap;
}
.mg-card > .mg-row > b {
min-width: 0;
}
.mg-options {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mg-option {
box-sizing: border-box;
height: 28px;
padding: 0 10px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
font: inherit;
font-size: 12px;
color: var(--tx-2);
background: transparent;
cursor: pointer;
transition: all var(--t-fast);
}
.mg-option.on {
background: color-mix(in srgb, var(--sc-manage) 12%, var(--bg-3));
border-color: color-mix(in srgb, var(--sc-manage) 60%, transparent);
color: var(--tx-1);
}
.mg-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mg-step {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
width: 114px;
height: 28px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg-0);
}
.mg-step button {
width: 24px;
height: 26px;
border: 0;
color: var(--tx-2);
background: transparent;
cursor: pointer;
}
.mg-step input {
width: 50px;
padding: 0;
border: 0;
background: transparent;
color: var(--tx-1);
font: 600 12px var(--mono);
text-align: center;
appearance: textfield;
-moz-appearance: textfield;
}
.mg-step input::-webkit-inner-spin-button {
appearance: none;
}
@keyframes management-enter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.mg-card .mg-muted {
font-size: 12px;
}
.mg-pin {
width: var(--manage-pin-size);
height: var(--manage-pin-size);
padding: 0;
border: 0;
border-radius: var(--manage-pin-radius);
display: grid;
place-items: center;
color: var(--tx-3);
background: transparent;
cursor: pointer;
transition:
color var(--t-fast),
background var(--t-fast);
}
.mg-pin:hover {
background: rgb(var(--ink) / 0.08);
color: var(--tx-1);
}
.mg-pin[aria-pressed="true"] {
color: var(--gold);
}
.mg-pin[aria-pressed="true"] svg {
fill: currentColor;
}
@@ -166,7 +166,6 @@
>
<div class="thumb">
{#if isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={src} muted playsinline preload="metadata" onerror={hideBroken}></video>
{:else if (opt.kind ?? kind) === 'audio'}
<Icon name="music" size={28} />
@@ -231,7 +230,7 @@
.tabs button.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--accent-ink);
}
.tabs button:focus-visible {
@@ -341,7 +340,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -78,6 +78,7 @@
// The same path can arrive twice (e.g. a scene clip that was also
// uploaded to the playground). Duplicate keys crash the keyed each
// in the picker grid, so dedupe by path first.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Local path deduplication scratch set for one filter pass; only the filtered asset array escapes.
const seen = new Set<string>();
return assetOptions.filter((o) => {
const match =
@@ -144,7 +145,6 @@
{:else if value && thumbSrc && isImageKind}
<img class="tile-thumb" src={thumbSrc} alt={displayLabel} onerror={hideBrokenThumb} />
{:else if value && isVideoKind && thumbSrc}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="tile-thumb" src={thumbSrc} muted playsinline></video>
{:else if value}
<div class="tile-file">
@@ -271,9 +271,9 @@
width: 20px;
height: 20px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.65);
background: var(--media-scrim);
border: none;
color: white;
color: var(--on-media);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
@@ -63,6 +63,7 @@
if (!open || !panelEl) return;
document.body.appendChild(panelEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates panel contents; effect owns body portal placement and teardown. Remove the moved root on close/unmount; listener cleanup stays separate.
panelEl?.remove();
};
});
@@ -173,7 +174,7 @@
padding: 0 4px;
border-radius: 9999px;
background: var(--accent);
color: white;
color: var(--accent-ink);
font-size: 10px;
font-weight: 700;
}
@@ -85,6 +85,7 @@
if (!open || !menuEl) return;
document.body.appendChild(menuEl);
return () => {
// eslint-disable-next-line svelte/no-dom-manipulating -- Template creates menu contents; effect owns body portal placement and teardown. Remove the moved root on close/unmount; selection and listener cleanup stay intact.
menuEl?.remove();
};
});
@@ -90,7 +90,7 @@
.step-btn:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
background: var(--bg-4);
}
.step-btn:focus-visible {
@@ -0,0 +1,253 @@
<script lang="ts">
import { onMount } from 'svelte';
let { onStart, onImport }: { onStart: () => void; onImport: () => void } =
$props();
let card: HTMLDivElement;
onMount(() => {
let raf = 0,
x = 0,
v = 0,
target = 0,
time = 0;
const reduced = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
document.documentElement.dataset.reduce === '1';
function animate(now: number) {
const dt = Math.min((now - time) / 1000 || 0.016, 0.032);
time = now;
v += ((target - x) * 100 - v * 10) * dt;
x += v * dt;
card.style.transform = `rotate(${10 + x}deg) translateY(${-Math.abs(x)}px)`;
if (Math.abs(v) + Math.abs(target - x) > 0.01)
raf = requestAnimationFrame(animate);
else raf = 0;
}
function go(value: number) {
if (reduced()) return;
target = value;
if (!raf) {
time = performance.now();
raf = requestAnimationFrame(animate);
}
}
const enter = () => go(-5),
leave = () => go(0);
card.addEventListener('pointerenter', enter);
card.addEventListener('pointerleave', leave);
return () => {
cancelAnimationFrame(raf);
card.removeEventListener('pointerenter', enter);
card.removeEventListener('pointerleave', leave);
};
});
</script>
<section class="refract-hero">
<div class="hero-copy">
<div class="eyebrow">● A SPACE FOR THE UNEXPECTED</div>
<h1>让想象,<br /><span>在这里成形。</span></h1>
<p>
从一个念头,到一组画面,再到一段故事。<br
/>你的模型、素材与灵感,都在同一个工作室。
</p>
<div class="hero-buttons">
<button class="a-btn primary" onclick={onStart}>开始创作 →</button><button
class="text-button"
onclick={onImport}>导入已有剧本 ↗</button
>
</div>
</div>
<div class="hero-art">
<img
class="hero-photo"
src="/refract/coast.jpg"
alt="海岸与薄雾构成的灵感画面"
/>
<div class="art-circle"></div>
<span class="hero-star">✳</span>
<div class="art-label">
VISUAL STUDY / 001<br />LIGHT, SPACE &amp; POSSIBILITY
</div>
<div class="hero-card" bind:this={card}>
<img src="/refract/arch.jpg" alt="建筑几何参考" /><span
>FOUND IN THE IN-BETWEEN</span
>
</div>
<div class="art-word">Beyond<br />the ordinary.</div>
</div>
</section>
<style>
.refract-hero {
display: grid;
grid-template-columns: 1fr 1.12fr;
min-height: 322px;
border-radius: 18px;
background:
radial-gradient(ellipse at 0 0, #7850ff1a, transparent 70%), #141419;
border: 1px solid var(--line);
overflow: hidden;
position: relative;
}
.hero-copy {
padding: 35px 40px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
z-index: 1;
}
.eyebrow {
color: var(--accent);
font-size: 9px;
letter-spacing: 1.3px;
margin-bottom: 19px;
}
h1 {
font-size: clamp(33px, 3vw, 47px);
line-height: 1.3;
margin: 0 0 15px;
font-weight: 650;
letter-spacing: -1.6px;
}
h1 span {
color: var(--accent);
}
p {
font-size: 12px;
line-height: 1.9;
color: var(--text-3);
margin: 0 0 23px;
}
.hero-buttons {
display: flex;
gap: 20px;
align-items: center;
}
.hero-buttons .a-btn {
height: 41px;
padding: 0 20px;
}
.text-button {
border: 0;
background: none;
color: var(--text-2);
font-size: 11px;
cursor: pointer;
}
.hero-art {
position: relative;
overflow: hidden;
background: #d2dbe4;
min-height: 322px;
isolation: isolate;
}
.hero-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 70%;
filter: contrast(0.92) saturate(0.7);
}
.art-circle {
position: absolute;
width: 270px;
height: 270px;
border: 1px solid #ffffff88;
border-radius: 50%;
top: 28px;
left: 75px;
z-index: 1;
}
.art-circle:before,
.art-circle:after {
content: '';
position: absolute;
border: 1px solid #ffffff66;
border-radius: 50%;
inset: 26px;
}
.art-circle:after {
inset: 57px;
}
.art-word {
position: absolute;
bottom: 22px;
left: 23px;
z-index: 2;
font-family: Georgia, serif;
font-size: 64px;
color: white;
line-height: 0.8;
font-style: italic;
letter-spacing: -4px;
}
.art-label {
position: absolute;
top: 21px;
right: 22px;
z-index: 2;
color: #fff;
font-size: 9px;
text-align: right;
letter-spacing: 1.4px;
}
.hero-card {
position: absolute;
right: 27px;
top: 60px;
width: 145px;
height: 188px;
border: 5px solid var(--surface);
background: var(--surface);
box-shadow: 0 20px 30px #1b2a3530;
transform: rotate(10deg);
z-index: 3;
}
.hero-card img {
width: 100%;
height: 155px;
object-fit: cover;
}
.hero-card span {
font-family: monospace;
letter-spacing: 0.4px;
font-size: 8px;
display: block;
margin-top: 5px;
text-align: center;
}
.hero-star {
position: absolute;
top: 40px;
left: 57px;
color: var(--lime);
font-size: 65px;
line-height: 1;
z-index: 2;
}
@media (max-width: 1250px) {
.hero-copy {
padding: 28px;
}
.hero-copy h1 {
font-size: 35px;
}
.hero-buttons {
gap: 10px;
}
.hero-card {
width: 125px;
height: 164px;
right: 18px;
}
.hero-card img {
height: 131px;
}
.art-circle {
left: 20px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,218 +1,234 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { NAV } from './nav';
import type { Pathname } from "$app/types";
import { resolve } from "$app/paths";
import { goto } from "$app/navigation";
import { createQuery } from "@tanstack/svelte-query";
import { toStore } from "svelte/store";
import { projects } from "$lib/api";
import { t } from "$lib/i18n.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import type { IconName } from "$lib/components/ui/icons";
import { NAV } from "./nav";
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
interface Entry {
key: string;
group: 'pages' | 'projects';
label: string;
icon: IconName;
href: string;
}
interface Entry {
key: string;
group: "pages" | "projects";
label: string;
icon: IconName;
href: Pathname;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state('');
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state("");
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ["projects"],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.id}`,
group: 'pages',
label: t(item.labelKey),
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: 'projects',
label: p.title,
icon: 'story',
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.href}`,
group: "pages",
label: item.label,
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: "projects",
label: p.title,
icon: "story",
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
$effect(() => {
if (!open) query = "";
});
function optionId(index: number) {
return `${listId}-${index}`;
}
function optionId(index: number) {
return `${listId}-${index}`;
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function go(entry: Entry) {
open = false;
void goto(resolve(entry.href));
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list
?.querySelector(`#${optionId(activeIndex)}`)
?.scrollIntoView({ block: "nearest" });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter') {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Enter") {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
</script>
<Modal bind:open title={t('shell.searchTitle')}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t('shell.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
aria-label={t('shell.searchTitle')}
onkeydown={handleKeydown}
/>
</div>
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
{#if entries.length === 0}
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
</p>
</div>
<Modal bind:open title={t("shell.searchTitle")}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t("shell.searchPlaceholder")}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length
? optionId(activeIndex)
: undefined}
aria-label={t("shell.searchTitle")}
onkeydown={handleKeydown}
/>
</div>
<div
class="palette-list"
id={listId}
role="listbox"
bind:this={list}
aria-label={t("shell.searchTitle")}
>
{#if entries.length === 0}
<p class="palette-empty">
{t("shell.searchEmpty", { q: query.trim() })}
</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === "pages"
? t("shell.searchGroupPages")
: t("shell.searchGroupProjects")}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t("shell.searchHintMove")}
<span class="a-kbd">Enter</span>{t("shell.searchHintOpen")}
<span class="a-kbd">Esc</span>{t("shell.searchHintClose")}
</p>
</div>
</Modal>
<style>
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
@@ -56,7 +57,7 @@
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
href={resolve('/')}
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
@@ -70,7 +71,7 @@
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<a class="a-menu-item" href={resolve('/')} onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
@@ -78,7 +79,7 @@
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
href={resolve(`/workbench/${project.id}`)}
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
@@ -93,7 +94,7 @@
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<a class="a-menu-item tray-row" href={resolve('/tasks')} title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
@@ -102,7 +103,7 @@
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<a class="a-menu-item tray-all" href={resolve('/tasks')} onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
@@ -103,7 +103,8 @@
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
function popIn(node: Element) {
void node;
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
@@ -123,7 +124,8 @@
};
}
function fadeScrim(_node: Element) {
function fadeScrim(node: Element) {
void node;
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
@@ -0,0 +1,241 @@
<script lang="ts">
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
let {
compact = false,
tiles = false,
}: { compact?: boolean; tiles?: boolean } = $props();
function move(event: KeyboardEvent) {
const steps: Record<string, number> = {
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
};
const step = steps[event.key];
if (!step && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const group = (event.currentTarget as HTMLElement).closest(
".choices",
) as HTMLElement;
const buttons = [...group.querySelectorAll<HTMLButtonElement>("button")];
const current = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
const next =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: (current + step + buttons.length) % buttons.length;
buttons[next]?.focus();
}
</script>
<div
class="choices"
class:compact
class:tiles
role="group"
aria-label="外观主题"
>
{#each THEMES as item (item.id)}
<button
class="choice"
class:selected={$theme === item.id}
aria-pressed={$theme === item.id}
onkeydown={move}
onclick={(event) => setTheme(item.id, event)}
>
<span class="preview" data-theme={item.id} aria-hidden="true"
><span class="rail"></span><span class="list"
><i></i><i></i><i></i></span
><span class="stage"
><i></i><b
>{#if tiles}<em></em>{/if}</b
></span
></span
>
<span class="copy"
><strong
>{item.name}{#if tiles && item.id === "night"}
<span class="mg-tag theme-default">默认</span>{/if}</strong
><small>{tiles ? item.description.split(" · ")[0] : item.english}</small
>{#if !tiles}<span>{item.description}</span>{/if}</span
>
<span class="check" class:tile-check={tiles} aria-hidden="true"
>{$theme === item.id ? "✓" : ""}</span
>
</button>
{/each}
</div>
{#if $themeStorageUnavailable}<p class="notice" role="status">
浏览器禁止本地存储:本次有效,其他操作不受影响
</p>{/if}
<style>
.choices {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.choices.tiles {
--choice-check-width: 0px;
--choice-box-sizing: border-box;
--choice-gap: 6px;
--choice-copy-gap: 6px;
--choice-tone-align: flex-start;
--choice-preview-art: var(--theme-preview-art);
--choice-dock-width: 100%;
--choice-dock-background: var(--bg-3);
--choice-radius: 12px;
--choice-name-size: 12.5px;
--choice-tone-font: 11px var(--font-body);
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
}
.tile-check {
display: none;
}
.tiles .copy {
flex-direction: column;
}
.choices.compact {
grid-template-columns: 1fr;
}
.choice {
box-sizing: var(--choice-box-sizing, content-box);
display: grid;
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
gap: var(--choice-gap, 8px);
padding: 8px;
border: 1px solid var(--line-2);
border-radius: var(--choice-radius, var(--r-md));
background: var(--bg-2);
color: var(--tx-1);
text-align: left;
cursor: pointer;
}
.choice:hover {
border-color: var(--line-3);
background: var(--bg-3);
}
.choice.selected {
border-color: var(--choice-selected, var(--gold));
box-shadow: var(--choice-shadow, none);
}
.choice:focus-visible {
outline: 2px solid var(--info);
outline-offset: 2px;
}
.compact .choice {
grid-template-columns: 76px minmax(0, 1fr) 18px;
align-items: center;
}
.preview {
box-sizing: var(--choice-box-sizing, content-box);
grid-column: 1 / -1;
height: 64px;
border-radius: var(--r-sm);
overflow: hidden;
display: grid;
grid-template-columns: 12px 22px 1fr;
background: var(--bg-0);
border: 1px solid var(--line-2);
}
.compact .preview {
grid-column: auto;
height: 48px;
}
.rail,
.list {
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.rail::before {
content: "";
display: block;
width: 4px;
height: 4px;
margin: 6px auto;
border-radius: 50%;
background: var(--gold);
}
.list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 3px;
}
.list i {
height: 4px;
border-radius: 2px;
background: var(--line-3);
}
.list i:first-child {
background: var(--sc-image);
}
.stage {
padding: 5px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stage i {
flex: 1;
border-radius: 3px;
background: var(--choice-preview-art, var(--bg-3));
border: 1px solid var(--line-2);
}
.stage b {
height: 9px;
width: var(--choice-dock-width, 18px);
align-self: end;
border-radius: 2px;
background: var(--choice-dock-background, var(--grad-gold));
display: flex;
justify-content: flex-end;
padding: 1px;
}
.stage em {
width: 14px;
border-radius: 2px;
background: var(--grad-gold);
}
.copy {
display: flex;
flex-wrap: wrap;
gap: var(--choice-copy-gap, 2px) 6px;
min-width: 0;
}
.copy strong {
font-size: var(--choice-name-size, 13px);
}
.copy small {
font: var(--choice-tone-font, 10px var(--mono));
color: var(--tx-3);
align-self: var(--choice-tone-align, center);
}
.copy > span {
width: 100%;
font-size: 11px;
color: var(--tx-2);
}
.check {
color: var(--gold);
}
.notice {
font-size: 12px;
color: var(--tx-2);
margin: 12px 0 0;
}
@media (max-width: 1366px) {
.choices:not(.compact):not(.tiles) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.theme-default {
box-sizing: border-box;
height: 16px;
font-size: 10px;
}
</style>
+123 -64
View File
@@ -1,72 +1,131 @@
import type { IconName } from '$lib/components/ui/icons';
import type { Pathname } from "$app/types";
import type { IconName } from "$lib/components/ui/icons";
// IDs are retained for the existing page contracts while B1–B4 replace layouts.
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'library'
| 'settings';
| "projects"
| "workbench"
| "image"
| "video"
| "tasks"
| "agents"
| "connections"
| "models"
| "library"
| "settings"
| "canvas"
| "workflows";
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
id: NavId;
label: string;
icon: IconName;
href: Pathname;
scene: "project" | "image" | "agent" | "manage" | "asset";
matches: (path: string) => boolean;
}
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
// and settings entries at the foot of the 60px rail.
export const NAV_MAIN: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
{
id: "projects",
label: "工作室",
icon: "home",
href: "/",
scene: "project",
matches: (p) => p === "/",
},
{
id: "workbench",
label: "项目",
icon: "folder",
href: "/workbench",
scene: "project",
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
},
{
id: "image",
label: "创作台",
icon: "image",
href: "/create",
scene: "image",
matches: (p) =>
["/create", "/image", "/video", "/playground", "/build-scene"].includes(
p,
),
},
{
id: "agents",
label: "智能体",
icon: "sparkle",
href: "/agents",
scene: "agent",
matches: (p) => p === "/agents",
},
{
id: "canvas",
label: "画布",
icon: "story",
href: "/canvas",
scene: "manage",
matches: (p) => p.startsWith("/canvas"),
},
{
id: "library",
label: "素材库",
icon: "assets",
href: "/library",
scene: "asset",
matches: (p) => p === "/library",
},
];
export const NAV_MANAGE: NavItem[] = [
{
id: "tasks",
label: "任务",
icon: "clock",
href: "/tasks",
scene: "manage",
matches: (p) => p === "/tasks",
},
{
id: "workflows",
label: "工作流",
icon: "playground",
href: "/workflows",
scene: "manage",
matches: (p) => p === "/workflows",
},
{
id: "connections",
label: "模型连接",
icon: "link",
href: "/model-connections",
scene: "manage",
matches: (p) =>
["/connections", "/models", "/model-connections"].includes(p),
},
];
export const NAV_FOOT: NavItem[] = [
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
{
id: "settings",
label: "设置",
icon: "settings",
href: "/settings",
scene: "manage",
matches: (p) => p === "/settings",
},
];
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
export interface TabItem {
id: 'projects' | 'workbench' | 'generate';
labelKey: string;
icon: IconName;
href: string;
/** Pages that light this tab up. */
covers: NavId[];
}
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
export const TABS: TabItem[] = [
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
{
id: 'generate',
labelKey: 'shell.tabGenerate',
icon: 'image',
href: '/image',
covers: ['image', 'video'],
},
];
/**
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
* is still its own route, so it is listed there too until the two
* generation pages merge.
*/
export const MORE_ITEMS: NavItem[] = NAV.filter(
(item) => !TABS.some((tab) => tab.href === item.href),
);
// Tools awaiting their replacement keep reachable entries in global search.
export const NAV = [
...NAV_MAIN,
...NAV_MANAGE,
...NAV_FOOT,
{ id: "video", label: "视频创作", icon: "film", href: "/video" },
{ id: "models", label: "大语言模型", icon: "assets", href: "/models" },
{
id: "connections",
label: "ComfyUI 连接",
icon: "link",
href: "/connections",
},
{ id: "image", label: "试验台", icon: "playground", href: "/playground" },
{ id: "video", label: "三维搭景", icon: "story", href: "/build-scene" },
] as const;
+27 -26
View File
@@ -1,37 +1,38 @@
<script lang="ts">
import { icons, type IconName } from './icons';
import { icons, type IconName } from "./icons";
interface Props {
name: IconName;
size?: number;
}
interface Props {
name: IconName;
size?: number;
}
let { name, size = 18 }: Props = $props();
let { name, size = 18 }: Props = $props();
const content = $derived(icons[name] ?? '');
const content = $derived(icons[name] ?? "");
</script>
{#if content}
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{@html content}
</svg>
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
style:stroke-width="var(--icon-stroke, 1.5)"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- content is selected only from the bundled icons.ts SVG registry; no API/user strings enter it. HTML preserves the authored SVG child elements. -->
{@html content}
</svg>
{/if}
<style>
.icon {
display: inline-block;
vertical-align: -0.125em;
flex-shrink: 0;
}
.icon {
display: inline-block;
vertical-align: -0.125em;
flex-shrink: 0;
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
import { enterModal, leaveModal, topModal } from './modalLayers';
import { t } from '$lib/i18n.svelte';
interface Props {
@@ -11,6 +12,9 @@
onclose?: () => void;
footer?: Snippet;
children: Snippet;
/** Open above a dock, retaining the shared dialog focus and keyboard behavior. */
anchor?: HTMLElement | null;
headerActions?: Snippet;
}
let {
@@ -21,6 +25,8 @@
onclose,
footer,
children,
anchor = null,
headerActions,
}: Props = $props();
const FOCUSABLE =
@@ -29,6 +35,29 @@
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
let anchorBox = $state({ left: 0, bottom: 0, width: 620, available: 560 });
$effect(() => {
if (!open || !anchor) return;
const source = anchor;
const place = () => {
const r = source.getBoundingClientRect();
anchorBox = {
left: r.left + 12,
bottom: innerHeight - r.top + 8,
width: r.width - 24,
available: Math.max(100, r.top - 64),
};
};
place();
const observer = new ResizeObserver(place);
observer.observe(source);
window.addEventListener('resize', place);
return () => {
observer.disconnect();
window.removeEventListener('resize', place);
};
});
const layer = Symbol('dialog');
function requestClose() {
if (!dismissible) return;
@@ -36,6 +65,7 @@
}
function handleKeydown(event: KeyboardEvent) {
if (!topModal(layer)) return;
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
@@ -75,20 +105,21 @@
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
enterModal(layer);
document.addEventListener('keydown', handleKeydown, true);
requestAnimationFrame(() => {
if (!panel || panel.contains(document.activeElement)) return;
if (!topModal(layer) || !panel || panel.contains(document.activeElement))
return;
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel).focus();
});
return () => {
document.body.style.overflow = previousOverflow;
const wasTop = topModal(layer);
leaveModal(layer);
document.removeEventListener('keydown', handleKeydown, true);
if (invoker?.isConnected) invoker.focus();
if (wasTop && invoker?.isConnected) invoker.focus();
onclose?.();
};
});
@@ -96,10 +127,20 @@
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div
class="modal-backdrop"
class:anchored={!!anchor}
onclick={handleBackdropClick}
>
<div
bind:this={panel}
class="modal-panel size-{size}"
style:left={anchor ? `${anchorBox.left}px` : undefined}
style:bottom={anchor ? `${anchorBox.bottom}px` : undefined}
style:width={anchor
? `min(var(--desk-pop-width), ${anchorBox.width}px)`
: undefined}
style:--anchor-available={`${anchorBox.available}px`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
@@ -111,6 +152,7 @@
{#if title}
<h2 class="modal-title" id={titleId}>{title}</h2>
{/if}
{@render headerActions?.()}
{#if dismissible}
<button
type="button"
@@ -164,6 +206,38 @@
.modal-panel.size-xl {
width: min(1180px, 100%);
}
.anchored {
background: transparent;
padding: 0;
}
.anchored .modal-panel {
box-sizing: border-box;
position: absolute;
height: var(--desk-pop-height);
max-height: min(64vh, var(--anchor-available));
border: 1px solid var(--line-2);
border-radius: var(--desk-dock-radius);
background: var(--desk-pop-bg);
box-shadow: var(--desk-pop-shadow);
backdrop-filter: blur(14px);
transform-origin: bottom left;
}
.anchored .modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.anchored .modal-title {
font-size: var(--fs-body);
}
.anchored .modal-body {
flex: 1;
min-height: 0;
padding: 12px 14px;
}
.anchored .modal-footer {
padding: 10px 14px;
border-top: 1px solid var(--line);
}
.modal-header {
display: flex;
align-items: center;
+18 -16
View File
@@ -92,14 +92,14 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import ProgressBar from '$lib/components/ui/ProgressBar.svelte';`
| Prop | Type | Default | Notes |
| --------------- | -------------- | ------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
| Prop | Type | Default | Notes |
| --------------- | ---------------------------------- | ---------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
```svelte
<ProgressBar value={job.progress} label={job.name} />
@@ -126,14 +126,16 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import Modal from '$lib/components/ui/Modal.svelte';`
| Prop | Type | Default | Notes |
| ------------- | ------------ | -------- | --------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
| Prop | Type | Default | Notes |
| --------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `anchor` | `HTMLElement \| null` | `null` | Optional dock anchor: opens above it, clamps to viewport, retains the dialog focus behavior. |
| `headerActions` | `Snippet` | — | Optional controls beside the title (for example restore workflow defaults). |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
Behavior: Esc and backdrop click close when dismissible; focus is trapped while
open; initial focus goes to the first focusable element (or the panel); focus is
+75 -70
View File
@@ -4,76 +4,81 @@
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
*/
export const icons = {
// Navigation
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
story:
'<path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
assets:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
script:
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
video:
'<rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20"/><path d="M17 2v20"/><path d="M2 12h20"/><path d="M2 7h5"/><path d="M2 17h5"/><path d="M17 17h5"/><path d="M17 7h5"/>',
queue:
'<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
playground:
'<path d="M10 2v7.53a2 2 0 0 1-.21.9L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45L14.21 10.4a2 2 0 0 1-.21-.9V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
// Actions
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'link-off':
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 2 8"/><path d="m8 8 8 8"/>',
trash:
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
retry:
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
download:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
upload:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
paperclip:
'<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
copy:
'<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
'external-link':
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
'zoom-in':
'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6"/><path d="M8 11h6"/>',
// Media transport
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
pause:
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
// Status
check: '<path d="M20 6 9 17l-5-5"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
alert:
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
sparkle:
'<path d="M12 3l1.9 5.76a2 2 0 0 0 1.24 1.24L21 12l-5.86 1.9a2 2 0 0 0-1.24 1.24L12 21l-1.9-5.86a2 2 0 0 0-1.24-1.24L3 12l5.86-1.9a2 2 0 0 0 1.24-1.24Z"/>',
// Media
image:
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>',
film: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 8h20"/><path d="M2 16h20"/><path d="M9 8v8"/><path d="M15 8v8"/>',
music:
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
folder:
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
// Chevrons
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-up': '<path d="m18 15-6-6-6 6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
// Misc
drag: '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
"search-manage": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
"upload-manage": '<path d="M12 16V4M6 9l6-6 6 6M4 20h16"/>',
"film-manage":
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
pin: '<path d="M12 17v5M5 17h14l-2-4V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8z"/>',
// Navigation
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
story:
'<path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
assets:
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
script:
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
video:
'<rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20"/><path d="M17 2v20"/><path d="M2 12h20"/><path d="M2 7h5"/><path d="M2 17h5"/><path d="M17 17h5"/><path d="M17 7h5"/>',
queue:
'<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
playground:
'<path d="M10 2v7.53a2 2 0 0 1-.21.9L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45L14.21 10.4a2 2 0 0 1-.21-.9V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
// Actions
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
"link-off":
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 2 8"/><path d="m8 8 8 8"/>',
trash:
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
retry:
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
download:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
upload:
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
paperclip:
'<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
"external-link":
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
"zoom-in":
'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6"/><path d="M8 11h6"/>',
// Media transport
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
pause:
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
// Status
check: '<path d="M20 6 9 17l-5-5"/>',
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
alert:
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
sparkle:
'<path d="M12 3l1.9 5.76a2 2 0 0 0 1.24 1.24L21 12l-5.86 1.9a2 2 0 0 0-1.24 1.24L12 21l-1.9-5.86a2 2 0 0 0-1.24-1.24L3 12l5.86-1.9a2 2 0 0 0 1.24-1.24Z"/>',
// Media
image:
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>',
film: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 8h20"/><path d="M2 16h20"/><path d="M9 8v8"/><path d="M15 8v8"/>',
music:
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
folder:
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
// Chevrons
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-up": '<path d="m18 15-6-6-6 6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
// Misc
drag: '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
} as const satisfies Record<string, string>;
export type IconName = keyof typeof icons;
@@ -0,0 +1,17 @@
// Only the uppermost dialog handles the keyboard. Nested dialogs keep the
// original body overflow until the final layer closes.
const layers: symbol[] = [];
let bodyOverflow = "";
export function enterModal(token: symbol) {
if (!layers.length) bodyOverflow = document.body.style.overflow;
layers.push(token);
document.body.style.overflow = "hidden";
}
export function topModal(token: symbol) {
return layers.at(-1) === token;
}
export function leaveModal(token: symbol) {
const index = layers.indexOf(token);
if (index >= 0) layers.splice(index, 1);
document.body.style.overflow = layers.length ? "hidden" : bodyOverflow;
}
@@ -111,7 +111,7 @@ let {
</div>
{#if previewUrl}
<div class="foot">
<a class="dl" href={previewUrl} download>
<a class="dl" href={previewUrl} download rel="external">
<Icon name="download" size={14} /> {t('clipMonitor.downloadClip')}
</a>
</div>
@@ -98,7 +98,6 @@
>
<div class="thumb">
{#if opt.path}
<!-- svelte-ignore a11y_media_has_caption -->
<video
src={assetUrl(opt.path)}
muted
@@ -272,7 +271,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -11,7 +11,7 @@
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import '$lib/components/SafeMedia.svelte';
import { assetUrl } from '$lib/api';
import { t } from '$lib/i18n.svelte';
@@ -100,7 +100,6 @@ title={t('filmstrip.clipTitle', {
{#if thumb?.kind === 'image'}
<img class="thumb-media" src={thumb.src} alt="" loading="lazy" />
{:else if thumb?.kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="thumb-media" src={thumb.src + '#t=0.1'} muted playsinline preload="metadata"></video>
{:else}
<span class="thumb-slate">{entry.label}</span>
@@ -66,7 +66,7 @@
<div class="block">
<span class="k">{t('scriptDrawer.characters')}</span>
<div class="chips">
{#each scene.characters as c}
{#each scene.characters as c (c.id)}
<span class="chip">{c.name}</span>
{/each}
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
/**
* VideoEditWorkspace — modern Edit layout for Project → Video.
* Hero monitor + filmstrip + meta strip + docked Omni composer.
@@ -11,7 +12,7 @@
import ClipMonitor from './ClipMonitor.svelte';
import ClipSourceModal from './ClipSourceModal.svelte';
import JobInputsDrawer from './JobInputsDrawer.svelte';
import PromptPreviewModal from './PromptPreviewModal.svelte';
import './PromptPreviewModal.svelte';
import SceneFilmstrip, { type FilmstripClip } from './SceneFilmstrip.svelte';
import SceneScriptDrawer from './SceneScriptDrawer.svelte';
import { t } from '$lib/i18n.svelte';
@@ -87,7 +88,6 @@ import { t } from '$lib/i18n.svelte';
}
let {
scenes,
filmClips,
selectedClip,
selectedClipId,
@@ -266,7 +266,7 @@ import { t } from '$lib/i18n.svelte';
<div class="no-wf">
<p class="empty-title">{t('videoEdit.noWf')}</p>
<p class="muted">
{t('videoEdit.enableWfPre')} <a href="/settings?tab=workflows">{t('videoEdit.wfLink')}</a>{t('videoEdit.enableWfPost')}
{t('videoEdit.enableWfPre')} <a href={resolve('/settings?tab=workflows')}>{t('videoEdit.wfLink')}</a>{t('videoEdit.enableWfPost')}
</p>
</div>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { WorkbenchAsset, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
@@ -162,7 +163,7 @@
</div>
{#if asset.description}<p class="asset-desc">{asset.description}</p>{/if}
{#if asset.job?.status === 'done' && asset.job.output}
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener">
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener external">
<img src={assetUrl(asset.job.output) ?? ''} alt={asset.name} loading="lazy" />
</a>
{/if}
@@ -174,7 +175,7 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
href={resolve(deskHref({
kind: 'image',
projectId,
stage: 'assets',
@@ -182,9 +183,11 @@
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
name: asset.name,
}),
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
})}
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
sourceKind: 'asset',
sourceId: asset.id,
}))}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { VideoShot, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
@@ -153,6 +154,7 @@
</div>
{#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
{#if shot.job?.status === 'done' && shot.job.output}
<!-- svelte-ignore a11y_media_has_caption (生成产物暂无字幕资产,保留声音与手动播放;字幕无障碍欠缺仍存在。) -->
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
@@ -165,14 +167,16 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
href={resolve(deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
sourceKind: 'shot',
sourceId: shot.id,
}))}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -23,27 +23,14 @@
return t(`workbench.stage.${key}`);
}
function charOf(key: string): string {
const map: Record<string, string> = {
script: '剧',
style: '视',
storyboard: '分',
assets: '素',
shot: '镜',
};
return map[key] ?? key;
}
function avatarVar(key: string): string {
const idx = ORDER.indexOf(key) + 1;
return `var(--avatar-${idx})`;
}
function stateOf(stage: WorkbenchStage): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
function stateOf(
stage: WorkbenchStage,
): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
if (stage.status === 'running') return 'running';
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
if (stage.status === 'awaiting_confirm') return stage.draft_edited ? 'edited' : 'draft';
if (stage.status === 'awaiting_confirm')
return stage.draft_edited ? 'edited' : 'draft';
return 'todo';
}
@@ -65,12 +52,13 @@
</script>
<ol class="stepper" data-testid="workbench-stepper">
{#each ORDER as key, i}
{#each ORDER as key, i (key)}
{@const stage = stages.find((s) => s.stage_key === key)}
{@const state = stage ? stateOf(stage) : 'todo'}
{@const open = stage ? unlocked(stages, key) : i === 0}
<li class="step">
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}></span>{/if}
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
></span>{/if}
<button
type="button"
class="step-btn"
@@ -80,14 +68,19 @@
data-state={state}
onclick={() => onSelect(key)}
aria-current={activeKey === key ? 'step' : undefined}
title={open ? label(key) : t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
title={open
? label(key)
: t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
>
<span class="avatar" style:background={avatarVar(key)}>{charOf(key)}</span>
<span class="avatar"
>{state === 'confirmed' ? '✓' : String(i + 1).padStart(2, '0')}</span
>
<span class="step-copy">
<span class="step-name">{label(key)}</span>
<span class="step-state st-{state}">
{#if state === 'running'}<span class="breath" aria-hidden="true"></span>{/if}
{stateLabel(stage!)}
{#if state === 'running'}<span class="breath" aria-hidden="true"
></span>{/if}
{stage ? stateLabel(stage) : t('workbench.state.todo')}
</span>
</span>
</button>
@@ -238,4 +231,113 @@
opacity: 0.8;
}
}
.stepper {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 7px;
padding: 0;
overflow: visible;
}
.step {
display: block;
}
.bar {
display: none;
}
.step-btn {
width: 100%;
padding: 12px 10px;
border: 0;
gap: 10px;
}
.avatar {
width: 27px;
height: 27px;
background: none !important;
border: 1px solid var(--line-2);
border-radius: 50%;
font: 9px var(--mono);
color: var(--text-3);
font-weight: 400;
}
.step-btn.active {
background: #e8eafe;
box-shadow: none;
}
.step-btn.active .avatar {
background: var(--accent) !important;
border-color: var(--accent);
color: white;
}
.step-name {
font-size: 11px;
font-weight: 550;
}
.step-state {
font-size: 9px;
margin-top: 5px;
}
.step-copy {
min-width: 0;
}
.step-btn.locked {
opacity: 0.7;
cursor: pointer;
}
.step-btn[data-state='confirmed'] .avatar {
border-color: var(--ok);
color: var(--ok);
}
.stepper {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 8px;
padding: 0;
overflow-x: auto;
}
.step {
display: flex;
flex: 1;
min-width: 130px;
}
.bar {
display: none;
}
.step-btn {
width: 100%;
border: 1px solid #ffffff10;
border-radius: 10px;
background: #12171c;
padding: 12px;
gap: 10px;
}
.step-btn.active {
background: #00cae012;
border-color: #00cae080;
box-shadow: none;
}
.step-btn.active .avatar {
background: #00cae012 !important;
color: var(--accent);
border-color: var(--accent);
}
.step-btn.active .step-name {
color: #86e8f1;
}
.step-state {
margin-top: 3px;
}
.step-btn[data-state='confirmed'] .avatar {
color: var(--ok);
}
.step-btn.locked {
opacity: 0.65;
}
.step-btn:hover:not(.active) {
background: #ffffff07;
}
</style>
@@ -198,6 +198,7 @@
function toggleIssue(f: WorkflowConvertFileReport) {
const key = rk(f);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Mutate an isolated copy then assign expandedIssues once; rendering observes the state replacement, not intermediate Set writes.
const next = new Set(expandedIssues);
if (next.has(key)) next.delete(key);
else next.add(key);
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/** Preserve the exact query/hash while keeping the destination typed as /create. */
export function createHref(
url: Pick<URL, "search" | "hash">,
): "/create" | `/create?${string}` | `/create#${string}` {
if (url.search) return `/create?${url.search.slice(1)}${url.hash}`;
if (url.hash) return `/create#${url.hash.slice(1)}`;
return "/create";
}
+4 -4
View File
@@ -5,8 +5,8 @@
* that already carry a timezone marker pass through unchanged.
*/
export function parseDbTs(raw: string | null | undefined): number {
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith('Z') ? s : `${s.replace(' ', 'T')}Z`);
if (!raw) return NaN;
const s = raw.trim();
if (/[zZ]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
return Date.parse(s.endsWith("Z") ? s : `${s.replace(" ", "T")}Z`);
}

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