Compare commits

..
Author SHA1 Message Date
总工 2f267a751f HEL-690: deploy S5 project flowback (feat/hel-684-project-attach @ 0841f5a) 2026-10-11 15:34:07 +08:00
施工员andmultica-agent 0841f5a296 feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 15:05:57 +08:00
总工 35aad34c7c HEL-689: deploy S4 three-band desk console (feat/hel-683-desk-console @ bef28fa) 2026-10-11 13:52:49 +08:00
施工员andmultica-agent bef28facee feat(desk): 三段式图片/视频生成台——任务模式栏+工作流挑选台+摘要带 (HEL-683 S4)
- 新增 components/desk/:StudioDesk 取代 StudioGenerate 成为 /image、/video
  共用页面;TaskModeRail(真实计数、未覆盖置灰、收藏/最近)、WorkflowBay
  (搜索、本机/云端分段、模型家族分组、待确认入口、增量渲染)、
  WorkflowPreview(输入要求预览)、LibraryRow/LibraryFilters/
  InputRequirementBadge
- 挑选时中栏 grid 列宽展开、右侧显示预览;选定后收成摘要带;
  data-reduce 直接跳变;≤1179px 挑选台为全屏浮层,≤760px 任务模式横条
- 挑选侧全部走 view=meta 轻量元数据,完整记录按 id 惰性加载;
  云端来源诚实标注未接入;?wf/?mode 深链;五阶段上下文与返回路径不变
- Composer 工作流芯片改为展开挑选台,删除 WorkflowPicker;
  i18n 29 键 ×7 语言;changelog
- e2e/shoot.mjs 适配挑选台键盘链,并修正 7 处 S1 灰阶化后失效的
  旧色值/文案断言(定影 + theme-1 灰阶信号色)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 13:19:30 +08:00
