feat: persist agent deliverables and explicit project draft handoff (HEL-732)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""Capture successful task replies without treating chat/errors as confirmed work."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
|
||||
|
||||
def complete_task(session_id: int, final: str) -> None:
|
||||
from calliope.agent.harness.plugins.interaction import latest_open_question
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if latest_open_question(session_id):
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='awaiting_input' WHERE id=?", (session_id,)
|
||||
)
|
||||
else:
|
||||
session = conn.execute(
|
||||
"SELECT * FROM agent_sessions WHERE id=?", (session_id,)
|
||||
).fetchone()
|
||||
message = conn.execute(
|
||||
"SELECT id FROM agent_messages WHERE session_id=? AND role='assistant' "
|
||||
"AND content=? AND (status IS NULL OR status NOT IN ('error','cancelled')) "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(session_id, final),
|
||||
).fetchone()
|
||||
if session and message and final.strip():
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO agent_deliverables "
|
||||
"(session_id,source_message_id,project_id,stage_key,title,content) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(
|
||||
session_id,
|
||||
message["id"],
|
||||
session["project_id"],
|
||||
session["agent_key"] or "script",
|
||||
session["title"][:200],
|
||||
final,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='awaiting_confirm' WHERE id=?",
|
||||
(session_id,),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='completed' WHERE id=?", (session_id,)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -40,8 +40,10 @@ class AgentRunner:
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, session_id),
|
||||
"UPDATE agent_sessions SET status = ?, task_status=CASE "
|
||||
"WHEN ? = 'running' THEN 'running' WHEN ? = 'error' THEN 'error' "
|
||||
"ELSE task_status END, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, status, status, session_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -201,7 +203,8 @@ class AgentRunner:
|
||||
).fetchall()]
|
||||
if stuck:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET status = 'idle' WHERE status = 'running'"
|
||||
"UPDATE agent_sessions SET status = 'idle', task_status='interrupted' "
|
||||
"WHERE status = 'running'"
|
||||
)
|
||||
conn.commit()
|
||||
logger.warning(
|
||||
@@ -295,6 +298,15 @@ class AgentRunner:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_summary=? WHERE id=?",
|
||||
(user_message.strip()[:300], session_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._set_status(session_id, "running")
|
||||
task = asyncio.create_task(
|
||||
self._run_session(session_id), name=f"agent-session-{session_id}"
|
||||
@@ -398,6 +410,9 @@ class AgentRunner:
|
||||
session_id=session_id,
|
||||
on_message=self._make_message_sink(session_id, default_agent_name),
|
||||
)
|
||||
from calliope.agent.deliverables import complete_task
|
||||
|
||||
complete_task(session_id, final)
|
||||
# Final answer is persisted + broadcast by the loop's emit() call
|
||||
# (which goes through the message sink). Only set status here.
|
||||
self._set_status(session_id, "idle")
|
||||
@@ -408,6 +423,14 @@ class AgentRunner:
|
||||
content="— run cancelled —",
|
||||
status="cancelled",
|
||||
)
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='cancelled' WHERE id=?", (session_id,)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._set_status(session_id, "idle")
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the chat as the reply
|
||||
|
||||
@@ -311,6 +311,36 @@ CREATE TABLE IF NOT EXISTS project_stages (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages(project_id);
|
||||
|
||||
-- B4: immutable source identity, editable draft, optimistic concurrency.
|
||||
CREATE TABLE IF NOT EXISTS agent_deliverables (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
source_message_id INTEGER NOT NULL,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
|
||||
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, source_message_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_deliverable_versions (
|
||||
deliverable_id INTEGER NOT NULL REFERENCES agent_deliverables(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(deliverable_id, revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS result_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_key TEXT NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
stage_key TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_key, project_id, stage_key)
|
||||
);
|
||||
|
||||
-- Audit trail of agent runs inside the flow (HEL-614): instruction in,
|
||||
-- normalized JSON artifact out, or a Chinese-readable failure reason.
|
||||
CREATE TABLE IF NOT EXISTS stage_runs (
|
||||
@@ -587,6 +617,13 @@ async def migrate_db(db_path: Path) -> None:
|
||||
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
|
||||
# Build Scene rail). The session list excludes origin='scene' so scene
|
||||
# sessions don't pollute the AI Canvas sidebar; Build Scene filters on it.
|
||||
b4_cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_sessions)")}
|
||||
if "task_summary" not in b4_cols:
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN task_summary TEXT NOT NULL DEFAULT ''")
|
||||
if "task_status" not in b4_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE agent_sessions ADD COLUMN task_status TEXT NOT NULL DEFAULT 'ready'"
|
||||
)
|
||||
session_cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_sessions)").fetchall()}
|
||||
if "origin" not in session_cols:
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN origin TEXT NOT NULL DEFAULT 'chat'")
|
||||
|
||||
@@ -20,19 +20,24 @@ from calliope.routers import (
|
||||
agent,
|
||||
assets,
|
||||
canvas,
|
||||
comfyui as comfy,
|
||||
events,
|
||||
jobs,
|
||||
library,
|
||||
playground,
|
||||
projects,
|
||||
results,
|
||||
scenes,
|
||||
settings as settings_router,
|
||||
shots,
|
||||
story,
|
||||
workbench,
|
||||
workflows,
|
||||
)
|
||||
from calliope.routers import (
|
||||
comfyui as comfy,
|
||||
)
|
||||
from calliope.routers import (
|
||||
settings as settings_router,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -141,6 +146,7 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
|
||||
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
|
||||
app.include_router(results.router, prefix="/api/results", tags=["results"])
|
||||
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
|
||||
app.include_router(workbench.router, prefix="/api/workbench", tags=["workbench"])
|
||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Editable task deliverables and explicit, idempotent result → stage drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.routers.workbench import StageKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DraftPatch(BaseModel):
|
||||
revision: int = Field(ge=1)
|
||||
content: str = Field(min_length=1, max_length=200_000)
|
||||
project_id: int | None = None
|
||||
stage_key: StageKey
|
||||
|
||||
|
||||
class StoreResult(BaseModel):
|
||||
project_id: int = Field(gt=0)
|
||||
stage_key: StageKey
|
||||
deliverable_id: int | None = Field(default=None, gt=0)
|
||||
revision: int | None = Field(default=None, gt=0)
|
||||
job_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_source(self):
|
||||
if (self.deliverable_id is None) == (self.job_id is None):
|
||||
raise ValueError("Choose exactly one result source")
|
||||
if self.deliverable_id is not None and self.revision is None:
|
||||
raise ValueError("Deliverable revision is required")
|
||||
return self
|
||||
|
||||
|
||||
def _public(row) -> dict[str, Any]:
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/deliverables")
|
||||
def list_deliverables(session_id: int | None = None) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
sql = "SELECT * FROM agent_deliverables"
|
||||
args = ()
|
||||
if session_id is not None:
|
||||
sql += " WHERE session_id=?"
|
||||
args = (session_id,)
|
||||
return [_public(r) for r in conn.execute(sql + " ORDER BY id DESC", args)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/deliverables/{draft_id}/versions")
|
||||
def list_versions(draft_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if not conn.execute("SELECT id FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone():
|
||||
raise HTTPException(404, "成果不存在")
|
||||
return [
|
||||
_public(row)
|
||||
for row in conn.execute(
|
||||
"SELECT * FROM agent_deliverable_versions WHERE deliverable_id=? "
|
||||
"ORDER BY revision DESC",
|
||||
(draft_id,),
|
||||
)
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/deliverables/{draft_id}")
|
||||
def patch_deliverable(draft_id: int, payload: DraftPatch) -> dict[str, Any]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "成果不存在")
|
||||
if payload.project_id is not None:
|
||||
from calliope.routers.workbench import _get_project
|
||||
|
||||
_get_project(conn, payload.project_id)
|
||||
if row["revision"] != payload.revision:
|
||||
raise HTTPException(409, "草稿已更新,请刷新后重试")
|
||||
# A linked session cannot silently move its work into another project.
|
||||
session = conn.execute(
|
||||
"SELECT project_id FROM agent_sessions WHERE id=?", (row["session_id"],)
|
||||
).fetchone()
|
||||
if session["project_id"] is not None and payload.project_id != session["project_id"]:
|
||||
raise HTTPException(409, "成果归属与原会话项目不一致")
|
||||
if not payload.content.strip():
|
||||
raise HTTPException(422, "成果内容不能为空")
|
||||
conn.execute(
|
||||
"INSERT INTO agent_deliverable_versions(deliverable_id,revision,content) "
|
||||
"VALUES (?,?,?)",
|
||||
(draft_id, row["revision"], row["content"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE agent_deliverables SET content=?,project_id=?,stage_key=?,revision=revision+1,"
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(payload.content, payload.project_id, payload.stage_key, draft_id),
|
||||
)
|
||||
conn.commit()
|
||||
return _public(
|
||||
conn.execute("SELECT * FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone()
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/store")
|
||||
async def store_result(payload: StoreResult) -> dict[str, Any]:
|
||||
from calliope.agent.llm import extract_json
|
||||
from calliope.routers.playground import _assert_path_in_assets
|
||||
from calliope.routers.workbench import (
|
||||
NORMALIZERS,
|
||||
StageContentError,
|
||||
_get_project,
|
||||
_stage_or_404,
|
||||
_stage_public,
|
||||
)
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
_get_project(conn, payload.project_id)
|
||||
stage = _stage_or_404(conn, payload.project_id, payload.stage_key)
|
||||
draft = None
|
||||
if payload.deliverable_id is not None:
|
||||
draft = conn.execute(
|
||||
"SELECT * FROM agent_deliverables WHERE id=?", (payload.deliverable_id,)
|
||||
).fetchone()
|
||||
if not draft:
|
||||
raise HTTPException(404, "成果不存在")
|
||||
if draft["revision"] != payload.revision:
|
||||
raise HTTPException(409, "草稿版本已变化,请先保存并刷新")
|
||||
if draft["project_id"] != payload.project_id or draft["stage_key"] != payload.stage_key:
|
||||
raise HTTPException(409, "请先保存成果的项目与阶段归属")
|
||||
session = conn.execute(
|
||||
"SELECT project_id FROM agent_sessions WHERE id=?", (draft["session_id"],)
|
||||
).fetchone()
|
||||
if session["project_id"] is not None and session["project_id"] != payload.project_id:
|
||||
raise HTTPException(409, "会话项目已变化,请重新核对成果归属")
|
||||
source_key = f"deliverable:{draft['id']}:{draft['revision']}"
|
||||
result = {
|
||||
"source_key": source_key,
|
||||
"title": draft["title"],
|
||||
"text": draft["content"],
|
||||
"session_id": draft["session_id"],
|
||||
}
|
||||
else:
|
||||
job = conn.execute("SELECT * FROM jobs WHERE id=?", (payload.job_id,)).fetchone()
|
||||
if not job:
|
||||
raise HTTPException(404, "生成结果不存在")
|
||||
project = conn.execute(
|
||||
"SELECT status FROM projects WHERE id=?", (job["project_id"],)
|
||||
).fetchone()
|
||||
if job["project_id"] != payload.project_id and (
|
||||
not project or project["status"] != "system"
|
||||
):
|
||||
raise HTTPException(409, "此结果属于其他项目")
|
||||
if job["status"] != "done" or job["kind"] not in ("image", "video"):
|
||||
raise HTTPException(409, "仅可存入已完成的图片或视频")
|
||||
paths = json.loads(job["output_paths_json"] or "[]")
|
||||
if (
|
||||
not paths
|
||||
or not isinstance(paths, list)
|
||||
or not all(isinstance(p, str) for p in paths)
|
||||
):
|
||||
raise HTTPException(422, "结果没有产物")
|
||||
for path in paths:
|
||||
if not _assert_path_in_assets(path).is_file():
|
||||
raise HTTPException(410, "产物文件已不存在")
|
||||
source_key = f"job:{job['id']}"
|
||||
result = {
|
||||
"source_key": source_key,
|
||||
"title": f"生成结果 #{job['id']}",
|
||||
"kind": job["kind"],
|
||||
"paths": paths,
|
||||
"job_id": job["id"],
|
||||
}
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM result_attachments WHERE source_key=? AND project_id=? AND stage_key=?",
|
||||
(source_key, payload.project_id, payload.stage_key),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {"ok": True, "already_attached": True, "stage": _stage_public(stage)}
|
||||
if stage["status"] == "running":
|
||||
raise HTTPException(409, "本阶段正在生成,请稍后再存入")
|
||||
content = json.loads(stage["content_json"] or "{}")
|
||||
if draft:
|
||||
text = draft["content"].strip()
|
||||
# Valid structured artifacts replace only the working draft.
|
||||
# Confirmed snapshots stay intact.
|
||||
parsed = None
|
||||
try:
|
||||
parsed = extract_json(text)
|
||||
except ValueError:
|
||||
if payload.stage_key == "script":
|
||||
parsed = {"text": text}
|
||||
if isinstance(parsed, dict):
|
||||
try:
|
||||
normalized = NORMALIZERS[payload.stage_key](parsed)
|
||||
normalized["incoming_results"] = content.get("incoming_results", [])
|
||||
content = normalized
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
incoming = list(content.get("incoming_results") or [])
|
||||
incoming.append(result)
|
||||
content["incoming_results"] = incoming
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?,status='awaiting_confirm',draft_edited=1,"
|
||||
"error=NULL,updated_at=CURRENT_TIMESTAMP WHERE project_id=? AND stage_key=?",
|
||||
(json.dumps(content, ensure_ascii=False), payload.project_id, payload.stage_key),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO result_attachments(source_key,project_id,stage_key) VALUES (?,?,?)",
|
||||
(source_key, payload.project_id, payload.stage_key),
|
||||
)
|
||||
if draft:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='returned' WHERE id=?",
|
||||
(draft["session_id"],),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (payload.project_id,)
|
||||
)
|
||||
conn.commit()
|
||||
row = _stage_or_404(conn, payload.project_id, payload.stage_key)
|
||||
out = {"ok": True, "already_attached": False, "stage": _stage_public(row)}
|
||||
finally:
|
||||
conn.close()
|
||||
await event_bus.publish(
|
||||
"workbench.stage.updated",
|
||||
{"project_id": payload.project_id, "stage_key": payload.stage_key},
|
||||
)
|
||||
return out
|
||||
@@ -302,6 +302,7 @@ def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"status": row["status"],
|
||||
"content": content,
|
||||
"assistant_note": (content or {}).get("assistant_note", ""),
|
||||
"incoming_results": (content or {}).get("incoming_results", []),
|
||||
"confirmed_content": (
|
||||
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
|
||||
),
|
||||
@@ -692,6 +693,8 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if old_content.get("incoming_results"):
|
||||
content["incoming_results"] = old_content["incoming_results"]
|
||||
if old_content.get("assistant_note"):
|
||||
content["assistant_note"] = old_content["assistant_note"]
|
||||
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
|
||||
@@ -820,6 +823,9 @@ async def confirm_stage(
|
||||
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raw_content = json.loads(stage["content_json"])
|
||||
if raw_content.get("incoming_results"):
|
||||
content["incoming_results"] = raw_content["incoming_results"]
|
||||
chosen = payload.chosen_style_id if payload else None
|
||||
if stage_key == "style":
|
||||
directions = content.get("directions") or []
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""B4 draft persistence, concurrency, scope, confirmation and migration regression."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from calliope.agent.deliverables import complete_task
|
||||
from calliope.agent.harness.runner import runner
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
|
||||
TEXT = (
|
||||
"第一场,清晨海岸,女孩将纸船推向海面,等待父亲归来。"
|
||||
"第二场,纸船带回树叶,女孩沿着灯塔的光继续前行。"
|
||||
)
|
||||
|
||||
|
||||
def setup(client, stage="script"):
|
||||
project = client.post("/api/projects", json={"title": "B4 isolated"}).json()
|
||||
session = client.post(
|
||||
"/api/agent/sessions",
|
||||
json={
|
||||
"project_id": project["id"],
|
||||
"agent_key": stage,
|
||||
},
|
||||
).json()
|
||||
runner._persist_message(session["id"], role="assistant", content=TEXT)
|
||||
complete_task(session["id"], TEXT)
|
||||
draft = client.get("/api/results/deliverables", params={"session_id": session["id"]}).json()[0]
|
||||
return project["id"], session["id"], draft
|
||||
|
||||
|
||||
def store(client, pid, draft, stage="script"):
|
||||
return client.post(
|
||||
"/api/results/store",
|
||||
json={
|
||||
"project_id": pid,
|
||||
"stage_key": stage,
|
||||
"deliverable_id": draft["id"],
|
||||
"revision": draft["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_draft_save_refresh_conflict_and_scope(client):
|
||||
pid, sid, draft = setup(client)
|
||||
body = {"revision": 1, "content": TEXT + "新结尾。", "project_id": pid, "stage_key": "script"}
|
||||
saved = client.patch(f"/api/results/deliverables/{draft['id']}", json=body)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["revision"] == 2
|
||||
versions = client.get(f"/api/results/deliverables/{draft['id']}/versions").json()
|
||||
assert len(versions) == 1 and versions[0]["content"] == TEXT
|
||||
assert (
|
||||
client.get("/api/results/deliverables", params={"session_id": sid}).json()[0]
|
||||
== saved.json()
|
||||
)
|
||||
assert client.patch(f"/api/results/deliverables/{draft['id']}", json=body).status_code == 409
|
||||
assert store(client, pid, draft).status_code == 409
|
||||
other = client.post("/api/projects", json={"title": "Other"}).json()["id"]
|
||||
assert store(client, other, saved.json()).status_code == 409
|
||||
body.update(revision=2, project_id=other)
|
||||
assert client.patch(f"/api/results/deliverables/{draft['id']}", json=body).status_code == 409
|
||||
assert client.get(f"/api/agent/sessions/{sid}").json()["task_status"] == "awaiting_confirm"
|
||||
|
||||
|
||||
def test_store_idempotency_confirm_restore_and_preserve_snapshot(client):
|
||||
pid, sid, draft = setup(client)
|
||||
url = f"/api/workbench/projects/{pid}/stages/script"
|
||||
old = {"text": TEXT + "已确认的旧版。"}
|
||||
assert client.patch(url, json={"content": old}).status_code == 200
|
||||
snapshot = client.post(url + "/confirm", json={}).json()["stage"]["confirmed_content"]
|
||||
res = store(client, pid, draft)
|
||||
assert res.status_code == 200, res.text
|
||||
stage = res.json()["stage"]
|
||||
assert stage["status"] == "awaiting_confirm"
|
||||
assert stage["confirmed_content"] == snapshot
|
||||
assert stage["content"]["text"] == TEXT
|
||||
assert len(stage["incoming_results"]) == 1
|
||||
assert store(client, pid, draft).json()["already_attached"] is True
|
||||
assert len(store(client, pid, draft).json()["stage"]["incoming_results"]) == 1
|
||||
assert client.get(f"/api/agent/sessions/{sid}").json()["task_status"] == "returned"
|
||||
assert client.post(url + "/restore", json={}).json()["stage"]["content"] == snapshot
|
||||
# A newer source revision is a new explicit handoff, never an automatic update.
|
||||
draft = client.patch(
|
||||
f"/api/results/deliverables/{draft['id']}",
|
||||
json={
|
||||
"revision": 1,
|
||||
"content": TEXT + "新版本。",
|
||||
"project_id": pid,
|
||||
"stage_key": "script",
|
||||
},
|
||||
).json()
|
||||
assert store(client, pid, draft).status_code == 200
|
||||
confirmed = client.post(url + "/confirm", json={}).json()["stage"]["confirmed_content"]
|
||||
assert confirmed["text"].endswith("新版本。")
|
||||
assert len(confirmed["incoming_results"]) == 1
|
||||
stages = client.get(f"/api/workbench/projects/{pid}").json()["stages"]
|
||||
assert all(s["status"] == "todo" for s in stages if s["stage_key"] != "script")
|
||||
|
||||
|
||||
def test_capture_idempotency_and_no_error_artifact(client):
|
||||
_, sid, _ = setup(client)
|
||||
complete_task(sid, TEXT)
|
||||
assert len(client.get("/api/results/deliverables", params={"session_id": sid}).json()) == 1
|
||||
runner._persist_message(sid, role="assistant", content="error", status="error")
|
||||
complete_task(sid, "error")
|
||||
assert len(client.get("/api/results/deliverables", params={"session_id": sid}).json()) == 1
|
||||
|
||||
|
||||
def test_sandbox_assignment_and_running_stage(client):
|
||||
pid, _, draft = setup(client)
|
||||
conn = get_db(settings.db_path)
|
||||
conn.execute("UPDATE project_stages SET status='running' WHERE project_id=?", (pid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Stage rows are created lazily; ensure they exist first.
|
||||
client.get(f"/api/workbench/projects/{pid}")
|
||||
conn = get_db(settings.db_path)
|
||||
conn.execute("UPDATE project_stages SET status='running' WHERE project_id=?", (pid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert store(client, pid, draft).status_code == 409
|
||||
assert client.get("/api/results/deliverables", params={"session_id": 99999}).json() == []
|
||||
|
||||
|
||||
def test_media_paths_scope_and_empty_errors(client):
|
||||
pid, _, _ = setup(client)
|
||||
other = client.post("/api/projects", json={"title": "other"}).json()["id"]
|
||||
path = settings.assets_dir / "b4.txt"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("isolated")
|
||||
conn = get_db(settings.db_path)
|
||||
job = conn.execute(
|
||||
"INSERT INTO jobs(project_id,kind,status,output_paths_json) VALUES (?,'image','done',?)",
|
||||
(pid, json.dumps([str(path)])),
|
||||
).lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
body = {"job_id": job, "project_id": other, "stage_key": "assets"}
|
||||
assert client.post("/api/results/store", json=body).status_code == 409
|
||||
body["project_id"] = pid
|
||||
result = client.post("/api/results/store", json=body)
|
||||
assert result.status_code == 200, result.text
|
||||
assert result.json()["stage"]["incoming_results"][0]["paths"] == [str(path)]
|
||||
assert client.post("/api/results/store", json=body).json()["already_attached"]
|
||||
assert (
|
||||
client.post(
|
||||
"/api/results/store", json={"project_id": pid, "stage_key": "bogus", "job_id": job}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/results/store", json={"project_id": pid, "stage_key": "assets"}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
path.unlink()
|
||||
assert client.post("/api/results/store", json=body).status_code == 404
|
||||
|
||||
|
||||
def test_migration_twice_and_sqlite_backup_rollback(client, tmp_path):
|
||||
pid, sid, draft = setup(client)
|
||||
conn = sqlite3.connect(settings.db_path)
|
||||
backup = sqlite3.connect(tmp_path / "before-b4.db")
|
||||
conn.backup(backup)
|
||||
backup.close()
|
||||
conn.close()
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
assert client.get("/api/results/deliverables").json()[0]["content"] == draft["content"]
|
||||
conn = get_db(settings.db_path)
|
||||
# Old-code SQL continues reading the original schema and rows.
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT title,status,project_id FROM agent_sessions WHERE id=?", (sid,)
|
||||
).fetchone()["project_id"]
|
||||
== pid
|
||||
)
|
||||
assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
|
||||
conn.close()
|
||||
@@ -98,9 +98,9 @@ check(
|
||||
JSON.stringify(stripSkillFrontmatter(plain)),
|
||||
);
|
||||
|
||||
// 5. Static: the page uses the shared helper; no `\A` regex remains.
|
||||
// 5. Static: the extracted skill editor uses the shared helper; no `\A` regex remains.
|
||||
const page = readFileSync(
|
||||
join(ROOT, "src/routes/agents/+page.svelte"),
|
||||
join(ROOT, "src/lib/components/agent/LegacyAgentWorkspace.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -834,6 +834,7 @@ export interface VideoShot {
|
||||
}
|
||||
|
||||
export interface WorkbenchStage {
|
||||
incoming_results?: IncomingResult[];
|
||||
assistant_note?: string;
|
||||
stage_key: WorkbenchStageKey;
|
||||
status: WorkbenchStageStatus;
|
||||
@@ -954,7 +955,65 @@ export const libraryApi = {
|
||||
|
||||
// ── Agents (agentic harness) ────────────────────────────────────────────
|
||||
|
||||
export interface AgentDeliverable {
|
||||
id: number;
|
||||
session_id: number;
|
||||
source_message_id: number;
|
||||
project_id: number | null;
|
||||
stage_key: WorkbenchStageKey;
|
||||
title: string;
|
||||
content: string;
|
||||
revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface IncomingResult {
|
||||
source_key: string;
|
||||
title: string;
|
||||
text?: string;
|
||||
kind?: string;
|
||||
paths?: string[];
|
||||
session_id?: number;
|
||||
job_id?: number;
|
||||
}
|
||||
export const resultsApi = {
|
||||
versions: (id: number) =>
|
||||
api<{ revision: number; content: string }[]>(
|
||||
`/api/results/deliverables/${id}/versions`,
|
||||
),
|
||||
list: (sessionId?: number) =>
|
||||
api<AgentDeliverable[]>(
|
||||
`/api/results/deliverables${sessionId == null ? "" : `?session_id=${sessionId}`}`,
|
||||
),
|
||||
patch: (
|
||||
id: number,
|
||||
payload: {
|
||||
revision: number;
|
||||
content: string;
|
||||
project_id: number | null;
|
||||
stage_key: WorkbenchStageKey;
|
||||
},
|
||||
) =>
|
||||
api<AgentDeliverable>(`/api/results/deliverables/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
store: (payload: {
|
||||
project_id: number;
|
||||
stage_key: WorkbenchStageKey;
|
||||
deliverable_id?: number;
|
||||
revision?: number;
|
||||
job_id?: number;
|
||||
}) =>
|
||||
api<{ ok: boolean; already_attached: boolean; stage: WorkbenchStage }>(
|
||||
"/api/results/store",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
),
|
||||
};
|
||||
|
||||
export interface AgentSession {
|
||||
task_summary?: string;
|
||||
task_status?: string;
|
||||
id: number;
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
studio?: boolean;
|
||||
messages: AgentMessage[];
|
||||
/** In-flight tool calls for the current turn (from agent.tool SSE). */
|
||||
liveTools: { tool: string; args?: Record<string, unknown> | null; result?: unknown; phase: 'running' | 'done' | 'error' }[];
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
studio = false,
|
||||
messages,
|
||||
liveTools,
|
||||
streaming,
|
||||
@@ -189,6 +191,35 @@
|
||||
</script>
|
||||
|
||||
<div class="chat" bind:this={listEl} onscroll={onScroll}>
|
||||
{#if studio}
|
||||
{#if loading}<p class="studio-empty">{t('agentChat.loading')}</p>{/if}
|
||||
{#each messages as m (m.id)}
|
||||
{#if m.role==='user' || m.role==='assistant'}
|
||||
<div class="studio-msg" class:me={m.role==='user'}>
|
||||
<div class="studio-avatar" title={m.role==='user'?'我':agentDisplayName(m.agent_name)}>{#if m.role==='user'}我{:else}<Icon name="project-agent" size={14}/>{/if}</div>
|
||||
<div class="studio-message-body">
|
||||
{#if m.reasoning}<ReasoningPanel reasoning={m.reasoning}/>{/if}
|
||||
<div class="studio-bubble" class:failed={m.status==='error'||m.status==='cancelled'}>
|
||||
{#if m.status==='steering'}<small>{t('agentChat.steering')}</small>{/if}
|
||||
{#each m.attachments??[] as a (a.path)}<a href={assetUrl(a.path)??'#'} target="_blank" rel="noopener external">{a.name}</a>{/each}
|
||||
{#each bubbleParts(m.content,(m.mentions??[]) as WorkflowMention[]) as part,i (`${i}-${part.type}`)}{#if part.type==='chip'}<span class="wf-chip">{part.text}</span>{:else}{part.text}{/if}{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if m.role==='tool'}
|
||||
<AgentToolCard name={m.tool_name??'tool'} args={m.tool_args} result={m.tool_result} phase={toolPhase(m)}/>
|
||||
{#each artifactJobs(m) as job(job.id)}<AgentArtifactCard {job}/>{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if lastQuestion && !running}<div class="question-card"><p>{lastQuestion.question}</p><div class="question-options">{#each lastQuestion.options as opt(opt)}<button type="button" class="question-option" onclick={()=>onAnswer?.(opt,lastQuestion.scope,lastQuestion.questionSeq)}>{opt}</button>{/each}</div></div>{/if}
|
||||
{#if running}
|
||||
{#each liveTools as tool,i(i)}<AgentToolCard name={tool.tool} args={tool.args} result={tool.result} phase={tool.phase}/>{/each}
|
||||
{#if streamingReasoning}<ReasoningPanel reasoning={streamingReasoning}/>{/if}
|
||||
{#if streaming}<div class="studio-msg"><div class="studio-avatar"><Icon name="project-agent" size={14}/></div><div class="studio-bubble">{streaming}</div></div>{/if}
|
||||
{/if}
|
||||
{#if !loading&&!messages.length&&!running}<p class="studio-empty">{t('agentChat.emptyHint')}</p>{/if}
|
||||
{:else}
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">
|
||||
<p class="empty-hint">{t('agentChat.loading')}</p>
|
||||
@@ -329,9 +360,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.studio-msg {display:flex;gap:10px;max-width:760px;}
|
||||
.studio-msg.me {align-self:flex-end;flex-direction:row-reverse;}
|
||||
.studio-avatar {width:28px;height:28px;border-radius:var(--agent-radius-8);flex:none;display:grid;place-items:center;background:color-mix(in srgb,var(--sc-agent) 18%,transparent);color:var(--sc-agent);}
|
||||
.studio-msg.me .studio-avatar {background:var(--bg-3);color:var(--tx-2);}
|
||||
.studio-message-body {display:flex;flex-direction:column;gap:10px;min-width:0;}
|
||||
.studio-bubble {padding:10px 13px;border-radius:var(--agent-bubble-radius);background:var(--bg-2);border:1px solid var(--line);font-size:13px;line-height:1.65;white-space:pre-wrap;word-break:break-word;}
|
||||
.studio-msg.me .studio-bubble {background:color-mix(in srgb,var(--gold) 10%,transparent);border-color:color-mix(in srgb,var(--gold) 25%,transparent);}
|
||||
.studio-bubble.failed {color:var(--err);border-color:var(--err);}
|
||||
.studio-empty {color:var(--tx-3);font-size:12px;}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -342,7 +384,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 4px 4px 12px;
|
||||
padding: var(--chat-padding, 4px 4px 12px);
|
||||
}
|
||||
.empty {
|
||||
margin: auto;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
studio?: boolean;
|
||||
running: boolean;
|
||||
onSend: (payload: AgentComposerPayload) => void;
|
||||
onCancel: () => void;
|
||||
@@ -34,6 +35,7 @@ interface Props {
|
||||
}
|
||||
|
||||
let {
|
||||
studio = false,
|
||||
running,
|
||||
onSend,
|
||||
onCancel,
|
||||
@@ -549,6 +551,7 @@ let {
|
||||
onpaste={onPaste}
|
||||
></div>
|
||||
<div class="actions">
|
||||
{#if studio}<button type="button" class="attach workflow-shortcut" disabled={busy} onclick={()=>{editorEl?.focus();mentionQuery='';mentionIndex=0;mentionAnchor=caretAnchor();mentionLocked=false;mentionOpen=true;}}>@ 工作流</button>{/if}
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
@@ -564,7 +567,7 @@ let {
|
||||
disabled={busy}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<Icon name="paperclip" size={16} />
|
||||
<Icon name="paperclip" size={16} />{#if studio}<span>参考材料</span>{/if}
|
||||
</button>
|
||||
{#if running}
|
||||
<button type="button" class="send stop" onclick={onCancel} title={t('agentComposer.stopTitle')}>
|
||||
@@ -617,8 +620,8 @@ let {
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--composer-top-padding, 10px);
|
||||
border-top: var(--composer-top-border, 1px) solid var(--border);
|
||||
}
|
||||
.composer.drag-over .box {
|
||||
border-color: var(--accent);
|
||||
@@ -686,18 +689,19 @@ let {
|
||||
}
|
||||
.box {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: var(--composer-box-align, flex-end);
|
||||
flex-direction: var(--composer-box-direction, row);
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--composer-box-bg, var(--bg-surface));
|
||||
border: var(--composer-box-border, 1px) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 8px 8px 12px;
|
||||
padding: var(--composer-box-padding, 8px 8px 8px 12px);
|
||||
}
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
min-height: var(--composer-editor-height, 28px);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
@@ -741,6 +745,7 @@ let {
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content:var(--composer-actions-justify,normal);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
@@ -749,14 +754,17 @@ let {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: var(--composer-attach-width,36px);
|
||||
height: var(--composer-attach-height,36px);
|
||||
padding:var(--composer-attach-padding,0px);
|
||||
margin-right:var(--composer-attach-margin,0px);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-shortcut {font-size:12px;white-space:nowrap;flex:none;}
|
||||
.attach:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
@@ -773,11 +781,11 @@ let {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
height: var(--composer-send-height, 36px);
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
background: var(--composer-send-bg, var(--accent));
|
||||
color: var(--accent-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
<style>
|
||||
.tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--tool-card-radius, var(--radius-sm));
|
||||
background: var(--tool-card-bg, var(--bg-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-card.error {
|
||||
@@ -67,7 +67,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
padding: var(--tool-head-padding, 7px 10px);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -82,7 +82,7 @@
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--tool-name-font, 12px);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
<script lang="ts">
|
||||
import "./workspace.css";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toStore } from "svelte/store";
|
||||
import AppShell from "$lib/components/shell/AppShell.svelte";
|
||||
import type { IconName } from "$lib/components/ui/icons";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import AgentChat from "./AgentChat.svelte";
|
||||
import AgentPlanPanel from "./AgentPlanPanel.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import AgentComposer from "./AgentComposer.svelte";
|
||||
import {
|
||||
agentApi,
|
||||
resultsApi,
|
||||
jobsApi,
|
||||
assetUrl,
|
||||
settings,
|
||||
workflows,
|
||||
workbenchApi,
|
||||
type AgentDeliverable,
|
||||
type AgentSession,
|
||||
type WorkbenchStageKey,
|
||||
} from "$lib/api";
|
||||
import { createUploadManager } from "$lib/comfy/useUpload.svelte";
|
||||
import type { AgentAttachment } from "$lib/agentComposer";
|
||||
import type { AgentComposerPayload } from "$lib/agentComposer";
|
||||
import { connectEvents } from "$lib/events";
|
||||
import { toast } from "$lib/toast";
|
||||
|
||||
const client = useQueryClient();
|
||||
const stageNames: Record<WorkbenchStageKey, string> = {
|
||||
script: "剧本创作",
|
||||
style: "视觉设计",
|
||||
storyboard: "分镜设计",
|
||||
assets: "素材资产",
|
||||
shot: "镜头脚本",
|
||||
};
|
||||
const assistantIcons: Record<string, IconName> = {
|
||||
script: "project-book",
|
||||
style: "nav-palette",
|
||||
storyboard: "project-grid",
|
||||
assets: "project-image",
|
||||
shot: "project-film",
|
||||
};
|
||||
const assistantColors: Record<string, string> = {
|
||||
script: "var(--sc-agent)",
|
||||
style: "var(--gold)",
|
||||
storyboard: "var(--sc-image)",
|
||||
assets: "var(--sc-asset)",
|
||||
shot: "var(--sc-video)",
|
||||
};
|
||||
const assistantDescriptions: Record<string, string> = {
|
||||
script: "故事、人物、场次与台词",
|
||||
style: "风格板、色彩与参考图",
|
||||
storyboard: "镜头拆分、景别与时长",
|
||||
assets: "角色、场景、道具出图",
|
||||
shot: "逐镜视频提示词与生成",
|
||||
};
|
||||
const keys = Object.keys(stageNames) as WorkbenchStageKey[];
|
||||
const registry = createQuery({
|
||||
queryKey: ["agents-registry"],
|
||||
queryFn: agentApi.listNamedAgents,
|
||||
});
|
||||
const config = createQuery({ queryKey: ["settings"], queryFn: settings.get });
|
||||
const skills = createQuery({
|
||||
queryKey: ["b4-skills"],
|
||||
queryFn: () => agentApi.listSkillsAdmin(),
|
||||
});
|
||||
const wfQuery = createQuery({
|
||||
queryKey: ["b4-workflows"],
|
||||
queryFn: workflows.list,
|
||||
});
|
||||
const projects = createQuery({
|
||||
queryKey: ["b4-projects"],
|
||||
queryFn: agentApi.listLinkableProjects,
|
||||
});
|
||||
const sessions = createQuery({
|
||||
queryKey: ["b4-sessions"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await Promise.all([
|
||||
agentApi.listSessions(),
|
||||
...keys.map(agentApi.listAgentSessions),
|
||||
])
|
||||
)
|
||||
.flat()
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at)),
|
||||
});
|
||||
const allResults = createQuery({
|
||||
queryKey: ["b4-results"],
|
||||
queryFn: () => resultsApi.list(),
|
||||
});
|
||||
const activeId = $derived(
|
||||
Number(page.url.searchParams.get("session")) || null,
|
||||
);
|
||||
const session = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ["agent-session", activeId],
|
||||
queryFn: () => agentApi.getSession(activeId!),
|
||||
enabled: activeId != null,
|
||||
})),
|
||||
);
|
||||
const taskResults = $derived(
|
||||
($allResults.data ?? []).filter((r) => r.session_id === activeId),
|
||||
);
|
||||
let filter = $state("all");
|
||||
let agentKey = $state<WorkbenchStageKey | "">("");
|
||||
let projectId = $state<number | null>(null);
|
||||
let draft = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let streaming = $state("");
|
||||
let reasoning = $state("");
|
||||
let particles = $state<HTMLCanvasElement | null>(null);
|
||||
let liveTools = $state<
|
||||
{
|
||||
tool: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
phase: "running" | "done" | "error";
|
||||
}[]
|
||||
>([]);
|
||||
const running = $derived($session.data?.running === true);
|
||||
let editing = $state<AgentDeliverable | null>(null);
|
||||
let text = $state("");
|
||||
let targetProject = $state<number | null>(null);
|
||||
let targetStage = $state<WorkbenchStageKey>("script");
|
||||
let saving = $state(false);
|
||||
let dirty = $state(false);
|
||||
let artifactTab = $state("shots");
|
||||
let compareOpen = $state(false);
|
||||
let previousText = $state("");
|
||||
async function comparePrevious() {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const versions = await resultsApi.versions(editing.id);
|
||||
previousText = versions[0]?.content ?? "";
|
||||
compareOpen = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
function parseContent(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const start = value.indexOf("{");
|
||||
const end = value.lastIndexOf("}");
|
||||
return JSON.parse(value.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function editShot(index: number, key: string, value: string | number) {
|
||||
const data = parseContent(text);
|
||||
if (!data || !Array.isArray(data.shots)) return;
|
||||
data.shots[index] = { ...data.shots[index], [key]: value };
|
||||
text = JSON.stringify(data, null, 2);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
let selectedSkill = $state("");
|
||||
let uploadInput = $state<HTMLInputElement | null>(null);
|
||||
let attachments = $state<AgentAttachment[]>([]);
|
||||
const uploads = createUploadManager();
|
||||
let uploadBusy = $state(false);
|
||||
async function upload(event: Event) {
|
||||
const files = Array.from((event.target as HTMLInputElement).files ?? []);
|
||||
uploadBusy = true;
|
||||
try {
|
||||
for (const file of files.slice(0, 8 - attachments.length)) {
|
||||
const path = await uploads.upload("b4-reference", file);
|
||||
const meta = uploads.uploadMetaFor(path);
|
||||
attachments.push({ path, name: meta.name, kind: meta.kind });
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
uploadBusy = false;
|
||||
(event.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}
|
||||
async function assignModel(id: string) {
|
||||
busy = true;
|
||||
try {
|
||||
await settings.update({
|
||||
agent_llm_assignments: {
|
||||
[activeAgent?.llm_role ?? "main"]: id || null,
|
||||
},
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ["settings"] });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const shownTasks = $derived(
|
||||
($sessions.data ?? []).filter(
|
||||
(s) =>
|
||||
filter === "all" ||
|
||||
(filter === "sandbox"
|
||||
? s.project_id == null
|
||||
: s.running ||
|
||||
s.task_status === "awaiting_confirm" ||
|
||||
s.task_status === "awaiting_input"),
|
||||
),
|
||||
);
|
||||
const activeAgent = $derived(
|
||||
($registry.data ?? []).find(
|
||||
(a) => a.key === ($session.data?.agent_key || agentKey),
|
||||
),
|
||||
);
|
||||
const enabledSkills = $derived(
|
||||
($skills.data ?? []).filter(
|
||||
(s) => s.is_enabled && (!agentKey || s.bindings?.[agentKey]),
|
||||
),
|
||||
);
|
||||
const profiles = $derived($config.data?.llm_profiles ?? []);
|
||||
function modelName(key?: string | null) {
|
||||
const a = ($registry.data ?? []).find((a) => a.key === key);
|
||||
const id =
|
||||
(a && $config.data?.agent_llm_assignments?.[a.llm_role]) ||
|
||||
$config.data?.llm_active_id;
|
||||
return (
|
||||
profiles.find((p) => p.id === id)?.name ||
|
||||
$config.data?.llm_model ||
|
||||
"未配置模型"
|
||||
);
|
||||
}
|
||||
function status(s: AgentSession) {
|
||||
return s.running
|
||||
? "运行中"
|
||||
: ({
|
||||
ready: "未开始",
|
||||
awaiting_confirm: "待你确认",
|
||||
returned: "已带回项目",
|
||||
completed: "已完成",
|
||||
error: "失败",
|
||||
cancelled: "已停止",
|
||||
interrupted: "运行中断",
|
||||
awaiting_input: "等待回答",
|
||||
}[s.task_status ?? "ready"] ?? "未开始");
|
||||
}
|
||||
function openResult(r: AgentDeliverable) {
|
||||
editing = r;
|
||||
text = r.content;
|
||||
targetProject = r.project_id;
|
||||
targetStage = r.stage_key;
|
||||
dirty = false;
|
||||
error = "";
|
||||
}
|
||||
async function openTask(id: number) {
|
||||
if (dirty) {
|
||||
error = "请先保存成果草稿,再切换任务。";
|
||||
return false;
|
||||
}
|
||||
streaming = "";
|
||||
reasoning = "";
|
||||
liveTools = [];
|
||||
await goto(resolve(`/agents?session=${id}`));
|
||||
return true;
|
||||
}
|
||||
async function launchAssistant(key: WorkbenchStageKey) {
|
||||
if (busy || uploadBusy) return;
|
||||
agentKey = key;
|
||||
if (draft.trim()) {
|
||||
await send({
|
||||
content: draft,
|
||||
mentions: [],
|
||||
attachments: [...attachments],
|
||||
});
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const s = await agentApi.createSession({
|
||||
agent_key: key,
|
||||
project_id: projectId,
|
||||
});
|
||||
await openTask(s.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function home() {
|
||||
if (dirty) {
|
||||
error = "请先保存成果草稿,再新建任务。";
|
||||
return;
|
||||
}
|
||||
editing = null;
|
||||
error = "";
|
||||
agentKey = "";
|
||||
await goto(resolve("/agents"));
|
||||
}
|
||||
$effect(() => {
|
||||
const id = activeId,
|
||||
results = taskResults;
|
||||
untrack(() => {
|
||||
if (editing?.session_id !== id) {
|
||||
editing = null;
|
||||
dirty = false;
|
||||
}
|
||||
if (!editing && results.length) openResult(results[0]);
|
||||
});
|
||||
});
|
||||
onMount(() => {
|
||||
if (!particles) return;
|
||||
const canvas = particles,
|
||||
ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const points = Array.from({ length: 46 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
v: 0.15 + Math.random() * 0.35,
|
||||
s: Math.random() * 1.6 + 0.4,
|
||||
a: Math.random() * 0.5 + 0.15,
|
||||
}));
|
||||
const resize = () => {
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
};
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
let raf = 0;
|
||||
const frame = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (
|
||||
!activeId &&
|
||||
document.documentElement.dataset.reduce !== "1" &&
|
||||
!matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
const color =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--particle")
|
||||
.trim() || "168,121,255";
|
||||
for (const point of points) {
|
||||
point.y -= point.v / Math.max(1, canvas.height);
|
||||
point.x +=
|
||||
(Math.sin((point.y * canvas.height) / 60) * 0.2) /
|
||||
Math.max(1, canvas.width);
|
||||
if (point.y < 0) {
|
||||
point.y = 1;
|
||||
point.x = Math.random();
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
point.x * canvas.width,
|
||||
point.y * canvas.height,
|
||||
point.s,
|
||||
0,
|
||||
7,
|
||||
);
|
||||
ctx.fillStyle = `rgba(${color},${point.a})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
frame();
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
};
|
||||
});
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: ["b4-sessions"] }),
|
||||
client.invalidateQueries({ queryKey: ["b4-results"] }),
|
||||
client.invalidateQueries({ queryKey: ["agent-session"] }),
|
||||
]);
|
||||
}
|
||||
onMount(() =>
|
||||
connectEvents((ev) => {
|
||||
if (ev.type.startsWith("job."))
|
||||
void client.invalidateQueries({ queryKey: ["b4-jobs"] });
|
||||
if (ev.type === "events.resync" || ev.type === "agent.session.updated") {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
ev.data?.session_id !== activeId &&
|
||||
(ev.data?.message as { session_id?: number } | undefined)
|
||||
?.session_id !== activeId
|
||||
)
|
||||
return;
|
||||
if (ev.type === "agent.token")
|
||||
streaming += String(ev.data?.content ?? "");
|
||||
if (ev.type === "agent.thinking")
|
||||
reasoning += String(ev.data?.content ?? "");
|
||||
if (ev.type === "agent.message") {
|
||||
streaming = "";
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["agent-session", activeId],
|
||||
});
|
||||
}
|
||||
if (ev.type === "agent.plan" || ev.type === "agent.task")
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["agent-session", activeId],
|
||||
});
|
||||
if (ev.type === "agent.tool") {
|
||||
const tool = String(ev.data?.tool ?? "");
|
||||
if (ev.data?.phase === "start")
|
||||
liveTools.push({
|
||||
tool,
|
||||
args: ev.data?.args as Record<string, unknown>,
|
||||
phase: "running",
|
||||
});
|
||||
else {
|
||||
const index = liveTools
|
||||
.map((t) => t.tool === tool && t.phase === "running")
|
||||
.lastIndexOf(true);
|
||||
if (index >= 0)
|
||||
liveTools[index] = {
|
||||
...liveTools[index],
|
||||
result: ev.data?.result,
|
||||
phase:
|
||||
(ev.data?.result as { ok?: boolean })?.ok === false
|
||||
? "error"
|
||||
: "done",
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
async function send(payload: AgentComposerPayload) {
|
||||
if (busy || uploadBusy) return;
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
let sid = activeId;
|
||||
if (!sid) {
|
||||
const s = await agentApi.createSession({
|
||||
project_id: projectId,
|
||||
agent_key: agentKey || null,
|
||||
});
|
||||
sid = s.id;
|
||||
await goto(resolve(`/agents?session=${sid}`));
|
||||
}
|
||||
const skill = enabledSkills.find((s) => s.dir === selectedSkill);
|
||||
if (
|
||||
skill &&
|
||||
payload.mentions.filter((m) => m.type === "skill").length < 4 &&
|
||||
!payload.mentions.some(
|
||||
(m) => m.type === "skill" && m.name === skill.dir,
|
||||
)
|
||||
)
|
||||
payload.mentions.push({
|
||||
type: "skill",
|
||||
name: skill.dir,
|
||||
description: skill.description,
|
||||
});
|
||||
await agentApi.postMessage(sid, payload);
|
||||
draft = "";
|
||||
attachments = [];
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!editing || saving) return null;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
const r = await resultsApi.patch(editing.id, {
|
||||
revision: editing.revision,
|
||||
content: text,
|
||||
project_id: targetProject,
|
||||
stage_key: targetStage,
|
||||
});
|
||||
openResult(r);
|
||||
await client.invalidateQueries({ queryKey: ["b4-results"] });
|
||||
toast.success("草稿已保存");
|
||||
return r;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
return null;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
async function returnToProject() {
|
||||
if (!editing || !targetProject || saving) return;
|
||||
const r = dirty ? await save() : editing;
|
||||
if (!r) return;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
const res = await resultsApi.store({
|
||||
deliverable_id: r.id,
|
||||
revision: r.revision,
|
||||
project_id: targetProject,
|
||||
stage_key: targetStage,
|
||||
});
|
||||
toast.success(
|
||||
res.already_attached ? "该版本已带回项目" : "已带回项目,等待确认",
|
||||
);
|
||||
await refresh();
|
||||
await goto(resolve(`/workbench/${targetProject}?stage=${targetStage}`));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
const projectJobs = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ["b4-jobs", targetProject],
|
||||
queryFn: () => jobsApi.list(targetProject!),
|
||||
enabled: targetProject != null,
|
||||
})),
|
||||
);
|
||||
function shotJob(id: string) {
|
||||
return ($projectJobs.data ?? []).find(
|
||||
(j) =>
|
||||
j.payload?.stage_key === targetStage &&
|
||||
j.payload?.target_id === id &&
|
||||
j.status === "done" &&
|
||||
j.output_paths?.length,
|
||||
);
|
||||
}
|
||||
const structuredShots = $derived.by(() => {
|
||||
const data = parseContent(text);
|
||||
return Array.isArray(data?.shots)
|
||||
? (data.shots as {
|
||||
id: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
duration_sec?: number;
|
||||
}[])
|
||||
: [];
|
||||
});
|
||||
async function redraw(shot: {
|
||||
id: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
duration_sec?: number;
|
||||
}) {
|
||||
if (!targetProject || saving || !shot.id) return;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
await workbenchApi.generate(targetProject, {
|
||||
kind: targetStage === "shot" ? "video" : "image",
|
||||
stage_key: targetStage,
|
||||
target_id: shot.id,
|
||||
prompt: shot.prompt || shot.description || "",
|
||||
duration_sec: shot.duration_sec,
|
||||
});
|
||||
toast.success(`仅镜头 ${shot.id} 已入队`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet header()}
|
||||
<header class="b4-header">
|
||||
<button class="b4-crumb" onclick={home}>智能体</button>{#if activeId}<span
|
||||
>/</span
|
||||
><b>{$session.data?.title ?? "加载任务…"}</b><span class="a-tag sc"
|
||||
>{$session.data ? status($session.data) : "加载中"}</span
|
||||
>{:else}<span class="b4-subtitle"
|
||||
>把创作任务交给助手,成果回到项目等你确认</span
|
||||
>{/if}<span class="b4-grow"></span>{#if activeId}<span class="b4-chip"
|
||||
>{modelName($session.data?.agent_key)}</span
|
||||
>{/if}<a class="a-btn secondary sm" href={resolve("/agents?legacy=1")}
|
||||
>原会话与技能管理</a
|
||||
>
|
||||
</header>
|
||||
{/snippet}
|
||||
<AppShell active="agents" title="智能体" workspace pageHeader={header}>
|
||||
<div class="b4" class:task-view={activeId != null}>
|
||||
<div class="aurora fx-only"><i></i><i></i><i></i></div>
|
||||
<canvas class="particles fx-only" bind:this={particles} aria-hidden="true"
|
||||
></canvas>
|
||||
<div class={activeId ? "atask" : "agent-grid"}>
|
||||
<aside class="tasklist">
|
||||
<div class="tl-h">
|
||||
<button class="a-btn scene" onclick={home}
|
||||
><Icon name="plus" size={14} />新任务</button
|
||||
>{#if !activeId}<div class="b4-seg">
|
||||
{#each [["active", "进行中"], ["all", "全部"], ["sandbox", "沙盒"]] as [value, label] (value)}<button
|
||||
class:on={filter === value}
|
||||
onclick={() => (filter = value)}>{label}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
</div>
|
||||
<div class="tl-b">
|
||||
{#each shownTasks as s (s.id)}<button
|
||||
class="titem"
|
||||
class:on={s.id === activeId}
|
||||
onclick={() => openTask(s.id)}
|
||||
title={s.task_summary || s.title}
|
||||
><b>{s.title}</b><span class="meta"
|
||||
><i class="dot"></i>{status(s)}<span class="b4-grow"></span>{s
|
||||
.project?.title ?? "沙盒"}</span
|
||||
></button
|
||||
>{:else}<p class="b4-empty">
|
||||
{$sessions.isPending ? "加载任务…" : "暂无任务"}
|
||||
</p>{/each}
|
||||
</div>
|
||||
</aside>
|
||||
{#if !activeId}
|
||||
<div class="agent-home">
|
||||
<div class="ahero">
|
||||
<div class="hero-copy">
|
||||
<span class="eyebrow">主创智能体 · 统筹五个阶段助手</span>
|
||||
<h2>从一个想法,到一组能直接开拍的镜头</h2>
|
||||
<p class="muted">
|
||||
它会先列计划,再调用剧本、视觉、分镜、素材与镜头助手;每一步的成果都是可编辑草稿,你确认后才写入项目。
|
||||
</p>
|
||||
<div class="steps">
|
||||
{#each ["说出想法", "确认计划", "编辑成果", "带回项目"] as label, i (label)}<span
|
||||
><i>{i + 1}</i>{label}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<div class="big-input">
|
||||
<textarea
|
||||
aria-label="任务想法"
|
||||
bind:value={draft}
|
||||
placeholder="描述你的创作想法、目标与要求…"
|
||||
></textarea>
|
||||
<div class="b4-row">
|
||||
<label class="b4-chip"
|
||||
><Icon name="nav-project" size={14} />绑定:<select
|
||||
aria-label="绑定项目"
|
||||
bind:value={projectId}
|
||||
><option value={null}>沙盒</option
|
||||
>{#each $projects.data ?? [] as p (p.id)}<option
|
||||
value={p.id}>{p.title}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
><label class="b4-chip"
|
||||
><Icon name="agent-cpu" size={14} /><select
|
||||
aria-label="任务模型"
|
||||
disabled={busy}
|
||||
value={$config.data?.agent_llm_assignments?.[
|
||||
activeAgent?.llm_role ?? "main"
|
||||
] ?? ""}
|
||||
onchange={(e) => assignModel(e.currentTarget.value)}
|
||||
><option value="">{modelName(agentKey)}</option
|
||||
>{#each profiles as p (p.id)}<option value={p.id}
|
||||
>{p.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
><label class="b4-chip"
|
||||
><Icon name="project-book" size={14} />技能
|
||||
<select aria-label="任务技能" bind:value={selectedSkill}
|
||||
><option value="">不指定</option
|
||||
>{#each enabledSkills as s (s.dir)}<option value={s.dir}
|
||||
>{s.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
><input
|
||||
hidden
|
||||
type="file"
|
||||
bind:this={uploadInput}
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.txt,.md,.docx"
|
||||
onchange={upload}
|
||||
/><button
|
||||
class="b4-chip"
|
||||
disabled={uploadBusy || busy}
|
||||
onclick={() => uploadInput?.click()}
|
||||
><Icon name="upload" size={14} />参考材料{attachments.length
|
||||
? ` · ${attachments.length}`
|
||||
: ""}</button
|
||||
><span class="b4-grow"></span><button
|
||||
class="a-btn primary"
|
||||
disabled={busy || uploadBusy || !draft.trim()}
|
||||
onclick={() =>
|
||||
send({
|
||||
content: draft,
|
||||
mentions: [],
|
||||
attachments: [...attachments],
|
||||
})}><Icon name="agent-send" size={14} />开始任务</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="orb fx-only">
|
||||
<div class="ring r2"></div>
|
||||
<div class="ring"></div>
|
||||
<div class="core"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sec-h">
|
||||
<h2>阶段助手</h2>
|
||||
<span class="dim">每个助手对应项目的一个阶段,也可以单独调用</span
|
||||
>
|
||||
</div>
|
||||
<div class="roster">
|
||||
{#each $registry.data ?? [] as a, i (a.key)}<button
|
||||
class="acard"
|
||||
style:--c={assistantColors[a.key]}
|
||||
disabled={busy || uploadBusy}
|
||||
onclick={() => launchAssistant(a.key as WorkbenchStageKey)}
|
||||
><span class="num">0{i + 1}</span><span class="ic"
|
||||
><Icon name={assistantIcons[a.key]} size={18} /></span
|
||||
>
|
||||
<div class="tx">
|
||||
<b>{stageNames[a.key as WorkbenchStageKey]}</b>
|
||||
<p>{assistantDescriptions[a.key]}</p>
|
||||
<div class="b4-row">
|
||||
<span class="a-tag">{a.skill_count} 个技能</span><span
|
||||
class="b4-grow"
|
||||
></span><span class="dim">{modelName(a.key)}</span>
|
||||
</div>
|
||||
</div></button
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-row">
|
||||
<div class="panel">
|
||||
<h3>最近成果<span class="dim">点开即可编辑,再带回项目</span></h3>
|
||||
<div class="outrow">
|
||||
{#each ($allResults.data ?? []).slice(0, 4) as r (r.id)}<button
|
||||
class="outcard"
|
||||
onclick={async () => {
|
||||
if (await openTask(r.session_id)) openResult(r);
|
||||
}}
|
||||
><div class="t"><Icon name="file" size={24} /></div>
|
||||
<div class="d">
|
||||
<b>{r.title}</b><span class="dim"
|
||||
>{stageNames[r.stage_key]} · 草稿 v{r.revision}</span
|
||||
>
|
||||
</div></button
|
||||
>{:else}<p class="b4-empty">
|
||||
暂无成果,完成任务后将在这里显示。
|
||||
</p>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>
|
||||
技能库<span class="a-tag">{($skills.data ?? []).length}</span
|
||||
><span class="b4-grow"></span><a
|
||||
class="a-btn sm ghost"
|
||||
href={resolve("/agents?legacy=1")}>管理</a
|
||||
>
|
||||
</h3>
|
||||
<div class="skills">
|
||||
{#each ($skills.data ?? [])
|
||||
.filter((s) => s.is_enabled)
|
||||
.slice(0, 7) as s (s.dir)}<button
|
||||
class="skill"
|
||||
title={s.description}
|
||||
onclick={() => {
|
||||
selectedSkill = s.dir;
|
||||
agentKey = "";
|
||||
}}><i></i>{s.name}</button
|
||||
>{:else}<span class="dim">尚未添加技能</span>{/each}<a
|
||||
class="skill dim"
|
||||
href={resolve("/agents?legacy=1")}>+ 学习新技能</a
|
||||
>
|
||||
</div>
|
||||
<p class="dim">
|
||||
技能 = 可复用的规则 +
|
||||
示例。可从对话中“记住这个做法”生成,也可导入 Markdown。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="convo">
|
||||
<div class="convo-b">
|
||||
{#if $session.data?.plan}<AgentPlanPanel
|
||||
plan={$session.data.plan}
|
||||
/>{/if}<AgentChat
|
||||
studio
|
||||
messages={$session.data?.messages ?? []}
|
||||
{liveTools}
|
||||
{streaming}
|
||||
streamingReasoning={reasoning}
|
||||
{running}
|
||||
loading={$session.isPending}
|
||||
onAnswer={(option, _scope, seq) =>
|
||||
send({
|
||||
content: option,
|
||||
mentions: [],
|
||||
attachments: [],
|
||||
answer_to: seq,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div class="convo-in">
|
||||
<AgentComposer
|
||||
studio
|
||||
{running}
|
||||
onSend={send}
|
||||
onCancel={() => {
|
||||
if (activeId) void agentApi.cancel(activeId);
|
||||
}}
|
||||
workflows={($wfQuery.data ?? [])
|
||||
.filter((w) => w.is_enabled)
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
kind: w.kind as "image" | "video",
|
||||
}))}
|
||||
skills={($skills.data ?? [])
|
||||
.filter((s) => s.is_enabled)
|
||||
.map((s) => ({
|
||||
name: s.dir,
|
||||
description: s.description,
|
||||
tags: s.tags,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="artifact">
|
||||
<div class="art-h">
|
||||
<div class="b4-row">
|
||||
<b>{editing ? stageNames[editing.stage_key] : "成果"} · 草稿</b
|
||||
><span class="a-tag sc">未确认</span><span class="b4-grow"
|
||||
></span><span class="dim"
|
||||
>{structuredShots.length
|
||||
? `${structuredShots.length} 镜 · ${structuredShots.reduce((sum, shot) => sum + (Number(shot.duration_sec) || 0), 0)}s`
|
||||
: editing
|
||||
? `v${editing.revision}`
|
||||
: ""}</span
|
||||
>
|
||||
</div>
|
||||
{#if taskResults.length > 1}<div class="b4-seg">
|
||||
{#each taskResults as r (r.id)}<button
|
||||
class:on={editing?.id === r.id}
|
||||
onclick={() => {
|
||||
if (!dirty) openResult(r);
|
||||
else error = "请先保存当前成果";
|
||||
}}>成果 {r.id}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
<div class="b4-seg art-tabs">
|
||||
<button
|
||||
class:on={artifactTab === "shots"}
|
||||
onclick={() => (artifactTab = "shots")}>镜头</button
|
||||
>
|
||||
<button disabled title="当前成果没有角色数据">角色</button>
|
||||
<button
|
||||
class:on={artifactTab === "text"}
|
||||
onclick={() => (artifactTab = "text")}>原文</button
|
||||
>
|
||||
<button onclick={comparePrevious} disabled={!editing}
|
||||
>对比上一版</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="art-b">
|
||||
{#if editing}
|
||||
{#if structuredShots.length && artifactTab === "shots"}
|
||||
{#each structuredShots as shot, i (shot.id)}
|
||||
<div class="shot">
|
||||
<div class="th">
|
||||
<span>{String(i + 1).padStart(2, "0")}</span
|
||||
>{#if shotJob(shot.id)?.kind === "video"}<video
|
||||
src={assetUrl(shotJob(shot.id)!.output_paths![0]) ??
|
||||
""}
|
||||
controls
|
||||
muted
|
||||
preload="metadata"
|
||||
aria-label={`镜头 ${shot.id} 视频`}
|
||||
|
||||
></video>{:else if shotJob(shot.id)?.output_paths?.[0]}<img
|
||||
src={assetUrl(shotJob(shot.id)!.output_paths![0]) ??
|
||||
""}
|
||||
alt={`镜头 ${shot.id} 草图`}
|
||||
/>{:else}<small class="dim">暂无草图</small>{/if}
|
||||
</div>
|
||||
<div class="shot-copy">
|
||||
<textarea
|
||||
aria-label={`镜头 ${shot.id} 描述`}
|
||||
value={shot.description || shot.prompt || ""}
|
||||
oninput={(e) =>
|
||||
editShot(
|
||||
i,
|
||||
shot.description != null ? "description" : "prompt",
|
||||
e.currentTarget.value,
|
||||
)}
|
||||
></textarea>
|
||||
<div class="b4-row">
|
||||
<label class="dim"
|
||||
><input
|
||||
aria-label={`镜头 ${shot.id} 时长`}
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={shot.duration_sec ?? 3}
|
||||
oninput={(e) =>
|
||||
editShot(
|
||||
i,
|
||||
"duration_sec",
|
||||
Number(e.currentTarget.value),
|
||||
)}
|
||||
/> 秒</label
|
||||
><span class="b4-grow"></span><button
|
||||
class="a-btn sm ghost"
|
||||
disabled={saving || !targetProject || dirty}
|
||||
onclick={() => redraw(shot)}>重画</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}<textarea
|
||||
class="draft-editor"
|
||||
aria-label="成果草稿"
|
||||
bind:value={text}
|
||||
oninput={() => (dirty = true)}
|
||||
></textarea>{/if}
|
||||
{:else}<p class="b4-empty">
|
||||
暂无成果。任务完成后,真实回复将在这里形成可编辑草稿。
|
||||
</p>{/if}
|
||||
</div>
|
||||
<div class="art-f">
|
||||
<label class="b4-row dim"
|
||||
>带回到<select
|
||||
aria-label="成果项目"
|
||||
bind:value={targetProject}
|
||||
onchange={() => (dirty = true)}
|
||||
disabled={!editing}
|
||||
><option value={null}>选择项目</option
|
||||
>{#each $projects.data ?? [] as p (p.id)}<option value={p.id}
|
||||
>{p.title}</option
|
||||
>{/each}</select
|
||||
><select
|
||||
aria-label="成果阶段"
|
||||
bind:value={targetStage}
|
||||
onchange={() => (dirty = true)}
|
||||
disabled={!editing}
|
||||
>{#each keys as key (key)}<option value={key}
|
||||
>{stageNames[key]}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<div class="b4-row">
|
||||
<button
|
||||
class="a-btn secondary"
|
||||
disabled={!editing || saving}
|
||||
onclick={save}>保存草稿</button
|
||||
><button
|
||||
class="a-btn primary"
|
||||
disabled={!editing || !targetProject || saving}
|
||||
onclick={returnToProject}
|
||||
><Icon name="arrow-right" size={14} />带回项目待确认</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{#if error}<div class="b4-error" role="alert">
|
||||
{error}<button class="a-btn sm ghost" onclick={() => (error = "")}
|
||||
>关闭</button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<Modal bind:open={compareOpen} title="对比上一版" size="lg">
|
||||
<div class="b4-compare">
|
||||
<section>
|
||||
<h3>上一保存版本</h3>
|
||||
<pre>{previousText || "尚无上一保存版本"}</pre>
|
||||
</section>
|
||||
<section>
|
||||
<h3>当前草稿{dirty ? " · 未保存" : ""}</h3>
|
||||
<pre>{text}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,960 @@
|
||||
/* Approved HTML source, agent home/task sections 06/07/15.
|
||||
Geometry and effects are copied from the sample; only selectors are namespaced.
|
||||
Theme-specific values belong to static/themes.css. */
|
||||
:where(.b4, .b4-header) :where(h1, h2, h3, p) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
:where(.b4, .b4-header) :where(button, input, textarea, select) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
:where(.b4, .b4-header) button {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.b4-header {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: var(--head);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
flex: none;
|
||||
color: var(--tx-1);
|
||||
font-size: 14px;
|
||||
}
|
||||
.b4-header .b4-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.b4-crumb {
|
||||
font-weight: 600;
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.b4-grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.b4-chip {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--agent-chip-radius);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b4 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
--scene: var(--sc-agent);
|
||||
line-height: 1.55;
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.b4,
|
||||
.b4 * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.b4 .b4-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.b4 .dim,
|
||||
.b4 .muted {
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.b4 .b4-seg {
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
border-radius: var(--agent-radius-9);
|
||||
background: var(--agent-seg-bg);
|
||||
border: 1px solid var(--line);
|
||||
gap: 2px;
|
||||
overflow: auto;
|
||||
}
|
||||
.b4 .b4-seg button {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
border-radius: var(--agent-radius-7);
|
||||
color: var(--tx-2);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b4 .b4-seg button.on {
|
||||
background: var(--agent-seg-selected-bg);
|
||||
color: var(--tx-1);
|
||||
box-shadow: var(--agent-seg-shadow);
|
||||
}
|
||||
.b4 .eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--agent-chip-radius);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
width: max-content;
|
||||
color: color-mix(in srgb, var(--scene) 75%, var(--hi));
|
||||
border: 1px solid color-mix(in srgb, var(--scene) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--scene) 10%, transparent);
|
||||
}
|
||||
|
||||
.b4 .sec-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
.b4 .big-input .b4-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.b4 .sec-h h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.b4 .sec-h .dim {
|
||||
font-size: 12px;
|
||||
}
|
||||
.b4 .b4-empty {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
padding: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.b4 .hero-copy {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.b4 .hero-copy h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.b4 .hero-copy > p {
|
||||
margin-top: 8px;
|
||||
max-width: 560px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.b4 .steps {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.b4 .titem {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.b4 .titem b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b4 .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--scene);
|
||||
}
|
||||
.b4 .panel h3 .dim {
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.b4 .panel > p {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.b4 .outcard {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.b4 .outcard .t {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--scene);
|
||||
background: var(--well);
|
||||
}
|
||||
.b4 .art-tabs {
|
||||
flex: none;
|
||||
}
|
||||
.b4 .shot .th :is(img, video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.b4 .shot-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.b4 .shot-copy textarea {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
field-sizing: content;
|
||||
margin: -1px -3px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.b4 .shot-copy input {
|
||||
width: 42px;
|
||||
font-size: 11px;
|
||||
border: 0;
|
||||
background: var(--well);
|
||||
}
|
||||
.b4 .shot .th {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--well);
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.b4-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.b4-compare pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.b4 .draft-editor {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--agent-radius-8);
|
||||
background: var(--well);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.b4 .art-f .a-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.b4 .art-f .primary {
|
||||
flex: 1.4;
|
||||
}
|
||||
.b4 .big-input select {
|
||||
field-sizing: content;
|
||||
width: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 90px;
|
||||
border: 0;
|
||||
background: none;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
}
|
||||
.b4 select {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--agent-chip-radius);
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
color: var(--tx-1);
|
||||
font-size: 11.5px;
|
||||
min-width: 0;
|
||||
max-width: 130px;
|
||||
}
|
||||
.b4 .b4-error {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
bottom: 120px;
|
||||
left: 280px;
|
||||
right: 20px;
|
||||
padding: 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--err);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.b4 {
|
||||
.aurora {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
.aurora i {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
opacity: var(--aur-op);
|
||||
animation: drift 18s var(--ease-io) infinite alternate;
|
||||
}
|
||||
.aurora i:nth-child(1) {
|
||||
width: 520px;
|
||||
height: 420px;
|
||||
left: 8%;
|
||||
top: -120px;
|
||||
background: var(--aur-1);
|
||||
}
|
||||
.aurora i:nth-child(2) {
|
||||
width: 420px;
|
||||
height: 380px;
|
||||
right: 4%;
|
||||
top: 40px;
|
||||
background: var(--aur-2);
|
||||
animation-delay: -6s;
|
||||
opacity: calc(var(--aur-op) * 0.55);
|
||||
}
|
||||
.aurora i:nth-child(3) {
|
||||
width: 460px;
|
||||
height: 360px;
|
||||
left: 38%;
|
||||
top: 260px;
|
||||
background: var(--aur-3);
|
||||
animation-delay: -12s;
|
||||
opacity: calc(var(--aur-op) * 0.36);
|
||||
}
|
||||
@keyframes drift {
|
||||
to {
|
||||
transform: translate(60px, 40px) scale(1.12);
|
||||
}
|
||||
}
|
||||
.particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.agent-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.tasklist {
|
||||
border-right: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg-1) 72%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.tl-h {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tl-b {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.titem {
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--agent-radius-10);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid transparent;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.titem:hover {
|
||||
background: rgb(var(--ink) / 0.03);
|
||||
}
|
||||
.titem.on {
|
||||
background: color-mix(in srgb, var(--sc-agent) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--sc-agent) 30%, transparent);
|
||||
}
|
||||
.titem b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.titem .meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.agent-home {
|
||||
overflow: auto;
|
||||
padding: 22px 26px 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.ahero {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
border-radius: var(--agent-radius-22);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--agent-hero-border);
|
||||
background: var(--agent-hero);
|
||||
padding: 26px 28px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 330px;
|
||||
gap: 24px;
|
||||
}
|
||||
.ahero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Cpath d='M32 0H0v32' fill='none' stroke='white' stroke-opacity='.04'/%3E%3C/svg%3E");
|
||||
-webkit-mask: linear-gradient(90deg, #000, transparent 80%);
|
||||
mask: linear-gradient(90deg, #000, transparent 80%);
|
||||
}
|
||||
.ahero h2 {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
background: var(--agent-title);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.big-input {
|
||||
margin-top: 14px;
|
||||
border-radius: var(--agent-radius-16);
|
||||
background: var(--agent-input-bg);
|
||||
border: 1px solid var(--agent-input-border);
|
||||
padding: 12px 14px 10px;
|
||||
transition:
|
||||
border-color var(--t-slow),
|
||||
box-shadow var(--t-slow);
|
||||
}
|
||||
.big-input:focus-within {
|
||||
border-color: color-mix(in srgb, var(--gold) 60%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--gold) 10%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--sc-agent) 20%, transparent);
|
||||
}
|
||||
.big-input textarea {
|
||||
width: 100%;
|
||||
height: 58px;
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.big-input .row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.steps span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 10px 0 4px;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgb(var(--ink) / 0.05);
|
||||
border: 1px solid var(--line-2);
|
||||
font-size: 11.5px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.steps span i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: 700 10px var(--mono);
|
||||
font-style: normal;
|
||||
background: var(--scene);
|
||||
color: var(--on-scene);
|
||||
}
|
||||
.orb {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.orb .core {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
background: var(--orb-core);
|
||||
box-shadow: var(--agent-orb-shadow);
|
||||
animation: breath 4.5s var(--ease-io) infinite;
|
||||
}
|
||||
.orb .ring {
|
||||
position: absolute;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgb(var(--ink) / 0.12);
|
||||
animation: spin 24s linear infinite;
|
||||
}
|
||||
.orb .ring::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
box-shadow: var(--agent-ring-shadow);
|
||||
}
|
||||
.orb .ring.r2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
animation-duration: 40s;
|
||||
animation-direction: reverse;
|
||||
border-style: dashed;
|
||||
border-color: rgb(var(--ink) / 0.07);
|
||||
}
|
||||
@keyframes breath {
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 120px color-mix(in srgb, var(--sc-agent) 75%, transparent);
|
||||
}
|
||||
}
|
||||
.roster {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.acard {
|
||||
--c: var(--sc-agent);
|
||||
position: relative;
|
||||
border-radius: var(--agent-radius-16);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-2);
|
||||
cursor: pointer;
|
||||
min-height: 176px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
transition:
|
||||
transform var(--t-slow) var(--ease-out),
|
||||
border-color var(--t-base),
|
||||
box-shadow var(--t-slow);
|
||||
}
|
||||
.acard:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: color-mix(in srgb, var(--c) 60%, transparent);
|
||||
box-shadow: 0 18px 40px color-mix(in srgb, var(--c) 22%, transparent);
|
||||
}
|
||||
.acard .bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
opacity: 0.55;
|
||||
transition:
|
||||
transform 0.8s var(--ease-out),
|
||||
opacity var(--t-slow);
|
||||
}
|
||||
.acard:hover .bg {
|
||||
transform: scale(1.06);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.acard .sh {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 15%,
|
||||
color-mix(in srgb, var(--bg-2) 95%, transparent) 75%
|
||||
);
|
||||
}
|
||||
.acard .num {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 6px;
|
||||
font: 800 36px/1 var(--mono);
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 1px rgb(var(--ink) / 0.22);
|
||||
}
|
||||
.acard .ic {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--agent-radius-9);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--bg-1) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 50%, transparent);
|
||||
color: var(--c);
|
||||
}
|
||||
.acard .tx {
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.acard .tx b {
|
||||
font-size: 14px;
|
||||
}
|
||||
.acard .tx p {
|
||||
font-size: 11.5px;
|
||||
color: var(--tx-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.acard .tx .b4-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.acard .tx .b4-row > .dim {
|
||||
font-size: 11px;
|
||||
}
|
||||
.mini-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.panel {
|
||||
border-radius: var(--agent-radius-16);
|
||||
border: 1px solid var(--line-2);
|
||||
background: color-mix(in srgb, var(--bg-2) 80%, transparent);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel h3 {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.skills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.skill {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--agent-radius-8);
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgb(var(--ink) / 0.03);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.skill i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--sc-agent);
|
||||
}
|
||||
.outrow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.outcard {
|
||||
border-radius: var(--agent-radius-10);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.outcard .t {
|
||||
height: 64px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
.outcard .d {
|
||||
padding: 7px 9px;
|
||||
font-size: 11.5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* agent task (session) */
|
||||
.atask {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 420px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.convo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.convo-b {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 760px;
|
||||
}
|
||||
.msg.me {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.msg .av {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--agent-radius-8);
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--sc-agent) 18%, transparent);
|
||||
color: var(--sc-agent);
|
||||
}
|
||||
.msg.me .av {
|
||||
background: var(--bg-3);
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.bub {
|
||||
padding: 10px 13px;
|
||||
border-radius: var(--agent-radius-12);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.msg.me .bub {
|
||||
background: color-mix(in srgb, var(--gold) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--gold) 25%, transparent);
|
||||
}
|
||||
.plan {
|
||||
border-radius: var(--agent-radius-12);
|
||||
border: 1px solid color-mix(in srgb, var(--sc-agent) 30%, transparent);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--sc-agent) 8%, transparent),
|
||||
color-mix(in srgb, var(--bg-2) 90%, transparent)
|
||||
);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.pstep {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.pstep .s {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line-3);
|
||||
font: 600 10px var(--mono);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.pstep.done .s {
|
||||
background: var(--ok);
|
||||
border-color: var(--ok);
|
||||
color: var(--on-ok);
|
||||
}
|
||||
.pstep.run .s {
|
||||
border-color: var(--sc-agent);
|
||||
color: var(--sc-agent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sc-agent) 20%, transparent);
|
||||
animation: breath 2s infinite;
|
||||
}
|
||||
.pstep.done b {
|
||||
color: var(--tx-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
.tool {
|
||||
border-radius: var(--agent-radius-10);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
.tool summary {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.tool summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.tool summary code {
|
||||
font-family: var(--mono);
|
||||
color: var(--sc-image);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.tool pre {
|
||||
margin: 0 12px 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--agent-radius-8);
|
||||
background: var(--well);
|
||||
font: 11px/1.6 var(--mono);
|
||||
color: var(--tx-2);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.thinking i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--sc-agent);
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
.thinking i:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.thinking i:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
.convo-in {
|
||||
flex: none;
|
||||
margin: 0 18px 14px;
|
||||
border-radius: var(--agent-radius-14);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-2);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.convo-in textarea {
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
height: 44px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.artifact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: color-mix(in srgb, var(--bg-1) 80%, transparent);
|
||||
}
|
||||
.art-h {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.art-b {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.shot {
|
||||
display: grid;
|
||||
grid-template-columns: 86px 1fr;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: var(--agent-shot-radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.shot .th {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: var(--agent-radius-6);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.shot .th span {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
font: 700 9.5px var(--mono);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.shot [contenteditable] {
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
margin: -1px -3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.shot [contenteditable]:hover {
|
||||
background: rgb(var(--ink) / 0.04);
|
||||
}
|
||||
.shot [contenteditable]:focus {
|
||||
background: color-mix(in srgb, var(--gold) 8%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gold) 35%, transparent);
|
||||
}
|
||||
.art-f {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-reduce="1"] .b4 .fx-only {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.b4 .fx-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
} from '$lib/components/generate/takes';
|
||||
import './InputRequirementBadge.svelte';
|
||||
import WorkflowBay from './WorkflowBay.svelte';
|
||||
import StoreResultModal from '$lib/components/generate/StoreResultModal.svelte';
|
||||
import DeskWorkflowRail from './DeskWorkflowRail.svelte';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
@@ -1034,8 +1035,10 @@
|
||||
// stale rows — the toast just reports what happened.
|
||||
// ------------------------------------------------------------------
|
||||
let attachBusyId = $state<number | null>(null);
|
||||
let storeJobId = $state<number | null>(null);
|
||||
|
||||
async function attachToProject(job: Job) {
|
||||
if (!workbenchCtxOf(job)) { storeJobId = job.id; return; }
|
||||
if (attachBusyId != null) return;
|
||||
attachBusyId = job.id;
|
||||
try {
|
||||
@@ -1236,7 +1239,6 @@
|
||||
inactive={false}
|
||||
canSendToSlot={!!imageSlotTarget || !!imageList}
|
||||
canAttach={!!selectedJob &&
|
||||
!!workbenchCtxOf(selectedJob) &&
|
||||
selectedJob.status === 'done'}
|
||||
attachBusy={attachBusyId === selectedJob?.id}
|
||||
onAttach={() => selectedJob && attachToProject(selectedJob)}
|
||||
@@ -1854,3 +1856,5 @@
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
<StoreResultModal jobId={storeJobId} onclose={()=>storeJobId=null} />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import { agentApi, resultsApi, type WorkbenchStageKey } from "$lib/api";
|
||||
import { toast } from "$lib/toast";
|
||||
let { jobId, onclose }: { jobId: number | null; onclose: () => void } =
|
||||
$props();
|
||||
const client = useQueryClient();
|
||||
const projects = createQuery({
|
||||
queryKey: ["b4-projects"],
|
||||
queryFn: agentApi.listLinkableProjects,
|
||||
});
|
||||
let projectId = $state<number | null>(null);
|
||||
let stage = $state<WorkbenchStageKey>("assets");
|
||||
let busy = $state(false),
|
||||
error = $state("");
|
||||
async function store() {
|
||||
if (!jobId || !projectId || busy) return;
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
const res = await resultsApi.store({
|
||||
job_id: jobId,
|
||||
project_id: projectId,
|
||||
stage_key: stage,
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ["workbench", projectId] });
|
||||
toast.success(
|
||||
res.already_attached
|
||||
? "该结果已存入此阶段"
|
||||
: "已存入项目草稿,等待确认",
|
||||
);
|
||||
onclose();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal open={jobId != null} title="存入项目草稿" {onclose} dismissible={!busy}>
|
||||
<p>选择项目和阶段,结果将在该阶段等待确认。</p>
|
||||
<label class="field"
|
||||
>项目<select aria-label="存入项目" bind:value={projectId}
|
||||
><option value={null}>选择项目</option
|
||||
>{#each $projects.data ?? [] as p (p.id)}<option value={p.id}
|
||||
>{p.title}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label class="field"
|
||||
>阶段<select aria-label="存入阶段" bind:value={stage}
|
||||
>{#each [["script", "剧本创作"], ["style", "视觉设计"], ["storyboard", "分镜设计"], ["assets", "素材资产"], ["shot", "镜头脚本"]] as [value, label] (value)}<option
|
||||
{value}>{label}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
{#snippet footer()}<button class="a-btn" disabled={busy} onclick={onclose}
|
||||
>取消</button
|
||||
><button class="a-btn primary" disabled={!projectId || busy} onclick={store}
|
||||
>存入待确认草稿</button
|
||||
>{/snippet}
|
||||
</Modal>
|
||||
@@ -105,7 +105,7 @@
|
||||
!!job.workbench_attach && !!wbCtx && job.workbench_attach.target_id === wbCtx.sourceId,
|
||||
);
|
||||
const canAttach = $derived(
|
||||
!!onAttach && !!wbCtx && !attached && st.tone === 'ok' && !fileGone && !!output,
|
||||
!!onAttach && !attached && st.tone === 'ok' && !fileGone && !!output,
|
||||
);
|
||||
|
||||
let portrait = $state(false);
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
|
||||
*/
|
||||
export const icons = {
|
||||
"agent-cpu":
|
||||
'<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
|
||||
"agent-send": '<path d="M4 12 20 4l-6 16-3-7z"/>',
|
||||
// Approved B3 sample geometry; retain existing tool icon contracts.
|
||||
"project-check": '<path d="M5 12.5 10 17l9-10"/>',
|
||||
"project-grid":
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { assetUrl, type IncomingResult } from "$lib/api";
|
||||
import { resolve } from "$app/paths";
|
||||
let { results }: { results: IncomingResult[] } = $props();
|
||||
</script>
|
||||
|
||||
{#each results as result (result.source_key)}
|
||||
<details class="stage-note" open data-testid="incoming-result">
|
||||
<summary>{result.title} · 待整理成果</summary>
|
||||
{#if result.text}<p>{result.text}</p>{/if}
|
||||
{#each result.paths ?? [] as path (path)}<a
|
||||
class="a-btn sm"
|
||||
href={assetUrl(path) ?? "#"}
|
||||
target="_blank"
|
||||
rel="noopener external"
|
||||
>查看{result.kind === "video" ? "视频" : "图片"}产物</a
|
||||
>{/each}
|
||||
{#if result.session_id}<a
|
||||
class="a-btn sm ghost"
|
||||
href={resolve(`/agents?session=${result.session_id}`)}>回智能体编辑</a
|
||||
>{/if}
|
||||
</details>
|
||||
{/each}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import "$lib/components/workbench/project.css";
|
||||
import IncomingResults from "./IncomingResults.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import StudioHeader from "$lib/components/workbench/StudioHeader.svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
@@ -473,6 +474,7 @@
|
||||
<div class="project-content">
|
||||
{#if panelProps}
|
||||
<div class="stage-panel">
|
||||
<IncomingResults results={currentStage?.incoming_results??[]} />
|
||||
{#if currentStage?.assistant_note || noteOpen}<details
|
||||
class="stage-note"
|
||||
open
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,6 +135,11 @@ code,
|
||||
);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.scene {
|
||||
background: color-mix(in srgb, var(--scene) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--scene) 45%, transparent);
|
||||
color: color-mix(in srgb, var(--scene) 70%, var(--hi));
|
||||
}
|
||||
.a-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
/* HEL-726 approved themes. Loaded before the app for first-paint safety. */
|
||||
:root,
|
||||
[data-theme="night"] {
|
||||
--agent-shot-radius: 10px;
|
||||
--agent-bubble-radius: 12px;
|
||||
--agent-tool-radius: 10px;
|
||||
--agent-chip-radius: var(--r-pill);
|
||||
--agent-orb-shadow: 0 0 80px
|
||||
color-mix(in srgb, var(--sc-agent) 60%, transparent);
|
||||
--agent-ring-shadow: 0 0 12px var(--gold);
|
||||
--agent-hero-border: color-mix(in srgb, var(--sc-agent) 28%, transparent);
|
||||
--agent-input-bg: color-mix(in srgb, var(--bg-0) 60%, transparent);
|
||||
--agent-input-border: rgb(var(--ink) / 0.12);
|
||||
--agent-seg-bg: var(--well);
|
||||
--agent-seg-selected-bg: var(--bg-3);
|
||||
--agent-seg-shadow: var(--sh-1);
|
||||
--agent-radius-22: 22px;
|
||||
--agent-radius-16: 16px;
|
||||
--agent-radius-14: 14px;
|
||||
--agent-radius-12: 12px;
|
||||
--agent-radius-10: 10px;
|
||||
--agent-radius-9: 9px;
|
||||
--agent-radius-8: 8px;
|
||||
--agent-radius-7: 7px;
|
||||
--agent-radius-6: 6px;
|
||||
|
||||
color-scheme: dark;
|
||||
/* surfaces: ink black with a faint blue cast, never pure #000 */
|
||||
--bg-0: #07080c;
|
||||
@@ -798,3 +821,117 @@ html.theme-anim *::after {
|
||||
:root:has(.project-workspace) {
|
||||
--toast-bottom: calc(var(--project-footer-height) + 24px);
|
||||
}
|
||||
|
||||
/* B4 grade geometry from the approved multi-theme sample. */
|
||||
:root[data-theme="grade"] {
|
||||
--agent-radius-22: 4px;
|
||||
--agent-radius-16: 4px;
|
||||
--agent-radius-14: 4px;
|
||||
--agent-radius-12: 4px;
|
||||
--agent-radius-10: 4px;
|
||||
--agent-radius-9: 4px;
|
||||
--agent-radius-8: 4px;
|
||||
--agent-radius-7: 4px;
|
||||
--agent-radius-6: 4px;
|
||||
}
|
||||
|
||||
:where(.b4, .b4-header) {
|
||||
--button-height: 32px;
|
||||
--button-padding: 12px;
|
||||
--button-radius: var(--project-button-radius);
|
||||
--button-font-size: 12.5px;
|
||||
--button-weight: 500;
|
||||
--button-sm-height: 26px;
|
||||
--button-sm-padding: 9px;
|
||||
--button-sm-font-size: 12px;
|
||||
--button-sm-radius: var(--project-button-sm-radius);
|
||||
--button-secondary-bg: var(--bg-3);
|
||||
--button-secondary-border: var(--line-2);
|
||||
--button-secondary-padding: 12px;
|
||||
--button-secondary-radius: var(--project-button-radius);
|
||||
--button-primary-height: 32px;
|
||||
--button-primary-padding: 12px;
|
||||
--button-primary-radius: var(--project-button-radius);
|
||||
--button-primary-weight: 700;
|
||||
--button-primary-border-width: 0px;
|
||||
--button-primary-disabled-opacity: 1;
|
||||
--button-primary-disabled-filter: var(--project-button-disabled-filter);
|
||||
--button-primary-disabled-bg: var(--project-button-disabled-bg);
|
||||
--button-primary-disabled-color: var(--project-button-disabled-color);
|
||||
}
|
||||
|
||||
.b4 {
|
||||
--chat-padding: 0px;
|
||||
--composer-top-padding: 0px;
|
||||
--composer-top-border: 0px;
|
||||
--composer-box-align: stretch;
|
||||
--composer-box-direction: column;
|
||||
--composer-box-bg: transparent;
|
||||
--composer-box-border: 0px;
|
||||
--composer-box-padding: 0px;
|
||||
--composer-editor-height: 44px;
|
||||
--composer-send-height: 26px;
|
||||
--composer-send-bg: var(--grad-gold);
|
||||
--composer-actions-justify: space-between;
|
||||
--composer-attach-width: auto;
|
||||
--composer-attach-height: 26px;
|
||||
--composer-attach-padding: 0 6px;
|
||||
--composer-attach-margin: auto;
|
||||
}
|
||||
|
||||
[data-theme="day"] {
|
||||
--agent-orb-shadow: 0 18px 50px rgba(109, 69, 208, 0.28);
|
||||
--agent-ring-shadow: none;
|
||||
--agent-hero-border: rgba(109, 69, 208, 0.22);
|
||||
--agent-input-bg: rgba(255, 255, 255, 0.75);
|
||||
--agent-input-border: rgba(38, 30, 18, 0.14);
|
||||
--agent-seg-bg: var(--bg-0);
|
||||
--agent-seg-selected-bg: #fff;
|
||||
--agent-seg-shadow:
|
||||
0 1px 2px rgba(60, 44, 20, 0.14), 0 0 0 1px rgba(38, 30, 18, 0.06);
|
||||
}
|
||||
[data-theme="grade"] {
|
||||
--agent-chip-radius: 3px;
|
||||
--agent-orb-shadow: 0 0 40px rgba(165, 152, 216, 0.25);
|
||||
--agent-ring-shadow: none;
|
||||
}
|
||||
|
||||
.b4 .shot {
|
||||
--button-sm-height: 22px;
|
||||
}
|
||||
|
||||
[data-theme="grade"] {
|
||||
--agent-shot-radius: 4px;
|
||||
--agent-bubble-radius: 4px;
|
||||
--agent-tool-radius: 4px;
|
||||
}
|
||||
[data-theme="bluehour"] {
|
||||
--agent-shot-radius: 14px;
|
||||
--agent-bubble-radius: 14px;
|
||||
--agent-tool-radius: 14px;
|
||||
}
|
||||
.b4 {
|
||||
--tool-card-radius: var(--agent-tool-radius);
|
||||
--tool-card-bg: var(--bg-1);
|
||||
--tool-head-padding: 8px 12px;
|
||||
--tool-name-font: 11.5px;
|
||||
}
|
||||
|
||||
.b4 {
|
||||
--tag-height: 18px;
|
||||
--tag-padding: 6px;
|
||||
--tag-border-width: 1px;
|
||||
--tag-border: var(--line);
|
||||
--tag-radius: 4px;
|
||||
--tag-background: rgb(var(--ink) / 0.05);
|
||||
--tag-color: var(--tx-2);
|
||||
--tag-font-size: 11px;
|
||||
}
|
||||
.b4 .roster .acard {
|
||||
--tag-color: var(--c);
|
||||
--tag-border: color-mix(in srgb, var(--c) 35%, transparent);
|
||||
}
|
||||
.b4 .a-tag.sc {
|
||||
--tag-color: var(--sc-agent);
|
||||
--tag-border: color-mix(in srgb, var(--sc-agent) 40%, transparent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
HEL-732 / B4:智能体任务、可编辑成果与显式存入项目草稿
|
||||
|
||||
基底:`leefer/hel-731-studio-project` @ `8d7e58645c9e63bb9b9e6a4ae8dfb031bf5960fb`。保留 B1–B3 及其返工。没有新增依赖、云接口、部署动作或线上数据操作。
|
||||
|
||||
## 行为与模块边界
|
||||
|
||||
- `/agents` 使用已确认 HTML 的首页/任务结构:真实任务列表、主创输入、五个阶段助手、最近成果、技能库;任务页复用 `AgentChat`、工具卡、`AgentPlanPanel`、上传与工作流/技能选择逻辑。没有模型或成果时显示真实空态。
|
||||
- 原页面完整移动为 `LegacyAgentWorkspace.svelte`,`/agents?legacy=1` 保留原会话、模型绑定、技能编辑与版本恢复;原 `?project=&task=` 深链继续走原画布交接。静态技能回归检查随编辑器移动到实际组件路径,检查规则未放宽。
|
||||
- runner 在成功结束一轮时,把最终真实回复捕获为独立成果草稿;错误、取消、提问暂停不会冒充完成成果。会话新增任务摘要和状态,聊天/工具日志仍是原来源。旧会话无需被伪造回填成新成果。
|
||||
- 成果支持文本编辑、分镜文字/时长编辑、版本冲突检查、项目/阶段归属、刷新恢复、真实上一保存版本对比。逐镜重画沿用现有 `workbench.generate`,传入唯一目标镜头;不改变生成协议或批量入队语义。
|
||||
- 创作台无来源的已完成结果打开通用项目/阶段选择器;有来源的结果继续走 B3 原回流。项目页增加待整理成果区;原固定确认底栏、确认快照、恢复、阶段隔离和视频手动播放保留。
|
||||
- 共享组件新增可选 `studio` 呈现及有默认值的 composer token;原画布等调用方维持默认呈现。`.a-btn.scene` 统一加入原按钮来源,页面不再建立第二套按钮样式。新页视觉值与主题差异在 `static/themes.css`;范围限定 `.b4`,不改 B3 按钮 token。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/results/deliverables[?session_id=]`:持久化成果列表。
|
||||
- `PATCH /api/results/deliverables/{id}`:`revision, content, project_id, stage_key`;成功增加版本,旧版本先写入历史;陈旧版本返回 409。绑定项目的原会话不允许静默把成果移动到别的项目。
|
||||
- `GET /api/results/deliverables/{id}/versions`:真实保存历史。
|
||||
- `POST /api/results/store`:`project_id, stage_key`,且恰好选择 `deliverable_id + revision` 或 `job_id`。
|
||||
- 成果按已保存归属存入;独立生成的 system 项目产物允许选择目标,已有业务项目产物拒绝跨项目误存;文件必须存在于合法 assets 目录,job 必须为已完成图片/视频。运行中的阶段拒绝写入。
|
||||
- 幂等键为 `deliverable:{id}:{revision}` 或 `job:{id}`,加项目和阶段唯一约束;事务内写入阶段与幂等记录,并发重试不会重复附加。陈旧成果版本、无产物、无效阶段、误归属均拒绝。
|
||||
- 合法结构化内容经原 `NORMALIZERS` 进入工作草稿;纯文本剧本沿用原剧本文字校验。其他非结构化回复/媒体保存在 `incoming_results` 待整理区,供人整理进阶段内容;原阶段确认仍要求合法阶段结构,不把附件本身当成已经完成整阶段。
|
||||
- 写入只把目标阶段置 `awaiting_confirm`;绝不写 `confirmed_content_json`,不确认或推进下一阶段。确认/编辑保留附加成果,恢复恢复原已确认快照。已经存入后再恢复,不会因重复 POST 自动复活被恢复掉的草稿;用户可以保存新版本后再次显式带回。
|
||||
|
||||
## 增量迁移和精确回退
|
||||
|
||||
新增 `agent_sessions.task_summary/task_status`,新增 `agent_deliverables`、`agent_deliverable_versions`、`result_attachments`。迁移由既有 `migrate_db` 执行,旧列与旧状态语义保留。
|
||||
|
||||
`e2e/hel732-migration.py` 在独立目录导出精确基底代码并创建旧版库,使用 SQLite Backup API 备份,再运行新迁移两次,逐字段比较全部已有项目、会话、消息和阶段,校验 `integrity_check/foreign_key_check`,由真实旧版应用启动并读取会话和已确认阶段,最后将迁移前备份恢复到另一离线文件,比较旧表和旧结构。证据包含迁移前备份、迁移后导出、恢复文件及 SHA256;均为合成隔离数据,不是用户库。
|
||||
|
||||
后续部署由总工执行,步骤不得省略:
|
||||
|
||||
1. 暂停写入并停止应用/队列,记录旧/新代码 SHA,备份配置和媒体;用 SQLite Backup API 制作一致数据库备份,执行完整性校验并记录 SHA256。不要仅复制运行中的主 `.db` 而漏掉 WAL。
|
||||
2. 在备份副本验证新版本启动、两次迁移和关键读写,再升级代码;本卡没有在测试站执行该步骤。
|
||||
3. **只回代码**:旧版已实际读取迁移后库;新增列/表可留在库中。但旧 UI 看不到新成果/版本记录,旧版阶段编辑/重新确认会去掉它不理解的 `incoming_results`。所以不能承诺代码回退保留 B4 新交互语义,回代码后应冻结这些编辑并留存迁移后导出。
|
||||
4. **精确回退数据**:停止所有数据库持有者;先用 Backup API 导出升级后的库以保留新写入。将当前 `.db` 和可能存在的 `-wal/-shm` 改名归档,不把旧 WAL 混入恢复库。把迁移前备份复制为新的数据库文件,校验 SHA256/完整性,再启动精确旧代码 SHA。隔离演练证明恢复文件与迁移前备份 SHA256 相同、旧表逐行相同且无新增表/列。
|
||||
5. 精确数据回退会舍弃备份之后的业务写入;有新数据时必须先导出并由负责人决定是否允许,不能在线 DROP 新表/列或以“只加列”断言无风险。媒体文件也应按一致备份与引用核对,不自动删除。
|
||||
|
||||
## 自测与视觉证据
|
||||
|
||||
执行脚本:`e2e/run-hel732.py`(循环回环端口,前台收集结果并在 finally 关闭每个子进程)、`e2e/hel732.cjs`、`e2e/hel732-visual.cjs`、`e2e/hel732-migration.py`;复跑 B3 `e2e/hel731.cjs`。Python/Playwright 和本机共享库通过环境变量指向既有安装,不增加项目依赖。
|
||||
|
||||
图证保留原 HTML 原始状态图;另交仅替换真实数据内容的参考图与实际页面并排图,覆盖四主题与三档尺寸、首页/任务两页。参考数据归一化没有改样本 CSS、布局、字号或尺寸;演示任务、模型计数和占位图未写入产品。尺寸/样式实测表和操作记录、完整 Playwright trace 随任务附件交付。最终视觉一致性仍须总工独立重拍审核,整套样本尚未完成。
|
||||
|
||||
|
||||
## 交接阻塞:真实内容与样本的可用性冲突
|
||||
|
||||
本批不能标记已完成:原 HTML 的 `.agent-home` 是纵向 flex,`.ahero` 允许收缩又 `overflow:hidden`。在 1366×768、1280×720 中,真实模型/技能名称、成果标题参与排版后,参考样本和照抄的实际页面都会把 hero 压矮,裁掉任务输入/开始任务按钮;因为裁切发生在 hero 内,单纯滚动外层不能找回这些控件。逐项数值一致不能替代这个实际可用性问题。
|
||||
|
||||
任务卡要求“样本与真实业务/技术冲突先列具体冲突交总管处理,不自行改样”。因此保留源码行为作为问题证据,交总管决定是否授权仅在此卡片设置 `flex-shrink:0`,让既有 `.agent-home {overflow:auto}` 承接纵向内容。建议不改网格列宽、字号、圆角、配色或其他页面。附件提供原样并排图和仅在隔离浏览器临时施加上述一条规则的建议图,建议图不代表已进入代码或通过验收。
|
||||
|
||||
通用选择器等新增真业务控件也使个别小尺寸的 flex 换行位置有差异,保留在数值表,不过滤视觉失败。完整功能与构建已交,PR 为待处理施工稿;收到总管对冲突的明确裁定后,继续修正、重拍并补测。本批未交 in_review,更不代表整套样本还原完毕。
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Loopback-only OpenAI-compatible model: actual tools execute inside the isolated app."""
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
OUT=Path('e2e/shots/hel732');OUT.mkdir(parents=True,exist_ok=True)
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args): pass
|
||||
def do_POST(self):
|
||||
payload=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))
|
||||
with (OUT/'model-requests.jsonl').open('a') as f: f.write(json.dumps(payload,ensure_ascii=False)+'\n')
|
||||
msgs=payload.get('messages',[])
|
||||
last_user=max((i for i,m in enumerate(msgs) if m.get('role')=='user'),default=-1)
|
||||
has_tool=any(m.get('role')=='tool' for m in msgs[last_user+1:])
|
||||
text='第一场,清晨的海岸,女孩将纸船放入海水,等待父亲归来。第二场,纸船带回一片树叶,她望向海平面,知道旅程才刚刚开始。'
|
||||
if 'B4_STORYBOARD' in json.dumps(msgs,ensure_ascii=False):
|
||||
text='以下是可编辑分镜成果:\n```json\n'+json.dumps({'shots':[{'id':f'b4-shot-{i}','description':f'隔离验证镜头 {i},女孩沿海岸走向灯塔。','duration_sec':4} for i in range(1,4)]},ensure_ascii=False)+'\n```'
|
||||
if msgs and str(msgs[0].get('content','')).startswith('You are the planner'):
|
||||
text=json.dumps({'mode':'single','tasks':[],'note':'隔离模型单任务'})
|
||||
delta={'content':text}
|
||||
if payload.get('tools') and not has_tool:
|
||||
delta={'tool_calls':[{'index':0,'id':'b4-workspace','type':'function','function':{'name':'get_workspace','arguments':'{"sections":["scenes"]}'}}]}
|
||||
if payload.get('stream'):
|
||||
self.send_response(200);self.send_header('Content-Type','text/event-stream');self.end_headers()
|
||||
self.wfile.write(('data: '+json.dumps({'id':'b4-fake','choices':[{'index':0,'delta':delta,'finish_reason':None}]},ensure_ascii=False)+'\n\n').encode())
|
||||
self.wfile.write(b'data: [DONE]\n\n');self.wfile.flush()
|
||||
else:
|
||||
data=json.dumps({'choices':[{'index':0,'message':{'role':'assistant',**delta},'finish_reason':'stop'}]},ensure_ascii=False).encode()
|
||||
self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(data)
|
||||
ThreadingHTTPServer(('127.0.0.1',18189),Handler).serve_forever()
|
||||
@@ -0,0 +1,12 @@
|
||||
// Review-only proposal: temporary browser style; never change product/sample code.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs');const out='e2e/shots/hel732';
|
||||
(async()=>{const b=await chromium.launch({args:['--no-sandbox']});const rows=[];
|
||||
try {for(const [width,height] of [[1366,768],[1280,720]]){
|
||||
const p=await b.newPage({viewport:{width,height}});await p.goto('http://127.0.0.1:5173/agents');await p.locator('.roster .acard').first().waitFor();
|
||||
const measure=()=>p.evaluate(()=>{const hero=document.querySelector('.ahero').getBoundingClientRect(),button=document.querySelector('.big-input .primary').getBoundingClientRect();return {heroHeight:hero.height,heroBottom:hero.bottom,buttonTop:button.top,buttonBottom:button.bottom,clipped:button.bottom>hero.bottom,viewport:innerHeight}});
|
||||
const before=await measure();await p.screenshot({path:`${out}/conflict-${width}-actual.png`,animations:'disabled'});
|
||||
await p.locator('.ahero').evaluate(e=>e.style.flexShrink='0');const proposal=await measure();await p.screenshot({path:`${out}/proposal-${width}-browser-only.png`,animations:'disabled'});
|
||||
rows.push({width,height,before,proposal,temporaryRule:'.ahero {flex-shrink:0}',productCodeChanged:false});await p.close();
|
||||
}fs.writeFileSync(out+'/conflict.json',JSON.stringify(rows,null,2));console.log('RECORDED visual blocker and browser-only proposal',rows);
|
||||
}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Executable isolated backup / migration / old-code read / exact restore exercise.
|
||||
No service, user database or production configuration is opened.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
BASE='8d7e58645c9e63bb9b9e6a4ae8dfb031bf5960fb'
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
OUT=ROOT/'e2e/shots/hel732/migration'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
OLD=OUT/'old-code';OLD.mkdir(exist_ok=True)
|
||||
archive=subprocess.run(['git','archive',BASE,'calliope-backend/src'],cwd=ROOT,check=True,stdout=subprocess.PIPE).stdout
|
||||
(OUT/'old-code.tar').write_bytes(archive)
|
||||
with tarfile.open(OUT/'old-code.tar') as tf:tf.extractall(OLD,filter='data')
|
||||
DB=OUT/'isolated.db'
|
||||
if DB.exists():DB.rename(OUT/f'previous-{__import__("time").time_ns()}.db')
|
||||
checks=[]
|
||||
def old_run(mode):
|
||||
code='''import asyncio,json,sys
|
||||
from pathlib import Path
|
||||
from calliope import config
|
||||
from calliope.db import migrate_db,get_db
|
||||
config.settings.data_dir=Path(sys.argv[1]).parent
|
||||
config.settings.db_name=Path(sys.argv[1]).name
|
||||
config.settings.assets_dir=Path(sys.argv[1]).parent/'assets'
|
||||
config.settings.dry_run=True
|
||||
config.Settings.save_config_file=lambda self:None
|
||||
if sys.argv[2]=='create':
|
||||
asyncio.run(migrate_db(config.settings.db_path))
|
||||
c=get_db(config.settings.db_path)
|
||||
p=c.execute("INSERT INTO projects(title,status) VALUES ('B3 isolated legacy','draft')").lastrowid
|
||||
c.execute("INSERT INTO agent_sessions(project_id,title,status) VALUES (?,'Legacy session','idle')",(p,))
|
||||
c.execute("INSERT INTO agent_messages(session_id,role,content) VALUES (1,'user','Existing legacy message')")
|
||||
c.execute("INSERT INTO jobs(project_id,kind,status,payload_json,output_paths_json) VALUES (1,'image','done',?,'[]')",(json.dumps({'legacy':True}),))
|
||||
content=json.dumps({'text':'Legacy confirmed story text, sufficiently long to preserve the old confirmation behavior.'})
|
||||
c.execute("INSERT INTO project_stages(project_id,stage_key,status,content_json,confirmed_content_json) VALUES (?,'script','confirmed',?,?)",(p,content,content))
|
||||
c.commit();c.close()
|
||||
else:
|
||||
from calliope.main import create_app
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(create_app()) as client:
|
||||
s=client.get('/api/agent/sessions/1');assert s.status_code==200,s.text
|
||||
assert any(m['content']=='Existing legacy message' for m in s.json()['messages'])
|
||||
jobs=client.get('/api/jobs?project_id=1');assert jobs.status_code==200,jobs.text
|
||||
assert jobs.json()[0]['status']=='done' and jobs.json()[0]['payload']['legacy'] is True
|
||||
w=client.get('/api/workbench/projects/1');assert w.status_code==200,w.text
|
||||
stage=w.json()['stages'][0];assert stage['status']=='confirmed'
|
||||
assert stage['content']==stage['confirmed_content']
|
||||
print(json.dumps({'session':s.json()['title'],'stage':stage['status'],'message_count':len(s.json()['messages'])}))
|
||||
'''
|
||||
env=os.environ.copy();env['PYTHONPATH']=str(OLD/'calliope-backend/src')
|
||||
result=subprocess.run([__import__('sys').executable,'-c',code,str(DB),mode],cwd=OUT,env=env,text=True,capture_output=True,check=True)
|
||||
(OUT/f'old-{mode}.log').write_text(result.stdout+result.stderr)
|
||||
checks.append('old code '+mode+' succeeded')
|
||||
old_run('create')
|
||||
def backup(src,dst):
|
||||
a=sqlite3.connect(src);b=sqlite3.connect(dst);a.backup(b);b.close();a.close()
|
||||
backup(DB,OUT/'pre-b4-backup.db')
|
||||
def dump(path):
|
||||
c=sqlite3.connect(path);rows={name:c.execute(f'SELECT * FROM {name}').fetchall() for name in ['projects','agent_sessions','agent_messages','project_stages','jobs']};c.close();return rows
|
||||
before=dump(DB)
|
||||
from calliope.db import migrate_db,get_db
|
||||
asyncio.run(migrate_db(DB));asyncio.run(migrate_db(DB))
|
||||
c=get_db(DB)
|
||||
for table,rows in before.items():
|
||||
current=c.execute(f'SELECT * FROM {table}').fetchall()
|
||||
# New columns are appended; every pre-existing field is equal.
|
||||
assert [tuple(r)[:len(rows[0])] for r in current]==rows
|
||||
assert c.execute('PRAGMA integrity_check').fetchone()[0]=='ok'
|
||||
assert c.execute('PRAGMA foreign_key_check').fetchall()==[]
|
||||
cols=[r[1] for r in c.execute('PRAGMA table_info(agent_sessions)')]
|
||||
assert cols.count('task_summary')==1 and cols.count('task_status')==1
|
||||
c.close();checks+=['migrate twice; no duplicate columns/tables','all original rows/fields preserved','integrity_check and foreign_key_check passed']
|
||||
old_run('read')
|
||||
# New-only payload survives an old-code read and startup.
|
||||
c=get_db(DB)
|
||||
c.execute("INSERT INTO agent_deliverables(session_id,source_message_id,project_id,stage_key,title,content) VALUES (1,1,1,'script','B4 isolated','New isolated deliverable')")
|
||||
c.commit();c.close()
|
||||
backup(DB,OUT/'post-b4-export.db')
|
||||
# Exact rollback to an offline copy of the old backup; never overwrite a live DB.
|
||||
backup(OUT/'pre-b4-backup.db',OUT/'restored.db')
|
||||
assert dump(OUT/'restored.db')==before
|
||||
c=sqlite3.connect(OUT/'restored.db')
|
||||
assert 'task_summary' not in [r[1] for r in c.execute('PRAGMA table_info(agent_sessions)')]
|
||||
assert c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name='agent_deliverables'").fetchone()[0]==0
|
||||
c.close();checks.append('exact pre-migration backup restored: old rows and old schema equal')
|
||||
manifest={p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in OUT.glob('*.db')}
|
||||
(OUT/'report.json').write_text(json.dumps({'base':BASE,'checks':checks,'sha256':manifest},indent=2))
|
||||
print(json.dumps({'checks':checks,'sha256':manifest},indent=2))
|
||||
@@ -0,0 +1,51 @@
|
||||
// Same-page/state comparison: normalize reference CONTENT only, retain its original CSS/layout.
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict');
|
||||
const out='e2e/shots/hel732',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247';
|
||||
const api=async p=>(await fetch(API+p)).json();
|
||||
const style=loc=>loc.evaluate(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return Object.fromEntries(['fontSize','fontWeight','letterSpacing','lineHeight','padding','gap','borderRadius','borderTopWidth','borderTopColor','backgroundColor','backgroundImage','color','boxShadow','filter','gridTemplateColumns'].map(k=>[k,s[k]]).concat([['x',r.x],['y',r.y],['width',r.width],['height',r.height]]))});
|
||||
(async()=>{
|
||||
const state=JSON.parse(fs.readFileSync(out+'/fixture-state.json'));
|
||||
const registry=await api('/api/agent/agents'),skills=await api('/api/agent/skills?all=true'),results=await api('/api/results/deliverables');
|
||||
const session=await api('/api/agent/sessions/'+state.board),shots=JSON.parse(state.br.content).shots;
|
||||
const sessions=(await Promise.all([api('/api/agent/sessions'),...registry.map(a=>api('/api/agent/sessions?agent_key='+a.key))])).flat().sort((a,b)=>b.updated_at.localeCompare(a.updated_at));
|
||||
const b=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../evidence/reference/夜片场-多主题交互样本.html'))});await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
const rows=[],operations=[];
|
||||
try{
|
||||
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
const c=await b.newContext({viewport:{width,height}}),a=await c.newPage(),s=await c.newPage();
|
||||
await a.goto(WEB+'/agents');await a.locator('.roster .acard').first().waitFor();await a.evaluate(t=>{document.documentElement.dataset.theme=t;localStorage.setItem('aigc-theme',t);document.documentElement.dataset.reduce='0'},theme);
|
||||
await s.goto('http://127.0.0.1:'+server.address().port);await s.evaluate(({theme,registry,skills,results,sessions,board})=>{
|
||||
go('agents');document.documentElement.dataset.theme=theme;
|
||||
const clear=e=>{while(e.firstChild)e.removeChild(e.firstChild)};
|
||||
for(const id of ['taskList','taskList2']){const list=document.getElementById(id);clear(list);for(const task of sessions){const item=document.createElement('div');item.className='titem'+(id==='taskList2'&&task.id===board?' on':'');const title=document.createElement('b');title.className='ellipsis';title.textContent=task.title;const meta=document.createElement('div');meta.className='meta';const dot=document.createElement('span');dot.className='dot';dot.style.background='var(--sc-agent)';const grow=document.createElement('span');grow.className='grow';meta.append(dot,({returned:'已带回项目',awaiting_confirm:'待你确认',ready:'未开始'}[task.task_status]||task.task_status||'未开始'),grow,task.project?.title||'沙盒');item.append(title,meta);list.append(item);}}
|
||||
const chips=document.querySelector('#p-agents .big-input .row').querySelectorAll('.chip');chips[0].querySelector('b').textContent='沙盒';chips[1].querySelector('b').textContent='reference-test';chips[2].querySelector('b').textContent='不指定';
|
||||
|
||||
document.querySelectorAll('#roster .acard').forEach((e,i)=>{e.querySelector('.bg').style.backgroundImage='none';e.querySelector('.tag').textContent=registry[i].skill_count+' 个技能';e.querySelector('.dim').textContent='reference-test';});
|
||||
const skillBox=document.querySelector('#p-agents .skills');clear(skillBox);for(const skill of skills.filter(x=>x.is_enabled)){const el=document.createElement('span');el.className='skill';el.append(document.createElement('i'),skill.name);skillBox.append(el);}const learn=document.createElement('span');learn.className='skill dim';learn.textContent='+ 学习新技能';skillBox.append(learn);
|
||||
document.querySelector('#p-agents .mini-row .panel:last-child .tag').textContent=String(skills.length);
|
||||
const outs=document.querySelector('#agentOuts');clear(outs);for(const r of results.slice(0,4)){const card=document.createElement('div');card.className='outcard';const thumb=document.createElement('div');thumb.className='t';const data=document.createElement('div');data.className='d';const title=document.createElement('b');title.textContent=r.title;const sub=document.createElement('span');sub.className='dim';sub.textContent=({script:'剧本创作',storyboard:'分镜设计'}[r.stage_key]||r.stage_key)+' · 草稿 v'+r.revision;data.append(title,sub);card.append(thumb,data);outs.append(card);}
|
||||
const input=document.querySelector('#p-agents .big-input textarea');input.placeholder='描述你的创作想法、目标与要求…';document.querySelector('#p-agents .big-input .primary').disabled=true;
|
||||
},{theme,registry,skills,results,sessions,board:state.board});
|
||||
await a.screenshot({path:`${out}/home-${theme}-${width}-actual.png`,animations:'disabled'});await s.screenshot({path:`${out}/home-${theme}-${width}-reference-content.png`,animations:'disabled'});
|
||||
for(const [name,actual,ref] of [['hero','.ahero','.ahero'],['title','.ahero h2','.ahero h2'],['assistant','.roster .acard','.roster .acard'],['input','.big-input','.big-input'],['tasklist','.tasklist','.tasklist'],['primary','.big-input .primary','.big-input .primary']])rows.push({theme,width,height,page:'home',name,actual:await style(a.locator(actual).first()),sample:await style(s.locator(ref).first())});
|
||||
await a.goto(WEB+'/agents?session='+state.board);await a.locator('.shot-copy textarea').first().waitFor();await s.evaluate(({shots,session})=>{
|
||||
go('agent-task');document.querySelector('#p-agent-task .crumb b').textContent=session.title;document.querySelector('#p-agent-task .ph>.tag').textContent='已带回项目';document.querySelector('#p-agent-task .art-h .row>.dim').textContent=shots.length+' 镜 · '+shots.reduce((sum,s)=>sum+(Number(s.duration_sec)||0),0)+'s';
|
||||
const list=document.querySelector('#shotList');const originals=Array.from(list.children);list.replaceChildren();for(const [i,shot]of shots.entries()){const el=originals[i].cloneNode(true);el.querySelector('.th').style.backgroundImage='none';el.querySelector('[contenteditable]').textContent=shot.description;el.querySelector('.tag.mono').textContent=shot.duration_sec+'s';list.append(el);}
|
||||
},{shots,session});
|
||||
await a.screenshot({path:`${out}/task-${theme}-${width}-actual.png`,animations:'disabled'});await s.screenshot({path:`${out}/task-${theme}-${width}-reference-content.png`,animations:'disabled'});
|
||||
for(const [name,actual,ref] of [['columns','.atask','.atask'],['artifact','.artifact','.artifact'],['header','.art-h','.art-h'],['footer','.art-f','.art-f'],['shot','.shot','.shot'],['save','.art-f .secondary','.art-f .btn:not(.primary)'],['primary','.art-f .primary','.art-f .primary']])rows.push({theme,width,height,page:'task',name,actual:await style(a.locator(actual).first()),sample:await style(s.locator(ref).first())});
|
||||
if(width===1440&&theme==='night'){
|
||||
await a.getByLabel('镜头 b4-shot-1 描述').fill('主题切换保持未保存编辑');await a.evaluate(async()=>{const {setTheme}=await import('/src/lib/theme.ts');setTheme('day')});await a.waitForFunction(()=>document.documentElement.dataset.theme==='day');assert.equal(await a.getByLabel('镜头 b4-shot-1 描述').inputValue(),'主题切换保持未保存编辑');operations.push('切换主题保持未保存成果文本');await a.screenshot({path:out+'/operation-theme-edit-preserved.png'});
|
||||
await a.getByRole('button',{name:'原文',exact:true}).click();assert((await a.getByLabel('成果草稿').inputValue()).includes('主题切换保持未保存编辑'));operations.push('逐镜编辑与原文视图同步');await a.screenshot({path:out+'/operation-raw-edit.png'});
|
||||
await a.reload();await a.locator('.shot-copy textarea').first().waitFor();await a.getByRole('button',{name:'对比上一版',exact:true}).click();await a.getByRole('dialog').waitFor();await a.screenshot({path:out+'/operation-version-comparison.png'});await a.keyboard.press('Escape');
|
||||
const tool=a.locator('.tool-card .head').first();await tool.click();await a.locator('.tool-card .body').first().waitFor();operations.push('工具详情真实参数与返回值可展开');await a.screenshot({path:out+'/operation-tool-expanded.png'});
|
||||
await a.evaluate(()=>document.documentElement.dataset.reduce='1');assert.equal(await a.locator('.b4 .aurora').evaluate(e=>getComputedStyle(e).display),'none');operations.push('减少动效隐藏本页装饰光晕与粒子');
|
||||
}
|
||||
await c.close();
|
||||
}
|
||||
fs.writeFileSync(out+'/style-values.json',JSON.stringify(rows,null,2));
|
||||
const compareKeys=['x','y','width','height','padding','borderRadius','fontSize','fontWeight','letterSpacing','filter','backgroundColor','backgroundImage','boxShadow','gridTemplateColumns'];const differences=rows.flatMap(r=>compareKeys.filter(k=>r.actual[k]!==r.sample[k]).map(k=>({theme:r.theme,width:r.width,page:r.page,name:r.name,property:k,actual:r.actual[k],sample:r.sample[k]})));fs.writeFileSync(out+'/measured-differences.json',JSON.stringify(differences,null,2));assert.deepEqual(differences,[],'Measured sample differences must be corrected');fs.writeFileSync(out+'/operations.json',JSON.stringify(operations,null,2));console.log('PASS normalized visual pairs: 24; measured selector pairs:',rows.length,'operations:',operations.length);
|
||||
}finally{await b.close();await new Promise(r=>server.close(r));}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,82 @@
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright');
|
||||
const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict');
|
||||
const API='http://127.0.0.1:8247',WEB='http://127.0.0.1:5173',out='e2e/shots/hel732';
|
||||
fs.mkdirSync(out,{recursive:true});const checks=[],errors=[];
|
||||
const ok=(n,v)=>{assert(v,n);checks.push(n);console.log('PASS',n)};
|
||||
async function api(p,b,method){const r=await fetch(API+p,{method:method||(b?'POST':'GET'),headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});if(!r.ok)throw Error(p+' '+await r.text());return r.json()}
|
||||
async function wait(fn){for(let i=0;i<120;i++){const r=await fn();if(r)return r;await new Promise(r=>setTimeout(r,150))}throw Error('condition timeout')}
|
||||
(async()=>{
|
||||
const b=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const c=await b.newContext({viewport:{width:1440,height:900}});await c.tracing.start({screenshots:true,snapshots:true,sources:true});const p=await c.newPage();p.setDefaultTimeout(15000);p.on('pageerror',e=>errors.push(e.message));
|
||||
const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../evidence/reference/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r));
|
||||
try{
|
||||
const project=await api('/api/projects',{title:'B4 隔离项目',idea:'海岸与纸船'}),other=await api('/api/projects',{title:'B4 另一个项目'});
|
||||
await p.goto(WEB+'/agents');await p.locator('.roster .acard').first().waitFor();
|
||||
ok('首页五个真实阶段助手',await p.locator('.roster .acard').count()===5);
|
||||
ok('无成果显示真实空态',(await p.locator('.outrow').innerText()).includes('暂无成果'));
|
||||
await p.getByLabel('绑定项目',{exact:true}).selectOption(String(project.id));
|
||||
await p.getByLabel('任务技能').selectOption({index:1});
|
||||
await p.getByLabel('任务想法').fill('请调用工具查看项目,然后写一个海岸纸船的完整故事。');
|
||||
await p.getByRole('button',{name:'开始任务',exact:true}).click();
|
||||
const sid=await wait(async()=>Number(new URL(p.url()).searchParams.get('session'))||null);
|
||||
const r=await wait(async()=>(await api('/api/results/deliverables?session_id='+sid))[0]);
|
||||
ok('真实会话链路产生持久化成果',r.content.length>30);
|
||||
const s=await api('/api/agent/sessions/'+sid);ok('任务摘要与状态持久化',s.task_summary.includes('海岸')&&s.task_status==='awaiting_confirm');
|
||||
ok('假模型通过应用实际调用工具',s.messages.some(m=>m.role==='tool'&&m.tool_name==='get_workspace'));
|
||||
ok('技能选择传入真实会话',s.messages.some(m=>m.role==='user'&&m.mentions?.some(x=>x.type==='skill')));
|
||||
await p.getByLabel('成果草稿').waitFor();const edited=r.content+'【浏览器编辑并保存】';await p.getByLabel('成果草稿').fill(edited);await p.getByRole('button',{name:'保存草稿',exact:true}).click();
|
||||
await wait(async()=>(await api('/api/results/deliverables?session_id='+sid))[0].revision===2);
|
||||
await p.reload();await p.getByLabel('成果草稿').waitFor();ok('编辑保存刷新恢复',await p.getByLabel('成果草稿').inputValue()===edited);
|
||||
await p.getByRole('button',{name:'带回项目待确认',exact:true}).click();await p.locator('[data-testid=incoming-result]').waitFor();
|
||||
const wb=await api('/api/workbench/projects/'+project.id),stage=wb.stages.find(x=>x.stage_key==='script');
|
||||
ok('带回指定项目阶段且仅待确认',stage.status==='awaiting_confirm'&&stage.content.text===edited&&stage.confirmed_content===null);
|
||||
ok('其他项目未出现成果',!JSON.stringify(await api('/api/workbench/projects/'+other.id)).includes('浏览器编辑'));
|
||||
await p.locator('[data-testid=stage-confirm-btn]').click();await wait(async()=>(await api('/api/workbench/projects/'+project.id)).stages[0].status==='confirmed');ok('真实项目页显式确认',true);
|
||||
const nr=(await api('/api/results/deliverables?session_id='+sid))[0];const body={deliverable_id:nr.id,revision:nr.revision,project_id:project.id,stage_key:'script'};
|
||||
ok('重复提交幂等',(await api('/api/results/store',body)).already_attached===true);
|
||||
const bad=await fetch(API+'/api/results/store',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...body,project_id:other.id})});ok('跨项目误存被拒绝',bad.status===409);
|
||||
// Actual structured reply and a single image job; no GPU/paid model.
|
||||
const board=await api('/api/agent/sessions',{project_id:project.id,agent_key:'storyboard',title:'B4 分镜隔离任务'});
|
||||
await api('/api/agent/sessions/'+board.id+'/messages',{content:'B4_STORYBOARD 请读取工作区并给出三个镜头。'});
|
||||
const br=await wait(async()=>(await api('/api/results/deliverables?session_id='+board.id))[0]);
|
||||
const parsed=JSON.parse(br.content.split('```json')[1].split('```')[0]);
|
||||
const saved=await api('/api/results/deliverables/'+br.id,{revision:br.revision,content:JSON.stringify(parsed,null,2),project_id:project.id,stage_key:'storyboard'},'PATCH');
|
||||
await api('/api/results/store',{project_id:project.id,stage_key:'storyboard',deliverable_id:saved.id,revision:saved.revision});
|
||||
await p.goto(WEB+'/agents?session='+board.id);await p.getByRole('button',{name:'重画',exact:true}).first().waitFor();
|
||||
await p.getByLabel('镜头 b4-shot-1 描述').fill('逐镜编辑后的真实描述');await p.getByLabel('镜头 b4-shot-1 时长').fill('6');await p.getByRole('button',{name:'保存草稿',exact:true}).click();
|
||||
await wait(async()=>(await api('/api/results/deliverables?session_id='+board.id))[0].revision===3);
|
||||
await p.getByRole('button',{name:'对比上一版',exact:true}).click();await p.getByRole('dialog').waitFor();ok('成果对比读取真实上一保存版本',(await p.getByRole('dialog').innerText()).includes('隔离验证镜头 1'));await p.getByRole('dialog').getByRole('button',{name:'关闭对话框',exact:true}).click();
|
||||
await p.reload();await p.getByLabel('镜头 b4-shot-1 描述').waitFor();ok('逐镜文字与时长保存刷新恢复',(await p.getByLabel('镜头 b4-shot-1 描述').inputValue())==='逐镜编辑后的真实描述'&&(await p.getByLabel('镜头 b4-shot-1 时长').inputValue())==='6');
|
||||
|
||||
const before=(await api('/api/jobs?project_id='+project.id)).length;
|
||||
await p.getByRole('button',{name:'重画',exact:true}).nth(1).click();
|
||||
const jobs=await wait(async()=>{const rows=await api('/api/jobs?project_id='+project.id);return rows.length>before?rows:null});
|
||||
ok('单镜重画仅新增一个任务',jobs.length===before+1);
|
||||
ok('单镜重画保留指定镜头',jobs[0].payload.target_id==='b4-shot-2');
|
||||
// Independent take → chooser → specific stage draft, original flow-back stays separately covered by B3.
|
||||
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:0,videos:false,unique_filename:true}));
|
||||
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
|
||||
await p.goto(WEB+'/create?kind=image&wf='+f.dual);await p.locator('#desk-prompt').waitFor();await p.locator('#desk-prompt').fill('B4 isolated independent image');
|
||||
const maxJob=Math.max(0,...(await api('/api/playground/jobs')).map(x=>x.id));await p.locator('.gen').click();
|
||||
const job=await wait(async()=>(await api('/api/playground/jobs')).find(j=>j.id>maxJob&&j.status==='done'));
|
||||
await p.getByRole('button',{name:'存入项目',exact:true}).click();await p.getByLabel('存入项目',{exact:true}).selectOption(String(other.id));await p.getByLabel('存入阶段').selectOption('assets');
|
||||
await p.getByRole('button',{name:'存入待确认草稿'}).click();
|
||||
const mediaStage=await wait(async()=>(await api('/api/workbench/projects/'+other.id)).stages.find(s=>s.stage_key==='assets'&&s.incoming_results?.length));
|
||||
ok('独立生成经通用选择器存入指定阶段',mediaStage.incoming_results[0].paths[0]===job.output_paths[0]&&mediaStage.status==='awaiting_confirm');
|
||||
await p.goto(WEB+'/agents?legacy=1');await p.locator('.agent-rail').waitFor();ok('原会话及技能管理仍可达',true);
|
||||
fs.writeFileSync(out+'/fixture-state.json',JSON.stringify({project,other,sid,board:board.id,br:(await api('/api/results/deliverables?session_id='+board.id))[0]},null,2));
|
||||
const sp=await c.newPage();const measures=[];
|
||||
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
|
||||
await p.setViewportSize({width,height});await sp.setViewportSize({width,height});
|
||||
await p.goto(WEB+'/agents');await p.evaluate(t=>{document.documentElement.dataset.theme=t;localStorage.setItem('aigc-theme',t);document.documentElement.dataset.reduce='0'},theme);await p.locator('.roster .acard').first().waitFor();
|
||||
await sp.goto('http://127.0.0.1:'+sample.address().port);await sp.evaluate(t=>{go('agents');document.documentElement.dataset.theme=t;document.documentElement.dataset.reduce='0'},theme);
|
||||
await p.screenshot({animations:"disabled",path:`${out}/home-${theme}-${width}-actual.png`});await sp.screenshot({animations:"disabled",path:`${out}/home-${theme}-${width}-sample.png`});
|
||||
measures.push({width,height,theme,page:'home',actual:await p.locator('.ahero').evaluate(e=>({width:e.offsetWidth,padding:getComputedStyle(e).padding,title:getComputedStyle(e.querySelector('h2')).fontSize,weight:getComputedStyle(e.querySelector('h2')).fontWeight})),sample:await sp.locator('.ahero').evaluate(e=>({width:e.offsetWidth,padding:getComputedStyle(e).padding,title:getComputedStyle(e.querySelector('h2')).fontSize,weight:getComputedStyle(e.querySelector('h2')).fontWeight}))});
|
||||
await p.goto(WEB+'/agents?session='+board.id);await p.locator('.shot-copy textarea').first().waitFor();await sp.evaluate(()=>go('agent-task'));
|
||||
await p.screenshot({animations:"disabled",path:`${out}/task-${theme}-${width}-actual.png`});await sp.screenshot({animations:"disabled",path:`${out}/task-${theme}-${width}-sample.png`});
|
||||
ok(`任务页成果底栏可见 ${theme} ${width}`,await p.locator('.art-f').evaluate(e=>{const r=e.getBoundingClientRect();return r.bottom<=innerHeight&&r.top>=0}));
|
||||
measures.push({width,height,theme,page:'task',actual:await p.locator('.atask').evaluate(e=>({columns:getComputedStyle(e).gridTemplateColumns,height:e.offsetHeight})),sample:await sp.locator('.atask').evaluate(e=>({columns:getComputedStyle(e).gridTemplateColumns,height:e.offsetHeight}))});
|
||||
}
|
||||
fs.writeFileSync(out+'/measurements.json',JSON.stringify(measures,null,2));ok('浏览器无运行时错误',errors.length===0);
|
||||
}finally{fs.writeFileSync(out+'/checks.json',JSON.stringify({checks,errors},null,2));await c.tracing.stop({path:out+'/trace.zip'});await b.close();await new Promise(r=>sample.close(r));}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Run the isolated regression stack in the foreground and always stop children."""
|
||||
import os, signal, socket, subprocess, time, urllib.request
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
os.chdir(root)
|
||||
py=os.environ.get('HEL732_PYTHON', 'python3')
|
||||
logs=root/'e2e/shots/hel732'; logs.mkdir(parents=True,exist_ok=True)
|
||||
env=os.environ.copy()
|
||||
env['PYTHONPATH']=str(root/'calliope-backend/src')
|
||||
for port in [8247,5173,18188,18189]:
|
||||
with socket.socket() as probe:
|
||||
assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use'
|
||||
# Every run gets fresh disposable state; retain prior evidence instead of deleting it.
|
||||
reference=root/'e2e/shots/reference'
|
||||
reuse_fixture=os.environ.get('HEL732_REUSE_FIXTURE')=='1'
|
||||
if reference.exists() and not reuse_fixture:reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
|
||||
mode_path=root/'e2e/fake_mode.json'
|
||||
saved_mode=mode_path.read_bytes() if mode_path.exists() else None
|
||||
processes=[]; handles=[]
|
||||
try:
|
||||
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/fake_agent_b4.py']),('web',['npm','--prefix',os.environ.get('HEL732_WEB_DIR',os.environ.get('HEL729_WEB_DIR','calliope-web')),'run','dev'])]:
|
||||
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
|
||||
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
|
||||
for i in range(100):
|
||||
try:
|
||||
urllib.request.urlopen(url,timeout=2); break
|
||||
except Exception:
|
||||
if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed')
|
||||
if i==99:raise
|
||||
time.sleep(.2)
|
||||
if not reuse_fixture:
|
||||
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
|
||||
failures=[]
|
||||
scripts=os.environ.get('HEL732_TESTS',os.environ.get('HEL729_TESTS','e2e/hel732.cjs')).split(',')
|
||||
for script in scripts:
|
||||
print('RUN',script,flush=True)
|
||||
result=subprocess.run(['node',script],env=env)
|
||||
if result.returncode:failures.append(script)
|
||||
print('FAILED SCRIPTS',failures,flush=True)
|
||||
if failures:raise SystemExit(1)
|
||||
|
||||
finally:
|
||||
for p in processes:
|
||||
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
|
||||
for p in processes:
|
||||
try:p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
|
||||
print("STOPPED TEST CHILDREN", [{"pid":p.pid,"exit":p.returncode} for p in processes],flush=True)
|
||||
for h in handles:h.close()
|
||||
|
||||
if saved_mode is not None:mode_path.write_bytes(saved_mode)
|
||||
elif mode_path.exists():mode_path.unlink()
|
||||
Reference in New Issue
Block a user