feat(desk): 生成结果回流原项目——可选上下文透传 + 结果卡回流动作 (HEL-684 S5)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-10-11 15:05:57 +08:00
co-authored by multica-agent
parent 35aad34c7c
commit 0841f5a296
21 changed files with 1213 additions and 27 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 {
@@ -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>;
}
@@ -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')}
+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} 个文件没有可放的槽位:当前工作流没有接这类输入',
@@ -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);
});