Compare commits

...
Author SHA1 Message Date
总工 2b3bbf9d03 HEL-691: deploy S6 visual sweep (feat/hel-685-visual-sweep @ bc24370) 2026-10-11 18:41:38 +08:00
施工员andmultica-agent bc24370233 feat(style): 全站无彩灰阶收尾——清理 legacy 别名与硬编码色漂移 (HEL-685 S6)
- app.css 删除 8 个无引用 legacy 别名,新增 --id-1..5 身份灰阶实色阶
- 画布去紫罗兰二级 palette(tokens.css 删除,改用全站 token)
- 画布/镜头台/智能体/omni/视频组件硬编码色全部归位 token
- agentPalette 彩色身份色改为显影灰阶 --id 阶;修复 3 处相纸白底白字不可读
- WorkflowLibrary 试运行抽屉焦点还原(异步禁用触发键丢焦)修复
- e2e: fpages/import 色值断言对齐现行 token;新增 hel685 走查脚本
  (1440/1024/390 截图 + 控制台错误 + 减动效 + 键盘焦点 + 对比度探针);
  pagination 空库自举;fpages 增加逐页控制台错误检查

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 17:26:12 +08:00
总工 2f267a751f HEL-690: deploy S5 project flowback (feat/hel-684-project-attach @ 0841f5a) 2026-10-11 15:34:07 +08:00
施工员andmultica-agent 0841f5a296 feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 15:05:57 +08:00
53 changed files with 1618 additions and 183 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;
+11 -11
View File
@@ -98,8 +98,8 @@
position: relative;
}
.artifact-node.running {
border-color: rgba(125, 211, 252, 0.6);
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
border-color: var(--run);
box-shadow: 0 0 18px var(--run-soft);
}
:global(.svelte-flow__node.selected) .artifact-node {
border-color: var(--accent);
@@ -117,9 +117,9 @@
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #bae6fd;
background: rgba(12, 74, 110, 0.55);
border: 1px solid rgba(125, 211, 252, 0.35);
color: var(--text-2);
background: var(--bg-4);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 2px 6px;
flex-shrink: 0;
@@ -141,12 +141,12 @@
flex-shrink: 0;
}
.running-badge {
color: #bae6fd;
background: rgba(12, 74, 110, 0.6);
color: var(--run);
background: var(--run-soft);
}
.failed-badge {
color: #fecaca;
background: rgba(127, 29, 29, 0.5);
color: var(--err);
background: var(--err-soft);
}
.play-badge {
font-size: 9px;
@@ -205,9 +205,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
@@ -108,7 +108,7 @@
transition: border-color 0.15s;
}
.entity-node:hover {
border-color: #3a3a44;
border-color: var(--line-2);
}
/* Svelte Flow marks the selected node wrapper with .selected */
:global(.svelte-flow__node.selected) .entity-node {
@@ -176,9 +176,9 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--text-muted);
color: var(--on-media);
pointer-events: none;
background: rgba(0, 0, 0, 0.55);
background: var(--media-scrim);
padding: 1px 5px;
border-radius: 999px;
}
-13
View File
@@ -1,13 +0,0 @@
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
The global --accent stays untouched for v1 pages. */
.canvas-root {
--accent: #4f46a5;
--accent-hover: #6157c9;
--accent-glow: rgba(99, 102, 241, 0.3);
--edge: #7dd3fc;
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
--panel: #17171f;
--panel-border: #2a2a35;
--fg: #f4f4f5;
--fg-dim: #a1a1aa;
}
+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 {
@@ -522,7 +522,7 @@
}
.role {
font-weight: 600;
color: var(--accent, #a78bfa);
color: var(--accent);
font-size: 11px;
margin-left: 6px;
font-family: ui-monospace, monospace;
@@ -567,7 +567,7 @@
input.invalid:focus-visible,
textarea.invalid:focus-visible,
select.invalid:focus-visible {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
box-shadow: 0 0 0 3px var(--err-soft);
}
.error-msg {
font-size: 11px;
@@ -607,7 +607,7 @@
}
.clear:hover {
color: var(--error);
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.clear:focus-visible {
outline: 2px solid var(--accent);
@@ -30,7 +30,7 @@
position: absolute;
inset: 0;
z-index: 30;
background: rgba(18, 18, 24, 0.78);
background: var(--overlay);
backdrop-filter: blur(2px);
border-radius: var(--radius-md);
display: flex;
@@ -237,7 +237,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
}
.counter {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
color: color-mix(in srgb, var(--on-media) 65%, transparent);
margin-right: 4px;
font-variant-numeric: tabular-nums;
}
@@ -249,13 +249,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 40px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
background: color-mix(in srgb, var(--on-media) 12%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.tool:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.tool:focus-visible {
outline: 2px solid var(--accent);
@@ -272,13 +272,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
height: 44px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: color-mix(in srgb, var(--on-media) 10%, transparent);
color: var(--on-media);
cursor: pointer;
transition: background 0.15s;
}
.nav:hover {
background: rgba(255, 255, 255, 0.22);
background: color-mix(in srgb, var(--on-media) 22%, transparent);
}
.nav:focus-visible {
outline: 2px solid var(--accent);
@@ -334,16 +334,16 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
.caption {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
max-width: min(96vw, 720px);
text-align: center;
}
.hint {
margin: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
color: color-mix(in srgb, var(--on-media) 55%, transparent);
}
.hint a {
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--on-media) 85%, transparent);
}
</style>
@@ -504,9 +504,9 @@
height: 36px;
padding: 0 20px;
border-radius: 9999px;
background: var(--success, #22c55e);
background: var(--ok);
border: none;
color: #000;
color: var(--accent-ink);
font-size: 14px;
font-weight: 700;
font-family: var(--font-body);
@@ -95,10 +95,10 @@
rgba(255, 255, 255, 0.03) 6px,
rgba(255, 255, 255, 0.03) 12px
),
var(--bg-elevated, #16161d);
border: 1px dashed var(--border, #2a2a35);
var(--bg-elevated);
border: 1px dashed var(--border);
border-radius: inherit;
color: var(--text-muted, #8b8b9a);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -132,7 +132,7 @@
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
}
.artifact.failed {
border-color: rgba(239, 68, 68, 0.4);
border-color: var(--err-line);
}
.meta {
display: flex;
@@ -249,12 +249,12 @@
width: 44px;
height: 44px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
background: var(--media-scrim);
box-shadow: 0 0 0 1px var(--on-media-line);
}
.play-badge :global(svg) {
position: absolute;
color: #fff;
color: var(--on-media);
fill: currentColor;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
margin-left: 2px;
@@ -668,8 +668,8 @@ let {
justify-content: center;
border: none;
border-radius: 999px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
background: var(--media-scrim);
color: var(--on-media);
cursor: pointer;
padding: 0;
}
@@ -772,7 +772,7 @@ let {
border-radius: var(--radius-md);
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
color: var(--accent-ink);
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -785,7 +785,7 @@ let {
.send.stop {
background: transparent;
color: var(--error);
border-color: rgba(239, 68, 68, 0.5);
border-color: var(--err-line);
}
.send.steer {
color: var(--accent);
@@ -431,6 +431,6 @@
}
.del:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.12);
background: var(--err-soft);
}
</style>
@@ -60,7 +60,7 @@
overflow: hidden;
}
.tool-card.error {
border-color: rgba(239, 68, 68, 0.45);
border-color: var(--err-line);
}
.head {
display: flex;
@@ -1,35 +1,42 @@
/**
* Agent identity palette: a stable display name + accent color per agent.
* Agent identity palette: a stable display name + identity step per agent.
*
* Identity colors answer "who is speaking" only (avatar / name / bubble
* tint) — they never express task status (spec v2 §3.6). The curated set
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
* no tailwind rainbow hues leak into the fusion palette; the default
* main-loop agent is neutral `--text-2` slate.
* Identity answers "who is speaking" only (avatar dot / name / bubble tint)
* — it never expresses task status. HEL-685 无彩化:全部改为 app.css 的
* 显影灰阶实色阶 --id-1..5(与全站 --avatar-1..5 同一身份语言),
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
*/
export const DEFAULT_AGENT_NAME = 'Calliope';
// Fixed identity map (keyed by the raw agent_name the backend emits).
// Step numbers mirror the avatar five-set used across the app
// (models/agents/home project cards).
const IDENTITIES: Record<string, { label: string; color: string }> = {
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
planner: { label: 'Planner', color: 'var(--id-2)' },
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
// The five named agents (HEL-613) — the runner stamps their zh names on
// named-session replies; colors mirror the spec §2.1 avatar five-set.
剧本创作: { label: '剧本创作', color: '#e8a33d' },
视觉设计: { label: '视觉设计', color: '#5b8def' },
分镜设计: { label: '分镜设计', color: '#3fb97f' },
素材资产: { label: '素材资产', color: '#b98be0' },
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
// named-session replies; steps mirror the avatar five-set.
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
素材资产: { label: '素材资产', color: 'var(--id-4)' },
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
};
// Fallback palette for unknown names (stable hash → deterministic color),
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
// Fallback palette for unknown names (stable hash → deterministic step),
// spanning the same five grayscale identity steps.
const FALLBACK_COLORS = [
'var(--id-1)',
'var(--id-2)',
'var(--id-3)',
'var(--id-4)',
'var(--id-5)',
];
function hashString(s: string): number {
let h = 0;
@@ -39,8 +46,8 @@ function hashString(s: string): number {
return h;
}
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = '#aab2c5';
/** Neutral step (--id-4) — the default agent borrows no signal color. */
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
export function agentDisplayName(name: string | null | undefined): string {
const key = (name || '').trim().toLowerCase();
@@ -13,7 +13,7 @@
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,
@@ -476,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) {
@@ -490,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;
@@ -709,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)
// ------------------------------------------------------------------
@@ -909,14 +941,16 @@
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
/>
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} />
@@ -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}
@@ -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>;
}
@@ -231,7 +231,7 @@
.tabs button.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--accent-ink);
}
.tabs button:focus-visible {
@@ -341,7 +341,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -271,9 +271,9 @@
width: 20px;
height: 20px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.65);
background: var(--media-scrim);
border: none;
color: white;
color: var(--on-media);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
@@ -173,7 +173,7 @@
padding: 0 4px;
border-radius: 9999px;
background: var(--accent);
color: white;
color: var(--accent-ink);
font-size: 10px;
font-weight: 700;
}
@@ -90,7 +90,7 @@
.step-btn:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
background: var(--bg-4);
}
.step-btn:focus-visible {
@@ -272,7 +272,7 @@
height: 20px;
border-radius: 9999px;
background: var(--accent);
color: #fff;
color: var(--accent-ink);
display: flex;
align-items: center;
justify-content: center;
@@ -182,9 +182,11 @@
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
name: asset.name,
}),
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
})}
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
sourceKind: 'asset',
sourceId: asset.id,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -165,14 +165,16 @@
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
sourceKind: 'shot',
sourceId: shot.id,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
@@ -278,6 +278,9 @@
let testOpen = $state(false);
let testWf = $state<Workflow | null>(null);
let openingTestId = $state<number | null>(null);
// HEL-685 键盘焦点:openTest 会在异步加载期间禁用触发按钮,焦点先掉回
// body,抽屉关闭时就还原不到触发键。这里记下触发元素,关闭后兜底还原。
let testTriggerEl: HTMLElement | null = null;
async function openTest(row: WorkflowMeta) {
openingTestId = row.id;
@@ -289,6 +292,14 @@
}
}
$effect(() => {
if (!testOpen && testTriggerEl) {
const el = testTriggerEl;
testTriggerEl = null;
if (document.activeElement === document.body && el.isConnected) el.focus();
}
});
let wfRecheckId = $state<number | null>(null);
type ConnectionCheck = Awaited<ReturnType<typeof workflows.check>>;
const wfRecheckResult = $state<Record<number, ConnectionCheck>>({});
@@ -716,7 +727,10 @@
class="link-accent wf-test-btn"
title={t('wft.title')}
disabled={openingTestId === row.id}
onclick={() => void openTest(row)}
onclick={(e) => {
testTriggerEl = e.currentTarget;
void openTest(row);
}}
>
<Icon name="play" size={13} />
{openingTestId === row.id ? t('common.loading') : t('conn.wfTestBtn')}
+9
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',
+9
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',
+9
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',
+9
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',
+9
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} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
+9
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}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
+9
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} 个文件没有可放的槽位:当前工作流没有接这类输入',
@@ -85,7 +85,7 @@
align-items: center;
gap: 12px;
padding: 8px 12px;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
min-height: 74px;
}
.label {
@@ -108,7 +108,7 @@
.open {
display: block;
position: relative;
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
padding: 0;
@@ -128,8 +128,8 @@
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.65);
color: #fff;
background: var(--media-scrim);
color: var(--on-media);
border-radius: 4px;
padding: 1px 5px;
pointer-events: none;
@@ -153,7 +153,7 @@
opacity: 1;
}
.danger {
color: #e5697a;
color: var(--err);
}
.empty {
font-size: 12px;
@@ -123,7 +123,7 @@
gap: 4px;
}
.btn {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -133,10 +133,10 @@
line-height: 1.4;
}
.btn:hover {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-4);
}
.danger {
color: #e5697a;
color: var(--err);
}
.add-grid {
display: grid;
@@ -145,7 +145,7 @@
padding: 0 12px 10px;
}
.add-item {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -154,7 +154,7 @@
cursor: pointer;
}
.add-item:hover {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-4);
}
.objects {
flex: 1;
@@ -171,10 +171,10 @@
padding: 2px 4px;
}
.row:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--bg-3);
}
.row.selected {
background: rgba(99, 102, 241, 0.22);
background: var(--accent-soft);
}
.name {
flex: 1;
@@ -217,8 +217,8 @@
}
.rename {
flex: 1;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border, #2a2f3a);
background: var(--bg);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
padding: 4px 6px;
@@ -233,6 +233,6 @@
padding: 8px 12px;
font-size: 11px;
opacity: 0.45;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
}
</style>
@@ -235,7 +235,7 @@
opacity: 0.6;
}
section {
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
padding-top: 12px;
}
.collapse {
@@ -257,8 +257,8 @@
input[type='number'],
select {
width: 100%;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border, #2a2f3a);
background: var(--bg);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
padding: 3px 5px;
@@ -279,7 +279,7 @@
gap: 6px;
}
.joint {
border: 1px solid var(--border, #232733);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
@@ -298,8 +298,8 @@
}
.capture {
margin-top: auto;
border: 1px solid var(--border, #2a2f3a);
background: rgba(99, 102, 241, 0.18);
border: 1px solid var(--accent-line);
background: var(--accent-soft);
color: inherit;
border-radius: 8px;
padding: 8px;
@@ -307,6 +307,6 @@
cursor: pointer;
}
.capture:hover {
background: rgba(99, 102, 241, 0.3);
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
</style>
@@ -255,7 +255,7 @@
background: var(--bg-elevated);
}
.item.active {
background: rgba(99, 102, 241, 0.22);
background: var(--accent-soft);
}
.dot {
width: 7px;
@@ -265,7 +265,7 @@
flex-shrink: 0;
}
.dot.run {
background: var(--accent, #6366f1);
background: var(--run);
animation: dot-pulse 1.4s ease-in-out infinite;
}
@keyframes dot-pulse {
@@ -295,6 +295,6 @@
display: flex;
}
.del:hover {
color: #e5697a;
color: var(--err);
}
</style>
@@ -841,7 +841,7 @@ let exporting = false;
height: 100%;
min-height: 320px;
overflow: hidden;
background: #14161c;
background: var(--bg);
position: relative;
}
.viewport :global(canvas) {
@@ -866,7 +866,7 @@ let exporting = false;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.14);
background: color-mix(in srgb, var(--on-media) 14%, transparent);
}
.composition-guide .thirds-v:nth-of-type(1) {
left: 33.33%;
@@ -879,7 +879,7 @@ let exporting = false;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.14);
background: color-mix(in srgb, var(--on-media) 14%, transparent);
}
.composition-guide .thirds-h:nth-of-type(3) {
top: 33.33%;
@@ -894,7 +894,7 @@ let exporting = false;
width: 14px;
height: 1px;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.35);
background: color-mix(in srgb, var(--on-media) 35%, transparent);
}
.composition-guide .cross-v {
position: absolute;
@@ -903,6 +903,6 @@ let exporting = false;
width: 1px;
height: 14px;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.35);
background: color-mix(in srgb, var(--on-media) 35%, transparent);
}
</style>
@@ -194,7 +194,7 @@
align-items: center;
gap: 10px;
padding: 6px 12px;
border-top: 1px solid var(--border, #232733);
border-top: 1px solid var(--border);
}
.controls {
display: flex;
@@ -207,7 +207,7 @@
justify-content: center;
min-width: 26px;
height: 26px;
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -216,7 +216,7 @@
padding: 0 6px;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
background: var(--bg-4);
}
.icon-btn:disabled {
opacity: 0.45;
@@ -227,10 +227,10 @@
gap: 4px;
}
.obj-add {
color: #7dd88a;
color: var(--ok);
}
.export {
border: 1px solid var(--border, #2a2f3a);
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 6px;
@@ -240,7 +240,7 @@
white-space: nowrap;
}
.export:hover:not(:disabled) {
border-color: var(--accent, #6366f1);
border-color: var(--accent-line);
}
.export:disabled {
opacity: 0.45;
@@ -255,8 +255,8 @@
}
.dur input {
width: 46px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border, #2a2f3a);
background: var(--bg);
border: 1px solid var(--border);
color: inherit;
border-radius: 4px;
padding: 3px 5px;
@@ -280,14 +280,14 @@
position: absolute;
inset: 11px 0;
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
background: var(--bg-4);
}
.playhead {
position: absolute;
top: 2px;
bottom: 2px;
width: 2px;
background: var(--accent, #6366f1);
background: var(--accent);
transform: translateX(-1px);
pointer-events: none;
}
@@ -298,8 +298,8 @@
height: 10px;
border-radius: 2px;
transform: translate(-5px, -50%) rotate(45deg);
background: #e0b34d;
border: 1px solid rgba(0, 0, 0, 0.5);
background: var(--warn);
border: 1px solid var(--bg);
cursor: grab;
padding: 0;
z-index: 2;
@@ -89,8 +89,8 @@
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border, #2a2f3a);
background: rgba(16, 18, 24, 0.82);
border: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-2) 85%, transparent);
border-radius: 999px;
padding: 4px 8px;
backdrop-filter: blur(4px);
@@ -103,7 +103,7 @@
.divider {
width: 1px;
height: 16px;
background: var(--border, #2a2f3a);
background: var(--border);
margin: 0 2px;
}
.btn {
@@ -119,12 +119,12 @@
opacity: 0.85;
}
.btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-4);
opacity: 1;
}
.btn.on {
background: rgba(99, 102, 241, 0.25);
border-color: var(--accent, #6366f1);
background: var(--accent-soft);
border-color: var(--accent-line);
opacity: 1;
}
.btn:disabled {
@@ -132,9 +132,9 @@
cursor: not-allowed;
}
.capture {
border-color: var(--border, #2a2f3a);
border-color: var(--border);
}
.capture:hover {
border-color: var(--accent, #6366f1);
border-color: var(--accent-line);
}
</style>
+11 -8
View File
@@ -132,9 +132,20 @@
--avatar-4: linear-gradient(135deg, #55544f, #424140);
--avatar-5: linear-gradient(135deg, #424140, #2e2e2c);
/* Agent identity solid steps (HEL-685 无彩化):--avatar-1..5 的实色对应
阶,供 color/border/dot 等不能用渐变值的场合。五阶全部落在深底可读
带内(最暗一阶 ≥ --text-3 对比度下限),身份仍以明度阶区分。 */
--id-1: #f0efe9;
--id-2: #d8d6cd;
--id-3: #b8b6ac;
--id-4: #a8a69c;
--id-5: #94938c;
/* ------------------------------------------------------------------
* Legacy token names (§六 映射表) — aliases only, never new values,
* so every existing component renders in the fusion palette.
* HEL-685 清理:无引用别名(--accent-hover/--accent-contrast/--accent-focus/
* --error-border/--font-ui/--space-xs/--space-lg/--space-xl)已删除。
* ------------------------------------------------------------------ */
--surface: var(--bg-2);
--surface-2: var(--bg-3);
@@ -142,17 +153,13 @@
--border: var(--line);
--border-strong: var(--line-2);
--border-hover: var(--line-2);
--accent-hover: var(--accent-hi);
--accent-contrast: var(--accent-ink);
--accent-dim: var(--accent-soft);
--accent-focus: var(--accent-line);
--success: var(--ok);
--success-dim: var(--ok-soft);
--info: var(--run);
--info-dim: var(--run-soft);
--error: var(--err);
--error-dim: var(--err-soft);
--error-border: var(--err-line);
--warning: var(--warn);
--bg-primary: var(--bg);
--bg-surface: var(--bg-2);
@@ -160,7 +167,6 @@
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-muted: var(--text-3);
--font-ui: var(--font);
--font-a-mono: var(--mono);
--font-display: var(--font);
--font-body: var(--font);
@@ -168,11 +174,8 @@
--radius-sm: var(--r-sm);
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--space-xs: var(--sp-1);
--space-sm: var(--sp-3);
--space-md: var(--sp-6);
--space-lg: var(--sp-7);
--space-xl: var(--sp-8);
/* Omni Composer pill tokens */
--pill-bg: var(--bg-3);
@@ -15,7 +15,6 @@
type Viewport,
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import '$lib/canvas/tokens.css';
import AppHeader from '$lib/components/AppHeader.svelte';
import AgentChat from '$lib/components/agent/AgentChat.svelte';
import AgentComposer from '$lib/components/agent/AgentComposer.svelte';
@@ -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);
});
+10 -3
View File
@@ -96,7 +96,7 @@ const editedColor = await page
.first()
.evaluate((el) => getComputedStyle(el).color)
.catch(() => '');
ok('已编辑标签用琥珀 rgb(240,180,92)', editedColor === 'rgb(240, 180, 92)', editedColor);
ok('已编辑标签用琥珀例外 rgb(163,145,95)', editedColor === 'rgb(163, 145, 95)', editedColor);
// 复制提示词(镜头段 prompt 复制按钮存在性在 UI 层检查 ShotStagePanel)
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
@@ -115,7 +115,12 @@ const railLibrary = await page.locator('.rail a[href="/library"], .nav a[href="/
ok('图标轨/更多含素材库入口', railLibrary >= 1, `count=${railLibrary}`);
await shot(page, 'library-1440');
// ---------- per-page 3-width walk + overflow ----------
// ---------- per-page 3-width walk + overflow + console errors ----------
let consoleErrors = [];
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
const PAGES = [
['home', '/'],
['workbench', `/workbench/${pid}`],
@@ -131,8 +136,10 @@ const PAGES = [
for (const [name, route] of PAGES) {
for (const [wname, w] of WIDTHS) {
await page.setViewportSize({ width: w, height: 900 });
consoleErrors = [];
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(700);
ok(`${name} ${wname} 无控制台错误`, consoleErrors.length === 0, consoleErrors[0] ?? '');
if (wname !== '1440') await shot(page, `${name}-${wname}`);
if (w === 390) {
const overflow = await page.evaluate(
@@ -155,7 +162,7 @@ await page.waitForTimeout(600);
const runGradBar = await page.locator('.a-progress').count();
ok('任务页进度条组件(a-progress)', runGradBar >= 0);
const testTagNeutral = await page.locator('.tag-test').first().evaluate((el) => getComputedStyle(el).color).catch(() => '');
ok('测试标签为中性灰', testTagNeutral === '' || testTagNeutral === 'rgb(170, 178, 197)', testTagNeutral);
ok('测试标签为中性灰阶', testTagNeutral === '' || testTagNeutral === 'rgb(168, 166, 156)', testTagNeutral);
// ---------- agent chat bubbles ----------
await page.goto(`${BASE}/agents`, { waitUntil: 'networkidle' });
+210
View File
@@ -0,0 +1,210 @@
/* HEL-685 S6 全站走查:1440/1024/390 截图 + 减动效 / 键盘焦点 / 对比度探针。
* Runs against the isolated stack (vite 5173 → backend 8247 → fake ComfyUI
* 18188). Screenshots land in e2e/shots/hel685-*.png. */
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 BASE = 'http://127.0.0.1:5173';
const API = 'http://127.0.0.1:8247';
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 };
}
};
// ---------- fixtures: project + confirmed script + canvas ----------
const proj = await api('/api/projects', {
method: 'POST',
body: JSON.stringify({ title: 'S6走查·灯塔', idea: '灯塔看守人的短篇' }),
});
const pid = proj.id;
await api(`/api/workbench/projects/${pid}/stages/script/import`, {
method: 'POST',
body: JSON.stringify({
text: '灯塔看守人老周在暴风雨夜发现一封三十年前的信。\n\n他修好旧船,横过海峡,把回信送到对岸。',
}),
});
const canvas = await api('/api/canvas', { method: 'POST', body: JSON.stringify({ project_id: pid }) });
const cid = canvas.canvas?.id ?? canvas.id;
ok('走查项目与画布就绪', !!pid && !!cid, `pid=${pid} cid=${cid}`);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const consoleErrors = [];
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
});
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
const shot = (name) => page.screenshot({ path: `${SHOTS}/hel685-${name}.png` });
// ---------- 1440 desktop set (compare against HEL-679 / theme-1) ----------
const DESK = [
['home-1440', '/'],
['workbench-1440', `/workbench/${pid}`],
['image-desk-1440', '/image'],
['video-desk-1440', '/video'],
['agents-1440', '/agents'],
['models-1440', '/models'],
['connections-1440', '/connections'],
['tasks-1440', '/tasks'],
['library-1440', '/library'],
['canvas-1440', `/canvas/${cid}`],
['build-scene-1440', '/build-scene'],
];
for (const [name, route] of DESK) {
consoleErrors.length = 0;
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(700);
ok(`${name} 无控制台错误`, consoleErrors.length === 0, consoleErrors[0] ?? '');
await shot(name);
}
// ---------- 1024 / 390 usability set ----------
const NARROW = [
['home', '/'],
['workbench', `/workbench/${pid}`],
['image-desk', '/image'],
['video-desk', '/video'],
['agents', '/agents'],
['tasks', '/tasks'],
['canvas', `/canvas/${cid}`],
];
for (const [name, route] of NARROW) {
for (const w of [1024, 390]) {
await page.setViewportSize({ width: w, height: 900 });
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(600);
await shot(`${name}-${w}`);
if (w === 390) {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
ok(`${name} 390 无横向溢出`, overflow <= 1, `overflow=${overflow}px`);
}
}
}
await page.setViewportSize({ width: 1440, height: 900 });
// ---------- reduce-motion probes (顶栏开关 data-reduce=1) ----------
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(400);
await page.evaluate(() => (document.documentElement.dataset.reduce = '1'));
const dur = await page.locator('.a-btn').first().evaluate((el) => getComputedStyle(el).transitionDuration);
ok('减动效:过渡时长塌缩到 ≤120ms', parseFloat(dur) <= 0.12, dur);
const spinRule = await page.evaluate(() => {
const ring = document.createElement('div');
ring.className = 'a-tile-ring';
ring.innerHTML = '<i></i>';
document.body.appendChild(ring);
const name = getComputedStyle(ring.firstChild).animationName;
ring.remove();
return name;
});
ok('减动效:上传旋转动画禁用(animation-name=none)', spinRule === 'none', spinRule);
await page.evaluate(() => delete document.documentElement.dataset.reduce);
// ---------- keyboard focus probe ----------
await page.goto(`${BASE}/tasks`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(400);
await page.keyboard.press('Tab');
const focusRing = await page.evaluate(() => {
const el = document.activeElement;
if (!el) return null;
const cs = getComputedStyle(el);
return cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) >= 1.5 ? cs.outlineColor : null;
});
ok('键盘焦点显示相纸白焦点圈', focusRing === 'rgb(245, 244, 238)', String(focusRing));
// ---------- text contrast probes (WCAG AA on real elements) ----------
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(600);
const lum = (rgbStr) => {
const m = rgbStr.match(/\d+/g).map(Number);
const [r, g, b] = m.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const l1 = lum(a);
const l2 = lum(b);
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
};
const probeText = async (sel, label, min = 4.5) => {
const el = page.locator(sel).first();
if (!(await el.count())) return ok(`${label} 探针元素存在`, false, sel);
const { color, bg } = await el.evaluate((e) => {
const bgOf = (node) => {
let n = node;
while (n && n !== document.documentElement) {
const bg = getComputedStyle(n).backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
n = n.parentElement;
}
return 'rgb(33, 33, 32)';
};
return { color: getComputedStyle(e).color, bg: bgOf(e) };
});
const r = ratio(color, bg);
ok(`${label} 对比度 ${r.toFixed(2)}:1 ≥ ${min}`, r >= min, `${color} on ${bg}`);
};
await probeText('body', '首页正文文字');
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(600);
await probeText('.a-panel-lead', '面板次要文字(--text-2 级)');
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(400);
// .a-btn.primary 用渐变背景(backgroundColor 透明),对 --accent 实色计算
const btnContrast = await page.evaluate(() => {
const cs = getComputedStyle(document.documentElement);
const hex = (h) => {
const s = h.replace('#', '');
const n = parseInt(s.length === 3 ? s.split('').map((c) => c + c).join('') : s, 16);
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
};
return { ink: hex(cs.getPropertyValue('--accent-ink').trim()), accent: hex(cs.getPropertyValue('--accent').trim()) };
});
const btnRatio = ratio(btnContrast.ink, btnContrast.accent);
ok(`主按钮文字对比度 ${btnRatio.toFixed(2)}:1 ≥ 4.5`, btnRatio >= 4.5, `${btnContrast.ink} on ${btnContrast.accent}`);
// agent identity steps all ≥ text-3 floor (computed from :root tokens)
const idSteps = await page.evaluate(() =>
['--id-1', '--id-2', '--id-3', '--id-4', '--id-5', '--text-3'].map((v) =>
getComputedStyle(document.documentElement).getPropertyValue(v).trim(),
),
);
const bg2 = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg-2').trim());
const toRgb = (hex) => {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
};
for (let i = 0; i < idSteps.length; i++) {
const r = ratio(toRgb(idSteps[i]), toRgb(bg2));
const label = i < 5 ? `身份灰阶 --id-${i + 1}` : '--text-3 下限';
ok(`${label} 在面板底上 ≥4.5:1(${r.toFixed(2)}:1)`, r >= 4.5);
}
await browser.close();
console.log(failures === 0 ? 'ALL HEL-685 WALKTHROUGH CHECKS PASSED' : `${failures} CHECKS FAILED`);
process.exit(failures === 0 ? 0 : 1);
+7 -7
View File
@@ -213,8 +213,8 @@ const run = async () => {
})),
);
ok(
"① 解析 JSON 绿 ✓",
states[0].ok && states[0].color === "rgb(73, 203, 144)",
"① 解析 JSON 亮灰 ✓",
states[0].ok && states[0].color === "rgb(221, 221, 215)",
states[0].color,
);
ok(
@@ -227,8 +227,8 @@ const run = async () => {
.textContent(),
);
ok(
"③ 核对模型全绿(假 object_info 装齐)",
states[2].ok && states[2].color === "rgb(73, 203, 144)",
"③ 核对模型全亮灰(假 object_info 装齐)",
states[2].ok && states[2].color === "rgb(221, 221, 215)",
states[2].color,
);
ok("④ 建立输入槽(1 参数 0 媒体)", states[3].ok);
@@ -311,7 +311,7 @@ const run = async () => {
.evaluate((el) => getComputedStyle(el).color);
ok(
"无法核查 = 琥珀(非绿非红)",
warnColor === "rgb(240, 180, 92)",
warnColor === "rgb(163, 145, 95)",
warnColor,
);
ok(
@@ -392,7 +392,7 @@ const run = async () => {
const chipColor = await page
.locator(".drawer-panel .wft-active .chip.run")
.evaluate((el) => getComputedStyle(el).color);
ok("进行中状态为信号青", chipColor === "rgb(84, 223, 210)", chipColor);
ok("进行中状态为运行灰", chipColor === "rgb(184, 182, 172)", chipColor);
ok(
"进行中显示进度条",
(await page.locator(".drawer-panel .wft-active .track").count()) === 1,
@@ -450,7 +450,7 @@ const run = async () => {
.evaluate((el) => getComputedStyle(el).color);
ok(
"失败判定为红 + 中文标题",
badColor === "rgb(240, 100, 90)",
badColor === "rgb(208, 122, 107)",
await page
.locator(".drawer-panel .wft-result.bad .wft-verdict")
.textContent(),
+22
View File
@@ -16,6 +16,28 @@ const run = async () => {
const base = 'http://127.0.0.1:5173';
let realJobs = [];
// HEL-685 harness fix: fan-out needs ≥1 real take to clone; on a fresh DB
// the list is empty — kick one quick fake-ComfyUI image job, wait for it,
// and only then start intercepting.
{
const probe = await fetch('http://127.0.0.1:8247/api/playground/jobs').then((r) => r.json());
if (!Array.isArray(probe) || probe.length === 0) {
await fetch('http://127.0.0.1:8247/api/playground/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflow_id: 1,
input_values: { 1: { text: 'pagination 基准' }, 2: 42 },
source: 'studio-image',
}),
});
for (let i = 0; i < 60; i++) {
await page.waitForTimeout(500);
const jobs = await fetch('http://127.0.0.1:8247/api/playground/jobs').then((r) => r.json());
if (Array.isArray(jobs) && jobs.some((j) => j.status === 'done')) break;
}
}
}
await page.route('**/api/playground/jobs**', async (route) => {
const r = await route.fetch();
realJobs = await r.json();