Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64c1116bca | ||
|
|
03bebec94a | ||
|
|
0f4d6a8961 | ||
|
|
06c9bd5000 | ||
|
|
acb6beca1c | ||
|
|
a6b04e5c91 | ||
|
|
634965f5f5 | ||
|
|
e62499c517 | ||
|
|
b6b3a9fed3 | ||
|
|
7bf63d0587 | ||
|
|
5cb75d5718 | ||
|
|
2304ab1538 | ||
|
|
8e84e4c21f | ||
|
|
35fd07af24 | ||
|
|
012a946f85 | ||
|
|
875dfcb41a | ||
|
|
780ac52ffc | ||
|
|
d4e199f3c8 | ||
|
|
ae9e34f6ab | ||
|
|
16933b1dfc | ||
|
|
07f6475b1a | ||
|
|
ac1c0f98a6 | ||
|
|
aaddd3dcaf | ||
|
|
2b3bbf9d03 | ||
|
|
bc24370233 | ||
|
|
2f267a751f | ||
|
|
0841f5a296 | ||
|
|
35aad34c7c |
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+29
-18
@@ -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>
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
@@ -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: (
|
||||
|
||||
@@ -98,8 +98,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 +117,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 +141,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 +205,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;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,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 +176,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -311,6 +311,13 @@ export interface Job {
|
||||
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 {
|
||||
|
||||
@@ -522,7 +522,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 +567,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 +607,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) => {
|
||||
@@ -179,9 +181,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}
|
||||
@@ -237,7 +238,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 +250,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 +273,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 +335,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]}<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -668,8 +668,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 +772,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 +785,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);
|
||||
|
||||
@@ -431,6 +431,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,35 +1,42 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// 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
|
||||
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; colors mirror the spec §2.1 avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: '#e8a33d' },
|
||||
视觉设计: { label: '视觉设计', color: '#5b8def' },
|
||||
分镜设计: { label: '分镜设计', color: '#3fb97f' },
|
||||
素材资产: { label: '素材资产', color: '#b98be0' },
|
||||
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
|
||||
// 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;
|
||||
@@ -39,8 +46,8 @@ function hashString(s: string): number {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
<script lang="ts">
|
||||
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,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
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 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) ?? '',
|
||||
);
|
||||
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="本次生成的作品"
|
||||
>
|
||||
{#each outputs as p, 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
|
||||
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="/assets"
|
||||
><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">
|
||||
{#each spec as s}<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,630 @@
|
||||
<script lang="ts">
|
||||
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('/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}<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}<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="/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="/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
@@ -1,18 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
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 +16,535 @@
|
||||
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),
|
||||
endpoints,
|
||||
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}<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}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
|
||||
><select aria-label="筛选模型家族" bind:value={family}
|
||||
><option value="all">全部模型</option>{#each families as 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="/canvas" class="a-btn primary">打开创作画布 ↗</a>
|
||||
</div>
|
||||
{#each cloudModels as profile}<article class="workflow-card">
|
||||
<div class="family">{profile.model}<span>已配置模型</span></div>
|
||||
<h3>{profile.name}</h3>
|
||||
<a href="/agents" class="tool-link">交给智能体创作 ↗</a>
|
||||
</article>{:else}<p class="empty">
|
||||
还没有匹配的模型。<a href="/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="/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="/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
@@ -5,8 +5,9 @@
|
||||
|
||||
/**
|
||||
* 上下文条 (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 +18,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,7 +26,9 @@
|
||||
<span class="from">
|
||||
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label} · {ctx.label}{/if}
|
||||
</span>
|
||||
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
|
||||
<span class="note">
|
||||
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
|
||||
</span>
|
||||
<a class="a-btn ghost xs back" href={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} />
|
||||
|
||||
@@ -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,
|
||||
@@ -55,17 +63,25 @@
|
||||
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 +92,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 +128,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 +157,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 +182,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 +201,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 +226,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 +244,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 +254,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,16 +295,42 @@
|
||||
<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')}
|
||||
@@ -272,10 +352,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 +374,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 +683,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>
|
||||
@@ -8,7 +8,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 +28,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 +44,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 +63,13 @@
|
||||
enter = false,
|
||||
flash = false,
|
||||
shake = false,
|
||||
attachBusy = false,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onAttach,
|
||||
}: Props = $props();
|
||||
|
||||
const st = $derived(jobStatusInfo(job));
|
||||
@@ -80,6 +96,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 +190,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={`/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>
|
||||
|
||||
@@ -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 }> = [
|
||||
@@ -263,6 +268,8 @@
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
attachBusy={attachBusyId === job.id}
|
||||
onAttach={onAttach ? () => onAttach(job) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -3,8 +3,9 @@ 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;
|
||||
@@ -15,10 +16,30 @@ export interface DeskContext {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
@@ -29,6 +50,10 @@ export function deskHref(ctx: DeskContext): string {
|
||||
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()}`;
|
||||
}
|
||||
|
||||
@@ -39,6 +64,13 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
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,
|
||||
@@ -47,9 +79,29 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,34 @@ import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from '.
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
/** 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 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>;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,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 +52,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 +73,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 +152,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 +174,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>
|
||||
@@ -185,7 +204,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 +219,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 +256,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 +295,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 +565,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}<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,645 @@
|
||||
<script lang="ts">
|
||||
import CloudModels from "./CloudModels.svelte";
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
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 Set<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,833 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
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 Set<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;
|
||||
}
|
||||
@@ -231,7 +231,7 @@
|
||||
.tabs button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.tabs button:focus-visible {
|
||||
@@ -341,7 +341,7 @@
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
padding: 0 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--accent-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -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 & 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,232 @@
|
||||
<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 { 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: string;
|
||||
}
|
||||
|
||||
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(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>
|
||||
|
||||
@@ -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}
|
||||
<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>
|
||||
@@ -1,72 +1,130 @@
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
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: string;
|
||||
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;
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<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"
|
||||
>
|
||||
{@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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -272,7 +272,7 @@
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -182,9 +182,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')}
|
||||
|
||||
@@ -165,14 +165,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={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';
|
||||
}
|
||||
|
||||
@@ -70,7 +57,8 @@
|
||||
{@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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2003,6 +2003,15 @@ export const de: Dict = {
|
||||
'desk.ctxDismiss': 'Herkunftshinweis schließen',
|
||||
'desk.ctxFrom': 'Aus „{project}“',
|
||||
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
|
||||
'desk.ctxBackfill': 'Nach Fertigstellung kann jedes Ergebnis über „Zurück ins Projekt“ an {stage} zurückgegeben werden',
|
||||
'desk.attachToProject': 'Zurück ins Projekt',
|
||||
'desk.attachHint': 'Dieses Ergebnis zurückgeben an: {label}',
|
||||
'desk.attaching': 'Wird zurückgegeben…',
|
||||
'desk.attachedTag': 'Zurückgegeben',
|
||||
'desk.attachBack': 'Zum Projekt',
|
||||
'desk.attachDoneToast': 'Zurück ins Quellprojekt übergeben — im Projekt sichtbar',
|
||||
'desk.attachAlreadyToast': 'Dieses Ergebnis wurde bereits zurückgegeben, nichts dupliziert',
|
||||
'desk.attachFailedToast': 'Rückgabe fehlgeschlagen: {detail}',
|
||||
'desk.dayDate': '{d}.{m}.',
|
||||
'desk.download': 'Herunterladen',
|
||||
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
|
||||
|
||||
@@ -2002,6 +2002,15 @@ export const en = {
|
||||
'desk.ctxDismiss': 'Dismiss source note',
|
||||
'desk.ctxFrom': 'From “{project}”',
|
||||
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
|
||||
'desk.ctxBackfill': 'When a take is done, use “Send back to project” on its card to return it to {stage}',
|
||||
'desk.attachToProject': 'Send back to project',
|
||||
'desk.attachHint': 'Send this take back to its source: {label}',
|
||||
'desk.attaching': 'Sending back…',
|
||||
'desk.attachedTag': 'Sent back',
|
||||
'desk.attachBack': 'Back to project',
|
||||
'desk.attachDoneToast': 'Sent back to the source project — open the project to see it',
|
||||
'desk.attachAlreadyToast': 'This take was already sent back; nothing duplicated',
|
||||
'desk.attachFailedToast': 'Could not send back: {detail}',
|
||||
'desk.dayDate': '{m}/{d}',
|
||||
'desk.download': 'Download',
|
||||
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
|
||||
|
||||
@@ -2003,6 +2003,15 @@ export const es: Dict = {
|
||||
'desk.ctxDismiss': 'Cerrar aviso de origen',
|
||||
'desk.ctxFrom': 'De «{project}»',
|
||||
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
|
||||
'desk.ctxBackfill': 'Al terminar, usa «Enviar al proyecto» en la tarjeta para devolver el resultado a {stage}',
|
||||
'desk.attachToProject': 'Enviar al proyecto',
|
||||
'desk.attachHint': 'Enviar este resultado a su origen: {label}',
|
||||
'desk.attaching': 'Enviando…',
|
||||
'desk.attachedTag': 'Enviado',
|
||||
'desk.attachBack': 'Ir al proyecto',
|
||||
'desk.attachDoneToast': 'Enviado al proyecto de origen: ábrelo para verlo',
|
||||
'desk.attachAlreadyToast': 'Este resultado ya fue enviado; no se duplicó nada',
|
||||
'desk.attachFailedToast': 'No se pudo enviar: {detail}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Descargar',
|
||||
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
|
||||
|
||||
@@ -2003,6 +2003,15 @@ export const fr: Dict = {
|
||||
'desk.ctxDismiss': 'Fermer la note d’origine',
|
||||
'desk.ctxFrom': 'Depuis « {project} »',
|
||||
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
|
||||
'desk.ctxBackfill': 'Une fois terminé, utilisez « Renvoyer au projet » sur la carte pour renvoyer le résultat vers {stage}',
|
||||
'desk.attachToProject': 'Renvoyer au projet',
|
||||
'desk.attachHint': 'Renvoyer ce résultat à sa source : {label}',
|
||||
'desk.attaching': 'Envoi…',
|
||||
'desk.attachedTag': 'Renvoyé',
|
||||
'desk.attachBack': 'Voir le projet',
|
||||
'desk.attachDoneToast': 'Renvoyé au projet source — ouvrez le projet pour le voir',
|
||||
'desk.attachAlreadyToast': 'Ce résultat a déjà été renvoyé ; rien de dupliqué',
|
||||
'desk.attachFailedToast': 'Échec du renvoi : {detail}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Télécharger',
|
||||
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
|
||||
|
||||
@@ -2001,6 +2001,15 @@ export const ja: Dict = {
|
||||
'desk.ctxDismiss': '出典の表示を閉じる',
|
||||
'desk.ctxFrom': '『{project}』から',
|
||||
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
|
||||
'desk.ctxBackfill': '生成が完了したら、結果カードの「プロジェクトへ戻す」で{stage}に戻せます',
|
||||
'desk.attachToProject': 'プロジェクトへ戻す',
|
||||
'desk.attachHint': 'この結果を戻し先に送る:{label}',
|
||||
'desk.attaching': '戻しています…',
|
||||
'desk.attachedTag': '戻しました',
|
||||
'desk.attachBack': 'プロジェクトへ',
|
||||
'desk.attachDoneToast': '送り元プロジェクトに戻しました。プロジェクトを開くと確認できます',
|
||||
'desk.attachAlreadyToast': 'この結果はすでに戻されています。重複は作られていません',
|
||||
'desk.attachFailedToast': '戻せませんでした:{detail}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': 'ダウンロード',
|
||||
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
|
||||
|
||||
@@ -2001,6 +2001,15 @@ export const ko: Dict = {
|
||||
'desk.ctxDismiss': '출처 안내 닫기',
|
||||
'desk.ctxFrom': '《{project}》에서',
|
||||
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
|
||||
'desk.ctxBackfill': '생성이 끝나면 결과 카드의 «프로젝트로 보내기»로 {stage}에 돌려보낼 수 있습니다',
|
||||
'desk.attachToProject': '프로젝트로 보내기',
|
||||
'desk.attachHint': '이 결과를 출처로 보내기: {label}',
|
||||
'desk.attaching': '보내는 중…',
|
||||
'desk.attachedTag': '보냄',
|
||||
'desk.attachBack': '프로젝트로',
|
||||
'desk.attachDoneToast': '원본 프로젝트로 보냈습니다. 프로젝트에서 확인하세요',
|
||||
'desk.attachAlreadyToast': '이 결과는 이미 보내졌습니다. 중복이 만들어지지 않았습니다',
|
||||
'desk.attachFailedToast': '보내기 실패: {detail}',
|
||||
'desk.dayDate': '{m}월 {d}일',
|
||||
'desk.download': '다운로드',
|
||||
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
|
||||
|
||||
@@ -1773,11 +1773,11 @@ export const zh: Dict = {
|
||||
'workbench.pickerFootHint': '也可以在项目总览直接「从创意开始」或「导入剧本」进入工作台。',
|
||||
'workbench.stageProgress': '五段进度',
|
||||
'workbench.activeJobs': '{n} 个任务进行中',
|
||||
'workbench.stage.script': '剧本创作',
|
||||
'workbench.stage.style': '视觉设计',
|
||||
'workbench.stage.script': '故事构思',
|
||||
'workbench.stage.style': '视觉方向',
|
||||
'workbench.stage.storyboard': '分镜设计',
|
||||
'workbench.stage.assets': '素材资产',
|
||||
'workbench.stage.shot': '镜头脚本',
|
||||
'workbench.stage.assets': '素材创作',
|
||||
'workbench.stage.shot': '镜头制作',
|
||||
'workbench.lockedStep': '先完成并确认「{stage}」后解锁',
|
||||
'workbench.state.todo': '待开始',
|
||||
'workbench.state.running': '生成中',
|
||||
@@ -2001,6 +2001,15 @@ export const zh: Dict = {
|
||||
'desk.ctxDismiss': '关闭来源提示',
|
||||
'desk.ctxFrom': '来自《{project}》',
|
||||
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
|
||||
'desk.ctxBackfill': '生成完成后,可在结果卡「回流到源项目」把结果送回{stage}',
|
||||
'desk.attachToProject': '回流到源项目',
|
||||
'desk.attachHint': '把该结果回流到来源:{label}',
|
||||
'desk.attaching': '回流中…',
|
||||
'desk.attachedTag': '已回流',
|
||||
'desk.attachBack': '返回项目',
|
||||
'desk.attachDoneToast': '已回流到源项目,返回项目即可查看',
|
||||
'desk.attachAlreadyToast': '该结果已回流过,未产生重复',
|
||||
'desk.attachFailedToast': '回流失败:{detail}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': '下载',
|
||||
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
|
||||
@@ -2009,7 +2018,7 @@ export const zh: Dict = {
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '用时',
|
||||
'desk.emptyFailed': '没有失败的记录',
|
||||
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
|
||||
'desk.emptyFeed': '还没有记录。在右侧写下描述,点「生成」。',
|
||||
'desk.filterAria': '筛选记录',
|
||||
'desk.filterAll': '全部',
|
||||
'desk.filterImage': '图片',
|
||||
@@ -2054,7 +2063,7 @@ export const zh: Dict = {
|
||||
'desk.removeRecord': '移除记录',
|
||||
'desk.restoreOrig': '恢复原值',
|
||||
'desk.reuse': '复用',
|
||||
'desk.reuseToComposer': '复用到输入台',
|
||||
'desk.reuseToComposer': '复用创作参数',
|
||||
'desk.seedRandom': '随机',
|
||||
'desk.sendToSlot': '送入当前图片槽',
|
||||
'desk.sent': '已提交',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Public reference imagery retained for the user-requested interface reconstruction.
|
||||
* Source: https://cn.lk666.ai/home?menu=zhinengti (story agent), 2026-10-14.
|
||||
* No account imagery, private history, authentication data, or remote calls. */
|
||||
export const agentStyles = [
|
||||
{
|
||||
name: '韩漫唯美',
|
||||
image: 'hanmanweimei.jpg',
|
||||
prompt: '细腻唯美的韩漫风格,柔和光线与人物情绪',
|
||||
},
|
||||
{
|
||||
name: '日系动画',
|
||||
image: 'rixidonghua.jpg',
|
||||
prompt: '日系动画风格,清晰线条与富有层次的色彩',
|
||||
},
|
||||
{
|
||||
name: '纯正国风',
|
||||
image: 'chunzhengguofeng.jpg',
|
||||
prompt: '国风水墨意境,传统色彩与写意构图',
|
||||
},
|
||||
{
|
||||
name: '激燃极光',
|
||||
image: 'jiranjiguang.jpg',
|
||||
prompt: '强烈动感、鲜明对比与戏剧化光效',
|
||||
},
|
||||
{
|
||||
name: '3D盲盒',
|
||||
image: '3dmanghe.jpg',
|
||||
prompt: '可爱的三维盲盒人物,圆润造型与柔和材质',
|
||||
},
|
||||
{
|
||||
name: '趣味黏土',
|
||||
image: 'quweiniantu.jpg',
|
||||
prompt: '手工黏土质感,温暖色彩与趣味造型',
|
||||
},
|
||||
{
|
||||
name: '美漫硬朗',
|
||||
image: 'meimanyinglang.jpg',
|
||||
prompt: '硬朗美漫风格,强烈阴影与有力线条',
|
||||
},
|
||||
{
|
||||
name: '写实画报',
|
||||
image: 'xieshihuabao.jpg',
|
||||
prompt: '写实电影画报风格,自然质感与精致光线',
|
||||
},
|
||||
{
|
||||
name: '治愈绘本',
|
||||
image: 'zhiyuhuiben.jpg',
|
||||
prompt: '温暖治愈的手绘绘本,柔和色彩与亲切人物',
|
||||
},
|
||||
{
|
||||
name: '冒险动漫',
|
||||
image: 'haizeiwangfengge.jpg',
|
||||
prompt: '明快的冒险动漫风格,夸张表情与充满活力的画面',
|
||||
},
|
||||
];
|
||||
export const agentThemes: Record<
|
||||
string,
|
||||
{ title: string; hint: string; color: string; hue: number }
|
||||
> = {
|
||||
script: {
|
||||
title: '把一句灵感,\n写成一个好故事。',
|
||||
hint: '打磨人物、冲突与故事节奏',
|
||||
color: '#f5b042',
|
||||
hue: 0,
|
||||
},
|
||||
style: {
|
||||
title: '让脑海里的画面,\n拥有自己的风格。',
|
||||
hint: '确定画风、色彩与光线',
|
||||
color: '#cba0ff',
|
||||
hue: 20,
|
||||
},
|
||||
storyboard: {
|
||||
title: '把故事的每一刻,\n放进恰当的镜头。',
|
||||
hint: '拆解构图、动作与镜头节奏',
|
||||
color: '#73ceee',
|
||||
hue: 85,
|
||||
},
|
||||
assets: {
|
||||
title: '人物、场景与细节,\n一起走进作品里。',
|
||||
hint: '整理角色、场景与参考素材',
|
||||
color: '#69d4bc',
|
||||
hue: 145,
|
||||
},
|
||||
shot: {
|
||||
title: '让静止的想象,\n有了流动的瞬间。',
|
||||
hint: '镜头提示词、图片与视频工具',
|
||||
color: '#ff95b0',
|
||||
hue: 300,
|
||||
},
|
||||
};
|
||||
@@ -85,7 +85,7 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 74px;
|
||||
}
|
||||
.label {
|
||||
@@ -108,7 +108,7 @@
|
||||
.open {
|
||||
display: block;
|
||||
position: relative;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -128,8 +128,8 @@
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
pointer-events: none;
|
||||
@@ -153,7 +153,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
.danger {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
.empty {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -133,10 +133,10 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.danger {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
.add-grid {
|
||||
display: grid;
|
||||
@@ -145,7 +145,7 @@
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
.add-item {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -154,7 +154,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.objects {
|
||||
flex: 1;
|
||||
@@ -171,10 +171,10 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.row.selected {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
@@ -217,8 +217,8 @@
|
||||
}
|
||||
.rename {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
@@ -233,6 +233,6 @@
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.45;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
section {
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.collapse {
|
||||
@@ -257,8 +257,8 @@
|
||||
input[type='number'],
|
||||
select {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
@@ -279,7 +279,7 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.joint {
|
||||
border: 1px solid var(--border, #232733);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
@@ -298,8 +298,8 @@
|
||||
}
|
||||
.capture {
|
||||
margin-top: auto;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
@@ -307,6 +307,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.capture:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.item.active {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
@@ -265,7 +265,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.run {
|
||||
background: var(--accent, #6366f1);
|
||||
background: var(--run);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
@@ -295,6 +295,6 @@
|
||||
display: flex;
|
||||
}
|
||||
.del:hover {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -841,7 +841,7 @@ let exporting = false;
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
background: #14161c;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
}
|
||||
.viewport :global(canvas) {
|
||||
@@ -866,7 +866,7 @@ let exporting = false;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--on-media) 14%, transparent);
|
||||
}
|
||||
.composition-guide .thirds-v:nth-of-type(1) {
|
||||
left: 33.33%;
|
||||
@@ -879,7 +879,7 @@ let exporting = false;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--on-media) 14%, transparent);
|
||||
}
|
||||
.composition-guide .thirds-h:nth-of-type(3) {
|
||||
top: 33.33%;
|
||||
@@ -894,7 +894,7 @@ let exporting = false;
|
||||
width: 14px;
|
||||
height: 1px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: color-mix(in srgb, var(--on-media) 35%, transparent);
|
||||
}
|
||||
.composition-guide .cross-v {
|
||||
position: absolute;
|
||||
@@ -903,6 +903,6 @@ let exporting = false;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: color-mix(in srgb, var(--on-media) 35%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
@@ -207,7 +207,7 @@
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -216,7 +216,7 @@
|
||||
padding: 0 6px;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -227,10 +227,10 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.obj-add {
|
||||
color: #7dd88a;
|
||||
color: var(--ok);
|
||||
}
|
||||
.export {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -240,7 +240,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export:hover:not(:disabled) {
|
||||
border-color: var(--accent, #6366f1);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.export:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -255,8 +255,8 @@
|
||||
}
|
||||
.dur input {
|
||||
width: 46px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
@@ -280,14 +280,14 @@
|
||||
position: absolute;
|
||||
inset: 11px 0;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
background: var(--accent, #6366f1);
|
||||
background: var(--accent);
|
||||
transform: translateX(-1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -298,8 +298,8 @@
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
transform: translate(-5px, -50%) rotate(45deg);
|
||||
background: #e0b34d;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
background: var(--warn);
|
||||
border: 1px solid var(--bg);
|
||||
cursor: grab;
|
||||
padding: 0;
|
||||
z-index: 2;
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: rgba(16, 18, 24, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg-2) 85%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -103,7 +103,7 @@
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border, #2a2f3a);
|
||||
background: var(--border);
|
||||
margin: 0 2px;
|
||||
}
|
||||
.btn {
|
||||
@@ -119,12 +119,12 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn.on {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: var(--accent, #6366f1);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-line);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn:disabled {
|
||||
@@ -132,9 +132,9 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.capture {
|
||||
border-color: var(--border, #2a2f3a);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.capture:hover {
|
||||
border-color: var(--accent, #6366f1);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** Approved HEL-726 themes. Colours live only in static/themes.css. */
|
||||
export const THEMES = [
|
||||
{
|
||||
id: "night",
|
||||
name: "夜片场",
|
||||
english: "Nocturne",
|
||||
description: "墨黑与钨丝金 · 沉浸式创作",
|
||||
},
|
||||
{
|
||||
id: "day",
|
||||
name: "日光棚",
|
||||
english: "Daylight",
|
||||
description: "暖白纸面 · 白天办公与长时间审稿",
|
||||
},
|
||||
{
|
||||
id: "grade",
|
||||
name: "调色间",
|
||||
english: "Grading",
|
||||
description: "中性深灰 · 判断作品色彩",
|
||||
},
|
||||
{
|
||||
id: "bluehour",
|
||||
name: "蓝调时刻",
|
||||
english: "Blue Hour",
|
||||
description: "靛蓝与珊瑚 · 提案与灵感发散",
|
||||
},
|
||||
] as const;
|
||||
export type ThemeId = (typeof THEMES)[number]["id"];
|
||||
export const THEME_KEY = "aigc-theme";
|
||||
export const theme = writable<ThemeId>("night");
|
||||
export const themeStorageUnavailable = writable(false);
|
||||
|
||||
export function isTheme(value: unknown): value is ThemeId {
|
||||
return THEMES.some((item) => item.id === value);
|
||||
}
|
||||
|
||||
let themeRequest = 0;
|
||||
let transition: ViewTransition | undefined;
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** HEL-726: 520ms reveal, 280ms fallback, immediate under reduced motion.
|
||||
* Only root tokens and the store change; no editor/component is remounted. */
|
||||
export function setTheme(id: ThemeId, event?: MouseEvent) {
|
||||
if (typeof document === "undefined" || !isTheme(id)) return;
|
||||
const root = document.documentElement;
|
||||
const request = ++themeRequest;
|
||||
transition?.skipTransition();
|
||||
clearTimeout(fallbackTimer);
|
||||
root.classList.remove("theme-anim");
|
||||
if (root.dataset.theme === id) return;
|
||||
const apply = () => {
|
||||
if (request !== themeRequest) return;
|
||||
root.dataset.theme = id;
|
||||
theme.set(id);
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, id);
|
||||
themeStorageUnavailable.set(false);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
};
|
||||
const reduced =
|
||||
root.dataset.reduce === "1" ||
|
||||
matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
if (document.startViewTransition) {
|
||||
if (event && event.detail !== 0) {
|
||||
root.style.setProperty("--theme-x", event.clientX + "px");
|
||||
root.style.setProperty("--theme-y", event.clientY + "px");
|
||||
}
|
||||
transition = document.startViewTransition(async () => {
|
||||
apply();
|
||||
await tick();
|
||||
});
|
||||
void transition.ready.catch(() => {});
|
||||
void transition.finished.catch(() => {});
|
||||
} else {
|
||||
root.classList.add("theme-anim");
|
||||
apply();
|
||||
fallbackTimer = setTimeout(() => root.classList.remove("theme-anim"), 340);
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const current = document.documentElement.dataset.theme;
|
||||
theme.set(isTheme(current) ? current : "night");
|
||||
try {
|
||||
localStorage.getItem(THEME_KEY);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
const sync = (event: StorageEvent) => {
|
||||
if (event.key !== THEME_KEY && event.key !== null) return;
|
||||
const id = isTheme(event.newValue) ? event.newValue : "night";
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
};
|
||||
window.addEventListener("storage", sync);
|
||||
return () => window.removeEventListener("storage", sync);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { WorkflowMeta } from "./comfy/types";
|
||||
/** Shared contract for creation and management. A directory file is never a runnable workflow. */
|
||||
export function isWorkflowRunnable(
|
||||
row: Pick<WorkflowMeta, "is_enabled" | "usage" | "needs_mapping">,
|
||||
): boolean {
|
||||
return row.is_enabled && !!row.usage && !row.needs_mapping;
|
||||
}
|
||||
export function workflowState(row: WorkflowMeta): {
|
||||
label: string;
|
||||
tone: string;
|
||||
} {
|
||||
if (row.needs_mapping || !row.usage) return { label: "待配置", tone: "warn" };
|
||||
if (!row.is_enabled) return { label: "已停用", tone: "" };
|
||||
return { label: "可运行", tone: "ok" };
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
import { initReduceMotion } from '$lib/reduceMotion.svelte';
|
||||
import "./app.css";
|
||||
import { initTheme } from "$lib/theme";
|
||||
import { onMount } from "svelte";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
|
||||
import ToastHost from "$lib/components/ToastHost.svelte";
|
||||
import { initReduceMotion } from "$lib/reduceMotion.svelte";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => initReduceMotion());
|
||||
onMount(() => initReduceMotion());
|
||||
onMount(initTheme);
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<slot />
|
||||
<ToastHost />
|
||||
<slot />
|
||||
<ToastHost />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -4,8 +4,21 @@
|
||||
// there is nothing yet, honest Chinese empty states are shown instead of
|
||||
// the sample progress rendered in the mockup.
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, projects, workbenchApi, workflows, type Job, type Project } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/svelte-query';
|
||||
import {
|
||||
jobsApi,
|
||||
projects,
|
||||
workbenchApi,
|
||||
workflows,
|
||||
type Job,
|
||||
type Project,
|
||||
} from '$lib/api';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AProjectCard from '$lib/components/home/AProjectCard.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
@@ -32,7 +45,10 @@
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
const workflowsQuery = createQuery({
|
||||
queryKey: ['workflows-meta'],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
|
||||
const allProjects = $derived($projectsQuery.data ?? []);
|
||||
// 进行中 = started the five-stage flow but not all five confirmed yet.
|
||||
@@ -42,7 +58,9 @@
|
||||
if (!st || st.stage_confirmed >= 5) return false;
|
||||
return (
|
||||
st.stage_confirmed > 0 ||
|
||||
Object.values(st.stage_status ?? {}).some((v) => v === 'running' || v === 'awaiting_confirm')
|
||||
Object.values(st.stage_status ?? {}).some(
|
||||
(v) => v === 'running' || v === 'awaiting_confirm',
|
||||
)
|
||||
);
|
||||
}).length,
|
||||
);
|
||||
@@ -72,7 +90,13 @@
|
||||
'Gritty, realistic',
|
||||
'Epic, sweeping',
|
||||
];
|
||||
const DURATIONS = ['30 seconds', '1 minute', '2 minutes', '5 minutes', '10 minutes'];
|
||||
const DURATIONS = [
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'2 minutes',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
];
|
||||
|
||||
let ideaForm = $state({
|
||||
title: '',
|
||||
@@ -84,6 +108,9 @@
|
||||
let scriptForm = $state({ title: '', script: '' });
|
||||
let showErrors = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (page.url.searchParams.get('new') === '1') openIdea();
|
||||
});
|
||||
function openIdea() {
|
||||
entryMode = 'idea';
|
||||
showErrors = false;
|
||||
@@ -114,7 +141,8 @@
|
||||
const errs: Record<string, string> = {};
|
||||
if (!scriptForm.title.trim()) errs.title = t('home.errTitle');
|
||||
if (!scriptForm.script.trim()) errs.script = t('home.errScript');
|
||||
else if (scriptForm.script.trim().length < 30) errs.script = t('home.errScriptShort');
|
||||
else if (scriptForm.script.trim().length < 30)
|
||||
errs.script = t('home.errScriptShort');
|
||||
return errs;
|
||||
});
|
||||
|
||||
@@ -128,7 +156,10 @@
|
||||
toast.success(t('projects.created', { title: p.title }));
|
||||
// Import the pasted script as the confirmed 剧本 baseline, then
|
||||
// continue from 视觉设计 (导入剧本可跳过剧本创作).
|
||||
$importScriptMutation.mutate({ projectId: p.id, text: scriptForm.script.trim() });
|
||||
$importScriptMutation.mutate({
|
||||
projectId: p.id,
|
||||
text: scriptForm.script.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const importScriptMutation = createMutation({
|
||||
@@ -139,7 +170,9 @@
|
||||
goto(`/workbench/${args.projectId}?stage=style`);
|
||||
},
|
||||
onError: (err, args) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projects.createFailed'),
|
||||
);
|
||||
// Never strand the user: open the workbench at 剧本创作 so the
|
||||
// text can be pasted/edited there even if the import failed.
|
||||
if (args) goto(`/workbench/${args.projectId}?stage=script`);
|
||||
@@ -157,7 +190,9 @@
|
||||
}),
|
||||
onSuccess: onIdeaCreated,
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projects.createFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -169,7 +204,9 @@
|
||||
}),
|
||||
onSuccess: onScriptCreated,
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projects.createFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -191,7 +228,9 @@
|
||||
// ------------------------------------------------------------------
|
||||
const recentJobs = $derived(($jobsQuery.data ?? []).slice(0, 10));
|
||||
const activeJobCount = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.status === 'pending' || j.status === 'running').length,
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.status === 'pending' || j.status === 'running',
|
||||
).length,
|
||||
);
|
||||
|
||||
const projectTitles = $derived.by(() => {
|
||||
@@ -207,7 +246,9 @@
|
||||
toast.success(t('home.jobRequeued'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('home.jobRetryFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('home.jobRetryFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,7 +261,8 @@
|
||||
// a1 task row: 「本机 · 生图」 tag + the project (or workflow) name.
|
||||
function jobTag(job: Job): { cls: string; label: string } {
|
||||
const kind = KIND_KEY[job.kind] ? t(KIND_KEY[job.kind]) : job.kind;
|
||||
const url = (job.payload?.endpoint as { base_url?: string } | undefined)?.base_url;
|
||||
const url = (job.payload?.endpoint as { base_url?: string } | undefined)
|
||||
?.base_url;
|
||||
if (!url) return { cls: 'tag-local', label: kind };
|
||||
const where = classifyEndpoint(url);
|
||||
return {
|
||||
@@ -230,25 +272,41 @@
|
||||
}
|
||||
|
||||
function workflowName(job: Job): string {
|
||||
return ($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ?? '';
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)
|
||||
?.name ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(job: Job): string {
|
||||
const project = isStandaloneJob(job) ? undefined : projectTitles.get(job.project_id);
|
||||
return project ? `《${project}》` : workflowName(job) || jobSourceLabel(job);
|
||||
const project = isStandaloneJob(job)
|
||||
? undefined
|
||||
: projectTitles.get(job.project_id);
|
||||
return project
|
||||
? `《${project}》`
|
||||
: workflowName(job) || jobSourceLabel(job);
|
||||
}
|
||||
|
||||
function jobMeta(job: Job): string {
|
||||
const wf = workflowName(job);
|
||||
const parts = [wf === jobTitle(job) ? '' : wf, jobSourceLabel(job), formatJobTime(job.created_at)];
|
||||
if (job.status === 'failed' && job.error && job.error !== 'cancelled') parts.push(job.error);
|
||||
const parts = [
|
||||
wf === jobTitle(job) ? '' : wf,
|
||||
jobSourceLabel(job),
|
||||
formatJobTime(job.created_at),
|
||||
];
|
||||
if (job.status === 'failed' && job.error && job.error !== 'cancelled')
|
||||
parts.push(job.error);
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function jobBar(job: Job): { value: number; tone: 'run' | 'success' | 'error' } {
|
||||
function jobBar(job: Job): {
|
||||
value: number;
|
||||
tone: 'run' | 'success' | 'error';
|
||||
} {
|
||||
if (job.status === 'done') return { value: 100, tone: 'success' };
|
||||
if (job.status === 'failed') return { value: 100, tone: 'error' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
if (job.status === 'running')
|
||||
return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
return { value: 0, tone: 'run' };
|
||||
}
|
||||
|
||||
@@ -263,7 +321,10 @@
|
||||
function formatJobTime(raw: string): string {
|
||||
const ts = parseDbTs(raw);
|
||||
if (!Number.isFinite(ts)) return '';
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// English values are the backend contract; display labels come from the
|
||||
@@ -302,9 +363,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="projects" title={t('home.title')} subtitle={t('home.subtitle')}>
|
||||
<AppShell active="projects" title="概览" subtitle={t('home.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
|
||||
<span class="status-chip"
|
||||
>{t('home.projectCount', {
|
||||
n: allProjects.length,
|
||||
m: inProgressCount,
|
||||
})}</span
|
||||
>
|
||||
<button
|
||||
class="a-btn"
|
||||
@@ -320,24 +385,49 @@
|
||||
<div class="home">
|
||||
<section class="main-col">
|
||||
{#if entryMode === 'none'}
|
||||
<div class="entries">
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
|
||||
<h2>{t('home.entryIdeaTitle')}</h2>
|
||||
<p>{t('home.entryIdeaBody')}</p>
|
||||
<button type="button" class="a-btn secondary full" onclick={openIdea}>
|
||||
{t('home.startIdea')}
|
||||
</button>
|
||||
</article>
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="script" size={18} /></span>
|
||||
<h2>{t('home.entryScriptTitle')}</h2>
|
||||
<p>{t('home.entryScriptBody')}</p>
|
||||
<button type="button" class="a-btn secondary full" onclick={openScript}>
|
||||
{t('home.importScript')}
|
||||
</button>
|
||||
</article>
|
||||
<div class="refract-greeting">
|
||||
<h2>欢迎回到工作室。</h2>
|
||||
<span>给今天的想象,留一点空间。</span>
|
||||
</div>
|
||||
<section class="studio-start">
|
||||
<div>
|
||||
<small>从这里开始今天的创作</small>
|
||||
<h1>让想法,成为下一件作品。</h1>
|
||||
<p>直接生成图片与视频,或与智能体一起推进项目。</p>
|
||||
<div class="start-actions">
|
||||
<button class="a-btn gold" onclick={openIdea}>+ 新建项目</button
|
||||
><button class="a-btn secondary" onclick={openScript}
|
||||
>导入剧本</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<img src="/refract/coast.jpg" alt="海岸光线与画面参考" />
|
||||
</section>
|
||||
{#if allProjects.length}{@const recent = allProjects[0]}<a
|
||||
class="resume-project"
|
||||
href={'/workbench/' + recent.id}
|
||||
><Icon name="folder" size={22} />
|
||||
<div>
|
||||
<small>继续最近的项目</small><strong>{recent.title}</strong>
|
||||
</div>
|
||||
<span>{recent.stats?.stage_confirmed ?? 0} / 5 阶段已确认</span><b
|
||||
>继续创作 →</b
|
||||
></a
|
||||
>{/if}
|
||||
<section class="refract-tools" aria-label="创作工具">
|
||||
{#each [{ href: '/image', icon: 'image', title: '图片创作', desc: '从文字、参考图到新画面' }, { href: '/video', icon: 'film', title: '视频创作', desc: '首尾帧、参考视频与运镜' }, { href: '/canvas', icon: 'story', title: '灵感画布', desc: '把零散的想法连接起来' }, { href: '/agents', icon: 'sparkle', title: '智能体', desc: '让专业搭档加入创作' }] as tool}
|
||||
<a href={tool.href}
|
||||
><span class="tool-icon"
|
||||
><Icon
|
||||
name={tool.icon as 'image' | 'film' | 'story' | 'sparkle'}
|
||||
size={20}
|
||||
/></span
|
||||
><span
|
||||
><strong>{tool.title}</strong><small>{tool.desc}</small></span
|
||||
><span class="tool-arrow">↗</span></a
|
||||
>
|
||||
{/each}
|
||||
</section>
|
||||
{:else if entryMode === 'idea'}
|
||||
<form class="entry-form" onsubmit={submitIdea}>
|
||||
<div class="form-head">
|
||||
@@ -352,14 +442,17 @@
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span
|
||||
>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={showErrors && ideaErrors.title}
|
||||
bind:value={ideaForm.title}
|
||||
placeholder={t('projects.titlePlaceholder')}
|
||||
/>
|
||||
{#if showErrors && ideaErrors.title}<span class="field-error">{ideaErrors.title}</span>{/if}
|
||||
{#if showErrors && ideaErrors.title}<span class="field-error"
|
||||
>{ideaErrors.title}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.ideaField')}</span>
|
||||
@@ -375,14 +468,17 @@
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.genre}>
|
||||
{#each GENRES as v (v)}<option value={v}>{t('genres.' + genreKey(v))}</option
|
||||
{#each GENRES as v (v)}<option value={v}
|
||||
>{t('genres.' + genreKey(v))}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.tone}>
|
||||
{#each TONES as v (v)}<option value={v}>{t('tones.' + toneKey(v))}</option>{/each}
|
||||
{#each TONES as v (v)}<option value={v}
|
||||
>{t('tones.' + toneKey(v))}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
@@ -395,8 +491,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
|
||||
<Button variant="primary" type="submit" loading={$ideaMutation.isPending}>
|
||||
<Button variant="ghost" onclick={closeEntry}
|
||||
>{t('projects.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={$ideaMutation.isPending}
|
||||
>
|
||||
{t('projects.create')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -415,14 +517,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span
|
||||
>
|
||||
<input
|
||||
class="field-input"
|
||||
class:invalid={showErrors && scriptErrors.title}
|
||||
bind:value={scriptForm.title}
|
||||
placeholder={t('projects.titlePlaceholder')}
|
||||
/>
|
||||
{#if showErrors && scriptErrors.title}<span class="field-error">{scriptErrors.title}</span
|
||||
{#if showErrors && scriptErrors.title}<span class="field-error"
|
||||
>{scriptErrors.title}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
@@ -434,13 +538,20 @@
|
||||
placeholder={t('home.scriptPlaceholder')}
|
||||
rows={9}
|
||||
></textarea>
|
||||
{#if showErrors && scriptErrors.script}<span class="field-error">{scriptErrors.script}</span
|
||||
{#if showErrors && scriptErrors.script}<span class="field-error"
|
||||
>{scriptErrors.script}</span
|
||||
>{/if}
|
||||
<span class="field-hint">{t('home.scriptHint')}</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
|
||||
<Button variant="primary" type="submit" loading={$scriptMutation.isPending}>
|
||||
<Button variant="ghost" onclick={closeEntry}
|
||||
>{t('projects.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={$scriptMutation.isPending}
|
||||
>
|
||||
{t('home.importScript')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -448,7 +559,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="projects-head">
|
||||
<h2>{t('home.myProjects')}</h2>
|
||||
<h2>最近的创作</h2>
|
||||
<a class="refract-all" href="/workbench">全部项目 →</a>
|
||||
{#if allProjects.length > 0}
|
||||
<span class="projects-hint">{t('home.myProjectsHint')}</span>
|
||||
{/if}
|
||||
@@ -483,7 +595,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cards">
|
||||
{#each allProjects as project (project.id)}
|
||||
{#each allProjects.slice(0, 3) as project (project.id)}
|
||||
<AProjectCard {project} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -491,12 +603,12 @@
|
||||
</section>
|
||||
|
||||
<aside class="tasks">
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="status-chip" class:run-chip={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="status-chip" class:run-chip={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $jobsQuery.isLoading}
|
||||
<div class="tasks-loading">{t('common.loading')}…</div>
|
||||
@@ -514,10 +626,14 @@
|
||||
<li class="job-row" class:failed={job.status === 'failed'}>
|
||||
<div class="job-top">
|
||||
<span class="a-tag">{tag.label}</span>
|
||||
<span class="job-name" title={jobTitle(job)}>{jobTitle(job)}</span>
|
||||
<span class="job-name" title={jobTitle(job)}
|
||||
>{jobTitle(job)}</span
|
||||
>
|
||||
<span class="job-side">
|
||||
{#if job.status === 'failed' && job.error !== 'cancelled'}
|
||||
<span class="job-status mono failed-text">{jobStatusLabel(job.status)}</span>
|
||||
<span class="job-status mono failed-text"
|
||||
>{jobStatusLabel(job.status)}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="retry-link"
|
||||
@@ -533,8 +649,12 @@
|
||||
? 'running-text'
|
||||
: 'queued-text'}"
|
||||
>
|
||||
{#if job.status === 'running'}<span class="dot breathe" aria-hidden="true"
|
||||
></span>{Math.round(bar.value)}%{:else}{jobStatusLabel(job.status)}{/if}
|
||||
{#if job.status === 'running'}<span
|
||||
class="dot breathe"
|
||||
aria-hidden="true"
|
||||
></span>{Math.round(bar.value)}%{:else}{jobStatusLabel(
|
||||
job.status,
|
||||
)}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -554,9 +674,9 @@
|
||||
<style>
|
||||
.home {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 344px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px 28px 40px;
|
||||
padding: 28px 36px 56px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -571,52 +691,6 @@
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ---------------- topbar chip + buttons ---------------- */
|
||||
.a-btn.full {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ---------------- entry cards / forms (a1 双入口) ---------------- */
|
||||
.entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 18px var(--sp-7) var(--sp-7);
|
||||
min-height: 168px;
|
||||
}
|
||||
.entry-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.entry-card h2 {
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-card p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-body-sm);
|
||||
line-height: 1.65;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
@@ -912,9 +986,6 @@
|
||||
.tasks {
|
||||
position: static;
|
||||
}
|
||||
.entries {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.f-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@@ -924,4 +995,210 @@
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-8);
|
||||
}
|
||||
}
|
||||
|
||||
.refract-greeting {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.refract-greeting h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.refract-greeting span {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.refract-tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin: -4px 0 8px;
|
||||
}
|
||||
.refract-tools a {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
gap: 13px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.tool-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #00cae016;
|
||||
color: var(--accent);
|
||||
}
|
||||
.refract-tools a:nth-child(2) .tool-icon {
|
||||
background: #f5b04216;
|
||||
color: #f5b042;
|
||||
}
|
||||
.refract-tools a:nth-child(3) .tool-icon {
|
||||
background: #7850ff16;
|
||||
color: #a78bfa;
|
||||
}
|
||||
.refract-tools a:nth-child(4) .tool-icon {
|
||||
background: #10b98116;
|
||||
color: #55d4ad;
|
||||
}
|
||||
.refract-tools strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.refract-tools small {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tool-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.refract-all {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tasks {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: none;
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
.job-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.tasks-note {
|
||||
display: none;
|
||||
}
|
||||
.home .cards {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.main-col {
|
||||
gap: 24px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.refract-tools a:hover {
|
||||
background: #ffffff06;
|
||||
}
|
||||
}
|
||||
|
||||
.home {
|
||||
max-width: none;
|
||||
padding: 24px 28px 30px;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 24px;
|
||||
}
|
||||
.studio-start {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #16161b;
|
||||
min-height: 210px;
|
||||
}
|
||||
.studio-start > div {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.studio-start small {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.studio-start h1 {
|
||||
font-size: 27px;
|
||||
font-weight: 550;
|
||||
margin: 12px 0;
|
||||
letter-spacing: -0.8px;
|
||||
}
|
||||
.studio-start p {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.studio-start img {
|
||||
width: 38%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.start-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.resume-project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 15px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
background: #16161b;
|
||||
}
|
||||
.resume-project small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.resume-project strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 550;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.resume-project > span {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.resume-project b {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
.refract-tools {
|
||||
gap: 10px;
|
||||
}
|
||||
.refract-tools a {
|
||||
padding: 12px;
|
||||
}
|
||||
.cards {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.tasks {
|
||||
padding: 16px;
|
||||
background: #16161b;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.home {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 20px;
|
||||
}
|
||||
.refract-tools {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.studio-start h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.tasks {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+718
-784
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,18 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AgentChat from '$lib/components/agent/AgentChat.svelte';
|
||||
import AgentComposer from '$lib/components/agent/AgentComposer.svelte';
|
||||
import { agentApi, playgroundApi, workflows, type AgentMessage, type AgentPlan, type AgentSession, type AgentTask } from '$lib/api';
|
||||
import {
|
||||
agentApi,
|
||||
playgroundApi,
|
||||
workflows,
|
||||
type AgentMessage,
|
||||
type AgentPlan,
|
||||
type AgentSession,
|
||||
type AgentTask,
|
||||
} from '$lib/api';
|
||||
import type { AgentComposerPayload, SkillOption, WorkflowOption } from '$lib/agentComposer';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { toast } from '$lib/toast';
|
||||
@@ -26,33 +34,33 @@
|
||||
// Selection persists in localStorage; deleting a session removes its
|
||||
// composition first (the FK alone would orphan it).
|
||||
|
||||
const ACTIVE_SESSION_KEY = 'calliope.shot.activeSession';
|
||||
const RAIL_KEY = 'calliope.shot.railCollapsed';
|
||||
const CHAT_KEY = 'calliope.shot.chatCollapsed';
|
||||
const ACTIVE_SESSION_KEY = 'calliope.shot.activeSession';
|
||||
const RAIL_KEY = 'calliope.shot.railCollapsed';
|
||||
const CHAT_KEY = 'calliope.shot.chatCollapsed';
|
||||
|
||||
let sessionId = $state<number | null>(null);
|
||||
let captures = $state<Capture[]>([]);
|
||||
let compositionReady = $state(false);
|
||||
let switching = $state(false);
|
||||
// SSR-safe: this component initializes during server render too, where
|
||||
// localStorage does not exist (a bare access 500s a direct page load).
|
||||
let railCollapsed = $state(false);
|
||||
let chatCollapsed = $state(false);
|
||||
let sessionId = $state<number | null>(null);
|
||||
let captures = $state<Capture[]>([]);
|
||||
let compositionReady = $state(false);
|
||||
let switching = $state(false);
|
||||
// SSR-safe: this component initializes during server render too, where
|
||||
// localStorage does not exist (a bare access 500s a direct page load).
|
||||
let railCollapsed = $state(false);
|
||||
let chatCollapsed = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
railCollapsed = localStorage.getItem(RAIL_KEY) === '1';
|
||||
chatCollapsed = localStorage.getItem(CHAT_KEY) === '1';
|
||||
});
|
||||
$effect(() => {
|
||||
railCollapsed = localStorage.getItem(RAIL_KEY) === '1';
|
||||
chatCollapsed = localStorage.getItem(CHAT_KEY) === '1';
|
||||
});
|
||||
|
||||
function toggleRail() {
|
||||
railCollapsed = !railCollapsed;
|
||||
localStorage.setItem(RAIL_KEY, railCollapsed ? '1' : '0');
|
||||
}
|
||||
function toggleRail() {
|
||||
railCollapsed = !railCollapsed;
|
||||
localStorage.setItem(RAIL_KEY, railCollapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
function toggleChat() {
|
||||
chatCollapsed = !chatCollapsed;
|
||||
localStorage.setItem(CHAT_KEY, chatCollapsed ? '1' : '0');
|
||||
}
|
||||
function toggleChat() {
|
||||
chatCollapsed = !chatCollapsed;
|
||||
localStorage.setItem(CHAT_KEY, chatCollapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
function isSceneSession(s: AgentSession): boolean {
|
||||
return s.project_id == null && s.origin === 'scene';
|
||||
@@ -101,10 +109,7 @@ function toggleChat() {
|
||||
const sessions = await agentApi.listSceneSessions();
|
||||
const stored = Number(localStorage.getItem(ACTIVE_SESSION_KEY) ?? '');
|
||||
const sceneSessions = sessions.filter(isSceneSession);
|
||||
const initial =
|
||||
sceneSessions.find((s) => s.id === stored) ??
|
||||
sceneSessions[0] ??
|
||||
null;
|
||||
const initial = sceneSessions.find((s) => s.id === stored) ?? sceneSessions[0] ?? null;
|
||||
if (initial) {
|
||||
await activateSession(initial.id, { persist: false });
|
||||
} else {
|
||||
@@ -186,9 +191,11 @@ function toggleChat() {
|
||||
exportStatus = t('buildScene.videoExported');
|
||||
try {
|
||||
if (!viewportRef) throw new Error(t('buildScene.legacyNeedsViewport'));
|
||||
const { dataUrl } = await (viewportRef as unknown as {
|
||||
exportVideoClip: () => Promise<{ dataUrl: string; ext: string }>;
|
||||
}).exportVideoClip();
|
||||
const { dataUrl } = await (
|
||||
viewportRef as unknown as {
|
||||
exportVideoClip: () => Promise<{ dataUrl: string; ext: string }>;
|
||||
}
|
||||
).exportVideoClip();
|
||||
const saved = await shotApi.uploadCapture(comp, dataUrl, 'viewport video export');
|
||||
if (saved) {
|
||||
await refreshCaptures();
|
||||
@@ -212,7 +219,12 @@ function toggleChat() {
|
||||
// is written once per transition, not per token.
|
||||
let thinkingAgent: string | null = null;
|
||||
let liveTools = $state<
|
||||
{ tool: string; args?: Record<string, unknown> | null; result?: unknown; phase: 'running' | 'done' | 'error' }[]
|
||||
{
|
||||
tool: string;
|
||||
args?: Record<string, unknown> | null;
|
||||
result?: unknown;
|
||||
phase: 'running' | 'done' | 'error';
|
||||
}[]
|
||||
>([]);
|
||||
let livePlan = $state<AgentPlan | null>(null);
|
||||
let composerDraft = $state('');
|
||||
@@ -231,7 +243,9 @@ function toggleChat() {
|
||||
queryFn: agentApi.listSceneSessions,
|
||||
});
|
||||
const sessions = $derived($sessionsQuery.data ?? []);
|
||||
const activeSession = $derived(sessionId != null ? (sessions.find((s) => s.id === sessionId) ?? null) : null);
|
||||
const activeSession = $derived(
|
||||
sessionId != null ? (sessions.find((s) => s.id === sessionId) ?? null) : null,
|
||||
);
|
||||
const running = $derived(Boolean(activeSession?.running || activeSession?.status === 'running'));
|
||||
|
||||
const skillsQuery = createQuery({
|
||||
@@ -259,10 +273,23 @@ function toggleChat() {
|
||||
);
|
||||
|
||||
const SHOT_SUGGESTIONS = [
|
||||
{ labelKey: 'buildScene.suggestionTwoPerson', prompt: 'Add a male and a female character facing each other, then frame a medium two-shot from the left.' },
|
||||
{ labelKey: 'buildScene.suggestionHeroPose', prompt: 'Add a character in a hero pose — arms wide — with a low-angle close-up.' },
|
||||
{ labelKey: 'buildScene.suggestionBlockout', prompt: 'Blockout a simple room: floor plane, two walls, and put a character inside.' },
|
||||
{ labelKey: 'buildScene.suggestionCapture', prompt: 'Frame this as a medium close-up and capture a reference image.' },
|
||||
{
|
||||
labelKey: 'buildScene.suggestionTwoPerson',
|
||||
prompt:
|
||||
'Add a male and a female character facing each other, then frame a medium two-shot from the left.',
|
||||
},
|
||||
{
|
||||
labelKey: 'buildScene.suggestionHeroPose',
|
||||
prompt: 'Add a character in a hero pose — arms wide — with a low-angle close-up.',
|
||||
},
|
||||
{
|
||||
labelKey: 'buildScene.suggestionBlockout',
|
||||
prompt: 'Blockout a simple room: floor plane, two walls, and put a character inside.',
|
||||
},
|
||||
{
|
||||
labelKey: 'buildScene.suggestionCapture',
|
||||
prompt: 'Frame this as a medium close-up and capture a reference image.',
|
||||
},
|
||||
];
|
||||
const suggestionOptions = $derived(
|
||||
SHOT_SUGGESTIONS.map((s) => ({ label: t(s.labelKey), prompt: s.prompt })),
|
||||
@@ -282,7 +309,9 @@ function toggleChat() {
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: ['agent-session', sessionId] });
|
||||
await client.invalidateQueries({
|
||||
queryKey: ['agent-session', sessionId],
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ['agent-sessions', 'scene'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -300,7 +329,12 @@ function toggleChat() {
|
||||
|
||||
function handleQuestionAnswer(option: string, _scope: string, questionSeq: number) {
|
||||
if (running || sessionId == null) return;
|
||||
handleSend({ content: option, mentions: [], attachments: [], answer_to: questionSeq });
|
||||
handleSend({
|
||||
content: option,
|
||||
mentions: [],
|
||||
attachments: [],
|
||||
answer_to: questionSeq,
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelRun() {
|
||||
@@ -308,7 +342,9 @@ function toggleChat() {
|
||||
try {
|
||||
await agentApi.cancel(sessionId);
|
||||
await client.invalidateQueries({ queryKey: ['agent-sessions', 'scene'] });
|
||||
await client.invalidateQueries({ queryKey: ['agent-session', sessionId] });
|
||||
await client.invalidateQueries({
|
||||
queryKey: ['agent-session', sessionId],
|
||||
});
|
||||
toast.info(t('buildScene.runCancelled'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('buildScene.cancelFailed'));
|
||||
@@ -326,11 +362,14 @@ function toggleChat() {
|
||||
|
||||
function appendMessage(sid: number, msg: AgentMessage) {
|
||||
if (sid !== sessionId) return;
|
||||
client.setQueryData<AgentSession & { messages: AgentMessage[] }>(['agent-session', sid], (old) => {
|
||||
if (!old || !Array.isArray(old.messages)) return old;
|
||||
if (old.messages.some((m) => m.id === msg.id)) return old;
|
||||
return { ...old, messages: [...old.messages, msg] };
|
||||
});
|
||||
client.setQueryData<AgentSession & { messages: AgentMessage[] }>(
|
||||
['agent-session', sid],
|
||||
(old) => {
|
||||
if (!old || !Array.isArray(old.messages)) return old;
|
||||
if (old.messages.some((m) => m.id === msg.id)) return old;
|
||||
return { ...old, messages: [...old.messages, msg] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---- agent tool live view: pose tools mutate server-side; SSE updates --
|
||||
@@ -372,7 +411,11 @@ function toggleChat() {
|
||||
// posts it (the router clears the request, which is the
|
||||
// request_capture tool's completion signal).
|
||||
if (shotStore.dragInProgress == false) {
|
||||
(viewportRef as unknown as { pollCaptureRequest?: () => void } | null)?.pollCaptureRequest?.();
|
||||
(
|
||||
viewportRef as unknown as {
|
||||
pollCaptureRequest?: () => void;
|
||||
} | null
|
||||
)?.pollCaptureRequest?.();
|
||||
}
|
||||
break;
|
||||
case 'shot.capture.saved':
|
||||
@@ -388,12 +431,12 @@ function toggleChat() {
|
||||
if (msg) {
|
||||
appendMessage(msg.session_id, msg);
|
||||
if (msg.session_id === sessionId) {
|
||||
if (msg.role === 'tool') liveTools = [];
|
||||
if (msg.role === 'assistant' && !msg.agent_name) {
|
||||
streaming = '';
|
||||
streamingReasoning = '';
|
||||
thinkingAgent = null;
|
||||
}
|
||||
if (msg.role === 'tool') liveTools = [];
|
||||
if (msg.role === 'assistant' && !msg.agent_name) {
|
||||
streaming = '';
|
||||
streamingReasoning = '';
|
||||
thinkingAgent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
client.invalidateQueries({ queryKey: ['agent-sessions', 'scene'] });
|
||||
@@ -429,20 +472,28 @@ function toggleChat() {
|
||||
phase: 'running',
|
||||
});
|
||||
} else if (phase === 'finish') {
|
||||
const idx = [...liveTools].reverse().findIndex((t) => t.tool === name && t.phase === 'running');
|
||||
const idx = [...liveTools]
|
||||
.reverse()
|
||||
.findIndex((t) => t.tool === name && t.phase === 'running');
|
||||
if (idx >= 0) {
|
||||
const real = liveTools.length - 1 - idx;
|
||||
liveTools[real] = {
|
||||
...liveTools[real],
|
||||
result: ev.data?.result,
|
||||
phase: (ev.data?.result as { ok?: boolean } | undefined)?.ok === false ? 'error' : 'done',
|
||||
phase:
|
||||
(ev.data?.result as { ok?: boolean } | undefined)?.ok === false
|
||||
? 'error'
|
||||
: 'done',
|
||||
};
|
||||
}
|
||||
}
|
||||
// A shot tool finished server-side: refetch promptly so the
|
||||
// viewport follows even before shot.updated SSE lands.
|
||||
if (POSE_TOOLS.has(name) && phase === 'finish') {
|
||||
shotStore.handleShotUpdated({ shot_id: shotStore.compositionId ?? undefined, reason: 'tool' });
|
||||
shotStore.handleShotUpdated({
|
||||
shot_id: shotStore.compositionId ?? undefined,
|
||||
reason: 'tool',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -475,95 +526,95 @@ function toggleChat() {
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<AppHeader active="build-scene" />
|
||||
|
||||
<div class="workspace">
|
||||
<ShotSessionRail
|
||||
{sessions}
|
||||
activeId={sessionId}
|
||||
onSelect={(id) => activateSession(id)}
|
||||
onNew={handleNewScene}
|
||||
onDelete={handleDeleteScene}
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
/>
|
||||
<aside class="left-panel">
|
||||
<ScenePanel />
|
||||
<div class="inspector-slot">
|
||||
<ShotInspector oncapture={captureFromUi} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="center-panel">
|
||||
{#if compositionReady}
|
||||
<div class="viewport-wrap">
|
||||
<ShotViewport bind:this={viewportRef} oncapture={handleCaptureDataUrl} />
|
||||
<AppShell active="image" title="镜头构图"
|
||||
><div class="shell">
|
||||
<div class="workspace">
|
||||
<ShotSessionRail
|
||||
{sessions}
|
||||
activeId={sessionId}
|
||||
onSelect={(id) => activateSession(id)}
|
||||
onNew={handleNewScene}
|
||||
onDelete={handleDeleteScene}
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
/>
|
||||
<aside class="left-panel">
|
||||
<ScenePanel />
|
||||
<div class="inspector-slot">
|
||||
<ShotInspector oncapture={captureFromUi} />
|
||||
</div>
|
||||
<TimelineStrip onExportVideo={handleExportVideo} />
|
||||
{#if exportStatus}
|
||||
<p class="export-status" data-export-status>
|
||||
{exportStatus}
|
||||
</p>
|
||||
{/if}
|
||||
<CaptureStrip {captures} compositionId={shotStore.compositionId} />
|
||||
{:else}
|
||||
<div class="loading">{t('buildScene.preparing')}</div>
|
||||
{/if}
|
||||
</main>
|
||||
</aside>
|
||||
|
||||
<aside class="chat-panel" class:collapsed={chatCollapsed}>
|
||||
<header class="chat-head">
|
||||
{#if chatCollapsed}
|
||||
<span class="chat-title-vert">{t('buildScene.title')}</span>
|
||||
<main class="center-panel">
|
||||
{#if compositionReady}
|
||||
<div class="viewport-wrap">
|
||||
<ShotViewport bind:this={viewportRef} oncapture={handleCaptureDataUrl} />
|
||||
</div>
|
||||
<TimelineStrip onExportVideo={handleExportVideo} />
|
||||
{#if exportStatus}
|
||||
<p class="export-status" data-export-status>
|
||||
{exportStatus}
|
||||
</p>
|
||||
{/if}
|
||||
<CaptureStrip {captures} compositionId={shotStore.compositionId} />
|
||||
{:else}
|
||||
<span class="chat-title">{activeSession?.title ?? t('buildScene.title')}</span>
|
||||
<span class="badge">{t('buildScene.builderBadge')}</span>
|
||||
<div class="loading">{t('buildScene.preparing')}</div>
|
||||
{/if}
|
||||
<button
|
||||
class="chat-toggle"
|
||||
onclick={toggleChat}
|
||||
title={chatCollapsed ? t('buildScene.expandChat') : t('buildScene.collapseChat')}
|
||||
aria-expanded={!chatCollapsed}
|
||||
>
|
||||
{chatCollapsed ? '‹' : '›'}
|
||||
</button>
|
||||
</header>
|
||||
{#if !chatCollapsed}
|
||||
<AgentChat
|
||||
{messages}
|
||||
{liveTools}
|
||||
{streaming}
|
||||
{streamingReasoning}
|
||||
jobs={[]}
|
||||
{running}
|
||||
loading={sessionId != null && $sessionQuery.isLoading}
|
||||
suggestions={suggestionOptions}
|
||||
onSuggestion={(text) => {
|
||||
composerDraft = text;
|
||||
composerNonce++;
|
||||
}}
|
||||
onAnswer={handleQuestionAnswer}
|
||||
/>
|
||||
<AgentComposer
|
||||
{running}
|
||||
draft={composerDraft}
|
||||
draftNonce={composerNonce}
|
||||
workflows={composerWorkflows}
|
||||
skills={skillOptions}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelRun}
|
||||
/>
|
||||
{/if}
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<aside class="chat-panel" class:collapsed={chatCollapsed}>
|
||||
<header class="chat-head">
|
||||
{#if chatCollapsed}
|
||||
<span class="chat-title-vert">{t('buildScene.title')}</span>
|
||||
{:else}
|
||||
<span class="chat-title">{activeSession?.title ?? t('buildScene.title')}</span>
|
||||
<span class="badge">{t('buildScene.builderBadge')}</span>
|
||||
{/if}
|
||||
<button
|
||||
class="chat-toggle"
|
||||
onclick={toggleChat}
|
||||
title={chatCollapsed ? t('buildScene.expandChat') : t('buildScene.collapseChat')}
|
||||
aria-expanded={!chatCollapsed}
|
||||
>
|
||||
{chatCollapsed ? '‹' : '›'}
|
||||
</button>
|
||||
</header>
|
||||
{#if !chatCollapsed}
|
||||
<AgentChat
|
||||
{messages}
|
||||
{liveTools}
|
||||
{streaming}
|
||||
{streamingReasoning}
|
||||
jobs={[]}
|
||||
{running}
|
||||
loading={sessionId != null && $sessionQuery.isLoading}
|
||||
suggestions={suggestionOptions}
|
||||
onSuggestion={(text) => {
|
||||
composerDraft = text;
|
||||
composerNonce++;
|
||||
}}
|
||||
onAnswer={handleQuestionAnswer}
|
||||
/>
|
||||
<AgentComposer
|
||||
{running}
|
||||
draft={composerDraft}
|
||||
draftNonce={composerNonce}
|
||||
workflows={composerWorkflows}
|
||||
skills={skillOptions}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelRun}
|
||||
/>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workspace {
|
||||
@@ -692,7 +743,7 @@ function toggleChat() {
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.workspace {
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
type Viewport,
|
||||
} from '@xyflow/svelte';
|
||||
import '@xyflow/svelte/dist/style.css';
|
||||
import '$lib/canvas/tokens.css';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AgentChat from '$lib/components/agent/AgentChat.svelte';
|
||||
import AgentComposer from '$lib/components/agent/AgentComposer.svelte';
|
||||
import AgentSessionSidebar from '$lib/components/agent/AgentSessionSidebar.svelte';
|
||||
@@ -96,7 +95,10 @@
|
||||
): { imagePath: string | null; videoPath: string | null } {
|
||||
if (node.entity_type === 'character') {
|
||||
const c = characters.find((x) => x.id === node.entity_id);
|
||||
return { imagePath: c?.sheet_path ?? c?.portrait_path ?? null, videoPath: null };
|
||||
return {
|
||||
imagePath: c?.sheet_path ?? c?.portrait_path ?? null,
|
||||
videoPath: null,
|
||||
};
|
||||
}
|
||||
if (node.entity_type === 'location') {
|
||||
const l = locations.find((x) => x.id === node.entity_id);
|
||||
@@ -137,9 +139,11 @@
|
||||
|
||||
// ---- media preview (lightbox) ------------------------------------------
|
||||
// Card clicks open the large preview here; nothing navigates away.
|
||||
let preview = $state<{ src: string; kind: 'image' | 'video'; caption: string } | null>(
|
||||
null,
|
||||
);
|
||||
let preview = $state<{
|
||||
src: string;
|
||||
kind: 'image' | 'video';
|
||||
caption: string;
|
||||
} | null>(null);
|
||||
|
||||
function openMediaPreview(kind: 'image' | 'video', path: string | null, caption: string) {
|
||||
const src = assetUrl(path);
|
||||
@@ -250,10 +254,7 @@
|
||||
dragInProgress = true;
|
||||
}
|
||||
|
||||
function onNodeDragStop(e: {
|
||||
targetNode: Node | null;
|
||||
nodes?: Node[];
|
||||
}) {
|
||||
function onNodeDragStop(e: { targetNode: Node | null; nodes?: Node[] }) {
|
||||
dragInProgress = false;
|
||||
// Group drags: targetNode is null, `nodes` is the whole selection.
|
||||
// Single drags: nodes[0] is the dragged node. Persist EVERYTHING —
|
||||
@@ -285,24 +286,18 @@
|
||||
(n.data as { status?: string }).status === 'queued',
|
||||
);
|
||||
if (blocked.length > 0) {
|
||||
toast.error(
|
||||
t('canvas.deleteBlocked', { count: blocked.length }),
|
||||
);
|
||||
toast.error(t('canvas.deleteBlocked', { count: blocked.length }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onDeleteCanvas({ nodes }: { nodes: Node[] }) {
|
||||
const ids = nodes
|
||||
.map(nodeServerId)
|
||||
.filter((id): id is number => id != null);
|
||||
const ids = nodes.map(nodeServerId).filter((id): id is number => id != null);
|
||||
if (ids.length === 0) return;
|
||||
let fileDeletedCount = 0;
|
||||
let keptCount = 0;
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => canvasApi.deleteNode(canvasId, id)),
|
||||
);
|
||||
const results = await Promise.allSettled(ids.map((id) => canvasApi.deleteNode(canvasId, id)));
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
if (r.value.file_deleted) fileDeletedCount++;
|
||||
@@ -371,9 +366,7 @@
|
||||
// localStorage first (synchronous, survives navigation + reloads
|
||||
// even if the request never lands), backend per canvas second.
|
||||
storageSet(viewportLocalStorageKey(canvasId), json);
|
||||
void canvasApi
|
||||
.patchCanvas(canvasId, { viewport_json: json })
|
||||
.catch(() => undefined);
|
||||
void canvasApi.patchCanvas(canvasId, { viewport_json: json }).catch(() => undefined);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -418,7 +411,12 @@
|
||||
// is written once per transition, not per token.
|
||||
let thinkingAgent: string | null = null;
|
||||
let liveTools = $state<
|
||||
{ tool: string; args?: Record<string, unknown> | null; result?: unknown; phase: 'running' | 'done' | 'error' }[]
|
||||
{
|
||||
tool: string;
|
||||
args?: Record<string, unknown> | null;
|
||||
result?: unknown;
|
||||
phase: 'running' | 'done' | 'error';
|
||||
}[]
|
||||
>([]);
|
||||
let livePlan = $state<AgentPlan | null>(null);
|
||||
let composerDraft = $state('');
|
||||
@@ -447,7 +445,7 @@
|
||||
})),
|
||||
);
|
||||
const messages = $derived($sessionQuery.data?.messages ?? []);
|
||||
const plan = $derived(livePlan ?? ($sessionQuery.data?.plan ?? null));
|
||||
const plan = $derived(livePlan ?? $sessionQuery.data?.plan ?? null);
|
||||
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
@@ -570,7 +568,11 @@
|
||||
}
|
||||
|
||||
function seedEmptySession(s: AgentSession) {
|
||||
client.setQueryData(['agent-session', s.id], { ...s, messages: [], plan: null });
|
||||
client.setQueryData(['agent-session', s.id], {
|
||||
...s,
|
||||
messages: [],
|
||||
plan: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function newSandbox() {
|
||||
@@ -686,7 +688,9 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const s = await agentApi.createSession(canvas?.project_id ? { project_id: canvas.project_id } : {});
|
||||
const s = await agentApi.createSession(
|
||||
canvas?.project_id ? { project_id: canvas.project_id } : {},
|
||||
);
|
||||
await client.invalidateQueries({ queryKey: ['agent-sessions'] });
|
||||
activeId = s.id;
|
||||
$sendMutation.mutate(payload);
|
||||
@@ -699,7 +703,12 @@
|
||||
* question seq so the backend records a structured approval. */
|
||||
function handleQuestionAnswer(option: string, _scope: string, questionSeq: number) {
|
||||
if (running || activeId == null) return;
|
||||
handleSend({ content: option, mentions: [], attachments: [], answer_to: questionSeq });
|
||||
handleSend({
|
||||
content: option,
|
||||
mentions: [],
|
||||
attachments: [],
|
||||
answer_to: questionSeq,
|
||||
});
|
||||
}
|
||||
|
||||
function upsertSession(s: AgentSession) {
|
||||
@@ -793,9 +802,7 @@
|
||||
...liveTools[real],
|
||||
result: ev.data?.result,
|
||||
phase:
|
||||
(ev.data?.result as { ok?: boolean } | undefined)?.ok === false
|
||||
? 'error'
|
||||
: 'done',
|
||||
(ev.data?.result as { ok?: boolean } | undefined)?.ok === false ? 'error' : 'done',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -814,9 +821,7 @@
|
||||
livePlan = livePlan
|
||||
? {
|
||||
...livePlan,
|
||||
tasks: livePlan.tasks.map((t, i) =>
|
||||
i === index ? { ...t, status } : t,
|
||||
),
|
||||
tasks: livePlan.tasks.map((t, i) => (i === index ? { ...t, status } : t)),
|
||||
}
|
||||
: livePlan;
|
||||
}
|
||||
@@ -936,7 +941,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let railCollapsed = $state(storageGet(RAIL_KEY) === '1');
|
||||
let railCollapsed = $state(storageGet(RAIL_KEY) !== '0');
|
||||
let chatCollapsed = $state(storageGet(CHAT_KEY) === '1');
|
||||
|
||||
function toggleRail() {
|
||||
@@ -955,7 +960,7 @@
|
||||
|
||||
// Stored in px; capped against the live window so a resized window can
|
||||
// never restore an oversize panel (max 30% of window width).
|
||||
let chatWidth = $state(420);
|
||||
let chatWidth = $state(300);
|
||||
let dragging = $state(false);
|
||||
|
||||
function clampChatWidth(px: number): number {
|
||||
@@ -1031,223 +1036,227 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="canvas-root shell">
|
||||
<AppHeader active="canvas" />
|
||||
|
||||
{#if $canvasQuery.isError}
|
||||
<div class="load-error" role="alert">
|
||||
{t('canvas.loadFailed')}
|
||||
{#if $canvasQuery.error instanceof Error}
|
||||
— {$canvasQuery.error.message}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if canvas}
|
||||
<div class="workspace">
|
||||
<AgentSessionSidebar
|
||||
{sessions}
|
||||
{activeId}
|
||||
onSelect={selectSession}
|
||||
onNewSandbox={newSandbox}
|
||||
onNewProjectChat={newProjectChat}
|
||||
onDelete={removeSession}
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
/>
|
||||
|
||||
<main class="flow-panel">
|
||||
<div class="titlecard" data-testid="titlecard">
|
||||
{#if titleEditing}
|
||||
<input
|
||||
class="title-input"
|
||||
bind:value={titleDraft}
|
||||
onblur={saveTitle}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') saveTitle();
|
||||
if (e.key === 'Escape') titleEditing = false;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="title-btn"
|
||||
onclick={() => (titleEditing = true)}
|
||||
title={t('canvas.rename')}
|
||||
>
|
||||
{canvas.title}
|
||||
</button>
|
||||
{/if}
|
||||
{#if savedFlash}
|
||||
<span class="saved">saved</span>
|
||||
<AppShell active="canvas" title="灵感画布"
|
||||
><div class="canvas-root shell">
|
||||
{#if $canvasQuery.isError}
|
||||
<div class="load-error" role="alert">
|
||||
{t('canvas.loadFailed')}
|
||||
{#if $canvasQuery.error instanceof Error}
|
||||
— {$canvasQuery.error.message}
|
||||
{/if}
|
||||
{#if canvas.project}
|
||||
<a class="project-chip" href="/workbench/{canvas.project.id}">
|
||||
<Icon name="folder" size={12} />
|
||||
{canvas.project.title}
|
||||
</a>
|
||||
</div>
|
||||
{:else if canvas}
|
||||
<div class="workspace">
|
||||
<AgentSessionSidebar
|
||||
{sessions}
|
||||
{activeId}
|
||||
onSelect={selectSession}
|
||||
onNewSandbox={newSandbox}
|
||||
onNewProjectChat={newProjectChat}
|
||||
onDelete={removeSession}
|
||||
collapsed={railCollapsed}
|
||||
onToggleCollapse={toggleRail}
|
||||
/>
|
||||
|
||||
<main class="flow-panel">
|
||||
<div class="titlecard" data-testid="titlecard">
|
||||
{#if titleEditing}
|
||||
<input
|
||||
class="title-input"
|
||||
bind:value={titleDraft}
|
||||
onblur={saveTitle}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') saveTitle();
|
||||
if (e.key === 'Escape') titleEditing = false;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="title-btn"
|
||||
onclick={() => (titleEditing = true)}
|
||||
title={t('canvas.rename')}
|
||||
>
|
||||
{canvas.title}
|
||||
</button>
|
||||
{/if}
|
||||
{#if savedFlash}
|
||||
<span class="saved">saved</span>
|
||||
{/if}
|
||||
{#if canvas.project}
|
||||
<a class="project-chip" href="/workbench/{canvas.project.id}">
|
||||
<Icon name="folder" size={12} />
|
||||
{canvas.project.title}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="unlink-btn"
|
||||
onclick={unlinkActiveSession}
|
||||
disabled={!activeSession || running}
|
||||
title={running
|
||||
? t('canvas.waitFinish')
|
||||
: activeSession
|
||||
? t('canvas.unlinkFrom', { title: canvas.project.title })
|
||||
: t('canvas.noChatYet')}
|
||||
aria-label={t('canvas.unlinkFrom', {
|
||||
title: canvas.project.title,
|
||||
})}
|
||||
>
|
||||
<Icon name="link-off" size={12} />
|
||||
{t('canvas.unlinkButton')}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="sandbox-chip">
|
||||
<Icon name="sparkle" size={12} />
|
||||
Sandbox
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SvelteFlow
|
||||
bind:nodes
|
||||
bind:viewport
|
||||
{nodeTypes}
|
||||
colorMode="light"
|
||||
minZoom={0.05}
|
||||
maxZoom={4}
|
||||
{initialViewport}
|
||||
// Never-saved canvas: fit all cards once nodes are measured.
|
||||
// fitView only queues when it's set at store creation, so the
|
||||
// prop is evaluated once here — later changes are ignored (the
|
||||
// store keeps fitViewQueued latched from the first run).
|
||||
fitView={initialViewport === undefined}
|
||||
onnodedragstart={onNodeDragStart}
|
||||
onnodedragstop={onNodeDragStop}
|
||||
onbeforedelete={onBeforeDeleteCanvas}
|
||||
ondelete={onDeleteCanvas}
|
||||
onmoveend={(_event, vp) => scheduleViewportSave(vp)}
|
||||
oninit={assertViewportOnInit}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1} />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<Panel position="bottom-center">
|
||||
<button
|
||||
type="button"
|
||||
class="tidy-btn"
|
||||
disabled={tidying || nodes.length === 0}
|
||||
onclick={tidyCanvas}
|
||||
title={t('canvas.tidyTitle')}
|
||||
>
|
||||
<Icon name="drag" size={14} />
|
||||
{t('canvas.tidy')}
|
||||
</button>
|
||||
</Panel>
|
||||
{#if nodes.length === 0 && !$canvasQuery.isLoading}
|
||||
<Panel position="top-center">
|
||||
<div class="canvas-empty-hint">
|
||||
<strong>{t('canvas.emptyBoard')}</strong>
|
||||
<span>{t('canvas.emptyHint')}</span>
|
||||
</div>
|
||||
</Panel>
|
||||
{/if}
|
||||
</SvelteFlow>
|
||||
</main>
|
||||
|
||||
{#if !chatCollapsed}
|
||||
<button
|
||||
type="button"
|
||||
class="unlink-btn"
|
||||
onclick={unlinkActiveSession}
|
||||
disabled={!activeSession || running}
|
||||
title={running
|
||||
? t('canvas.waitFinish')
|
||||
: activeSession
|
||||
? t('canvas.unlinkFrom', { title: canvas.project.title })
|
||||
: t('canvas.noChatYet')}
|
||||
aria-label={t('canvas.unlinkFrom', { title: canvas.project.title })}
|
||||
>
|
||||
<Icon name="link-off" size={12} />
|
||||
{t('canvas.unlinkButton')}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="sandbox-chip">
|
||||
<Icon name="sparkle" size={12} />
|
||||
Sandbox
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SvelteFlow
|
||||
bind:nodes
|
||||
bind:viewport
|
||||
{nodeTypes}
|
||||
colorMode="dark"
|
||||
minZoom={0.05}
|
||||
maxZoom={4}
|
||||
{initialViewport}
|
||||
// Never-saved canvas: fit all cards once nodes are measured.
|
||||
// fitView only queues when it's set at store creation, so the
|
||||
// prop is evaluated once here — later changes are ignored (the
|
||||
// store keeps fitViewQueued latched from the first run).
|
||||
fitView={initialViewport === undefined}
|
||||
onnodedragstart={onNodeDragStart}
|
||||
onnodedragstop={onNodeDragStop}
|
||||
onbeforedelete={onBeforeDeleteCanvas}
|
||||
ondelete={onDeleteCanvas}
|
||||
onmoveend={(_event, vp) => scheduleViewportSave(vp)}
|
||||
oninit={assertViewportOnInit}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1} />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<Panel position="bottom-center">
|
||||
<button
|
||||
type="button"
|
||||
class="tidy-btn"
|
||||
disabled={tidying || nodes.length === 0}
|
||||
onclick={tidyCanvas}
|
||||
title={t('canvas.tidyTitle')}
|
||||
>
|
||||
<Icon name="drag" size={14} />
|
||||
{t('canvas.tidy')}
|
||||
</button>
|
||||
</Panel>
|
||||
{#if nodes.length === 0 && !$canvasQuery.isLoading}
|
||||
<Panel position="top-center">
|
||||
<div class="canvas-empty-hint">
|
||||
<strong>{t('canvas.emptyBoard')}</strong>
|
||||
<span>{t('canvas.emptyHint')}</span>
|
||||
class="chat-splitter"
|
||||
class:dragging
|
||||
aria-label={t('canvas.resizePanel', {
|
||||
width: Math.round(chatWidth),
|
||||
})}
|
||||
onmousedown={startDrag}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
chatWidth = clampChatWidth(chatWidth + 24);
|
||||
storageSet(CHAT_W_KEY, String(Math.round(chatWidth)));
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
chatWidth = clampChatWidth(chatWidth - 24);
|
||||
storageSet(CHAT_W_KEY, String(Math.round(chatWidth)));
|
||||
}
|
||||
}}
|
||||
></button>
|
||||
<aside class="chat-panel" style:width="{chatWidth}px">
|
||||
<header class="chat-head">
|
||||
<div class="chat-title-block">
|
||||
{#if activeSession}
|
||||
<span class="chat-title">{activeSession.title}</span>
|
||||
{:else}
|
||||
<span class="chat-title muted">{t('canvas.noChatSelected')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Panel>
|
||||
{/if}
|
||||
</SvelteFlow>
|
||||
</main>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-toggle"
|
||||
onclick={toggleChat}
|
||||
title={t('canvas.collapseChat')}
|
||||
aria-label={t('canvas.collapseChat')}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
<AgentChat
|
||||
{messages}
|
||||
{liveTools}
|
||||
{streaming}
|
||||
{streamingReasoning}
|
||||
{jobs}
|
||||
{running}
|
||||
loading={activeId != null && $sessionQuery.isLoading}
|
||||
suggestions={SUGGESTIONS}
|
||||
onSuggestion={setComposerDraft}
|
||||
onAnswer={handleQuestionAnswer}
|
||||
/>
|
||||
{#if running && plan && plan.tasks.length > 0}
|
||||
<AgentPlanPanel {plan} />
|
||||
{/if}
|
||||
{#key activeId}
|
||||
<AgentComposer
|
||||
{running}
|
||||
draft={composerDraft}
|
||||
draftNonce={composerNonce}
|
||||
workflows={enabledWorkflows}
|
||||
skills={skillOptions}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelRun}
|
||||
/>
|
||||
{/key}
|
||||
</aside>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="chat-expand"
|
||||
onclick={toggleChat}
|
||||
title={t('canvas.expandChat')}
|
||||
aria-label={t('canvas.expandChat')}
|
||||
>
|
||||
<Icon name="chevron-left" size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !chatCollapsed}
|
||||
<button
|
||||
type="button"
|
||||
class="chat-splitter"
|
||||
class:dragging
|
||||
aria-label={t('canvas.resizePanel', { width: Math.round(chatWidth) })}
|
||||
onmousedown={startDrag}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
chatWidth = clampChatWidth(chatWidth + 24);
|
||||
storageSet(CHAT_W_KEY, String(Math.round(chatWidth)));
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
chatWidth = clampChatWidth(chatWidth - 24);
|
||||
storageSet(CHAT_W_KEY, String(Math.round(chatWidth)));
|
||||
}
|
||||
}}
|
||||
></button>
|
||||
<aside class="chat-panel" style:width="{chatWidth}px">
|
||||
<header class="chat-head">
|
||||
<div class="chat-title-block">
|
||||
{#if activeSession}
|
||||
<span class="chat-title">{activeSession.title}</span>
|
||||
{:else}
|
||||
<span class="chat-title muted">{t('canvas.noChatSelected')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-toggle"
|
||||
onclick={toggleChat}
|
||||
title={t('canvas.collapseChat')}
|
||||
aria-label={t('canvas.collapseChat')}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
<AgentChat
|
||||
{messages}
|
||||
{liveTools}
|
||||
{streaming}
|
||||
{streamingReasoning}
|
||||
{jobs}
|
||||
{running}
|
||||
loading={activeId != null && $sessionQuery.isLoading}
|
||||
suggestions={SUGGESTIONS}
|
||||
onSuggestion={setComposerDraft}
|
||||
onAnswer={handleQuestionAnswer}
|
||||
/>
|
||||
{#if running && plan && plan.tasks.length > 0}
|
||||
<AgentPlanPanel {plan} />
|
||||
{/if}
|
||||
{#key activeId}
|
||||
<AgentComposer
|
||||
{running}
|
||||
draft={composerDraft}
|
||||
draftNonce={composerNonce}
|
||||
workflows={enabledWorkflows}
|
||||
skills={skillOptions}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelRun}
|
||||
/>
|
||||
{/key}
|
||||
</aside>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="chat-expand"
|
||||
onclick={toggleChat}
|
||||
title={t('canvas.expandChat')}
|
||||
aria-label={t('canvas.expandChat')}
|
||||
>
|
||||
<Icon name="chevron-left" size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<ImageLightbox
|
||||
src={preview.src}
|
||||
kind={preview.kind}
|
||||
alt={preview.caption}
|
||||
caption={preview.caption}
|
||||
onClose={() => (preview = null)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if preview}
|
||||
<ImageLightbox
|
||||
src={preview.src}
|
||||
kind={preview.kind}
|
||||
alt={preview.caption}
|
||||
caption={preview.caption}
|
||||
onClose={() => (preview = null)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workspace {
|
||||
@@ -1480,7 +1489,7 @@ title={running
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.workspace {
|
||||
@@ -1499,4 +1508,34 @@ title={running
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-panel {
|
||||
background: #eae9e3;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 17px;
|
||||
margin: 20px 0 20px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workspace {
|
||||
background: var(--bg);
|
||||
}
|
||||
.titlecard {
|
||||
background: var(--surface);
|
||||
border-color: var(--line);
|
||||
box-shadow: 0 4px 12px #292a2d0a;
|
||||
}
|
||||
.chat-panel {
|
||||
margin: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
:global(.svelte-flow) {
|
||||
--xy-background-color-default: #eae9e3;
|
||||
--xy-background-pattern-color-default: #c8c9bc;
|
||||
--xy-edge-stroke-default: #a7b0e4;
|
||||
}
|
||||
.canvas-empty-hint {
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
|
||||
const kind = $derived(
|
||||
page.url.searchParams.get('kind') === 'video' ? 'video' : 'image',
|
||||
);
|
||||
</script>
|
||||
|
||||
{#key kind + page.url.search}<StudioDesk {kind} />{/key}
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
onMount(() => {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('kind', 'image');
|
||||
void goto('/create' + url.search + url.hash, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<StudioDesk kind="image" />
|
||||
<p>正在打开创作台…</p>
|
||||
|
||||
@@ -1,453 +1,275 @@
|
||||
<script lang="ts">
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { libraryApi, type LibraryMediaItem } from '$lib/api';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AttachToProject from '$lib/components/AttachToProject.svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { libraryApi, type LibraryMediaItem } from "$lib/api";
|
||||
import { assetUrl } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
|
||||
import AttachToProject from "$lib/components/AttachToProject.svelte";
|
||||
import ImageLightbox from "$lib/components/ImageLightbox.svelte";
|
||||
import SafeMedia from "$lib/components/SafeMedia.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import { toast } from "$lib/toast";
|
||||
|
||||
const client = useQueryClient();
|
||||
const client = useQueryClient();
|
||||
|
||||
const mediaQuery = createQuery({
|
||||
queryKey: ['library-media'],
|
||||
queryFn: libraryApi.list,
|
||||
});
|
||||
const mediaQuery = createQuery({
|
||||
queryKey: ["library-media"],
|
||||
queryFn: libraryApi.list,
|
||||
});
|
||||
|
||||
const items = $derived($mediaQuery.data ?? []);
|
||||
const items = $derived($mediaQuery.data ?? []);
|
||||
|
||||
// ---- filter + lazy paging ------------------------------------------------
|
||||
// The listing endpoint returns the full (small) file index; filtering and
|
||||
// paging are client-side. Media heavy <video> cards make a 50-per-page cap
|
||||
// necessary even so.
|
||||
// ---- filter + lazy paging ------------------------------------------------
|
||||
// The listing endpoint returns the full (small) file index; filtering and
|
||||
// paging are client-side. Media heavy <video> cards make a 50-per-page cap
|
||||
// necessary even so.
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
let filter = $state<'all' | 'image' | 'video'>('all');
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let loadingMore = $state(false);
|
||||
let sentinel = $state<HTMLElement | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
let filter = $state<"all" | "image" | "video">("all");
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let loadingMore = $state(false);
|
||||
let sentinel = $state<HTMLElement | null>(null);
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'all' ? items : items.filter((i) => i.kind === filter),
|
||||
);
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMore = $derived(filtered.length > visible.length);
|
||||
const remaining = $derived(filtered.length - visible.length);
|
||||
const filtered = $derived(
|
||||
filter === "all" ? items : items.filter((i) => i.kind === filter),
|
||||
);
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMore = $derived(filtered.length > visible.length);
|
||||
const remaining = $derived(filtered.length - visible.length);
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || loadingMore) return;
|
||||
loadingMore = true;
|
||||
// Yield a frame so the loading row paints before the (sync) append.
|
||||
setTimeout(() => {
|
||||
visibleCount += PAGE_SIZE;
|
||||
loadingMore = false;
|
||||
}, 120);
|
||||
}
|
||||
function loadMore() {
|
||||
if (!hasMore || loadingMore) return;
|
||||
loadingMore = true;
|
||||
// Yield a frame so the loading row paints before the (sync) append.
|
||||
setTimeout(() => {
|
||||
visibleCount += PAGE_SIZE;
|
||||
loadingMore = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Auto-append the next page when the sentinel scrolls near the viewport.
|
||||
if (!sentinel || !hasMore) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMore();
|
||||
},
|
||||
{ rootMargin: '400px' },
|
||||
);
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
$effect(() => {
|
||||
// Auto-append the next page when the sentinel scrolls near the viewport.
|
||||
if (!sentinel || !hasMore) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// New filter → back to the first page.
|
||||
void filter;
|
||||
visibleCount = PAGE_SIZE;
|
||||
});
|
||||
$effect(() => {
|
||||
// New filter → back to the first page.
|
||||
void filter;
|
||||
visibleCount = PAGE_SIZE;
|
||||
});
|
||||
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let preview = $state<{ src: string; kind: 'image' | 'video'; name: string } | null>(null);
|
||||
let attachItem = $state<LibraryMediaItem | null>(null);
|
||||
let confirmingDelete = $state(false);
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let preview = $state<{
|
||||
src: string;
|
||||
kind: "image" | "video";
|
||||
name: string;
|
||||
} | null>(null);
|
||||
let attachItem = $state<LibraryMediaItem | null>(null);
|
||||
let confirmingDelete = $state(false);
|
||||
|
||||
const selectedCount = $derived(selected.size);
|
||||
const allSelected = $derived(
|
||||
filtered.length > 0 && filtered.every((i) => selected.has(i.path)),
|
||||
);
|
||||
const selectedCount = $derived(selected.size);
|
||||
const allSelected = $derived(
|
||||
filtered.length > 0 && filtered.every((i) => selected.has(i.path)),
|
||||
);
|
||||
|
||||
function toggle(path: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
selected = next;
|
||||
}
|
||||
function toggle(path: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
selected = allSelected ? new Set() : new Set(filtered.map((i) => i.path));
|
||||
}
|
||||
function toggleAll() {
|
||||
selected = allSelected ? new Set() : new Set(filtered.map((i) => i.path));
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: (paths: string[]) => libraryApi.deleteMany(paths),
|
||||
onSuccess: (res) => {
|
||||
selected = new Set();
|
||||
confirmingDelete = false;
|
||||
void client.invalidateQueries({ queryKey: ['library-media'] });
|
||||
const kept = res.results.filter((r) => r.status === 'kept');
|
||||
if (kept.length > 0) {
|
||||
toast.error(t('library.deletedPartial', { kept: kept.length }));
|
||||
} else {
|
||||
toast.success(t('library.deleted', { count: res.deleted.length }));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
confirmingDelete = false;
|
||||
toast.error(err instanceof Error ? err.message : t('library.deleteFailed'));
|
||||
},
|
||||
});
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: (paths: string[]) => libraryApi.deleteMany(paths),
|
||||
onSuccess: (res) => {
|
||||
selected = new Set();
|
||||
confirmingDelete = false;
|
||||
void client.invalidateQueries({ queryKey: ["library-media"] });
|
||||
const kept = res.results.filter((r) => r.status === "kept");
|
||||
if (kept.length > 0) {
|
||||
toast.error(t("library.deletedPartial", { kept: kept.length }));
|
||||
} else {
|
||||
toast.success(t("library.deleted", { count: res.deleted.length }));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
confirmingDelete = false;
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("library.deleteFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteSelected() {
|
||||
if (selected.size === 0) return;
|
||||
$deleteMutation.mutate([...selected]);
|
||||
}
|
||||
function deleteSelected() {
|
||||
if (selected.size === 0) return;
|
||||
$deleteMutation.mutate([...selected]);
|
||||
}
|
||||
|
||||
function openPreview(item: LibraryMediaItem) {
|
||||
const src = assetUrl(item.path);
|
||||
if (!src) return;
|
||||
preview = { src, kind: item.kind, name: item.name };
|
||||
}
|
||||
function openPreview(item: LibraryMediaItem) {
|
||||
const src = assetUrl(item.path);
|
||||
if (!src) return;
|
||||
preview = { src, kind: item.kind, name: item.name };
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="library" title={t('nav.library')} subtitle={t('library.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip" data-testid="library-count">
|
||||
{t('library.itemCount', { count: filtered.length })}
|
||||
</span>
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
|
||||
<Icon name="close" size={14} />
|
||||
{t('library.deleteSelected', { count: selectedCount })}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="library">
|
||||
<div class="toolbar">
|
||||
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'all'}
|
||||
aria-pressed={filter === 'all'}
|
||||
onclick={() => (filter = 'all')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterAll')}</span>
|
||||
<span class="chip-count">{items.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'image'}
|
||||
aria-pressed={filter === 'image'}
|
||||
onclick={() => (filter = 'image')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterImage')}</span>
|
||||
<span class="chip-count">{items.filter((i) => i.kind === 'image').length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'video'}
|
||||
aria-pressed={filter === 'video'}
|
||||
onclick={() => (filter = 'video')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterVideo')}</span>
|
||||
<span class="chip-count">{items.filter((i) => i.kind === 'video').length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="select-all">
|
||||
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
|
||||
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if $mediaQuery.isLoading}
|
||||
<div class="grid" aria-busy="true">
|
||||
{#each Array(8) as _, i (i)}
|
||||
<div class="card skeleton-card"><Skeleton height="220px" /></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $mediaQuery.isError}
|
||||
<EmptyState title={t('library.loadError')}>
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" onclick={() => $mediaQuery.refetch()}>
|
||||
<Icon name="retry" size={14} />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filtered.length === 0}
|
||||
<EmptyState
|
||||
title={filter === 'all'
|
||||
? t('library.emptyTitle')
|
||||
: t('library.emptyFilterTitle', {
|
||||
kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo')
|
||||
})}
|
||||
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visible as item (item.path)}
|
||||
<div
|
||||
class="card"
|
||||
class:selected={selected.has(item.path)}
|
||||
class:a-frame={selected.has(item.path)}
|
||||
style:--frame-inset="3px"
|
||||
style:--frame-arm="16px"
|
||||
>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.path)}
|
||||
onchange={() => toggle(item.path)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="media a-photo"
|
||||
onclick={() => openPreview(item)}
|
||||
title={item.name}
|
||||
>
|
||||
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
|
||||
</button>
|
||||
<div class="meta">
|
||||
<span class="name" title={item.name}>{item.name}</span>
|
||||
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<Button variant="secondary" onclick={() => (attachItem = item)}>
|
||||
{t('library.addToProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
|
||||
<div class="more-row">
|
||||
{#if loadingMore}
|
||||
<span class="loading-note">{t('library.loadingMore')}</span>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={loadMore}>
|
||||
{t('library.loadMore', { count: remaining })}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
<ManagementPage active="library" title="素材库" scene="gold">
|
||||
{#snippet tools()}<div class="mg-seg asset-tabs" aria-label="素材类型">
|
||||
{#each [["all", "全部"], ["image", "图片"], ["video", "视频"]] as [value, label]}<button
|
||||
class:on={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as typeof filter)}>{label}</button
|
||||
>{/each}<button disabled title="当前素材接口不提供音频索引">音频</button
|
||||
><button disabled title="当前素材接口不提供项目归属信息">未归属</button>
|
||||
</div>{/snippet}
|
||||
{#snippet actions()}
|
||||
<span class="mg-dim">{filtered.length} 项素材</span><label class="mg-dim"
|
||||
><input type="checkbox" checked={allSelected} onchange={toggleAll} />
|
||||
{allSelected ? t("library.deselectAll") : t("library.selectAll")}</label
|
||||
>
|
||||
<span class="mg-tag" title="当前素材索引没有项目归属信息">项目:全部</span
|
||||
>{#if selectedCount}<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => (confirmingDelete = true)}
|
||||
>删除已选 {selectedCount} 项</Button
|
||||
>{/if}{/snippet}
|
||||
|
||||
{#if $mediaQuery.isLoading}<p class="mg-empty">
|
||||
正在读取素材…
|
||||
</p>{:else if $mediaQuery.isError}<div class="mg-empty" role="alert">
|
||||
素材加载失败<Button onclick={() => $mediaQuery.refetch()}>重试</Button>
|
||||
</div>{:else if !visible.length}<p class="mg-empty">
|
||||
还没有素材。创作台生成的作品会在这里保留。
|
||||
</p>{:else}
|
||||
<div class="mg-media">
|
||||
{#each visible as item (item.path)}<article class="asset-card">
|
||||
<button
|
||||
class="asset-preview"
|
||||
onclick={() => openPreview(item)}
|
||||
aria-label={"预览 " + item.name}
|
||||
><SafeMedia
|
||||
src={assetUrl(item.path)}
|
||||
kind={item.kind}
|
||||
alt={item.name}
|
||||
controls={false}
|
||||
/></button
|
||||
>
|
||||
<div class="asset-meta">
|
||||
<label class="mg-name"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.path)}
|
||||
onchange={() => toggle(item.path)}
|
||||
/><b title={item.name}>{item.name}</b><small
|
||||
>{item.kind} · {fmtSize(item.size)}</small
|
||||
></label
|
||||
><Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (attachItem = item)}>存入项目</Button
|
||||
>
|
||||
</div>
|
||||
</article>{/each}
|
||||
{#if hasMore}<div bind:this={sentinel}>
|
||||
<Button loading={loadingMore} onclick={loadMore}
|
||||
>再显示 {Math.min(50, remaining)} 项</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>{/if}
|
||||
</ManagementPage>
|
||||
{#if preview}
|
||||
<ImageLightbox
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
kind={preview.kind}
|
||||
caption={preview.name}
|
||||
onClose={() => (preview = null)}
|
||||
/>
|
||||
<ImageLightbox
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
kind={preview.kind}
|
||||
caption={preview.name}
|
||||
onClose={() => (preview = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
open={attachItem !== null}
|
||||
title={t('library.addToProject')}
|
||||
onclose={() => (attachItem = null)}
|
||||
open={attachItem !== null}
|
||||
title={t("library.addToProject")}
|
||||
onclose={() => (attachItem = null)}
|
||||
>
|
||||
{#if attachItem}
|
||||
<p class="modal-file">{attachItem.name}</p>
|
||||
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
|
||||
{/if}
|
||||
{#if attachItem}
|
||||
<p class="modal-file">{attachItem.name}</p>
|
||||
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmingDelete}
|
||||
title={t('library.deleteSelected', { count: selectedCount })}
|
||||
message={t('library.confirmDelete', { count: selectedCount })}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger={true}
|
||||
onconfirm={deleteSelected}
|
||||
oncancel={() => (confirmingDelete = false)}
|
||||
open={confirmingDelete}
|
||||
title={t("library.deleteSelected", { count: selectedCount })}
|
||||
message={t("library.confirmDelete", { count: selectedCount })}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
danger={true}
|
||||
onconfirm={deleteSelected}
|
||||
oncancel={() => (confirmingDelete = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.library {
|
||||
padding: 20px 28px 40px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-6);
|
||||
margin-bottom: var(--sp-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip-count {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-body-sm);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-all input {
|
||||
accent-color: var(--accent-lo);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.card.selected {
|
||||
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
|
||||
}
|
||||
.card :global(.media-el) {
|
||||
object-fit: cover;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 240px;
|
||||
}
|
||||
.check {
|
||||
position: absolute;
|
||||
top: var(--sp-3);
|
||||
left: var(--sp-3);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--media-scrim);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent-lo);
|
||||
cursor: pointer;
|
||||
}
|
||||
.media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--media-scrim);
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 相纸白边 (HEL-680):素材墙里的作品按相纸呈现 */
|
||||
.media.a-photo {
|
||||
border: var(--paper-w, 4px) solid var(--accent);
|
||||
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
|
||||
background: var(--accent);
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.media :global(img),
|
||||
.media :global(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.card-actions {
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5);
|
||||
}
|
||||
.load-more {
|
||||
height: 1px;
|
||||
}
|
||||
.more-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-6) 0 var(--sp-2);
|
||||
}
|
||||
.loading-note {
|
||||
font-size: var(--fs-body-sm);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.modal-file {
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.library {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-8);
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
.asset-tabs {
|
||||
width: 300px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.asset-card {
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.asset-preview {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: var(--media-bg);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
.asset-preview :global(img),
|
||||
.asset-preview :global(video) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.asset-meta {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.asset-meta label {
|
||||
flex: 1;
|
||||
}
|
||||
.asset-meta input {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
.modal-file {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
// Both existing management pages stay usable until B2 combines their tabs.
|
||||
export function load({ url }: { url: URL }) {
|
||||
const destination =
|
||||
url.searchParams.get("tab") === "llm" ? "/models" : "/connections";
|
||||
redirect(307, destination + url.search + new URL(url.href).hash);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { agentApi, canvasApi } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
let failed = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const sessions = await agentApi.listSessions();
|
||||
const sandbox = sessions.find((s) => s.project_id == null);
|
||||
const s = sandbox ?? (await agentApi.createSession({}));
|
||||
const graph = await canvasApi.ensureForSession(s.id);
|
||||
goto(`/canvas/${graph.canvas.id}?session=${s.id}`, { replaceState: true });
|
||||
} catch (err) {
|
||||
failed = err instanceof Error ? err.message : t('playground.openFailed');
|
||||
}
|
||||
import { page } from '$app/state';
|
||||
onMount(() => {
|
||||
void goto('/create' + page.url.search + page.url.hash, {
|
||||
replaceState: true,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if failed}
|
||||
<div class="redirect-error" role="alert">{failed}</div>
|
||||
{:else}
|
||||
<div class="redirecting">{t('playground.opening')}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.redirecting,
|
||||
.redirect-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.redirect-error {
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
<p>正在打开创作台…</p>
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user