总工 316fba97e1 HEL-688: deploy S4 workflow import wizard & meta admin (feat/hel-682-workflow-import-admin @ 1c00587) 2026-10-11 12:35:44 +08:00
33 changed files with 3354 additions and 424 deletions
@@ -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,
}
@@ -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()
+26
View File
@@ -696,6 +696,15 @@ export const playgroundApi = {
endpoint_id?: string | null;
/** Origin recorded on the job payload (tasks page labels). */
source?: "playground" | "studio-image" | "studio-video";
/** Optional real workbench source (HEL-684) — validated server-side and
* stamped on the job so finished takes can flow back to their row. */
context?: {
project_id: number;
stage: "assets" | "shot";
source_kind: "asset" | "shot";
source_id: string;
label?: string;
};
}) =>
api<{ ok: boolean; job: Job }>("/api/playground/generate", {
method: "POST",
@@ -713,7 +722,24 @@ export const playgroundApi = {
job_id: number;
deleted_files: string[];
missing_files: string[];
removed_workbench_links?: number[];
}>(`/api/playground/jobs/${jobId}`, { method: "DELETE" }),
/** Flow a finished take back to the workbench row it came from (HEL-684).
* The server derives the target from the job's own recorded context —
* idempotent, never overwrites, refuses stale source rows. */
attachWorkbench: (jobId: number) =>
api<{
ok: boolean;
already_attached: boolean;
job_id: number;
attached_job_id: number;
project_id: number;
stage: "assets" | "shot";
target_id: string;
}>("/api/playground/attach-workbench", {
method: "POST",
body: JSON.stringify({ job_id: jobId }),
}),
attach: (payload: {
path: string;
project_id: number;
+7
View File
@@ -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 {
@@ -0,0 +1,79 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMediaIO } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
/**
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
*/
interface Props {
io: WorkflowMediaIO | null | undefined;
/** Compact form for row summaries (no out side when empty-in). */
compact?: boolean;
}
let { io, compact = false }: Props = $props();
type Medium = 'image' | 'video' | 'audio';
const LABELS: Record<Medium, string> = {
image: 'wl.mediaImage',
video: 'wl.mediaVideo',
audio: 'wl.mediaAudio',
};
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
</script>
{#if inList.length || outList.length}
<span class="req" aria-label={t('desk.bayReqAria')}>
{#each inList as m (m)}
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
{#each outList as m (m)}
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
</span>
{:else if !compact}
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
{/if}
<style>
.req {
display: inline-flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 3px;
height: 18px;
padding: 0 5px;
border-radius: var(--r-sm);
background: var(--bg-4);
color: var(--text-2);
font-size: var(--fs-mini);
white-space: nowrap;
}
.pill.in {
color: var(--text);
}
.pill.none .lbl {
color: var(--text-3);
}
.arrow {
color: var(--text-3);
font-size: var(--fs-mini);
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
/**
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
*/
interface Props {
search: string;
source: 'local' | 'cloud';
favOnly: boolean;
resultHint: string;
searchEl?: HTMLInputElement | null;
onSetSearch: (v: string) => void;
onSetSource: (s: 'local' | 'cloud') => void;
onToggleFav: () => void;
}
let {
search,
source,
favOnly,
resultHint,
searchEl = $bindable(null),
onSetSearch,
onSetSource,
onToggleFav,
}: Props = $props();
</script>
<div class="filters">
<div class="search">
<Icon name="search" size={14} />
<input
bind:this={searchEl}
class="search-input"
type="search"
value={search}
placeholder={t('desk.baySearchPh')}
aria-label={t('desk.baySearchPh')}
aria-keyshortcuts="/"
oninput={(e) => onSetSearch(e.currentTarget.value)}
/>
</div>
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
{t('desk.baySourceLocal')}
</button>
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
{t('desk.baySourceCloud')}
</button>
</div>
<button
type="button"
class="a-chip fav"
class:on={favOnly}
aria-pressed={favOnly}
title={t('desk.bayFavOnly')}
onclick={onToggleFav}
>
<Icon name="star" size={13} />
<span class="lbl">{t('desk.bayFavOnly')}</span>
</button>
<span class="hint">{resultHint}</span>
</div>
<style>
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 1 1 180px;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.search:focus-within {
border-color: var(--accent-line);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
.source {
flex-shrink: 0;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.hint {
margin-left: auto;
color: var(--text-3);
font-size: var(--fs-mini);
white-space: nowrap;
}
@media (max-width: 760px) {
.hint {
display: none;
}
}
</style>
@@ -0,0 +1,170 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
*/
interface Props {
meta: WorkflowMeta;
current: boolean;
active: boolean;
endpoints: Array<{ id: string; name: string }>;
onPick: () => void;
onActive: () => void;
}
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
const endpointName = $derived(
meta.endpoint_id
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
: t('desk.wfDefaultConn'),
);
</script>
<button
type="button"
class="row"
class:active
class:current
role="option"
aria-selected={active ? 'true' : undefined}
aria-current={current ? 'true' : undefined}
onclick={onPick}
onpointermove={onActive}
onfocus={onActive}
>
<span class="main">
<span class="name">
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
<span class="name-txt">{meta.name}</span>
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
</span>
<span class="sub">
<span class="a-tag">{usageLabel(meta.usage!)}</span>
<InputRequirementBadge io={meta.media_io} compact />
</span>
</span>
<span class="side">
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
</span>
</button>
<style>
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
box-sizing: border-box;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
/* 长列表性能:视口外的行不参与布局/绘制。 */
content-visibility: auto;
contain-intrinsic-size: auto 52px;
transition: background-color var(--d-state) ease;
}
.row:hover,
.row.active {
background: var(--bg-3);
}
.row:focus-visible {
outline: none;
background: var(--bg-3);
box-shadow: 0 0 0 1px var(--accent-line);
}
.main {
display: grid;
gap: 3px;
flex: 1;
min-width: 0;
}
.name {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.fav {
display: inline-flex;
flex-shrink: 0;
color: var(--accent);
}
.cur-mark {
flex-shrink: 0;
color: var(--text-3);
font-size: var(--fs-mini);
}
.sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-1);
}
.side {
flex-shrink: 0;
}
.conn {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-3);
font-size: var(--fs-mini);
}
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
.row.current {
position: relative;
}
.row.current::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
</style>
@@ -2,7 +2,7 @@
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
@@ -13,13 +13,14 @@
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
import { readDeskContext } from '$lib/components/generate/context';
import { readDeskContext, workbenchContextOf } from '$lib/components/generate/context';
import {
changedParams,
classifySchema,
listPaths,
missingMedia,
schemaDefaults,
usageLabel,
validateForm,
type BlockReason,
type DeskKind,
@@ -27,8 +28,18 @@
type FormValues,
} from '$lib/components/generate/schema';
import { matchesFilter, type FeedFilter } from '$lib/components/generate/takes';
import InputRequirementBadge from './InputRequirementBadge.svelte';
import TaskModeRail from './TaskModeRail.svelte';
import WorkflowBay from './WorkflowBay.svelte';
import WorkflowPreview from './WorkflowPreview.svelte';
import { apiErrorMessage, assetUrl, comfy, jobsApi, playgroundApi, projects, settings, workflows } from '$lib/api';
import { USAGE_BY_KIND, type Job, type Workflow } from '$lib/comfy/types';
import {
USAGE_BY_KIND,
type Job,
type Workflow,
type WorkflowMeta,
type WorkflowUsage,
} from '$lib/comfy/types';
import { compactInputValues } from '$lib/comfy/promptInput';
import { connectEvents } from '$lib/events';
import { handleJobEvent } from '$lib/jobProgress';
@@ -38,11 +49,11 @@
import { t } from '$lib/i18n.svelte';
/**
* 生成台 (spec-v2 §3.3, HEL-650 C 批): one desk for images and video — the
* take feed with the composer below and the inspector on the right.
* `/image` and `/video` only preselect: the default workflow comes from
* that kind's usages and the feed opens filtered to it. Slots and params
* follow the selected workflow's JSON (see generate/schema.ts).
* 三段式生成台 (HEL-683, HEL-674 §二 / HEL-679 融合样稿): 左栏任务
* 模式、中部工作流挑选台(挑选时展开,选定后收成摘要带)、右侧沿用
* 参数与结果组件。`/image` 与 `/video` 共用本台,`kind` 决定任务模式,
* `?wf=<id>` 深链指定工作流、`?mode=<usage>` 预选任务模式。项目五阶段
* 的上下文条与返回路径保持不变(回流属 S5,不在本卡)。
*/
interface Props {
kind: DeskKind;
@@ -56,7 +67,10 @@
// Data
// ------------------------------------------------------------------
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
// HEL-683: the desk browses S2's lightweight metadata (view=meta, no
// workflow_json). Full records are fetched per id on demand — the whole
// list (~7.6MB at 150+ rows) never loads here.
const metaQuery = createQuery({ queryKey: ['workflows-meta'], queryFn: workflows.listMeta });
const jobsQuery = createQuery({
queryKey: ['playground-jobs'],
queryFn: playgroundApi.jobs,
@@ -77,13 +91,75 @@
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
const dryRun = $derived($settingsQuery.data?.dry_run === true);
// HEL-636/638: only confirmed usages with a settled output mapping.
const deskWorkflows = $derived(
($workflowsQuery.data ?? []).filter((w) => w.is_enabled && (w.usage ?? null) !== null && !w.needs_mapping),
// Picker-side metadata: runnable rows of this kind + honest pending count.
const allMetas = $derived($metaQuery.data ?? []);
const metaById = $derived(new Map(allMetas.map((m) => [m.id, m])));
const deskMetas = $derived(
allMetas.filter((m) => m.is_enabled && m.usage !== null && !m.needs_mapping && m.kind === kind),
);
const workflowById = $derived(new Map(($workflowsQuery.data ?? []).map((w) => [w.id, w])));
const workflowOf = (job: Job): Workflow | undefined =>
job.workflow_id != null ? workflowById.get(job.workflow_id) : undefined;
const pendingCount = $derived(allMetas.filter((m) => m.needs_mapping && m.kind === kind).length);
// Full records, lazily fetched per id (selected workflow, feed jobs'
// workflows, the bay's preview row) and cached until invalidated.
const details = new SvelteMap<number, Workflow>();
const inflight = new Set<number>();
async function ensureDetail(id: number): Promise<Workflow | null> {
const hit = details.get(id);
if (hit) return hit;
if (inflight.has(id)) return null;
inflight.add(id);
try {
const wf = await workflows.get(id);
details.set(id, wf);
return wf;
} catch {
return null;
} finally {
inflight.delete(id);
}
}
const neededIds = $derived.by(() => {
const ids = new Set<number>();
if (workflowId != null) ids.add(workflowId);
if (bayOpen && bayActiveId != null) ids.add(bayActiveId);
for (const j of allJobs) if (j.workflow_id != null) ids.add(j.workflow_id);
return ids;
});
$effect(() => {
for (const id of neededIds) {
if (!details.has(id)) void ensureDetail(id);
}
});
/**
* A job's workflow: the full record when loaded, else a metadata stub
* (name/kind/usage render instantly; input-dependent views stay empty
* until the record lands). Undefined only when the row is truly gone —
* the 工作流已删除 tag stays honest.
*/
function stubFromMeta(m: WorkflowMeta): Workflow {
return {
id: m.id,
name: m.name,
kind: m.kind,
usage: m.usage,
is_enabled: m.is_enabled,
input_schema: [],
workflow_json: {},
description: m.description,
prompt_profile: m.prompt_profile,
} as unknown as Workflow;
}
const workflowOf = (job: Job): Workflow | undefined => {
if (job.workflow_id == null) return undefined;
const full = details.get(job.workflow_id);
if (full) return full;
const m = metaById.get(job.workflow_id);
return m ? stubFromMeta(m) : undefined;
};
const allJobs = $derived($jobsQuery.data ?? []);
@@ -136,6 +212,61 @@
return off;
});
// ------------------------------------------------------------------
// Bay (挑选台) state — mode, deep links, open/collapse
// ------------------------------------------------------------------
type TaskMode = WorkflowUsage | 'all';
let mode = $state<TaskMode>(untrack(initMode));
function initMode(): TaskMode {
const q = page.url.searchParams.get('mode');
return q && USAGE_BY_KIND[kind].includes(q as WorkflowUsage) ? (q as WorkflowUsage) : 'all';
}
let bayOpen = $state(false);
let bayActiveId = $state<number | null>(null);
let bay = $state<WorkflowBay | null>(null);
const modeMetas = $derived(mode === 'all' ? deskMetas : deskMetas.filter((m) => m.usage === mode));
function openBay() {
bayActiveId = workflowId;
bayOpen = true;
}
function closeBay() {
bayOpen = false;
}
function pickWorkflow(id: number) {
bayOpen = false;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
// ?wf=<id> deep link: preselect once the metadata is in (invalid → ignored).
let wfDeepLinkDone = false;
$effect(() => {
if (wfDeepLinkDone) return;
const raw = Number(page.url.searchParams.get('wf'));
if (!Number.isInteger(raw) || raw <= 0) {
wfDeepLinkDone = true;
return;
}
if ($metaQuery.isLoading) return;
wfDeepLinkDone = true;
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
});
async function toggleFavorite(id: number, fav: boolean) {
try {
await workflows.update(id, { favorite: fav });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
await client.invalidateQueries({ queryKey: ['workflows'] });
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
// ------------------------------------------------------------------
// Form state
// ------------------------------------------------------------------
@@ -150,7 +281,7 @@
let promptEl = $state<HTMLTextAreaElement | null>(null);
let pickerFor = $state<string | null>(null);
const currentWorkflow = $derived(deskWorkflows.find((w) => w.id === workflowId) ?? null);
const currentWorkflow = $derived(workflowId != null ? (details.get(workflowId) ?? null) : null);
const schema = $derived(currentWorkflow?.input_schema ?? []);
const ds = $derived(classifySchema(schema, currentWorkflow?.kind ?? kind));
@@ -161,16 +292,13 @@
endpointId = currentWorkflow?.endpoint_id ?? activeId ?? '';
});
// Default workflow: this entry's usages in priority order, runnable ones first.
// Default workflow: this entry's usages in priority order (metadata-side;
// error-level warnings surface through the block reason once loaded).
$effect(() => {
if (workflowId != null && deskWorkflows.some((w) => w.id === workflowId)) return;
if (workflowId != null && (details.has(workflowId) || deskMetas.some((m) => m.id === workflowId))) return;
const usages = USAGE_BY_KIND[kind];
const runnable = deskWorkflows.filter((w) => !(w.warnings ?? []).some((x) => x.level === 'error'));
const pick =
usages.map((u) => runnable.find((w) => w.usage === u)).find(Boolean) ??
deskWorkflows.find((w) => usages.includes(w.usage!)) ??
runnable[0] ??
deskWorkflows[0];
const candidates = deskMetas.filter((m) => m.usage != null && usages.includes(m.usage));
const pick = usages.map((u) => candidates.find((m) => m.usage === u)).find(Boolean) ?? candidates[0] ?? null;
workflowId = pick?.id ?? null;
});
@@ -328,6 +456,10 @@
openParams();
return;
}
if (b.target === 'desk-wf') {
openBay();
return;
}
await tick();
const el = b.target ? document.getElementById(b.target) : null;
if (el && !el.getClientRects().length) {
@@ -344,6 +476,11 @@
// ------------------------------------------------------------------
let sentTimer: ReturnType<typeof setTimeout> | null = null;
// HEL-684: the workbench source is sent along only while the context
// bar is up (dismissing it = independent generation, nothing written
// to any project) and only when the full real source identity is there.
const wbSendContext = $derived(ctx && !ctxDismissed ? workbenchContextOf(ctx) : undefined);
async function submit() {
if (submitting || slotBusy) return;
if (block) {
@@ -358,6 +495,7 @@
input_values: compactInputValues(values) as Record<string, unknown>,
endpoint_id: endpointId || null,
source: wf.kind === 'image' ? 'studio-image' : 'studio-video',
...(wbSendContext ? { context: wbSendContext } : {}),
});
submittedIds.add(res.job.id);
selectedJobId = res.job.id;
@@ -372,6 +510,8 @@
else if (!narrow) inspOpen = true;
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
// 最近使用由后端在入队时记录 (queue/manager);这里只刷新轻量列表。
void client.invalidateQueries({ queryKey: ['workflows-meta'] });
} catch (err) {
toast.error(t('studio.submitFailedToast', { detail: apiErrorMessage(err) }));
} finally {
@@ -494,9 +634,14 @@
}
/** Fill the composer back with a past take's values (on its own workflow). */
function reuse(job: Job) {
const wf = deskWorkflows.find((w) => w.id === job.workflow_id);
if (!wf) {
async function reuse(job: Job) {
const id = job.workflow_id;
if (id == null) {
toast.error(t('studio.reuseMissingWf'));
return;
}
const wf = (details.get(id) ?? (await ensureDetail(id))) as Workflow | null;
if (!wf || !(wf.input_schema ?? []).length) {
toast.error(t('studio.reuseMissingWf'));
return;
}
@@ -570,6 +715,32 @@
});
}
// ------------------------------------------------------------------
// 回流到源项目 (HEL-684): explicit, one take at a time. The server
// re-validates the source row, is idempotent on repeats and refuses
// stale rows — the toast just reports what happened.
// ------------------------------------------------------------------
let attachBusyId = $state<number | null>(null);
async function attachToProject(job: Job) {
if (attachBusyId != null) return;
attachBusyId = job.id;
try {
const res = await playgroundApi.attachWorkbench(job.id);
toast.success(
res.already_attached
? t('desk.attachAlreadyToast')
: t('desk.attachDoneToast', { label: ctx?.label ?? '' }),
);
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(t('desk.attachFailedToast', { detail: apiErrorMessage(err) }));
} finally {
attachBusyId = null;
}
}
// ------------------------------------------------------------------
// 从结果选 (finished image takes → an image slot)
// ------------------------------------------------------------------
@@ -594,8 +765,9 @@
}
// ------------------------------------------------------------------
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦搜索/提示词 · ↑↓/J/K 切换
// (挑选台打开时游走挑选行,否则切换记录)· 空格播放/暂停 · Delete 删
// 选中 · ] 检视区 · Esc 关挑选台/浮层
// ------------------------------------------------------------------
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
@@ -615,6 +787,19 @@
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
if (e.key === 'Escape' && bayOpen) {
e.preventDefault();
closeBay();
return;
}
// 挑选台开着时,键盘归挑选台(其容器自己处理 ↑↓/Enter/Esc)。
if (bayOpen) {
if (e.key === '/' && !typing(e.target)) {
e.preventDefault();
bay?.focusSearch();
}
return;
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void submit();
@@ -652,6 +837,14 @@
inspOpen = false;
}
}
// ------------------------------------------------------------------
// Band (选定后的摘要带) helpers
// ------------------------------------------------------------------
const currentMeta = $derived(allMetas.find((m) => m.id === workflowId) ?? null);
const previewMeta = $derived(allMetas.find((m) => m.id === bayActiveId) ?? null);
const previewFull = $derived(bayActiveId != null ? (details.get(bayActiveId) ?? null) : null);
const picking = $derived(bayOpen && wide);
</script>
<svelte:window onkeydown={onKeydown} />
@@ -685,82 +878,133 @@
{/snippet}
<AppShell active={kind} title={t('desk.title')}>
<div class="desk" class:insp-off={inspOff}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
selectedId={selectedJob?.id ?? null}
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
<div class="studio-desk" class:picking>
<TaskModeRail {kind} metas={deskMetas} active={mode} onSelect={(m) => (mode = m)} onPickWorkflow={pickWorkflow} />
<div class="bay-col">
<WorkflowBay
bind:this={bay}
open={bayOpen}
metas={modeMetas}
{pendingCount}
selectedId={workflowId}
bind:activeId={bayActiveId}
{endpoints}
onPick={pickWorkflow}
onClose={closeBay}
/>
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
<div class="desk-empty" role="note">
<Icon name="playground" size={16} />
<span>{t('desk.noWorkflows')}</span>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
<section class="main-col">
{#if !bayOpen}
<div class="bay-band">
<button type="button" class="band-hit" onclick={openBay} title={t('desk.bandChange')}>
<Icon name="playground" size={14} />
<span class="band-mode">{mode === 'all' ? t('desk.modeAll') : usageLabel(mode)}</span>
<span class="band-sep" aria-hidden="true">·</span>
<span class="band-name">
{currentWorkflow?.name ?? currentMeta?.name ?? t('desk.pickWorkflow')}
</span>
{#if currentMeta?.usage}<span class="a-tag">{usageLabel(currentMeta.usage)}</span>{/if}
<InputRequirementBadge io={currentMeta?.media_io} compact />
<span class="band-change">
<span class="band-change-txt">{t('desk.bandChange')}</span>
<Icon name="chevron-right" size={13} />
</span>
</button>
</div>
{/if}
<Composer
{ds}
{values}
workflows={deskWorkflows}
current={currentWorkflow}
{endpoints}
{endpointId}
conn={connState}
{errors}
{showErrors}
{block}
{changedCount}
{submitting}
{sent}
bind:slotBusy
bind:promptEl
onSet={setVal}
onPickWorkflow={(wf) => (workflowId = wf.id)}
onPickEndpoint={(id) => {
endpointTouched = true;
endpointId = id;
}}
onSubmit={submit}
onReveal={reveal}
onOpenParams={openParams}
onFromResults={(nodeId) => (pickerFor = nodeId)}
/>
{#if picking}
<WorkflowPreview
meta={previewMeta}
full={previewFull}
currentId={workflowId}
{endpoints}
onUse={() => pickWorkflow(bayActiveId ?? workflowId ?? 0)}
onToggleFav={(fav) => previewMeta && toggleFavorite(previewMeta.id, fav)}
/>
{/if}
<div class="desk" class:insp-off={inspOff} class:off={picking}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
selectedId={selectedJob?.id ?? null}
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
{attachBusyId}
onAttach={attachToProject}
/>
{#if !$metaQuery.isLoading && !deskMetas.length}
<div class="desk-empty" role="note">
<Icon name="playground" size={16} />
<span>{t('desk.noWorkflows')}</span>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
{/if}
<Composer
{ds}
{values}
current={currentWorkflow}
{endpoints}
{endpointId}
conn={connState}
{errors}
{showErrors}
{block}
{changedCount}
{submitting}
{sent}
bind:slotBusy
bind:promptEl
onSet={setVal}
onOpenBay={openBay}
onPickEndpoint={(id) => {
endpointTouched = true;
endpointId = id;
}}
onSubmit={submit}
onReveal={reveal}
onOpenParams={openParams}
onFromResults={(nodeId) => (pickerFor = nodeId)}
/>
</section>
<Inspector
bind:tab
job={selectedJob}
wf={selectedJob ? workflowOf(selectedJob) : undefined}
{nowMs}
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>
</div>
</section>
<Inspector
bind:tab
job={selectedJob}
wf={selectedJob ? workflowOf(selectedJob) : undefined}
{nowMs}
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>
</div>
</AppShell>
@@ -779,17 +1023,48 @@
</Modal>
<style>
.studio-desk {
display: grid;
grid-template-columns: 204px 0px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
}
.studio-desk.picking {
grid-template-columns: 204px clamp(340px, 28vw, 440px) minmax(0, 1fr);
}
:global(:root[data-reduce='1']) .studio-desk {
transition: none;
}
.bay-col {
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--line);
}
.main-col {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.desk {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
flex: 1;
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
}
.desk.insp-off {
grid-template-columns: minmax(0, 1fr) 0px;
}
.desk.off {
display: none;
}
.center {
display: flex;
flex-direction: column;
@@ -808,6 +1083,65 @@
color: var(--warn);
font-size: var(--fs-aux);
}
/* 摘要带(选定后):整条可点,展开挑选台。 */
.bay-band {
flex-shrink: 0;
padding: var(--sp-2) var(--sp-7);
}
.band-hit {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: var(--sp-2) var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
border-color var(--d-state) ease,
color var(--d-state) ease;
}
.band-hit:hover {
border-color: var(--line-2);
color: var(--text);
}
.band-hit:focus-visible {
outline: none;
border-color: var(--accent-line);
}
.band-mode {
flex-shrink: 0;
color: var(--text-3);
}
.band-sep {
color: var(--text-3);
}
.band-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 500;
}
.band-change {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
color: var(--text-3);
}
.band-change-txt {
font-size: var(--fs-mini);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -838,7 +1172,12 @@
color: var(--text-3);
font-size: var(--fs-aux);
}
/* 中屏:挑选台为全屏浮层(组件内处理),列宽归零。 */
@media (max-width: 1179px) {
.studio-desk,
.studio-desk.picking {
grid-template-columns: 204px 0px minmax(0, 1fr);
}
.desk,
.desk.insp-off {
grid-template-columns: minmax(0, 1fr);
@@ -846,10 +1185,33 @@
.desk-empty {
margin: 0 var(--sp-6) var(--sp-3);
}
.bay-band {
padding: var(--sp-2) var(--sp-6);
}
}
/* 窄屏:任务模式变横条,摘要带仍可用。 */
@media (max-width: 760px) {
.studio-desk,
.studio-desk.picking {
grid-template-columns: minmax(0, 1fr) 0px;
grid-template-rows: auto minmax(0, 1fr);
}
.bay-col {
grid-column: 2;
grid-row: 1 / span 2;
}
.main-col {
grid-column: 1;
grid-row: 2;
}
.desk-empty {
margin: 0 var(--sp-5) var(--sp-2);
}
.bay-band {
padding: var(--sp-1) var(--sp-4) var(--sp-2);
}
.band-change-txt {
display: none;
}
}
</style>
@@ -0,0 +1,310 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
/**
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
*/
interface Props {
kind: 'image' | 'video';
metas: WorkflowMeta[];
active: WorkflowUsage | 'all';
onSelect: (mode: WorkflowUsage | 'all') => void;
onPickWorkflow: (id: number) => void;
}
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
}
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
});
const favorites = $derived(
metas
.filter((m) => m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
const recents = $derived(
metas
.filter((m) => m.last_used_at && !m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
</script>
<nav class="rail" aria-label={t('desk.modeRailAria')}>
<p class="rail-label">{t('desk.modeRailTitle')}</p>
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
<button
type="button"
class="mode"
class:on={active === 'all'}
aria-pressed={active === 'all'}
onclick={() => onSelect('all')}
>
<span class="mode-lbl">{t('desk.modeAll')}</span>
<span class="mode-n">{metas.length}</span>
</button>
{#each modes as m (m.usage)}
<button
type="button"
class="mode"
class:on={active === m.usage}
class:off={m.count === 0}
disabled={m.count === 0}
aria-pressed={active === m.usage}
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
onclick={() => onSelect(m.usage)}
>
<span class="mode-lbl">{usageLabel(m.usage)}</span>
<span class="mode-n">{m.count}</span>
</button>
{/each}
</div>
<div class="lists">
{#if recents.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="clock" size={12} />
{t('desk.railRecent')}
</p>
<ul class="pick-list">
{#each recents as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if favorites.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="star" size={12} />
{t('desk.railFav')}
</p>
<ul class="pick-list">
{#each favorites as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
</nav>
<style>
.rail {
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
display: flex;
align-items: center;
gap: var(--sp-1);
margin: 0 0 var(--sp-2);
padding: 0 var(--sp-3);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
white-space: nowrap;
}
.modes {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
box-sizing: border-box;
width: 100%;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-body);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.mode:hover:not(:disabled) {
background: var(--bg-3);
color: var(--text);
}
.mode:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.mode.on {
background: var(--bg-3);
color: var(--text);
}
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
.mode.on {
position: relative;
}
.mode.on::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
.mode.off {
color: var(--text-3);
opacity: 0.55;
cursor: not-allowed;
}
.mode-n {
color: var(--text-3);
font-size: var(--fs-mini);
font-variant-numeric: tabular-nums;
}
.lists {
display: flex;
flex-direction: column;
gap: var(--sp-4);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--line);
}
.rail-sec {
min-width: 0;
}
.pick-list {
display: flex;
flex-direction: column;
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
}
.pick {
display: block;
width: 100%;
box-sizing: border-box;
padding: var(--sp-2) var(--sp-4);
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.pick:hover {
background: var(--bg-3);
color: var(--text);
}
.pick:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.pick-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
@media (max-width: 760px) {
.rail {
flex-direction: row;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-right: 0;
border-bottom: 1px solid var(--line);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
.rail-label {
margin: 0;
padding: 0;
flex-shrink: 0;
}
.modes {
flex-direction: row;
gap: var(--sp-1);
}
.mode {
width: auto;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--line);
font-size: var(--fs-aux);
white-space: nowrap;
}
.mode.on::after {
inset: 1px;
}
.lists {
display: none;
}
}
</style>
@@ -0,0 +1,488 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { tick } from 'svelte';
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。
*/
interface Props {
open: boolean;
metas: WorkflowMeta[];
pendingCount: number;
selectedId: number | null;
activeId: number | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (id: number) => void;
onClose: () => 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',
};
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.
$effect(() => {
void search;
void favOnly;
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;
if (idx >= visibleCount) visibleCount = idx + 48;
const id = setTimeout(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${target}"]`)
?.scrollIntoView({ block: 'center' });
}, 0);
return () => clearTimeout(id);
});
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);
});
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();
}
}
function onScroll() {
const el = listEl;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) {
if (visibleCount < flat.length) visibleCount += 48;
}
}
</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>
</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}
{: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>
{/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>
<style>
.bay {
display: flex;
flex-direction: column;
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 {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-2) var(--sp-4);
}
.bay-title {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
}
.bay-title :global(svg) {
color: var(--text-3);
}
.close {
margin-left: auto;
}
.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;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-1);
}
.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);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.04em;
}
.more {
align-self: center;
margin: var(--sp-3) 0;
}
.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;
}
.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;
}
}
</style>
@@ -0,0 +1,247 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
* 完整记录槽位摘要),确认后进入创作区。
*/
interface Props {
meta: WorkflowMeta | null;
full: Workflow | null;
currentId: number | null;
endpoints: Array<{ id: string; name: string }>;
onUse: () => void;
onToggleFav: (fav: boolean) => void;
}
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
const isCurrent = $derived(meta != null && meta.id === currentId);
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
const endpointName = $derived.by(() => {
const id = meta?.endpoint_id;
if (!id) return t('desk.wfDefaultConn');
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
});
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',
};
</script>
<div class="preview">
{#if meta}
<div class="head">
<h3 class="name" title={meta.name}>{meta.name}</h3>
<button
type="button"
class="fav"
class:on={meta.favorite}
aria-pressed={meta.favorite}
aria-label={t('desk.bayFavOnly')}
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
onclick={() => onToggleFav(!meta.favorite)}
>
<Icon name="star" size={16} />
</button>
</div>
<div class="tags">
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
{#if meta.category && CATEGORY_LABELS[meta.category]}
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
{/if}
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
</div>
{#if meta.description}
<p class="desc">{meta.description}</p>
{/if}
<section class="sec">
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
<div class="req-line">
<InputRequirementBadge io={meta.media_io} />
</div>
{#if full}
<p class="slot-summary">{slotSummary(full)}</p>
{:else}
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
{/if}
</section>
<dl class="kv">
<div class="kv-row">
<dt>{t('studio.connLabel')}</dt>
<dd>{endpointName}</dd>
</div>
{#if meta.last_used_at}
<div class="kv-row">
<dt>{t('desk.pvLastUsed')}</dt>
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
</div>
{/if}
{#if (full?.input_schema ?? []).length}
<div class="kv-row">
<dt>{t('desk.pvParamCount')}</dt>
<dd>{(full?.input_schema ?? []).length}</dd>
</div>
{/if}
</dl>
<div class="use">
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
<Icon name="check" size={13} />
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
</button>
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
</div>
{:else}
<div class="preview-empty">
<Icon name="search" size={20} />
<p>{t('desk.pvEmpty')}</p>
</div>
{/if}
</div>
<style>
.preview {
display: flex;
flex-direction: column;
gap: var(--sp-4);
flex: 1;
min-height: 0;
box-sizing: border-box;
height: 100%;
padding: var(--sp-7);
overflow-y: auto;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
}
.name {
flex: 1;
min-width: 0;
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.fav {
flex-shrink: 0;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: none;
color: var(--text-3);
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.fav:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.desc {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
line-height: 1.65;
white-space: pre-line;
}
.sec {
display: grid;
gap: var(--sp-2);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
}
.sec-title {
margin: 0;
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
}
.req-line {
display: flex;
}
.slot-summary {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
}
.slot-summary.dim {
color: var(--text-3);
}
.kv {
display: grid;
gap: var(--sp-1);
margin: 0;
}
.kv-row {
display: flex;
gap: var(--sp-3);
font-size: var(--fs-aux);
}
.kv-row dt {
flex-shrink: 0;
width: 72px;
color: var(--text-3);
}
.kv-row dd {
margin: 0;
color: var(--text-2);
min-width: 0;
overflow-wrap: anywhere;
}
.use {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: auto;
padding-top: var(--sp-4);
}
.use-hint {
margin: 0;
color: var(--err);
font-size: var(--fs-mini);
}
.preview-empty {
display: grid;
place-content: center;
justify-items: center;
gap: var(--sp-3);
height: 100%;
color: var(--text-3);
font-size: var(--fs-aux);
}
</style>
@@ -5,7 +5,6 @@
import { t } from '$lib/i18n.svelte';
import ParamChip from './ParamChip.svelte';
import SlotTray from './SlotTray.svelte';
import WorkflowPicker from './WorkflowPicker.svelte';
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
/**
@@ -13,11 +12,12 @@
* row of chips — workflow, connection, common params, 参数 — ending in
* 生成 with its reason line. The whole composer takes dropped files.
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
* HEL-683: the workflow chip no longer opens a dropdown — it expands the
* desk's middle picker bay (StudioDesk WorkflowBay), the single picker.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
endpointId: string;
@@ -32,7 +32,7 @@
slotBusy?: boolean;
promptEl?: HTMLTextAreaElement | null;
onSet: (nodeId: string, value: FormValue) => void;
onPickWorkflow: (wf: Workflow) => void;
onOpenBay: () => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
@@ -43,7 +43,6 @@
let {
ds,
values,
workflows,
current,
endpoints,
endpointId,
@@ -57,7 +56,7 @@
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onPickWorkflow,
onOpenBay,
onPickEndpoint,
onSubmit,
onReveal,
@@ -165,7 +164,18 @@
{/if}
<div class="foot">
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
title={current?.name ?? t('desk.pickWorkflow')}
onclick={onOpenBay}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
</div>
<div class="conn opt">
<button
type="button"
@@ -312,6 +322,10 @@
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
}
.picker {
position: relative;
min-width: 0;
}
.conn {
position: relative;
}
@@ -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}&nbsp;· {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} />
@@ -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}
@@ -1,277 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { slotSummary, usageLabel } from './schema';
/**
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
* pop grouped by usage with search; each row shows what the workflow
* really takes (slot summary from its JSON), its connection and whether
* it last ran. Picking keeps the prompt and carried-over images.
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
* active row follows ↑↓ (and the pointer), Enter uses it, and the
* search's Enter picks the active row instead of always the first.
*/
interface Props {
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (wf: Workflow) => void;
}
let { workflows, current, endpoints, onPick }: Props = $props();
let open = $state(false);
let query = $state('');
let activeId = $state<number | null>(null);
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
function statusOf(wf: Workflow): Status {
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
const lt = wf.last_test;
if (!lt) return { tone: '', label: t('desk.wfUntested') };
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
return { tone: '', label: t('desk.wfTesting') };
}
function endpointLabel(wf: Workflow): string {
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
return ep ? ep.name : t('desk.wfDefaultConn');
}
const rows = $derived(
workflows.map((wf) => ({
wf,
summary: slotSummary(wf),
conn: endpointLabel(wf),
status: statusOf(wf),
usage: wf.usage ? usageLabel(wf.usage) : '',
})),
);
const groups = $derived.by(() => {
const q = query.trim().toLowerCase();
const hit = (r: (typeof rows)[number]) =>
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
return WORKFLOW_USAGES.map((u) => ({
usage: u,
items: rows.filter((r) => r.wf.usage === u && hit(r)),
})).filter((g) => g.items.length);
});
/** Flattened rows in display order — the roving selection walks these. */
const flat = $derived(groups.flatMap((g) => g.items));
// Keep the active row real: default to the current workflow, clamp on
// filter changes, never point at a row that is no longer listed.
$effect(() => {
if (!open) return;
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((r) => r.wf.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
}
function pickActive() {
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
if (row) pick(row.wf);
}
function pick(wf: Workflow) {
open = false;
query = '';
if (wf.id !== current?.id) onPick(wf);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
pickActive();
}
}
</script>
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
aria-haspopup="dialog"
aria-expanded={open}
title={current?.name ?? t('desk.pickWorkflow')}
onclick={() => (open = !open)}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
<ShellPop
bind:open
label={t('desk.pickWorkflow')}
placement="top"
width="520px"
arrowRoving={false}
onclose={() => (query = '')}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop-body" onkeydown={onKeydown}>
<div class="search">
<Icon name="search" size={14} />
<input
class="search-input"
type="search"
bind:value={query}
placeholder={t('desk.wfSearchPh')}
aria-label={t('desk.wfSearchPh')}
/>
</div>
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
{#each groups as g (g.usage)}
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
{#each g.items as r (r.wf.id)}
{@const cur = r.wf.id === current?.id}
<button
type="button"
class="a-menu-item row"
class:off={r.status.tone === 'err'}
class:active={r.wf.id === activeId}
role="option"
aria-current={cur ? 'true' : undefined}
aria-selected={r.wf.id === activeId ? 'true' : undefined}
onclick={() => pick(r.wf)}
onpointermove={() => (activeId = r.wf.id)}
>
<span class="main">
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
<span class="sub">
<span class="a-tag">{r.summary}</span>
<span class="a-tag">{r.conn}</span>
</span>
</span>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
{/each}
{:else}
<p class="empty">{t('desk.wfNoMatch')}</p>
{/each}
</div>
<div class="foot">
<a class="a-btn ghost sm" href="/connections">
<Icon name="upload" size={13} />
{t('desk.wfImport')}
</a>
<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>
</div>
</div>
</ShellPop>
</div>
<style>
.picker {
position: relative;
min-width: 0;
}
.pop-body {
display: flex;
flex-direction: column;
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
border-bottom: 1px solid var(--line);
color: var(--text-3);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
.list {
max-height: min(380px, 50vh);
overflow-y: auto;
padding: var(--sp-1) 0;
}
.row {
align-items: center;
}
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
.row.active {
background: var(--bg-4);
}
.main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.main .a-menu-label {
font-weight: 500;
}
.row.off .a-menu-label {
color: var(--text-3);
}
.sub {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
}
.foot {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-1) 0;
border-top: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
@media (max-width: 760px) {
.keys {
display: none;
}
}
</style>
@@ -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>;
}
@@ -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')}
+39
View File
@@ -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',
@@ -2081,6 +2090,36 @@ export const de: Dict = {
'desk.wfUntested': 'Nicht getestet',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Gestern',
// --- Dreispaltige Desk (HEL-683) ---
'desk.bandChange': "Workflow wechseln",
'desk.bayCloudBody': "Cloud-Modelle für Bild / Video (gpt-image2, nano-banana, Jimeng, Kling, …) sind noch nicht angebunden. Nach der Anbindung erscheinen sie mit ihren echten Fähigkeiten — nie als unbenutzbare Einträge.",
'desk.bayCloudTitle': "Cloud-Generierung nicht angebunden",
'desk.bayCount': "{n} / {total} Workflows",
'desk.bayFavOnly': "Favoriten",
'desk.bayMore': "{n} weitere anzeigen",
'desk.bayNoFamily': "Modell unbekannt",
'desk.bayPending': "{n} offen (Zweck / Ausgabe ungeklärt)",
'desk.bayReqAria': "Eingabeanforderungen",
'desk.bayReqTitle': "Eingabeanforderungen",
'desk.baySearchPh': "Name, Beschreibung, Modell, Zweck suchen…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Generierungsquelle",
'desk.baySourceLocal': "Lokal",
'desk.modeAll': "Alle",
'desk.modeEmpty': "Für diesen Modus gibt es noch keinen nutzbaren Workflow",
'desk.modeRailAria': "Aufgabenmodi",
'desk.modeRailTitle': "Aufgabenmodi",
'desk.pvBackCreate': "Zurück zum Erstellen",
'desk.pvCurrent': "Aktueller Workflow",
'desk.pvEmpty': "Links einen Workflow wählen, um die Eingaben zu sehen",
'desk.pvFav': "Zu Favoriten",
'desk.pvLastUsed': "Zuletzt genutzt",
'desk.pvParamCount': "Parameter",
'desk.pvSlotsLoading': "Eingabeslots werden gelesen…",
'desk.pvUnfav': "Aus Favoriten entfernen",
'desk.pvUse': "Diesen Workflow verwenden",
'desk.railFav': "Favoriten",
'desk.railRecent': "Zuletzt",
'wfd.stepParse': 'JSON parsen',
'wfd.stepNodes': 'Knoten erkennen',
'wfd.stepModels': 'Modelle prüfen',
+39
View File
@@ -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',
@@ -2080,6 +2089,36 @@ export const en = {
'desk.wfUntested': 'Not tested',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Yesterday',
// --- Three-section desk (HEL-683) ---
'desk.bandChange': "Change workflow",
'desk.bayCloudBody': "Cloud image / video models (gpt-image2, nano-banana, Jimeng, Kling, …) are not connected to this product yet. Once integrated they will be listed with their real capabilities — never as unusable entries.",
'desk.bayCloudTitle': "Cloud generation not connected",
'desk.bayCount': "{n} / {total} workflows",
'desk.bayFavOnly': "Favorites",
'desk.bayMore': "Show {n} more",
'desk.bayNoFamily': "Unrecognized model",
'desk.bayPending': "{n} pending confirmation (usage / output unsettled)",
'desk.bayReqAria': "Input requirements",
'desk.bayReqTitle': "Input requirements",
'desk.baySearchPh': "Search name, description, model, usage…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Generation source",
'desk.baySourceLocal': "Local",
'desk.modeAll': "All",
'desk.modeEmpty': "No runnable workflow for this mode yet",
'desk.modeRailAria': "Task modes",
'desk.modeRailTitle': "Task modes",
'desk.pvBackCreate': "Back to creating",
'desk.pvCurrent': "Current workflow",
'desk.pvEmpty': "Pick a workflow on the left to see its input requirements",
'desk.pvFav': "Add to favorites",
'desk.pvLastUsed': "Last used",
'desk.pvParamCount': "Parameters",
'desk.pvSlotsLoading': "Loading input slots…",
'desk.pvUnfav': "Remove from favorites",
'desk.pvUse': "Use this workflow",
'desk.railFav': "Favorites",
'desk.railRecent': "Recent",
'wfd.stepParse': 'Parse JSON',
'wfd.stepNodes': 'Identify nodes',
'wfd.stepModels': 'Verify models',
+39
View File
@@ -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',
@@ -2081,6 +2090,36 @@ export const es: Dict = {
'desk.wfUntested': 'Sin probar',
'desk.workflow': 'Flujo',
'desk.yesterday': 'Ayer',
// --- Escritorio de tres secciones (HEL-683) ---
'desk.bandChange': "Cambiar flujo",
'desk.bayCloudBody': "Los modelos de nube para imagen / vídeo (gpt-image2, nano-banana, Jimeng, Kling, …) aún no están conectados a este producto. Al integrarse se listarán con sus capacidades reales, nunca como entradas inutilizables.",
'desk.bayCloudTitle': "Generación en la nube no conectada",
'desk.bayCount': "{n} / {total} flujos",
'desk.bayFavOnly': "Favoritos",
'desk.bayMore': "Mostrar {n} más",
'desk.bayNoFamily': "Modelo no identificado",
'desk.bayPending': "{n} pendientes de confirmar (uso / salida sin fijar)",
'desk.bayReqAria': "Requisitos de entrada",
'desk.bayReqTitle': "Requisitos de entrada",
'desk.baySearchPh': "Buscar nombre, descripción, modelo, uso…",
'desk.baySourceCloud': "Nube",
'desk.baySourceLabel': "Origen de generación",
'desk.baySourceLocal': "Local",
'desk.modeAll': "Todos",
'desk.modeEmpty': "Aún no hay flujo utilizable para este modo",
'desk.modeRailAria': "Modos de tarea",
'desk.modeRailTitle': "Modos de tarea",
'desk.pvBackCreate': "Volver a crear",
'desk.pvCurrent': "Flujo actual",
'desk.pvEmpty': "Elige un flujo a la izquierda para ver sus requisitos",
'desk.pvFav': "Añadir a favoritos",
'desk.pvLastUsed': "Último uso",
'desk.pvParamCount': "Parámetros",
'desk.pvSlotsLoading': "Leyendo ranuras de entrada…",
'desk.pvUnfav': "Quitar de favoritos",
'desk.pvUse': "Usar este flujo",
'desk.railFav': "Favoritos",
'desk.railRecent': "Recientes",
'wfd.stepParse': 'Analizar JSON',
'wfd.stepNodes': 'Identificar nodos',
'wfd.stepModels': 'Verificar modelos',
+39
View File
@@ -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',
@@ -2081,6 +2090,36 @@ export const fr: Dict = {
'desk.wfUntested': 'Non testé',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Hier',
// --- Bureau en trois sections (HEL-683) ---
'desk.bandChange': "Changer de flux",
'desk.bayCloudBody': "Les modèles cloud d'image / vidéo (gpt-image2, nano-banana, Jimeng, Kling, …) ne sont pas encore connectés à ce produit. Une fois intégrés, ils seront listés avec leurs capacités réelles — jamais en entrées inutilisables.",
'desk.bayCloudTitle': "Génération cloud non connectée",
'desk.bayCount': "{n} / {total} flux",
'desk.bayFavOnly': "Favoris",
'desk.bayMore': "Afficher {n} de plus",
'desk.bayNoFamily': "Modèle non identifié",
'desk.bayPending': "{n} à confirmer (usage / sortie non fixés)",
'desk.bayReqAria': "Exigences d'entrée",
'desk.bayReqTitle': "Exigences d'entrée",
'desk.baySearchPh': "Rechercher nom, description, modèle, usage…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Source de génération",
'desk.baySourceLocal': "Local",
'desk.modeAll': "Tous",
'desk.modeEmpty': "Aucun flux utilisable pour ce mode pour l'instant",
'desk.modeRailAria': "Modes de tâche",
'desk.modeRailTitle': "Modes de tâche",
'desk.pvBackCreate': "Retour à la création",
'desk.pvCurrent': "Flux actuel",
'desk.pvEmpty': "Choisissez un flux à gauche pour voir ses exigences d'entrée",
'desk.pvFav': "Ajouter aux favoris",
'desk.pvLastUsed': "Dernière utilisation",
'desk.pvParamCount': "Paramètres",
'desk.pvSlotsLoading': "Lecture des emplacements d’entrée…",
'desk.pvUnfav': "Retirer des favoris",
'desk.pvUse': "Utiliser ce flux",
'desk.railFav': "Favoris",
'desk.railRecent': "Récents",
'wfd.stepParse': 'Analyser le JSON',
'wfd.stepNodes': 'Identifier les nœuds',
'wfd.stepModels': 'Vérifier les modèles',
+39
View File
@@ -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} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
@@ -2079,6 +2088,36 @@ export const ja: Dict = {
'desk.wfUntested': '未テスト',
'desk.workflow': 'ワークフロー',
'desk.yesterday': '昨日',
// --- 三段構成デスク (HEL-683) ---
'desk.bandChange': "ワークフローを変更",
'desk.bayCloudBody': "クラウドの画像/動画モデル(gpt-image2、nano-banana、即夢、Kling など)はまだ本製品に接続されていません。接続後は実際の機能とともに一覧表示され、使えない項目が並ぶことはありません。",
'desk.bayCloudTitle': "クラウド生成は未接続",
'desk.bayCount': "{n} / {total} 件のワークフロー",
'desk.bayFavOnly': "お気に入り",
'desk.bayMore': "さらに {n} 件表示",
'desk.bayNoFamily': "モデル未識別",
'desk.bayPending': "確認待ち {n} 件(用途/出力未確定)",
'desk.bayReqAria': "入力要件",
'desk.bayReqTitle': "入力要件",
'desk.baySearchPh': "名前・説明・モデル・用途を検索…",
'desk.baySourceCloud': "クラウド",
'desk.baySourceLabel': "生成ソース",
'desk.baySourceLocal': "ローカル",
'desk.modeAll': "すべて",
'desk.modeEmpty': "このモードで利用できるワークフローはまだありません",
'desk.modeRailAria': "タスクモード",
'desk.modeRailTitle': "タスクモード",
'desk.pvBackCreate': "作成に戻る",
'desk.pvCurrent': "現在のワークフロー",
'desk.pvEmpty': "左でワークフローを選ぶと入力要件が表示されます",
'desk.pvFav': "お気に入りに追加",
'desk.pvLastUsed': "最終使用",
'desk.pvParamCount': "パラメータ数",
'desk.pvSlotsLoading': "入力スロットを読み込み中…",
'desk.pvUnfav': "お気に入りから外す",
'desk.pvUse': "このワークフローを使う",
'desk.railFav': "お気に入り",
'desk.railRecent': "最近の使用",
'wfd.stepParse': 'JSON を解析',
'wfd.stepNodes': 'ノードを識別',
'wfd.stepModels': 'モデルを確認',
+39
View File
@@ -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}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
@@ -2079,6 +2088,36 @@ export const ko: Dict = {
'desk.wfUntested': '테스트 안 함',
'desk.workflow': '워크플로',
'desk.yesterday': '어제',
// --- 3단 구성 데스크 (HEL-683) ---
'desk.bandChange': "워크플로 변경",
'desk.bayCloudBody': "클라우드 이미지 / 동영상 모델(gpt-image2, nano-banana, 지멍, 클링 등)은 아직 이 제품에 연결되지 않았습니다. 연결 후 실제 기능과 함께 표시되며, 사용할 수 없는 항목은 나타나지 않습니다.",
'desk.bayCloudTitle': "클라우드 생성 미연결",
'desk.bayCount': "{n} / {total}개 워크플로",
'desk.bayFavOnly': "즐겨찾기",
'desk.bayMore': "{n}개 더 보기",
'desk.bayNoFamily': "미식별 모델",
'desk.bayPending': "확인 대기 {n}개(용도 / 출력 미확정)",
'desk.bayReqAria': "입력 요구 사항",
'desk.bayReqTitle': "입력 요구 사항",
'desk.baySearchPh': "이름, 설명, 모델, 용도 검색…",
'desk.baySourceCloud': "클라우드",
'desk.baySourceLabel': "생성 소스",
'desk.baySourceLocal': "로컬",
'desk.modeAll': "전체",
'desk.modeEmpty': "이 모드에서 사용 가능한 워크플로가 아직 없습니다",
'desk.modeRailAria': "작업 모드",
'desk.modeRailTitle': "작업 모드",
'desk.pvBackCreate': "만들기로 돌아가기",
'desk.pvCurrent': "현재 워크플로",
'desk.pvEmpty': "왼쪽에서 워크플로를 선택하면 입력 요구 사항이 표시됩니다",
'desk.pvFav': "즐겨찾기에 추가",
'desk.pvLastUsed': "최근 사용",
'desk.pvParamCount': "매개변수 수",
'desk.pvSlotsLoading': "입력 슬롯 읽는 중…",
'desk.pvUnfav': "즐겨찾기에서 제거",
'desk.pvUse': "이 워크플로 사용",
'desk.railFav': "즐겨찾기",
'desk.railRecent': "최근 사용",
'wfd.stepParse': 'JSON 파싱',
'wfd.stepNodes': '노드 식별',
'wfd.stepModels': '모델 확인',
+39
View File
@@ -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} 个文件没有可放的槽位:当前工作流没有接这类输入',
@@ -2079,6 +2088,36 @@ export const zh: Dict = {
'desk.wfUntested': '未试运行',
'desk.workflow': '工作流',
'desk.yesterday': '昨天',
// --- 三段式生成台 (HEL-683) ---
'desk.bandChange': "更换工作流",
'desk.bayCloudBody': "云端生图 / 生视频模型(gpt-image2、nano-banana、即梦、可灵等)尚未接入本产品。接入后这里会按真实能力列出,不会展示无法使用的条目。",
'desk.bayCloudTitle': "云端生成未接入",
'desk.bayCount': "{n} / {total} 个工作流",
'desk.bayFavOnly': "收藏",
'desk.bayMore': "再显示 {n} 个",
'desk.bayNoFamily': "未识别模型",
'desk.bayPending': "{n} 个待确认(用途 / 输出未定)",
'desk.bayReqAria': "输入要求",
'desk.bayReqTitle': "输入要求",
'desk.baySearchPh': "搜索名称、说明、模型、用途…",
'desk.baySourceCloud': "云端",
'desk.baySourceLabel': "生成来源",
'desk.baySourceLocal': "本机",
'desk.modeAll': "全部",
'desk.modeEmpty': "该模式暂无可用工作流",
'desk.modeRailAria': "任务模式",
'desk.modeRailTitle': "任务模式",
'desk.pvBackCreate': "返回创作",
'desk.pvCurrent': "当前工作流",
'desk.pvEmpty': "在左侧选择一个工作流查看输入要求",
'desk.pvFav': "加入收藏",
'desk.pvLastUsed': "最近使用",
'desk.pvParamCount': "参数项",
'desk.pvSlotsLoading': "正在读取输入槽位…",
'desk.pvUnfav': "取消收藏",
'desk.pvUse': "使用此工作流",
'desk.railFav': "收藏",
'desk.railRecent': "最近使用",
'wfd.stepParse': '解析 JSON',
'wfd.stepNodes': '识别节点',
'wfd.stepModels': '核对模型',
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
</script>
<StudioGenerate kind="image" />
<StudioDesk kind="image" />
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
</script>
<StudioGenerate kind="video" />
<StudioDesk kind="video" />
@@ -0,0 +1,46 @@
# S4:三段式图片 / 视频生成台(HEL-683)
按 HEL-679 已确认的三张融合样稿,把 `/image` 与 `/video` 两个入口接入同一
个三段式生成台。本批只动 `calliope-web`,生成后端与项目回流(S5)零改动。
## 三段式布局(新增 `components/desk/`)
- `StudioDesk.svelte` 取代 `StudioGenerate.svelte` 成为 `/image`、`/video`
的共用页面(薄壳不变,`kind` 决定任务模式);右栏沿用既有
`Composer / SlotTray / Inspector / TakeFeed / ContextBar`,仅重排布局。
- 左栏 `TaskModeRail.svelte`:任务模式按真实工作流统计计数,未覆盖模式置灰;
底部收藏 / 最近使用直达;≤760px 退化为横向模式条。
- 中栏挑选台 `WorkflowBay.svelte`(含 `LibraryFilters / LibraryRow /
InputRequirementBadge`):搜索、本机 / 云端来源分段、按模型家族分组、
收藏过滤、待确认计数与入口;长列表增量渲染(48/批)+ `content-visibility`,
键盘 ↑↓/J/K 游走、Enter 使用、Esc 关闭。
- 挑选时中栏展开(grid 列宽动画,`data-reduce` 下直接跳变),右侧显示
`WorkflowPreview.svelte` 输入要求预览(S2 元数据 + 按需加载的槽位摘要);
选定后收成摘要带(band),一键重新展开。≤1179px 挑选台为全屏浮层。
- 云端来源是诚实占位:明确标注「云端生成未接入」,不造可点击的虚假能力;
数据结构按 `source: 'comfy' | 'cloud'` 预留。
## 数据与路由
- 挑选台 / 任务模式 / 摘要带全部走 `GET /api/workflows?view=meta` 轻量元数据
(150+ 条不下载 workflow_json);完整记录改为按 id 惰性加载(当前工作流、
记录流里出现的工作流、预览行),feed 名称先用元数据桩、schema 到位后补全,
「工作流已删除」判定以元数据为准,不在加载期误报。
- 新增查询参数:`?wf=<id>` 深链指定工作流、`?mode=<task-mode>` 预选任务模式;
五阶段 `deskHref` 的 `from=workbench` 上下文条与返回路径保持不变。
- Composer 的工作流芯片从 380px 下拉弹层改为展开挑选台(`#desk-wf` 语义
不变,仍是缺工作流时的定位目标);`WorkflowPicker.svelte` 删除。
- 最近使用沿用后端入队时写入的 `last_used_at`(queue/manager 原有逻辑),
提交后刷新轻量列表即可。
## 验证
- `npm run check`(0 错误,2 条基线既有警告)、`npm run test`、`npm run build`
通过;prettier 对本批文件通过(仓库 lint 基线因 prettier 3.9 全量重排 +
无 eslint 配置本就不可用,未扩面修复)。
- `e2e/shoot.mjs` 全绿:挑选台键盘链(展开→搜索→↑↓→Enter→调参→提交)、
SSE 状态语义、删除撤销、任务页、窄屏无溢出、减动效;同时把 7 处 S1 灰阶
化后未更新的旧色值断言修正为 theme-1 实际值(定影文案 + 灰阶信号色)。
- `e2e/lastframe.mjs` 全绿(视频检视、尾帧导出、手机弹层无回归)。
- 手工走查:`?wf` / `?mode` / `from=workbench` 上下文预填与返回、1024/390
无横向溢出,截图见 `e2e/shots/hel683-*.png`。
@@ -0,0 +1,50 @@
# S5:生成结果回流原项目(HEL-684)
在 S4 三段式生成台基础上,让从项目五阶段资产 / 镜头进入生成台的结果可以
回流原项目。沿用现有 `DeskContext` 与 attach 语义,增量改动:生成请求可携带
可选项目上下文,结果卡提供明确的「回流到源项目」动作与返回提示;独立
playground 生成行为保持原样。本批后端只改 `playground.py`,前端不重写任何
生成 / 项目业务。
## 后端(`calliope/routers/playground.py`)
- `PlaygroundGenerate` 新增可选 `context: {project_id, stage, source_kind,
source_id, label}`。入队前逐项校验真实性:项目存在且非 playground 隐藏
项目、阶段内容里确有该资产 / 镜头行、阶段与生成类型匹配(assets→image、
shot→video)——任何不符直接 4xx,绝不猜测或静默记录来源。任务本身仍挂在
playground 项目上,记录流 / SSE / 任务页行为不变;校验通过的来源以
`payload.workbench` 戳进任务。
- 新增 `POST /api/playground/attach-workbench`:把一条已完成 take 回流到其
记录的真实来源行。落库形态复用工作台原生展示链路——插入一条
`payload.source='workbench'` + `target_id` 的 done 任务行,`_enrich_stage`
既有逻辑即把产物显示在资产 / 镜头卡上(与流程内生成同一机制,之后流程内
重新生成会自然接管显示)。幂等:重复回流返回既有链接行,不产生重复;
源行已因阶段重新生成消失时 409 拒绝,不误写。完成后发 `job.completed` +
`asset.ready`,打开着的工作台页即时刷新。
- `GET /api/playground/jobs` 为已回流 take 附 `workbench_attach` 标记
(服务端真值,重载后界面仍如实显示「已回流」)。
- `DELETE /api/playground/jobs/{id}` 在删除产物文件时同步清掉由该 take 建立
的回流链接行(与既有 `_clear_entity_refs` 同理),项目行不会指向已删文件。
## 前端(`calliope-web`)
- `generate/context.ts`:`DeskContext` 增加可选 `sourceKind/sourceId`,
`deskHref/readDeskContext` 透传(URL `source_kind/source_id`);不完整或
不自洽的旧链接自动退回「仅预填」行为。新增 `workbenchContextOf` 只在来源
完整且台类型与阶段匹配时给出请求上下文。
- 工作台 `AssetsStagePanel / ShotStagePanel` 的「在生成台调参」链接带上真实
行标识;`ContextBar` 有来源时提示「可在结果卡回流」,无来源保持原文案。
- `StudioDesk` 提交时携带上下文(关闭来源条 = 独立生成,不写项目);
`TakeCard` 为带真实来源的完成 take 提供「回流到源项目」按钮,回流后显示
「已回流」标记 + 「返回项目」直达链接;i18n 9 键 × 7 语言。
## 验证
- 后端 `pytest`:新增 `tests/test_playground_workbench_flowback.py` 8 例
(上下文透传 / 拒假 / 回流 / 幂等 / 重建后拒写 / 流程内接管 / 删除清理),
全量 829 passed,4 个失败为 S2 起既有基线(已用基线代码对拍确认与本批无关)。
- 前端 `npm run check`(0 错误,2 条基线既有警告)、`npm run test`、
`npm run build` 通过。
- e2e(fake_comfy 全新库逐套跑):新增 `e2e/flowback.mjs` 19 检查全过
(上下文条提示→提交→回流→已回流重载→返回项目见产物→不回流不误写→独立
无动作→视频镜头路径);`shoot.mjs` 56 检查、`lastframe.mjs` 全过。
+216
View File
@@ -0,0 +1,216 @@
/* HEL-684 S5 browser validation against the isolated stack:
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
* Covers: context bar hint, submit carries the workbench context, the
* explicit 回流到源项目 action, the attached state surviving reload,
* the artifact landing on the workbench source row, the cancel path
* (context take, no attach → project untouched) and the independent
* path (no context → no attach action). */
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOTS = path.join(ROOT, 'shots');
fs.mkdirSync(SHOTS, { recursive: true });
const API = 'http://127.0.0.1:8247';
const MODE_FILE = path.join(ROOT, 'fake_mode.json');
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
let failures = 0;
function ok(name, cond, extra = '') {
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
console.log(line);
if (!cond) failures++;
}
const api = async (p, init) => {
const r = await fetch(API + p, {
headers: { 'Content-Type': 'application/json' },
...init,
});
const body = await r.text();
try {
return JSON.parse(body);
} catch {
return { raw: body, status: r.status };
}
};
async function waitFor(fn, timeout = 30000, step = 300) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, step));
}
return null;
}
const run = async () => {
setMode({ mode: 'ok', delay_sec: 0 });
// ---- isolated project with real stage rows ----
const proj = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({ title: '回流验证项目' }),
});
const patch = await api(`/api/workbench/projects/${proj.id}/stages/assets`, {
method: 'PATCH',
body: JSON.stringify({
content: {
assets: [
{
id: 'asset-e2e',
kind: 'character',
name: '林夏',
description: '',
prompt: 'a stoic courier in the rain',
negative_prompt: '',
},
],
},
}),
});
ok('准备:资产阶段有真实行', patch.ok === true);
const shotPatch = await api(`/api/workbench/projects/${proj.id}/stages/shot`, {
method: 'PATCH',
body: JSON.stringify({
content: {
shots: [
{
id: 'vshot-e2e',
shot_no: 'S1',
description: '',
prompt: 'slow push in on the courier',
duration_sec: 5,
},
],
},
}),
});
ok('准备:镜头阶段有真实行', shotPatch.ok === true);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
// ---------- 1. 从资产行进入生成台:上下文条给出回流提示 ----------
const deskUrl =
`http://127.0.0.1:5173/image?from=workbench&project=${proj.id}&stage=assets` +
`&label=${encodeURIComponent('角色「林夏」')}&prompt=${encodeURIComponent('a stoic courier in neon rain')}` +
`&source_kind=asset&source_id=asset-e2e`;
await page.goto(deskUrl);
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
const note = await page.locator('.context-bar .note').textContent();
ok('上下文条提示可回流', (note || '').includes('回流'), note);
ok('上下文条预填提示词', (await page.inputValue('#desk-prompt')).includes('stoic courier'));
await page.screenshot({ path: `${SHOTS}/hel684-01-desk-context.png` });
// ---------- 2. 提交带上下文的生成 → 完成 → 出现「回流到源项目」 ----------
await page.click('.a-btn.primary'); // 生成
const job = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.output_paths.length && JSON.stringify(j.payload?.workbench ?? {}).includes('asset-e2e'));
});
ok('生成任务完成且携带真实来源(payload.workbench)', !!job, job ? `job ${job.id}` : 'not found');
await page.waitForTimeout(1200);
const take = page.locator(`.take[data-job="${job.id}"]`);
await take.scrollIntoViewIfNeeded();
const attachBtn = take.locator('button.attach');
ok('结果卡出现「回流到源项目」动作', (await attachBtn.count()) === 1);
await page.screenshot({ path: `${SHOTS}/hel684-02-attach-action.png` });
// ---------- 3. 点击回流 → 已回流 + 返回项目 ----------
await attachBtn.click();
const attachedTag = take.locator('.actions .a-tag', { hasText: '已回流' });
await attachedTag.waitFor({ timeout: 15000 });
ok('回流后结果卡显示「已回流」', (await attachedTag.count()) === 1);
const backLink = take.locator('.actions a', { hasText: '返回项目' });
ok('回流后提供「返回项目」链接', (await backLink.count()) === 1);
const overview1 = await api(`/api/workbench/projects/${proj.id}`);
const row1 = overview1.stages.find((s) => s.stage_key === 'assets').content.assets[0];
ok('项目资产行挂上回流产物', row1.job && row1.job.output === job.output_paths[0], JSON.stringify(row1.job || {}));
await page.screenshot({ path: `${SHOTS}/hel684-03-attached.png` });
// ---------- 4. 重载后仍是已回流(服务端标记,非客户端记忆) ----------
await page.reload();
await page.waitForSelector(`.take[data-job="${job.id}"]`, { timeout: 15000 });
await page
.locator(`.take[data-job="${job.id}"] .actions .a-tag`, { hasText: '已回流' })
.waitFor({ timeout: 15000 });
ok('重载后仍显示「已回流」', true);
// ---------- 5. 返回项目看到产物 ----------
await page.locator(`.take[data-job="${job.id}"] .actions a`, { hasText: '返回项目' }).click();
await page.waitForURL(new RegExp(`/workbench/${proj.id}.*stage=assets`), { timeout: 15000 });
await page.waitForSelector('[data-testid="asset-grid"] .asset-card img', { timeout: 15000 });
ok('工作台资产卡显示回流图片', true);
await page.screenshot({ path: `${SHOTS}/hel684-04-workbench-row.png`, fullPage: false });
// ---------- 6. 取消回流:带上下文生成但不动回流 → 项目不误写 ----------
await page.goto(deskUrl);
await page.waitForSelector('#desk-prompt');
await page.fill('#desk-prompt', 'second take on purpose not attached');
await page.click('.a-btn.primary');
const job2 = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.id !== job.id && JSON.stringify(j.payload?.workbench ?? {}).includes('asset-e2e'));
});
ok('第二条带上下文任务完成', !!job2);
await page.waitForTimeout(1000);
const overview2 = await api(`/api/workbench/projects/${proj.id}`);
const row2 = overview2.stages.find((s) => s.stage_key === 'assets').content.assets[0];
ok('未点回流 → 资产行保持第一份产物(不误写)', row2.job && row2.job.output === job.output_paths[0]);
const jobsOnProject = (await api(`/api/jobs?project_id=${proj.id}`)).length ?? 0;
ok('项目任务数不因未回流而增加', jobsOnProject === 1, `${jobsOnProject} job(s)`);
// ---------- 7. 独立生成(无上下文)→ 无回流动作 ----------
await page.goto('http://127.0.0.1:5173/image');
await page.waitForSelector('#desk-prompt');
await page.fill('#desk-prompt', 'standalone take without any context');
await page.click('.a-btn.primary');
const job3 = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.id !== job.id && j.id !== job2?.id && !j.payload?.workbench);
});
ok('独立任务完成且无来源', !!job3);
await page.waitForTimeout(1000);
ok('独立结果卡没有回流动作', (await page.locator(`.take[data-job="${job3.id}"] button.attach`).count()) === 0);
// ---------- 8. 镜头(视频)路径:上下文 + 回流 ----------
setMode({ mode: 'ok', delay_sec: 0, videos: true });
await page.goto(
`http://127.0.0.1:5173/video?from=workbench&project=${proj.id}&stage=shot` +
`&label=${encodeURIComponent('镜头 S1')}&prompt=${encodeURIComponent('slow push in on the courier')}` +
`&source_kind=shot&source_id=vshot-e2e`
);
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
await page.click('.a-btn.primary');
const vjob = await waitFor(async () => {
const jobs = await api('/api/playground/jobs');
return jobs.find((j) => j.status === 'done' && j.kind === 'video' && JSON.stringify(j.payload?.workbench ?? {}).includes('vshot-e2e'));
}, 40000);
ok('视频任务完成且携带镜头来源', !!vjob);
await page.waitForTimeout(1200);
await page.locator(`.take[data-job="${vjob.id}"] button.attach`).click();
await page.waitForSelector(`.take[data-job="${vjob.id}"] .a-tag.ok`, { timeout: 15000 });
const overview3 = await api(`/api/workbench/projects/${proj.id}`);
const shotRow = overview3.stages.find((s) => s.stage_key === 'shot').content.shots[0];
ok('镜头行挂上回流视频', shotRow.job && shotRow.job.output === vjob.output_paths[0], JSON.stringify(shotRow.job || {}));
await page.screenshot({ path: `${SHOTS}/hel684-05-shot-flowback.png` });
await browser.close();
if (failures) {
console.log(`\n${failures} check(s) FAILED`);
process.exit(1);
}
console.log('\nall checks passed');
};
run().catch((e) => {
console.error(e);
process.exit(1);
});
+23 -18
View File
@@ -69,14 +69,15 @@ const run = async () => {
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
// ---------- 工作流选择器:↑↓ 高亮 + Enter 使用 ----------
// ---------- 挑选台(HEL-683 三段式):摘要带展开 → ↑↓ 高亮 + Enter 使用 ----------
await page.goto('http://127.0.0.1:5173/image');
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
await page.click('#desk-wf');
await page.waitForSelector('.shell-pop');
const activeRows = page.locator('.shell-pop .row.active');
await page.click('.bay-band .band-hit');
await page.waitForSelector('.bay .row.active', { timeout: 10000 });
await page.waitForSelector('.bay .search-input');
const activeRows = page.locator('.bay .row.active');
const nActive = await activeRows.count();
ok('工作流面板有默认高亮行', nActive === 1);
ok('挑选台有默认高亮行', nActive === 1);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
const label1 = await activeRows.first().textContent();
@@ -86,15 +87,19 @@ const run = async () => {
const chipBefore = await page.locator('#desk-wf .lbl').textContent();
await page.keyboard.press('Enter');
await page.waitForTimeout(400);
ok('Enter 使用高亮的工作流(挑选台收起)', (await page.locator('.bay.open').count()) === 0);
const chipAfter = await page.locator('#desk-wf .lbl').textContent();
ok('Enter 使用高亮的工作流', chipBefore !== chipAfter || (await activeRows.count()) === 0, `${chipBefore} → ${chipAfter}`);
await page.screenshot({ path: `${SHOTS}/01-picker-roving.png` });
ok('输入台芯片跟随选中工作流', chipBefore !== chipAfter, `${chipBefore} → ${chipAfter}`);
await page.screenshot({ path: `${SHOTS}/01-bay-roving.png` });
// 回到文生图工作流
await page.click('#desk-wf');
await page.fill('.shell-pop .search-input', '文生图');
// 回到文生图工作流(搜索唯一命中「回归」)
await page.click('.bay-band .band-hit');
await page.waitForSelector('.bay .search-input');
await page.fill('.bay .search-input', '回归');
await page.waitForSelector('.bay .row.active');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
ok('搜索后 Enter 选中文生图工作流', (await page.locator('#desk-wf .lbl').textContent()).includes('文生图'));
// ---------- 提交 → 完成(绿标签 + 自动切结果页) ----------
setMode({ mode: 'ok', delay_sec: 0 });
@@ -116,9 +121,9 @@ const run = async () => {
// 绿标签
const doneCard = page.locator('.take', { hasText: '柴犬' }).first();
const doneTag = await doneCard.locator('.state .a-tag').first().textContent();
ok('完成态为绿色「完成」标签(非金色)', (doneTag || '').includes('完成'), doneTag);
ok('完成态为「定影」标签(HEL-680 灰阶语义)', (doneTag || '').includes('定影'), doneTag);
const tagColor = await doneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('完成标签用信号绿 rgb(73,203,144)', tagColor === 'rgb(73, 203, 144)', tagColor);
ok('完成标签用灰阶信号色 rgb(221,221,215)', tagColor === 'rgb(221, 221, 215)', tagColor);
await page.screenshot({ path: `${SHOTS}/02-desk-done.png` });
// ---------- 慢任务:排队灰 + 运行青 + 进度条 ----------
@@ -130,9 +135,9 @@ const run = async () => {
await page.click('.a-btn.primary'); // 第二个:queued 灰
await page.waitForTimeout(4000);
const runTag = await stateTag(page, '观察进度').textContent();
ok('运行中为青色「生成中」', /生成中|执行/.test(runTag || ''), runTag);
ok('运行中为「生成中/执行中」', /生成中|执行/.test(runTag || ''), runTag);
const runTagColor = await stateTag(page, '观察进度').evaluate((el) => getComputedStyle(el).color);
ok('运行标签用信号青 rgb(84,223,210)', runTagColor === 'rgb(84, 223, 210)', runTagColor);
ok('运行标签用灰阶信号色 rgb(184,182,172)', runTagColor === 'rgb(184, 182, 172)', runTagColor);
const hasBar = (await page.locator('.take', { hasText: '观察进度' }).locator('.a-progress').count()) > 0;
ok('运行中卡片有进度条', hasBar);
const queueTag = await stateTag(page, '第二个任务').textContent();
@@ -175,7 +180,7 @@ const run = async () => {
const failTag = await failCard.locator('.state .a-tag').first().textContent();
ok('失败为红色「失败」标签', (failTag || '').includes('失败'), failTag);
const failColor = await failCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('失败标签用信号红 rgb(240,100,90)', failColor === 'rgb(240, 100, 90)', failColor);
ok('失败标签用信号红 rgb(208,122,107)(灰阶底上的红)', failColor === 'rgb(208, 122, 107)', failColor);
const errLine = failCard.locator('.err-txt');
ok('失败自动展开一行中文结论', (await errLine.count()) === 1 && (await errLine.textContent()).length > 4);
await errLine.click();
@@ -199,7 +204,7 @@ const run = async () => {
const undoBtn = page.locator('.toast .action', { hasText: '撤销' });
ok('出现带「撤销」的 Toast', (await undoBtn.count()) === 1);
const undoColor = await undoBtn.evaluate((el) => getComputedStyle(el).color);
ok('撤销动作按钮为金色文字 rgb(242,199,132)', undoColor === 'rgb(242, 199, 132)', undoColor);
ok('撤销动作按钮为强调白文字 rgb(245,244,238)', undoColor === 'rgb(245, 244, 238)', undoColor);
await page.screenshot({ path: `${SHOTS}/05-undo-toast.png` });
const countDeleted = await cardCount(page);
ok('删除后行立即收起', countDeleted === countBefore - 1, `${countBefore} → ${countDeleted}`);
@@ -237,7 +242,7 @@ const run = async () => {
const goneTag = await goneCard.locator('.state .a-tag').first().textContent();
ok('文件缺失为琥珀「文件缺失」', (goneTag || '').includes('文件缺失'), goneTag);
const goneColor = await goneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('缺失标签用琥珀 rgb(240,180,92)', goneColor === 'rgb(240, 180, 92)', goneColor);
ok('缺失标签用信号琥珀 rgb(163,145,95)', goneColor === 'rgb(163, 145, 95)', goneColor);
ok('缺失态给「用相同参数重做」', (await goneCard.getByRole('button', { name: '用相同参数重做' }).count()) === 1);
ok('缺失态给「移除记录」', (await goneCard.getByRole('button', { name: '移除记录' }).count()) === 1);
await goneCard.getByRole('button', { name: '用相同参数重做' }).click();
@@ -284,7 +289,7 @@ const run = async () => {
ok('任务页筛选芯片组(含失败/进行中)', (await chips.count()) === 5);
// 默认「全部」选中即金色文字(accent-soft 底)
const selChipColor = await chips.first().evaluate((el) => getComputedStyle(el).color);
ok('选中筛选芯片为金色文字', selChipColor === 'rgb(242, 199, 132)', selChipColor);
ok('选中筛选芯片为强调白文字', selChipColor === 'rgb(245, 244, 238)', selChipColor);
const failedChipColor = await chips.nth(3).evaluate((el) => getComputedStyle(el).color);
await chips.nth(3).click(); // 失败
await page.waitForTimeout(300);