Compare commits

..
Author SHA1 Message Date
总工 93df1c55c1 HEL-873 r3: merge PR #24 (HEL-887 delete cleanup, e88dc0a) 2026-10-27 01:10:09 +08:00
施工员2号andmultica-agent e88dc0ae40 fix(HEL-887): keep selection for kept files, unblock invalid-only delete, composited locate pulse
- library deleteMutation.onSuccess: only paths the server really deleted
  leave the selection (accumulated across 200-path chunks); kept files
  stay checked, current focus cleared only when its file is gone;
  in-modal freed-file delete and card-delete-with-file prune the same way
- usage modal: direct delete offered whenever valid_total === 0,
  regardless of invalid_total (dead references never block); invalid
  count note kept in both branches
- e2e runner: isolated_data_removed recorded after the
  TemporaryDirectory context exits and exact child PIDs are waited on
  (daemon PID compared first); isolated dir recorded for verification
- locate gold ring: static ring kept, pulse moved to a ::before feedback
  layer animated with transform/opacity only (no box-shadow repaint),
  gated to data-motion=full; calm/off/reduced-motion stay static
- e2e: +14 checks (46 total) — mixed kept/deleted selection, failed
  request retry, cross-200-batch with kept file, invalid-only in-modal
  delete, ring computed-style/keyframe evidence; HEL884_STRICT=0 records
  the full before-fix failure table

Co-authored-by: multica-agent <github@multica.ai>
2026-10-27 01:02:12 +08:00
总工 679969bc5b HEL-873 r2: merge PR #23 (library usage locate, 433fd71) 2026-10-26 23:55:13 +08:00
总工 a175fe57dd HEL-873 r2: merge PR #22 (visual investigation, c3039343) 2026-10-26 23:55:13 +08:00
施工员2号andmultica-agent 433fd719f1 feat(HEL-884): locate canvas occupancy, accurate card handling, close the delete loop
- GET /api/library/media/usage: read-only occupancy report per file —
  every live canvas card with canvas title, project/session scope, and
  the real consequence of deleting that card (deletes_file / keep reason)
- _path_on_canvas joins canvas: orphan nodes (canvas row gone, FK-cascade
  dead metadata) no longer count as occupancy; they are reported as
  invalid references with totals instead of blocking deletion
- canvas deleteNode: keep_file=true release-only variant (card removed,
  file always preserved); artifact cards with a live pending/running job
  refuse deletion (409) so completion events cannot resurrect them
- library UI: occupancy modal on kept(on_canvas) with locate / delete
  card (accurate impact text) / release-only actions, per-path free-file
  delete closing the loop, invalid-reference note, offset paging for
  large counts, library selection persisted across canvas round-trips
- canvas UI: ?locate=<node> deep-link centers the card (computed
  viewport, no store.fitView — its nodes write-back looped the graph
  rebuild effect), gold ring highlight + dismiss, back-to-library chip
- i18n keys for all 7 languages; e2e runner + 32-check real-browser
  regression on isolated data

Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 23:48:15 +08:00
施工员andmultica-agent c3039343ca HEL-883: audit deployed visuals and mount canvas lightbox regressions
Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 23:04:14 +08:00
总工 2ad5d33c72 HEL-873: merge PR #21 (library delete reasons, 5163230) 2026-10-26 21:17:19 +08:00
总工andmultica-agent e81f191e64 HEL-873: merge PR #20 (reference preview, e08b32c)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 21:17:15 +08:00
施工员2号andmultica-agent 5163230433 fix(HEL-872): library delete reports real kept reasons, not a merged vague one
List only excludes project-data refs, but delete also kept canvas-card
files with the same 'referenced' reason the UI merged into 被项目使用或
无法删除 — every fresh playground output (materialized as a canvas card)
looked undeletable from 素材库 with a wrong explanation.

Backend: split the kept reason into referenced (project data) vs
on_canvas (live canvas card); protections unchanged.
Frontend: per-reason kept summary with counts and an actionable hint,
error toast names failure and stays retryable, readable API error text.
E2E: isolated real-page regression (cancel/single/batch/retry) +
backend tests for the on_canvas reason.

Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 20:51:03 +08:00
施工员andmultica-agent e08b32c064 HEL-871: preview existing reference media from composer slots
Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 20:32:27 +08:00
leefer 344fede198 Merge pull request 'HEL-850: 仅对翻译和润色关闭模型思考 (reasoning_effort none)' (#19) from agent/2/9c4d105d16eb into leefer/hel-731-studio-project 2026-10-25 22:04:29 +08:00
leefer a8efdd3a66 HEL-853: fix Chinese render approval and keep refusal priority (PR #18, reviewed head 49dc71b7) 2026-10-25 21:36:25 +08:00
leefer 796dff27ad HEL-849: unify agent composer select interactions (PR #17, reviewed head e5356bd) 2026-10-25 21:10:26 +08:00
施工员2号andmultica-agent 4cda754eab feat(HEL-850): disable thinking for translate/polish via reasoning_effort none
LM Studio's OpenAI-compat endpoint silently ignores chat_template_kwargs
(probed live); reasoning_effort "none" verifiably removes reasoning tokens
on both streaming and blocking paths. Pass it through the existing
LLMClient extra_body channel for translate/polish first and correction
requests only; all other callers keep their default policy.

Co-authored-by: multica-agent <github@multica.ai>
2026-10-25 20:47:12 +08:00
leeferandmultica-agent 49dc71b727 HEL-853: recognize Chinese render approvals and prioritize refusals
Co-authored-by: multica-agent <github@multica.ai>
2026-10-25 20:43:42 +08:00
43 changed files with 3789 additions and 78 deletions
@@ -13,8 +13,9 @@ There is deliberately no message-length cap ("regenerate the full script
for this project, keep the characters and locations consistent" is one
command, not a suspicious wall of text), no global-negation veto ("don't
overthink, render all scenes" is a command), and no politeness filter.
Negation is CLAUSE-scoped: only a "no" inside ~4 words of the cue vetoes
that cue.
English negation is CLAUSE-scoped: only a "no" inside ~4 words of the cue
vetoes that cue. Chinese refusals override assent in the same latest input
(e.g. "可以,但先不要生成"); Chinese questions/descriptions grant nothing.
"""
from __future__ import annotations
@@ -55,6 +56,41 @@ _RENDER_REQUEST_RE = re.compile(
# Compound splitter: "text2video" → "text video" so the bare token matches.
_COMPOUND_SPLIT_RE = re.compile(r"(?<=[a-z])(?:2|to|-)(?=[a-z])", re.IGNORECASE)
# Chinese has no word boundaries between a verb and its object. Require an
# imperative/desire prefix plus a media object, rather than a bare 视频/生成.
_CLAUSE_RE = r"[,.;!?,。!?、;]|\bbut\b|\bhowever\b"
_ZH_RENDER_RE = re.compile(
r"^(?:(?:请|请你|帮我|请帮我|麻烦|麻烦你|我想|我要|我想要)\s*)?"
r"(?:(?:开始|继续|重新)\s*)?"
r"(?:(?:生成|制作|渲染|画|绘制).{0,40}(?:视频|图片|图像|一张图|张图|video|image)"
r"|出图)",
re.IGNORECASE,
)
_ZH_CONFIRM_RE = re.compile(
r"^(?:确认(?:生成)?|确定|同意|好(?:的)?|可以|行|是(?:的)?|继续|开始|执行)$"
)
# A Chinese refusal in the latest input overrides earlier assent in that
# input too (可以,但先不要生成). Do not change English clause-scoped rules.
_ZH_REFUSAL_RE = re.compile(
r"(?:暂不|先不|不要|不用|不必|不能|不想|不可以|不|别)\s*"
r"(?:(?:再|先|继续|开始)\s*)?(?:生成|制作|渲染|出图|画|确认|同意|执行|继续|render|generate)"
r"|(?:取消|停止|暂停|停下|拒绝)\s*(?:生成|制作|渲染|出图|render|generate)"
r"|(?:^|[\s,。!?、;,.!?;])(?:否|不|暂不|先不|不要|不用了?|取消|停止|暂停|停下|等等)(?:了|吧)?"
r"(?=$|[\s,。!?、;,.!?;])",
re.IGNORECASE,
)
_ZH_QUESTION_RE = re.compile(r"[??]|(?:吗|么|如何|怎么|能否|是否|可否|什么|为什么)")
_ZH_DESCRIPTION_RE = re.compile(
r"^(?:说明|文档|例如|比如|这是|这里|我想了解|我想知道|如果|假如|也许|可能|考虑)"
r"|(?:生成|制作|渲染).{0,40}(?:视频|图片|图像)"
r"(?:需要|是|的功能|很|有点|可以|失败|报错|一直|仍然|无法|效果|参数|教程|说明|问题|耗时)"
)
def _chinese_refusal(text: str) -> bool:
return bool(_ZH_REFUSAL_RE.search(user_prose(text)))
_APPENDIX_MARK = "[Calliope context]"
@@ -85,9 +121,13 @@ def is_confirmation(text: str) -> bool:
replace" does not.
"""
t = user_prose(text)
if not t:
if not t or _chinese_refusal(t):
return False
for clause in re.split(r"[,.;!?]|\bbut\b|\bhowever\b", t, flags=re.IGNORECASE):
if re.search(r"[\u4e00-\u9fff]", t) and _ZH_QUESTION_RE.search(t):
return False
if _ZH_CONFIRM_RE.fullmatch(re.split(_CLAUSE_RE, t, maxsplit=1)[0].strip()):
return True
for clause in re.split(_CLAUSE_RE, t, flags=re.IGNORECASE):
m = _CONFIRM_RE.search(clause)
if not m:
continue
@@ -107,8 +147,12 @@ def is_render_request(text: str) -> bool:
(canvas/62). Only a negation adjacent to the cue counts against it.
"""
t = user_prose(text)
if not t:
if not t or _chinese_refusal(t):
return False
if re.search(r"[\u4e00-\u9fff]", t) and _ZH_QUESTION_RE.search(t):
return False
if not _ZH_DESCRIPTION_RE.search(t) and _ZH_RENDER_RE.search(t):
return True
expanded = _COMPOUND_SPLIT_RE.sub(" ", t)
if not _RENDER_REQUEST_RE.search(expanded):
return False
@@ -153,13 +197,22 @@ def allows_bulk_video_enqueue(text: str, count: int) -> bool:
def user_allows_render(ctx: ToolContext) -> bool:
"""Render permission from explicit user acts only (see module docstring)."""
from calliope.agent.harness import log as session_log
latest = session_log.latest_user_message(ctx.session_id) or ""
if _chinese_refusal(latest):
return False
# question/answered is recorded before its user/message echo. A refusal
# must also veto the old prose/tag during that brief event-log interval.
answer = latest_answer(ctx.session_id)
if (answer and answer.get("scope") == "render"
and _chinese_refusal(str(answer.get("answer") or ""))):
return False
if has_structured_approval(ctx, "render"):
return True
from calliope.agent.harness import log as session_log
if session_log.latest_user_has_workflow_tag(ctx.session_id):
return True
latest = session_log.latest_user_message(ctx.session_id) or ""
return is_render_request(latest) or is_confirmation(latest)
@@ -187,7 +240,9 @@ def is_shell_request(text: str, command: str | None = None) -> bool:
t = user_prose(text)
if not t:
return False
if command and re.search(rf"(?<![A-Za-z0-9_-]){re.escape(command)}(?![A-Za-z0-9_-])", t, re.IGNORECASE):
if command and re.search(
rf"(?<![A-Za-z0-9_-]){re.escape(command)}(?![A-Za-z0-9_-])", t, re.IGNORECASE
):
for clause in re.split(r"[,.;!?]|\bbut\b|\bhowever\b", t, flags=re.IGNORECASE):
m = re.search(
rf"(?<![A-Za-z0-9_-]){re.escape(command)}(?![A-Za-z0-9_-])", clause, re.IGNORECASE
@@ -218,27 +273,33 @@ def user_allows_shell(ctx: ToolContext, command: str | None = None) -> bool:
# option text as a normal message with answer_to; the runner records
# question/answered immediately before its user/message echo.
_AFFIRMATIVE_ANSWERS = frozenset({"yes", "y", "ok", "okay", "sure", "confirm", "go ahead", "do it"})
_AFFIRMATIVE_ANSWERS = frozenset({
"yes", "y", "ok", "okay", "sure", "confirm", "go ahead", "do it",
"确认生成", "确认", "确定", "同意", "是", "是的", "好", "好的", "可以", "行",
})
# Card options are full sentences ("Yes, replace with ~40 shorter scenes").
# The FIRST word decides: yes-family grants, no-family refuses, anything
# else falls back to the exact-match set. Mechanical, not prose guessing.
_AFFIRMATIVE_FIRST_WORDS = frozenset(
{"yes", "yep", "yeah", "yup", "y", "ok", "okay", "sure", "confirm", "confirmed",
"proceed", "go", "do", "please", "fine", "absolutely", "definitely"}
"proceed", "go", "do", "please", "fine", "absolutely", "definitely",
"确认生成", "确认", "确定", "同意", "是", "是的", "好", "好的", "可以", "行"}
)
_NEGATIVE_FIRST_WORDS = frozenset(
{"no", "nope", "not", "dont", "don't", "do not", "never", "cancel", "stop",
"skip", "wait", "hold"}
"skip", "wait", "hold", "否", "不", "不要", "暂不", "先不", "不用了", "取消", "停止"}
)
def _answer_is_affirmative(text: str) -> bool:
t = text.strip().lower()
if not t:
if not t or _chinese_refusal(t):
return False
if re.search(r"[\u4e00-\u9fff]", t) and _ZH_QUESTION_RE.search(t):
return False
if t in _AFFIRMATIVE_ANSWERS:
return True
first = re.split(r"[,\s.!]+", t, maxsplit=1)[0]
first = re.split(r"[,\s.!,。!?、;]+", t, maxsplit=1)[0]
if first in _AFFIRMATIVE_FIRST_WORDS:
return True
if first in _NEGATIVE_FIRST_WORDS or t.startswith(("don't", "do not", "no ")):
+52 -11
View File
@@ -875,14 +875,21 @@ def _project_path_referenced(conn, path: str) -> bool:
def _path_on_canvas(conn, path: str, *, exclude_canvas_node_id: int | None = None) -> bool:
"""True when a live canvas node card points at path."""
"""True when a live canvas node card points at path.
Occupancy requires the owning canvas row to exist: canvas_node→canvas is
an ON DELETE CASCADE foreign key, so a node whose canvas is gone is dead
metadata no user entry can reach — it must not block file deletion
(HEL-884). Orphan rows, when present, are reported by the library usage
endpoint as invalid references instead of silently blocking.
"""
sql = (
"SELECT 1 FROM canvas_node WHERE deleted = 0 "
"AND replace(artifact_path, '\\', '/') = ? AND id != ? LIMIT 1"
"SELECT 1 FROM canvas_node n JOIN canvas c ON c.id = n.canvas_id "
"WHERE n.deleted = 0 "
"AND replace(n.artifact_path, '\\', '/') = ? AND n.id != ? LIMIT 1"
)
row = conn.execute(
sql, (_norm_media_path(path), exclude_canvas_node_id if exclude_canvas_node_id is not None else -1)
).fetchone()
exclude = exclude_canvas_node_id if exclude_canvas_node_id is not None else -1
row = conn.execute(sql, (_norm_media_path(path), exclude)).fetchone()
return row is not None
@@ -928,19 +935,54 @@ def _file_deletable(conn, path: str, roots: list[Path]) -> tuple[bool, str | Non
return True, None
def _node_job_running(conn, job_id: int | None, node_status: str | None) -> bool:
"""True when the node's tracked job is still pending/running.
Deleting a running job's card now would be undone the moment the job
completes: the SSE materializer (and the agent artifact tool) re-post
the output card, resurrecting it after refresh. The live jobs table
wins over the possibly-stale node.status column.
"""
if job_id is not None:
job = conn.execute(
"SELECT status FROM jobs WHERE id = ?", (job_id,)
).fetchone()
if job is not None:
# The jobs row is authoritative; a stale node.status ('running'
# left over from a finished job) must not block deletion forever.
return job["status"] in ("pending", "running")
return node_status in ("pending", "running", "queued")
@router.delete("/{canvas_id}/nodes/{node_id}")
async def delete_node(canvas_id: int, node_id: int) -> dict[str, Any]:
async def delete_node(
canvas_id: int, node_id: int, keep_file: bool = False
) -> dict[str, Any]:
"""Remove a card. By default the artifact file is unlinked too when this
was its last reference and it lives in library-deletable folders — the
caller must not present that as lossless. keep_file=true is the explicit
release-only variant: the card goes, the file always stays."""
conn = get_db(settings.db_path)
file_deleted = False
reason: str | None = None
try:
row = conn.execute(
"SELECT id, type, artifact_path, entity_type, entity_id FROM canvas_node "
"SELECT id, type, artifact_path, entity_type, entity_id,"
" job_id, status FROM canvas_node "
"WHERE id = ? AND canvas_id = ?",
(node_id, canvas_id),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Node not found")
if row["artifact_path"] and _node_job_running(
conn, row["job_id"], row["status"]
):
# The completion event would re-materialize this card — refuse
# until the job settles so "deleted" stays deleted.
raise HTTPException(
status_code=409,
detail="Card is still generating; wait for the job to finish first",
)
if row["entity_type"] is not None and row["entity_id"] is not None:
# Entity nodes tombstone — hard delete would let auto-seed resurrect
# the card the user deliberately removed.
@@ -951,7 +993,7 @@ async def delete_node(canvas_id: int, node_id: int) -> dict[str, Any]:
else:
artifact_path = row["artifact_path"]
conn.execute("DELETE FROM canvas_node WHERE id = ?", (node_id,))
if artifact_path:
if artifact_path and not keep_file:
norm_path = artifact_path.replace("\\", "/")
referenced = _path_is_referenced(
conn, artifact_path, exclude_canvas_node_id=node_id
@@ -980,7 +1022,6 @@ async def delete_node(canvas_id: int, node_id: int) -> dict[str, Any]:
finally:
conn.close()
await event_bus.publish(
"canvas.updated",
{"canvas_id": canvas_id, "reason": "node_deleted", "node_id": node_id},
"canvas.updated", {"canvas_id": canvas_id, "reason": "node_deleted", "node_id": node_id},
)
return {"ok": True, "file_deleted": file_deleted, "reason": reason}
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
@@ -16,7 +16,11 @@ from pydantic import BaseModel
from calliope.config import settings
from calliope.db import get_db
from calliope.routers.canvas import (
_file_deletable,
_node_job_running,
_norm_media_path,
_path_is_referenced,
_path_on_canvas,
_playground_file_roots,
_project_path_referenced,
)
@@ -64,6 +68,136 @@ class LibraryDeleteRequest(BaseModel):
paths: list[str]
USAGE_PAGE_LIMIT = 50
INVALID_LIST_LIMIT = 20
def _usage_scope(conn, row) -> dict[str, Any]:
"""Project/session context of a canvas, for display only."""
if row["project_id"] is not None:
proj = conn.execute(
"SELECT title FROM projects WHERE id = ?", (row["project_id"],)
).fetchone()
return {
"kind": "project",
"id": int(row["project_id"]),
"title": proj["title"] if proj else None,
}
if row["agent_session_id"] is not None:
sess = conn.execute(
"SELECT title FROM agent_sessions WHERE id = ?", (row["agent_session_id"],)
).fetchone()
return {
"kind": "session",
"id": int(row["agent_session_id"]),
"title": sess["title"] if sess else None,
}
return {"kind": None, "id": None, "title": None}
def _usage_card(conn, row, roots: list[Path]) -> dict[str, Any]:
"""One live canvas card that references the path, with the exact
consequence of deleting it (mirrors canvas delete_node's decision)."""
referenced_elsewhere = _path_is_referenced(
conn, row["artifact_path"], exclude_canvas_node_id=row["node_id"]
)
allowed, deny_reason = _file_deletable(conn, row["artifact_path"], roots)
deletes_file = not referenced_elsewhere and allowed
keep_reason: str | None = None
if not deletes_file:
keep_reason = (
"referenced" if referenced_elsewhere else deny_reason
)
return {
"canvas_id": int(row["canvas_id"]),
"canvas_title": row["canvas_title"],
"node_id": int(row["node_id"]),
"node_title": row["node_title"],
"node_type": row["node_type"],
"node_status": row["node_status"],
"job_id": row["job_id"],
"job_running": _node_job_running(conn, row["job_id"], row["node_status"]),
"scope": _usage_scope(conn, row),
"deleting_card_deletes_file": deletes_file,
"file_keep_reason": keep_reason,
}
@router.get("/media/usage")
async def library_media_usage(path: str, offset: int = 0) -> dict[str, Any]:
"""Read-only occupancy report for one library file: every live canvas
card pointing at it, which canvas/project/session owns the card, and
what deleting that card would do to the file.
Direct SELECTs only — this must never seed entities (canvas GET does),
materialize artifacts, or write anything. Nodes whose canvas row is gone
(FK-cascade contract violation = unreachable metadata) are reported as
invalid references, not as occupancy, and carry no links.
"""
path = str(path or "").strip()
if not path:
raise HTTPException(status_code=400, detail="path is required")
if offset < 0:
raise HTTPException(status_code=400, detail="offset must be >= 0")
conn = get_db(settings.db_path)
try:
norm = _norm_media_path(path)
where = (
"n.deleted = 0 AND replace(n.artifact_path, '\\', '/') = ?"
)
valid_rows = conn.execute(
f"""
SELECT n.id AS node_id, n.title AS node_title, n.type AS node_type,
n.status AS node_status, n.job_id AS job_id,
n.artifact_path AS artifact_path,
c.id AS canvas_id, c.title AS canvas_title,
c.project_id AS project_id, c.agent_session_id AS agent_session_id
FROM canvas_node n JOIN canvas c ON c.id = n.canvas_id
WHERE {where}
ORDER BY c.id, n.id
""",
(norm,),
).fetchall()
invalid_rows = conn.execute(
f"""
SELECT n.id AS node_id, n.canvas_id AS canvas_id, n.title AS node_title
FROM canvas_node n LEFT JOIN canvas c ON c.id = n.canvas_id
WHERE c.id IS NULL AND {where}
ORDER BY n.canvas_id, n.id
LIMIT ?
""",
(norm, INVALID_LIST_LIMIT),
).fetchall()
invalid_total = conn.execute(
f"""
SELECT COUNT(*) AS n FROM canvas_node n LEFT JOIN canvas c ON c.id = n.canvas_id
WHERE c.id IS NULL AND {where}
""",
(norm,),
).fetchone()["n"]
roots = _playground_file_roots(conn)
page = valid_rows[offset : offset + USAGE_PAGE_LIMIT]
usages = [_usage_card(conn, row, roots) for row in page]
return {
"path": path,
"usages": usages,
"valid_total": len(valid_rows),
"invalid": [
{
"node_id": int(r["node_id"]),
"canvas_id": int(r["canvas_id"]),
"node_title": r["node_title"],
}
for r in invalid_rows
],
"invalid_total": int(invalid_total),
"offset": offset,
"limit": USAGE_PAGE_LIMIT,
}
finally:
conn.close()
def _iter_media_files(folder: Path) -> list[Path]:
if not folder.is_dir():
return []
@@ -112,8 +246,9 @@ async def list_library_media() -> list[dict[str, Any]]:
@router.delete("/media")
async def delete_library_media(payload: LibraryDeleteRequest) -> dict[str, Any]:
"""Bulk-delete unlinked library files. Referenced/project-owned/foreign
paths are kept and reported per-path."""
"""Bulk-delete unlinked library files. Kept reasons per path:
referenced (project data), on_canvas (live canvas card),
project_owned/outside_assets (not library-deletable), unlink_failed."""
if not payload.paths:
raise HTTPException(status_code=400, detail="paths must not be empty")
if len(payload.paths) > 200:
@@ -140,9 +275,12 @@ async def delete_library_media(payload: LibraryDeleteRequest) -> dict[str, Any]:
if not under_root:
results.append({"path": path, "status": "kept", "reason": "project_owned"})
continue
if _path_is_referenced(conn, path):
if _project_path_referenced(conn, path):
results.append({"path": path, "status": "kept", "reason": "referenced"})
continue
if _path_on_canvas(conn, path):
results.append({"path": path, "status": "kept", "reason": "on_canvas"})
continue
try:
target.unlink()
deleted.append(path)
@@ -19,6 +19,10 @@ from calliope.config import settings
router = APIRouter()
TOTAL_TIMEOUT = 120
CHUNK_TIMEOUT = 45
# Verified against the assigned translate/polish LM Studio service: sending
# reasoning_effort "none" removes reasoning tokens on both the streaming and
# blocking paths, while chat_template_kwargs was silently ignored there.
NO_THINKING_BODY = {"reasoning_effort": "none"}
# Ordered, lossless syntax tokens. Ordinary words and bare numbers stay editable.
# Tags/dialogue/parameters are opaque before looking for brackets inside them.
_TECH = re.compile(
@@ -115,8 +119,14 @@ async def _disconnected(request: Request) -> None:
await asyncio.sleep(0.05)
async def _run(request: Request, client: LLMClient, messages: list[dict], temp: float) -> str:
upstream = asyncio.create_task(client.chat(messages, temperature=temp))
async def _run(
request: Request,
client: LLMClient,
messages: list[dict],
temp: float,
extra_body: dict | None = None,
) -> str:
upstream = asyncio.create_task(client.chat(messages, temperature=temp, extra_body=extra_body))
disconnect = asyncio.create_task(_disconnected(request))
try:
done, _ = await asyncio.wait({upstream, disconnect}, return_when=asyncio.FIRST_COMPLETED)
@@ -192,7 +202,13 @@ async def transform(body: Transform, request: Request) -> dict:
timeout=CHUNK_TIMEOUT,
)
result = await asyncio.wait_for(
_run(request, client, messages, 0.2 if direction else 0.6),
_run(
request,
client,
messages,
0.2 if direction else 0.6,
NO_THINKING_BODY,
),
max(0.001, TOTAL_TIMEOUT - (time.monotonic() - started)),
)
if not result.strip():
@@ -151,6 +151,76 @@ def test_delete_missing_file_counts_as_deleted(client):
assert ghost in [Path(p) for p in r.json()["deleted"]]
def test_canvas_referenced_file_listed_but_kept_with_on_canvas_reason(client):
"""HEL-872: a live canvas card's file shows in the library list (list only
excludes project-data refs) but must survive delete with its own reason,
distinct from a project-data reference."""
scratch = _scratch_project()
out = _write_image(
Path(config_module.settings.assets_dir) / str(scratch) / "image",
"canvas-card-image.png",
)
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO canvas (id, title) VALUES (1, 'Sandbox')"
)
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, artifact_path, status)"
" VALUES (1, 'image', 'card', ?, 'done')",
(str(out),),
)
conn.commit()
finally:
conn.close()
listed = client.get("/api/library/media").json()
assert str(out) in [i["path"] for i in listed], "canvas file is library media"
r = client.request("DELETE", "/api/library/media", json={"paths": [str(out)]})
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status"] == "kept"
assert res["reason"] == "on_canvas"
assert out.exists(), "canvas-referenced file must survive"
def test_mixed_batch_reports_per_path_reasons(client):
"""One deletable upload + one canvas-referenced scratch output in a single
batch: the deletable file is really unlinked, the canvas one is kept."""
scratch = _scratch_project()
up = _uploads_dir()
free = _write_image(up, "dddd1212-free.png")
held = _write_image(
Path(config_module.settings.assets_dir) / str(scratch) / "video",
"canvas-card-video.mp4",
)
conn = get_db(config_module.settings.db_path)
try:
conn.execute("INSERT INTO canvas (id, title) VALUES (1, 'Sandbox')")
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, artifact_path, status)"
" VALUES (1, 'video', 'card', ?, 'done')",
(str(held),),
)
conn.commit()
finally:
conn.close()
r = client.request(
"DELETE", "/api/library/media", json={"paths": [str(free), str(held)]}
)
assert r.status_code == 200
body = r.json()
assert body["deleted"] == [str(free)]
assert not free.exists(), "unlinked upload is really deleted"
results = {res["path"]: res for res in body["results"]}
assert results[str(free)]["status"] == "deleted"
assert results[str(held)]["status"] == "kept"
assert results[str(held)]["reason"] == "on_canvas"
assert held.exists()
def test_delete_empty_paths_rejected(client):
r = client.request("DELETE", "/api/library/media", json={"paths": []})
assert r.status_code == 400
+178 -2
View File
@@ -89,7 +89,8 @@ def test_sandbox_scratch_output_delete_removes_file(client):
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO projects (title, idea, status) VALUES ('Playground Scratch', NULL, 'system')"
"INSERT INTO projects (title, idea, status)"
" VALUES ('Playground Scratch', NULL, 'system')"
)
conn.commit()
row = conn.execute(
@@ -224,10 +225,185 @@ def test_node_delete_keeps_file_referenced_by_other_canvas_node(client):
graph = _ensure_sandbox_canvas(client)
canvas_id = graph["canvas"]["id"]
node_a = _create_artifact_node(canvas_id, str(f))
node_b = _create_artifact_node(canvas_id, str(f))
_create_artifact_node(canvas_id, str(f))
r = client.delete(f"/api/canvas/{canvas_id}/nodes/{node_a}")
assert r.status_code == 200
assert r.json()["file_deleted"] is False
assert r.json()["reason"] == "referenced"
assert f.exists(), "file still shown by the second card"
def test_delete_node_keep_file_releases_reference_only(client):
"""keep_file=true is the explicit release-only variant: card removed,
file always preserved even when it would otherwise be unlinked."""
assets_dir = config_module.settings.assets_dir
up = assets_dir / "uploads"
up.mkdir(parents=True, exist_ok=True)
f = up / "dddd4444-keep.png"
f.write_bytes(b"png")
graph = _ensure_sandbox_canvas(client)
canvas_id = graph["canvas"]["id"]
node_id = _create_artifact_node(canvas_id, str(f))
r = client.delete(f"/api/canvas/{canvas_id}/nodes/{node_id}?keep_file=true")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["file_deleted"] is False
assert body["reason"] is None
assert f.exists(), "release-only must preserve the original file"
conn = get_db(config_module.settings.db_path)
try:
assert (
conn.execute("SELECT 1 FROM canvas_node WHERE id = ?", (node_id,)).fetchone()
is None
), "card itself is really removed"
finally:
conn.close()
# The file is now unoccupied → library delete closes the loop.
r = client.request("DELETE", "/api/library/media", json={"paths": [str(f)]})
assert r.json()["results"][0]["status"] == "deleted"
assert not f.exists()
def _insert_job(project_id: int, status: str) -> int:
conn = get_db(config_module.settings.db_path)
try:
cur = conn.execute(
"INSERT INTO jobs (project_id, kind, status) VALUES (?, 'image', ?)",
(project_id, status),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _any_project_id() -> int:
"""Any projects row for jobs.project_id (jobs only need a live FK)."""
conn = get_db(config_module.settings.db_path)
try:
row = conn.execute("SELECT id FROM projects ORDER BY id LIMIT 1").fetchone()
if row:
return int(row["id"])
cur = conn.execute(
"INSERT INTO projects (title, idea) VALUES ('Jobs host', 'x')"
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def test_delete_node_blocked_while_job_running(client):
"""A running job's card must not be deletable — the completion event
would re-materialize it (resurrection after refresh)."""
graph = _ensure_sandbox_canvas(client)
canvas_id = graph["canvas"]["id"]
job_id = _insert_job(_any_project_id(), "running")
assets_dir = config_module.settings.assets_dir
up = assets_dir / "uploads"
up.mkdir(parents=True, exist_ok=True)
f = up / "eeee5555-running.png"
f.write_bytes(b"png")
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, job_id, status)"
" VALUES (?, 'image', '生成中', 0, 0, ?, ?, 'running')",
(canvas_id, str(f), job_id),
)
node_id = int(
conn.execute(
"SELECT id FROM canvas_node WHERE canvas_id = ? ORDER BY id DESC LIMIT 1",
(canvas_id,),
).fetchone()["id"]
)
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/canvas/{canvas_id}/nodes/{node_id}")
assert r.status_code == 409
assert f.exists(), "blocked delete must not remove the file"
# Job settles → delete goes through (and the done status never blocks).
conn = get_db(config_module.settings.db_path)
try:
conn.execute("UPDATE jobs SET status = 'done' WHERE id = ?", (job_id,))
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/canvas/{canvas_id}/nodes/{node_id}")
assert r.status_code == 200
assert r.json()["file_deleted"] is True
def test_delete_node_stale_running_status_with_done_job_allowed(client):
"""The jobs row is authoritative: a stale node.status='running' left over
from a finished job must not block deletion forever."""
graph = _ensure_sandbox_canvas(client)
canvas_id = graph["canvas"]["id"]
job_id = _insert_job(_any_project_id(), "done")
assets_dir = config_module.settings.assets_dir
up = assets_dir / "uploads"
up.mkdir(parents=True, exist_ok=True)
f = up / "ffff6666-stale.png"
f.write_bytes(b"png")
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, job_id, status)"
" VALUES (?, 'image', '旧状态卡', 0, 0, ?, ?, 'running')",
(canvas_id, str(f), job_id),
)
node_id = int(
conn.execute(
"SELECT id FROM canvas_node WHERE canvas_id = ? ORDER BY id DESC LIMIT 1",
(canvas_id,),
).fetchone()["id"]
)
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/canvas/{canvas_id}/nodes/{node_id}")
assert r.status_code == 200
assert r.json()["file_deleted"] is True
def test_delete_node_no_job_still_blocks_on_live_status(client):
"""Cards without a jobs row (manually posted) fall back to node.status."""
graph = _ensure_sandbox_canvas(client)
canvas_id = graph["canvas"]["id"]
assets_dir = config_module.settings.assets_dir
up = assets_dir / "uploads"
up.mkdir(parents=True, exist_ok=True)
f = up / "aaaa7777-nostatusjob.png"
f.write_bytes(b"png")
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '排队卡', 0, 0, ?, 'queued')",
(canvas_id, str(f)),
)
node_id = int(
conn.execute(
"SELECT id FROM canvas_node WHERE canvas_id = ? ORDER BY id DESC LIMIT 1",
(canvas_id,),
).fetchone()["id"]
)
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/canvas/{canvas_id}/nodes/{node_id}")
assert r.status_code == 409
@@ -0,0 +1,166 @@
"""HEL-853: Chinese approvals through the real pre-execute/queue boundary.
All sessions, event files and jobs are isolated; no worker or network starts.
Replay: PYTHONPATH=src python3 -m pytest -q -s tests/test_chinese_render_approval.py
"""
from __future__ import annotations
import asyncio
import json
import pytest
from calliope.agent.harness import get_registry, log, policy
from calliope.agent.harness.registry import ToolContext, _db
from calliope.db import migrate_db
POSITIVES = [
"我想生成一段美女扭屁股视频", "确认生成", "是,生成视频",
"确认生成。", "好的!", "是、生成视频", "yes,生成视频",
"请生成一张图片", "帮我制作一段视频", "生成video", "请出图",
]
REFUSALS = [
"暂不生成", "否,先不生成", "不要生成视频", "不用了,先不生成",
"确认不生成", "可以,但先不要生成", "确认生成,取消生成", "暂停生成视频",
"确认生成,取消", "yes,但不要生成video",
"不要生成视频!", "否。生成视频", "否?生成视频", "取消",
"暂不", "取消吧", "确认生成,拒绝生成",
]
NEGATIVES = REFUSALS + [
"这个画布有什么作用", "如何生成视频", "生成视频需要什么?",
"可以生成视频吗", "确认生成?", "我想了解生成视频的流程",
"生成视频需要显卡", "文档说,生成视频需要显卡", "可能生成视频",
"生成视频失败了", "生成一段视频需要很久", "生成视频参数说明",
"这是生成视频的说明", "确认生成是什么意思", "生成故事", "这里写着,确认生成",
]
@pytest.fixture
def sandbox(monkeypatch, tmp_path):
from calliope.config import Settings, settings
monkeypatch.setattr(settings, "data_dir", tmp_path)
monkeypatch.setattr(settings, "assets_dir", tmp_path / "assets")
monkeypatch.setattr(settings, "dry_run", True)
monkeypatch.setattr(Settings, "save_config_file", lambda self: None)
asyncio.run(migrate_db(settings.db_path))
with _db() as conn:
sid = conn.execute("INSERT INTO agent_sessions (title) VALUES ('HEL-853')").lastrowid
wid = conn.execute(
"INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema, "
"prompt_profile, is_enabled) VALUES ('isolated', 'video', ?, '[]', '[]', 'prose', 1)",
(json.dumps({"12": {"class_type": "CLIPTextEncode", "inputs": {},
"_meta": {"title": "P (Input:prompt)"}}}),),
).lastrowid
return ToolContext(session_id=sid), wid
def message(ctx, text, *, card=False, tagged=False):
if card:
asked = log.append_event(ctx.session_id, log.QUESTION_ASKED, {
"question": "确认开始生成吗?", "options": ["确认生成", "暂不生成"], "scope": "render",
})
log.append_event(ctx.session_id, log.QUESTION_ANSWERED, {
"question_seq": asked.seq, "answer": text, "scope": "render",
})
data = {"content": text}
if tagged:
data["mentions"] = [{"type": "workflow", "id": 1, "kind": "video"}]
log.append_event(ctx.session_id, log.USER_MESSAGE, data)
def execute(ctx, wid, expected):
with _db() as conn:
before = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
result = asyncio.run(get_registry().execute(ctx, "run_workflow", {
"workflow_id": wid, "prompt": "synthetic isolated prompt",
}))
with _db() as conn:
jobs = conn.execute("SELECT payload_json, status FROM jobs ORDER BY id").fetchall()
assert result.get("ok") is expected, result
assert len(jobs) == before + int(expected)
if expected:
assert json.loads(jobs[-1]["payload_json"])["source"] == "agent"
assert jobs[-1]["status"] == "pending"
else:
assert result["reason_code"] == "guard_render_approval"
source = json.loads(jobs[-1]["payload_json"])["source"] if expected else None
print(json.dumps({"input": log.latest_user_message(ctx.session_id), "ok": result["ok"],
"reason_code": result.get("reason_code"), "jobs": len(jobs),
"source": source},
ensure_ascii=False))
@pytest.mark.parametrize("text", POSITIVES)
@pytest.mark.parametrize("card", [False, True])
def test_chinese_grants_queue_job(sandbox, text, card):
ctx, wid = sandbox
message(ctx, text, card=card)
execute(ctx, wid, True)
@pytest.mark.parametrize("text", NEGATIVES)
@pytest.mark.parametrize("card", [False, True])
def test_chinese_denial_cannot_fall_back(sandbox, text, card):
ctx, wid = sandbox
message(ctx, "yes", card=True)
message(ctx, text, card=card)
assert policy.is_confirmation(text) is False
assert policy.is_render_request(text) is False
assert policy._answer_is_affirmative(text) is False
execute(ctx, wid, False)
@pytest.mark.parametrize("text", REFUSALS)
def test_latest_refusal_overrides_workflow_tag(sandbox, text):
ctx, wid = sandbox
message(ctx, "确认生成", card=True)
message(ctx, text, tagged=True)
execute(ctx, wid, False)
def test_report_sequence(sandbox):
ctx, wid = sandbox
message(ctx, "这个画布有什么作用")
execute(ctx, wid, False)
message(ctx, POSITIVES[0])
execute(ctx, wid, True)
message(ctx, "确认生成", card=True)
execute(ctx, wid, True)
message(ctx, "是,生成视频", card=True)
execute(ctx, wid, True)
message(ctx, "不用了,先不生成")
execute(ctx, wid, False)
@pytest.mark.parametrize(
"text", ["确认生成", "是,生成视频", "确认生成。", "好的!", "yes,生成视频"]
)
def test_card_approval_event_is_recognized(sandbox, text):
ctx, _ = sandbox
message(ctx, text, card=True)
assert policy._answer_is_affirmative(text) is True
assert policy.has_structured_approval(ctx, "render") is True
assert policy.has_structured_approval(ctx, "shell") is False
@pytest.mark.parametrize("text", REFUSALS)
@pytest.mark.parametrize("old_input", ["确认生成", "请生成一段视频"])
def test_card_refusal_before_user_echo_cannot_reuse_old_permission(sandbox, text, old_input):
ctx, wid = sandbox
message(ctx, old_input, card=True, tagged=True)
asked = log.append_event(ctx.session_id, log.QUESTION_ASKED, {
"question": "继续吗?", "scope": "render",
})
log.append_event(ctx.session_id, log.QUESTION_ANSWERED, {
"question_seq": asked.seq, "answer": text, "scope": "render",
})
execute(ctx, wid, False)
def test_new_confirmation_after_cancellation(sandbox):
ctx, wid = sandbox
message(ctx, "暂不生成", card=True)
execute(ctx, wid, False)
message(ctx, "确认生成", card=True)
execute(ctx, wid, True)
@@ -0,0 +1,314 @@
"""Library usage report (HEL-884) — occupancy location for kept files.
The /api/library/media/usage endpoint must be read-only, list every live
canvas card that references a file (canvas + project/session context),
report orphan nodes as invalid references instead of occupancy, and state
the real consequence of deleting each card.
"""
from __future__ import annotations
from pathlib import Path
import calliope.config as config_module
from calliope.db import get_db
def _scratch_project(client) -> int:
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO projects (title, idea, status)"
" VALUES ('Playground Scratch', NULL, 'system')"
)
conn.commit()
row = conn.execute(
"SELECT id FROM projects WHERE status = 'system' LIMIT 1"
).fetchone()
return int(row["id"])
finally:
conn.close()
def _scratch_output(scratch_pid: int, name: str) -> Path:
d = Path(config_module.settings.assets_dir) / str(scratch_pid) / "image"
d.mkdir(parents=True, exist_ok=True)
f = d / name
f.write_bytes(b"png")
return f
def _add_card(canvas_id: int, path: str, *, title: str = "card") -> int:
conn = get_db(config_module.settings.db_path)
try:
cur = conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', ?, 0, 0, ?, 'done')",
(canvas_id, title, path),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _usage(client, path: str, offset: int = 0) -> dict:
r = client.get(
"/api/library/media/usage", params={"path": path, "offset": offset}
)
assert r.status_code == 200, r.text
return r.json()
def test_usage_lists_cards_across_canvases_with_scope(client):
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-multi.png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
sandbox = client.post("/api/canvas", json={"agent_session_id": sid}).json()
pid = client.post("/api/projects", json={"title": "占用项目", "idea": "x"}).json()["id"]
project_canvas = client.post("/api/canvas", json={"project_id": pid}).json()
n1 = _add_card(sandbox["canvas"]["id"], str(f), title="沙盒卡片")
n2 = _add_card(project_canvas["canvas"]["id"], str(f), title="项目卡片")
body = _usage(client, str(f))
assert body["valid_total"] == 2
assert body["invalid_total"] == 0
cards = {u["node_id"]: u for u in body["usages"]}
assert set(cards) == {n1, n2}, "both references returned, not just the first"
by_node = cards[n1]
assert by_node["canvas_title"] == sandbox["canvas"]["title"]
assert by_node["node_title"] == "沙盒卡片"
assert by_node["scope"]["kind"] == "session"
assert by_node["scope"]["id"] == sid
proj_card = cards[n2]
assert proj_card["scope"]["kind"] == "project"
assert proj_card["scope"]["id"] == pid
assert proj_card["scope"]["title"] == "占用项目"
def test_usage_reports_deleting_card_consequence(client):
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-effect.png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
g = client.post("/api/canvas", json={"agent_session_id": sid}).json()
sole = _add_card(g["canvas"]["id"], str(f), title="唯一引用")
body = _usage(client, str(f))
card = body["usages"][0]
assert card["node_id"] == sole
assert card["deleting_card_deletes_file"] is True
assert card["file_keep_reason"] is None
# A second card on another canvas → deleting either keeps the file.
sid2 = client.post("/api/agent/sessions", json={}).json()["id"]
g2 = client.post("/api/canvas", json={"agent_session_id": sid2}).json()
_add_card(g2["canvas"]["id"], str(f), title="第二处引用")
body = _usage(client, str(f))
assert body["valid_total"] == 2
assert all(
u["deleting_card_deletes_file"] is False
and u["file_keep_reason"] == "referenced"
for u in body["usages"]
)
def test_usage_project_owned_file_never_deletes_file(client):
pid = client.post("/api/projects", json={"title": "Own", "idea": "x"}).json()["id"]
f = (
Path(config_module.settings.assets_dir)
/ str(pid)
/ "image"
/ "job-3-image.png"
)
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
g = client.post("/api/canvas", json={"agent_session_id": sid}).json()
_add_card(g["canvas"]["id"], str(f))
card = _usage(client, str(f))["usages"][0]
assert card["deleting_card_deletes_file"] is False
assert card["file_keep_reason"] == "project_owned"
def test_usage_orphan_nodes_reported_invalid_and_not_occupancy(client):
"""A node whose canvas row is gone is dead metadata: reported separately,
never linked, and never allowed to block the file's deletion."""
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-orphan.png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
g = client.post("/api/canvas", json={"agent_session_id": sid}).json()
node_id = _add_card(g["canvas"]["id"], str(f), title="孤儿卡")
conn = get_db(config_module.settings.db_path)
try:
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("DELETE FROM canvas WHERE id = ?", (g["canvas"]["id"],))
conn.commit()
finally:
conn.close()
body = _usage(client, str(f))
assert body["valid_total"] == 0 and body["usages"] == []
assert body["invalid_total"] == 1
assert body["invalid"][0]["node_id"] == node_id
r = client.request("DELETE", "/api/library/media", json={"paths": [str(f)]})
assert r.status_code == 200
res = r.json()["results"][0]
assert res["status"] == "deleted", "orphan reference must not keep the file"
assert not f.exists()
def test_usage_orphan_does_not_protect_file_for_node_delete(client):
"""Card on a live canvas + an orphan node sharing the file: deleting the
live card removes the file (the orphan is not a live reference)."""
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-orphan-live.png")
up = Path(config_module.settings.assets_dir) / "uploads"
up.mkdir(parents=True, exist_ok=True)
shared = up / "usage-orphan-shared.png"
shared.write_bytes(b"png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
live = client.post("/api/canvas", json={"agent_session_id": sid}).json()
live_node = _add_card(live["canvas"]["id"], str(shared))
conn = get_db(config_module.settings.db_path)
try:
cur = conn.execute(
"INSERT INTO canvas (title) VALUES ('将被删除的画布')"
)
ghost_canvas = int(cur.lastrowid)
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '幽灵卡', 0, 0, ?, 'done')",
(ghost_canvas, str(shared)),
)
conn.commit()
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("DELETE FROM canvas WHERE id = ?", (ghost_canvas,))
conn.commit()
finally:
conn.close()
r = client.delete(f"/api/canvas/{live['canvas']['id']}/nodes/{live_node}")
assert r.status_code == 200
body = r.json()
assert body["file_deleted"] is True, "orphan must not count as referenced"
assert not shared.exists()
assert f.exists(), "unrelated scratch output untouched"
def test_usage_is_read_only_no_entity_seeding(client):
"""The report must not seed entity cards the way canvas GET does."""
pid = client.post("/api/projects", json={"title": "Seedless", "idea": "x"}).json()
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO characters (project_id, name, appearance) VALUES (?, 'C', 'x')",
(pid["id"],),
)
cur = conn.execute(
"INSERT INTO canvas (project_id, title) VALUES (?, '未打开的画布')",
(pid["id"],),
)
canvas_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
f = _scratch_output(_scratch_project(client), "usage-readonly.png")
_add_card(canvas_id, str(f))
body = _usage(client, str(f))
assert body["valid_total"] == 1
conn = get_db(config_module.settings.db_path)
try:
n = conn.execute(
"SELECT COUNT(*) AS n FROM canvas_node WHERE canvas_id = ? AND type = 'entity'",
(canvas_id,),
).fetchone()["n"]
finally:
conn.close()
assert n == 0, "usage endpoint must not seed entity nodes"
def test_usage_matches_backslash_paths(client):
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-backslash.png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
g = client.post("/api/canvas", json={"agent_session_id": sid}).json()
conn = get_db(config_module.settings.db_path)
try:
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '反斜杠卡', 0, 0, ?, 'done')",
(g["canvas"]["id"], str(f).replace("/", "\\")),
)
conn.commit()
finally:
conn.close()
body = _usage(client, str(f))
assert body["valid_total"] == 1
assert body["usages"][0]["node_title"] == "反斜杠卡"
def test_usage_flags_running_jobs(client):
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-running.png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
g = client.post("/api/canvas", json={"agent_session_id": sid}).json()
conn = get_db(config_module.settings.db_path)
try:
cur = conn.execute(
"INSERT INTO jobs (project_id, kind, status) VALUES (?, 'image', ?)",
(scratch, "running"),
)
job_id = int(cur.lastrowid)
conn.execute(
"INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, job_id, status)"
" VALUES (?, 'image', '生成中', 0, 0, ?, ?, 'running')",
(g["canvas"]["id"], str(f), job_id),
)
conn.commit()
finally:
conn.close()
card = _usage(client, str(f))["usages"][0]
assert card["job_running"] is True
def test_usage_paginates_large_reference_counts(client):
scratch = _scratch_project(client)
f = _scratch_output(scratch, "usage-many.png")
sid = client.post("/api/agent/sessions", json={}).json()["id"]
g = client.post("/api/canvas", json={"agent_session_id": sid}).json()
for i in range(3):
_add_card(g["canvas"]["id"], str(f), title=f"卡{i}")
page1 = _usage(client, str(f), offset=0)
assert page1["valid_total"] == 3
assert len(page1["usages"]) == 3
assert page1["limit"] == 50 and page1["offset"] == 0
conn = get_db(config_module.settings.db_path)
try:
rows = conn.execute(
"SELECT id FROM canvas_node WHERE canvas_id = ? AND artifact_path IS NOT NULL",
(g["canvas"]["id"],),
).fetchall()
ids = [r["id"] for r in rows]
finally:
conn.close()
# Deterministic order (canvas, node id) keeps paging stable.
assert [u["node_id"] for u in page1["usages"]] == sorted(ids)
def test_usage_requires_path(client):
r = client.get("/api/library/media/usage")
assert r.status_code in (400, 422), "missing path rejected"
r = client.get("/api/library/media/usage", params={"path": " "})
assert r.status_code == 400
r = client.get("/api/library/media/usage", params={"path": "x", "offset": -1})
assert r.status_code == 400
@@ -502,3 +502,48 @@ def test_polish_can_rephrase_within_group(client, provider, text, reply):
response = call(client, text=text, action="polish")
assert response.status_code == 200, response.text
assert response.json()["result"] == reply.strip()
def test_thinking_off_on_first_and_correction_requests(client, provider):
provider.replies = [
"broken",
lambda payload: payload["messages"][1]["content"].replace("霓虹灯", "neon lights"),
]
response = call(client, text="(霓虹灯:1.2)")
assert response.status_code == 200, response.text
assert len(provider.calls) == 2
assert [call_["reasoning_effort"] for call_ in provider.calls] == ["none", "none"]
assert [call_["temperature"] for call_ in provider.calls] == [0.2, 0.2]
def test_polish_thinking_off_on_first_and_correction_requests(client, provider):
provider.replies = ["broken", lambda payload: payload["messages"][1]["content"]]
response = client.post(
"/api/prompt-tools/transform", json={"action": "polish", "text": "a cat (neon:1.2)"}
)
assert response.status_code == 200, response.text
assert len(provider.calls) == 2
assert [call_["reasoning_effort"] for call_ in provider.calls] == ["none", "none"]
assert [call_["temperature"] for call_ in provider.calls] == [0.6, 0.6]
def test_blocking_fallback_preserves_thinking_off(client, provider):
provider.blocking = True
provider.reply = lambda payload: payload["messages"][1]["content"].replace("猫", "cat")
assert call(client).status_code == 200
assert len(provider.calls) == 2 and not provider.calls[1].get("stream")
assert [call_["reasoning_effort"] for call_ in provider.calls] == ["none", "none"]
@pytest.mark.asyncio
async def test_plain_llm_call_not_forced_thinking_off(provider):
from calliope.agent.llm import LLMClient
client = LLMClient(base_url=f"http://127.0.0.1:{provider.server_port}/v1", model="translate")
try:
assert await client.chat([{"role": "user", "content": "hi"}], 0.7) == "a cat"
finally:
await client.close()
assert provider.calls
assert all("reasoning_effort" not in call_ for call_ in provider.calls)
+37 -2
View File
@@ -772,6 +772,37 @@ export interface LibraryMediaItem {
mtime: string;
}
/** Where one library file is still used on a canvas (HEL-884). */
export interface LibraryUsageScope {
kind: "project" | "session" | null;
id: number | null;
title: string | null;
}
export interface LibraryUsageCard {
canvas_id: number;
canvas_title: string;
node_id: number;
node_title: string | null;
node_type: string;
node_status: string;
job_id: number | null;
job_running: boolean;
scope: LibraryUsageScope;
deleting_card_deletes_file: boolean;
file_keep_reason: string | null;
}
export interface LibraryMediaUsage {
path: string;
usages: LibraryUsageCard[];
valid_total: number;
invalid: { node_id: number; canvas_id: number; node_title: string | null }[];
invalid_total: number;
offset: number;
limit: number;
}
// ── Five-stage creation workbench (HEL-614) ─────────────────────────────
export type WorkbenchStageKey =
@@ -942,6 +973,10 @@ export interface LibraryDeleteResult {
export const libraryApi = {
list: () => api<LibraryMediaItem[]>("/api/library/media"),
usage: (path: string, offset = 0) =>
api<LibraryMediaUsage>(
`/api/library/media/usage?path=${encodeURIComponent(path)}&offset=${offset}`,
),
deleteMany: (paths: string[]) =>
api<{
ok: boolean;
@@ -1343,9 +1378,9 @@ export const canvasApi = {
method: "POST",
body: JSON.stringify(payload),
}),
deleteNode: (canvasId: number, nodeId: number) =>
deleteNode: (canvasId: number, nodeId: number, keepFile = false) =>
api<{ ok: boolean; file_deleted?: boolean; reason?: string | null }>(
`/api/canvas/${canvasId}/nodes/${nodeId}`,
`/api/canvas/${canvasId}/nodes/${nodeId}${keepFile ? "?keep_file=true" : ""}`,
{ method: "DELETE" },
),
tidy: (canvasId: number) =>
@@ -15,6 +15,8 @@
artifactPath: string | null;
kind: 'image' | 'video';
status: string;
/** HEL-884 locate deep-link: this card is the one being looked for. */
located?: boolean;
/** Called on media click: opens the large preview (no navigation). */
onOpenMedia?: (kind: 'image' | 'video') => void;
}
@@ -65,7 +67,9 @@
<div
class="canvas-card artifact-node a-viewfinder"
class:a-in-frame={selected}
class:located={data.located}
data-kind={data.kind}
data-testid={data.located ? 'located-card' : undefined}
>
<header>
<span class="type-chip">{data.kind}</span>
@@ -13,6 +13,8 @@
imagePath: string | null;
/** Clip path — when set the card becomes a playable video card. */
videoPath: string | null;
/** HEL-884 locate deep-link: this card is the one being looked for. */
located?: boolean;
/** Called on card click: opens the large media preview (no navigation). */
onOpenMedia?: (kind: 'image' | 'video') => void;
}
@@ -56,7 +58,9 @@
<div
class="canvas-card entity-node a-viewfinder"
class:a-in-frame={selected}
class:located={data.located}
data-kind={data.entityType}
data-testid={data.located ? 'located-card' : undefined}
role="button"
tabindex="0"
onclick={activate}
+37
View File
@@ -75,6 +75,43 @@
.canvas-root .svelte-flow__node:focus-visible .canvas-card {
border-color: color-mix(in srgb, var(--scene) 45%, transparent);
}
/* HEL-884 locate deep-link: the looked-for card gets an unmistakable gold
ring until dismissed (20s) so it reads at any zoom. HEL-887: the ring
itself is a static layer; the pulse breathes on a separate ::before
feedback layer animated with transform/opacity only — composited, no
per-frame box-shadow repaint. */
.canvas-card.located {
border-color: var(--gold);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--gold) 45%, transparent),
var(--sh-2);
}
.canvas-card.located::before {
content: "";
position: absolute;
inset: -3px;
border-radius: calc(var(--r-lg) + 3px);
border: 3px solid color-mix(in srgb, var(--gold) 45%, transparent);
opacity: 0;
pointer-events: none;
}
.canvas-card.artifact-node.located::before {
border-radius: calc(var(--r-md) + 3px);
}
html[data-motion="full"] .canvas-card.located::before {
animation: located-pulse 1.6s var(--ease-out) infinite;
}
@keyframes located-pulse {
0%,
100% {
opacity: 0;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.04);
}
}
.canvas-card:focus-visible {
outline: none;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { enterModal, leaveModal, topModal } from './ui/modalLayers';
import SafeMedia from '$lib/components/SafeMedia.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
@@ -15,7 +16,9 @@
onnavigate?: (i: number) => void;
caption?: string;
/** Render a video player instead of a zoomable image. */
kind?: 'image' | 'video';
kind?: 'image' | 'video' | 'audio';
/** Explicit replacement action for input media; viewing itself never uploads. */
onReplace?: () => void;
}
let {
@@ -27,10 +30,34 @@
onnavigate,
caption,
kind = 'image',
onReplace,
}: Props = $props();
let zoomed = $state(false);
let panel = $state<HTMLDivElement | null>(null);
const layer = Symbol('media-preview');
const focusable =
'a[href], button:not([disabled]), video[controls], audio[controls]';
const open = $derived(!!src);
$effect(() => {
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
enterModal(layer);
document.addEventListener('keydown', onKeydown, true);
const frame = requestAnimationFrame(() => {
if (topModal(layer))
(panel?.querySelector<HTMLElement>(focusable) ?? panel)?.focus();
});
return () => {
cancelAnimationFrame(frame);
const wasTop = topModal(layer);
leaveModal(layer);
document.removeEventListener('keydown', onKeydown, true);
if (wasTop && invoker?.isConnected) invoker.focus();
};
});
const isVideo = $derived(kind === 'video');
$effect(() => {
@@ -38,6 +65,7 @@
zoomed = false;
});
const isAudio = $derived(kind === 'audio');
const hasNav = $derived(Boolean(images && images.length > 1 && onnavigate));
function step(delta: number) {
@@ -46,8 +74,26 @@
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
else if (hasNav && e.key === 'ArrowLeft') step(-1);
if (!topModal(layer)) return;
if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
onClose();
} else if (e.key === 'Tab' && panel) {
const items = Array.from(
panel.querySelectorAll<HTMLElement>(focusable),
).filter((el) => el.getClientRects().length > 0);
const first = items[0];
const last = items.at(-1);
const inside = panel.contains(document.activeElement);
if (e.shiftKey && (!inside || document.activeElement === first)) {
e.preventDefault();
(last ?? panel).focus();
} else if (!e.shiftKey && (!inside || document.activeElement === last)) {
e.preventDefault();
(first ?? panel).focus();
}
} else if (hasNav && e.key === 'ArrowLeft') step(-1);
else if (hasNav && e.key === 'ArrowRight') step(1);
}
@@ -76,29 +122,35 @@
}
</script>
<svelte:window onkeydown={src ? onKeydown : undefined} />
{#if src}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
bind:this={panel}
class="backdrop"
role="dialog"
aria-modal="true"
aria-label={isVideo ? t('lightbox.videoPreview') : t('lightbox.imagePreview')}
aria-label={isAudio
? t('lightbox.mediaPreview')
: isVideo
? t('lightbox.videoPreview')
: t('lightbox.imagePreview')}
tabindex="-1"
onclick={onClose}
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="toolbar">
{#if hasNav}
<span class="counter">{index + 1} / {images?.length ?? 0}</span>
{/if}
{#if !isVideo}
{#if !isVideo && !isAudio}
<button
class="tool"
type="button"
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
onclick={() => (zoomed = !zoomed)}
onclick={(e) => {
e.stopPropagation();
zoomed = !zoomed;
}}
>
<Icon name="zoom-in" size={17} />
</button>
@@ -125,6 +177,20 @@
{/if}
</button>
{/if}
{#if onReplace}
<button
class="tool"
type="button"
aria-label={t('studio.refReplace')}
title={t('studio.refReplace')}
onclick={(e) => {
e.stopPropagation();
onReplace?.();
}}
>
<Icon name="project-refresh" size={17} />
</button>
{/if}
<a
class="tool"
href={src}
@@ -172,7 +238,7 @@
</button>
{/if}
{#if isVideo}
{#if isVideo || isAudio}
<!-- Clicks on the player (play/pause/seek) must not close the modal -->
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="player-frame" onclick={(e) => e.stopPropagation()}>
@@ -180,8 +246,10 @@
class="player"
{src}
{alt}
kind="video"
label={t('lightbox.videoUnavailable')}
{kind}
label={isAudio
? t('lightbox.audioUnavailable')
: t('lightbox.videoUnavailable')}
controls
preload="metadata"
/>
@@ -204,7 +272,7 @@
<p class="caption">{caption}</p>
{/if}
<p class="hint">
{#if isVideo}
{#if isVideo || isAudio}
{t('lightbox.closeHint')} ·
{:else}
{t('lightbox.clickToZoom')} · {t('lightbox.closeHint')} ·
@@ -4,7 +4,7 @@
interface Props {
src: string | null | undefined;
alt?: string;
kind?: 'image' | 'video';
kind?: 'image' | 'video' | 'audio';
label?: string;
class?: string;
loading?: 'lazy' | 'eager';
@@ -67,7 +67,10 @@
</script>
{#if usable && src}
{#if kind === 'video'}
{#if kind === 'audio'}
<audio class={klass} {src} {controls} {preload} onerror={markFailed}
></audio>
{:else if kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class={klass}
@@ -227,6 +227,7 @@
{#if paired && index > 0}<Icon name="arrow-right" size={14} />{/if}
{@const reject = rejects[s.inp.nodeId]?.[0]}
<MediaSlot
previewContext={ds}
caption={paired}
label={s.label}
hint={s.lastFrame && !s.inp.required
@@ -262,6 +263,7 @@
{/each}
{#each ds.lists as entry (entry.nodeId)}
<RefImageGrid
previewContext={ds}
label={entry.label}
mediaKind={listKind(entry)}
paths={listPaths(values, entry)}
@@ -1,4 +1,5 @@
<script lang="ts">
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import {
@@ -11,10 +12,11 @@
/**
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
* (click to preview; replace from the preview toolbar) with a ✕ on hover/focus. `need` = required but empty
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
*/
interface Props {
previewContext?: unknown;
label: string;
caption?: boolean;
/** Hint after the label, e.g. 必填 · 1 张. */
@@ -40,6 +42,7 @@
}
let {
previewContext,
label,
caption = false,
hint,
@@ -69,7 +72,14 @@
dims = '';
});
const filled = $derived(!!value && !broken);
const filled = $derived(!!value);
let preview = $state(false);
$effect(() => {
void previewContext;
void value;
void kind;
preview = false;
});
const meta = $derived(
[uploadBaseName(value), dims, size != null ? formatBytes(size) : '']
.filter(Boolean)
@@ -113,6 +123,18 @@
};
</script>
<ImageLightbox
src={preview ? assetUrl(value) : null}
{kind}
alt={label}
caption={meta}
onClose={() => (preview = false)}
onReplace={() => {
preview = false;
pick();
}}
/>
<div class="a-slot">
{#if !caption}<div class="a-slot-label">
<span>{label}</span>
@@ -148,17 +170,17 @@
<div
class="a-tile"
class:drag={dragDepth > 0}
class:bad={!!error}
class:bad={!!error || broken}
{...dragHandlers}
>
<button
type="button"
id={anchorId}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${meta}`}
aria-label={`${t('studio.refReplace')} ${label}`}
title={`${t('lightbox.mediaPreview')} · ${meta}`}
aria-label={`${label} · ${kind === 'video' ? t('lightbox.videoPreview') : kind === 'image' ? t('lightbox.imagePreview') : t('lightbox.mediaPreview')}`}
disabled={uploading}
onclick={pick}
onclick={() => (preview = true)}
>
{#if kind === 'image'}
<img
@@ -1,4 +1,5 @@
<script lang="ts">
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import {
@@ -12,7 +13,7 @@
/**
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
* 1…N in the order they reach the workflow — click a tile to replace it,
* 1…N in the order they reach the workflow — click a tile to preview it (replace from the preview toolbar),
* ✕ to remove; a dashed add tile while below `max`; rejected files show
* as red tiles that take no place in the list.
*/
@@ -21,6 +22,7 @@
message: string;
}
interface Props {
previewContext?: unknown;
label: string;
paths: string[];
max: number;
@@ -48,6 +50,7 @@
}
let {
previewContext,
label,
paths,
max,
@@ -68,6 +71,17 @@
onFromScene,
}: Props = $props();
let previewIndex = $state<number | null>(null);
$effect(() => {
void previewContext;
void paths;
void mediaKind;
previewIndex = null;
});
const previewPath = $derived(
previewIndex == null ? '' : (paths[previewIndex] ?? ''),
);
let addInput = $state<HTMLInputElement | null>(null);
let replaceInput = $state<HTMLInputElement | null>(null);
let replaceIndex = $state(-1);
@@ -77,7 +91,9 @@
const uploading = $derived(!!uploadingName);
const remaining = $derived(Math.max(0, max - paths.length));
const accept = $derived(mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind));
const accept = $derived(
mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind),
);
const isMedia = $derived(mediaKind !== 'image');
const hintLabel = $derived(
isMedia
@@ -130,6 +146,22 @@
}
</script>
<ImageLightbox
src={previewPath ? assetUrl(previewPath) : null}
kind={mediaKind}
alt={label}
caption={previewPath ? metaOf(previewPath) : ''}
images={paths.map((path) => assetUrl(path) ?? '')}
index={previewIndex ?? 0}
onnavigate={(i) => (previewIndex = i)}
onClose={() => (previewIndex = null)}
onReplace={() => {
const i = previewIndex;
previewIndex = null;
if (i != null) pickReplace(i);
}}
/>
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
@@ -175,10 +207,10 @@
<button
type="button"
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
title={`${t('lightbox.mediaPreview')} · ${metaOf(path)}`}
aria-label={`${t('lightbox.mediaPreview')} ${label} ${i + 1}`}
disabled={uploading}
onclick={() => pickReplace(i)}
onclick={() => (previewIndex = i)}
>
{#if mediaKind === 'image'}
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
import { enterModal, leaveModal, topModal } from './modalLayers';
import { t } from '$lib/i18n.svelte';
/**
@@ -36,6 +37,7 @@
const titleId = `drawer-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
const layer = Symbol('drawer');
function requestClose() {
if (!dismissible) return;
@@ -43,6 +45,7 @@
}
function handleKeydown(event: KeyboardEvent) {
if (!topModal(layer)) return;
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
@@ -68,7 +71,7 @@
if (event.shiftKey && (!focusInside || active === first)) {
event.preventDefault();
last.focus();
} else if (!focusInside || active === last) {
} else if (!event.shiftKey && (!focusInside || active === last)) {
event.preventDefault();
first.focus();
}
@@ -82,20 +85,20 @@
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
enterModal(layer);
document.addEventListener('keydown', handleKeydown, true);
requestAnimationFrame(() => {
if (!panel || panel.contains(document.activeElement)) return;
if (!topModal(layer) || !panel || panel.contains(document.activeElement)) return;
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel).focus();
});
return () => {
document.body.style.overflow = previousOverflow;
const wasTop = topModal(layer);
leaveModal(layer);
document.removeEventListener('keydown', handleKeydown, true);
if (invoker?.isConnected) invoker.focus();
if (wasTop && invoker?.isConnected) invoker.focus();
onclose?.();
};
});
+7
View File
@@ -222,6 +222,8 @@ export const de: Dict = {
"lightbox.download": "Herunterladen",
"lightbox.downloadMedia": "Medium herunterladen",
"lightbox.fitToScreen": "An Bildschirm anpassen",
"lightbox.mediaPreview": "Medienvorschau",
"lightbox.audioUnavailable": "Audio nicht verfügbar",
"lightbox.imagePreview": "Bildvorschau",
"lightbox.imageUnavailable": "Bild nicht verfügbar",
"lightbox.next": "Weiter",
@@ -306,6 +308,11 @@ export const de: Dict = {
"canvas.noChatYet": "Noch kein Chat auf diesem Canvas",
"canvas.collapseChat": "Chat einklappen",
"canvas.expandChat": "Chat ausklappen",
"canvas.locateMissing":
"Karte nicht gefunden — sie wurde möglicherweise bereits gelöscht",
"canvas.located": "Gesuchte Karte gefunden",
"canvas.backToLibrary": "Zurück zur Mediathek",
"canvas.locateDismiss": "Markierung aufheben",
"canvas.deleteBlocked":
"Projekt-Assets und laufende Jobs können hier nicht gelöscht werden – über die Projekt-Assets ersetzen",
"canvas.deleteFailed": "{count} Karte(n) konnten nicht gelöscht werden",
+7
View File
@@ -218,6 +218,8 @@ export const en = {
"lightbox.download": "Download",
"lightbox.downloadMedia": "Download media",
"lightbox.fitToScreen": "Fit to screen",
"lightbox.mediaPreview": "Media preview",
"lightbox.audioUnavailable": "Audio unavailable",
"lightbox.imagePreview": "Image preview",
"lightbox.imageUnavailable": "Image unavailable",
"lightbox.next": "Next",
@@ -299,6 +301,11 @@ export const en = {
"canvas.noChatYet": "No chat on this canvas yet",
"canvas.collapseChat": "Collapse chat",
"canvas.expandChat": "Expand chat",
"canvas.locateMissing":
"Could not find that card — it may have been deleted already",
"canvas.located": "Located the card you were looking for",
"canvas.backToLibrary": "Back to library",
"canvas.locateDismiss": "Dismiss highlight",
"canvas.deleteBlocked":
"Project assets and running jobs can’t be deleted here — replace them from Project Assets",
"canvas.deleteFailed": "Could not delete {count} card(s)",
+7
View File
@@ -225,6 +225,8 @@ export const es: Dict = {
"lightbox.download": "Descargar",
"lightbox.downloadMedia": "Descargar contenido",
"lightbox.fitToScreen": "Ajustar a la pantalla",
"lightbox.mediaPreview": "Vista previa multimedia",
"lightbox.audioUnavailable": "Audio no disponible",
"lightbox.imagePreview": "Vista previa de imagen",
"lightbox.imageUnavailable": "Imagen no disponible",
"lightbox.next": "Siguiente",
@@ -308,6 +310,11 @@ export const es: Dict = {
"canvas.noChatYet": "Aún no hay ningún chat en este canvas",
"canvas.collapseChat": "Contraer chat",
"canvas.expandChat": "Expandir chat",
"canvas.locateMissing":
"No se encontró la tarjeta; puede que ya se haya borrado",
"canvas.located": "Tarjeta buscada localizada",
"canvas.backToLibrary": "Volver a la biblioteca",
"canvas.locateDismiss": "Quitar resaltado",
"canvas.deleteBlocked":
"Los assets del proyecto y los trabajos en curso no se pueden eliminar aquí – reemplázalos desde los assets del proyecto",
"canvas.deleteFailed": "No se pudieron eliminar {count} tarjeta(s)",
+7
View File
@@ -224,6 +224,8 @@ export const fr: Dict = {
"lightbox.download": "Télécharger",
"lightbox.downloadMedia": "Télécharger le média",
"lightbox.fitToScreen": "Ajuster à l'écran",
"lightbox.mediaPreview": "Aperçu du média",
"lightbox.audioUnavailable": "Audio indisponible",
"lightbox.imagePreview": "Aperçu de l'image",
"lightbox.imageUnavailable": "Image indisponible",
"lightbox.next": "Suivant",
@@ -310,6 +312,11 @@ export const fr: Dict = {
"canvas.noChatYet": "Pas encore de discussion sur ce canevas",
"canvas.collapseChat": "Réduire la discussion",
"canvas.expandChat": "Développer la discussion",
"canvas.locateMissing":
"Carte introuvable — elle a peut-être déjà été supprimée",
"canvas.located": "Carte recherchée localisée",
"canvas.backToLibrary": "Retour à la médiathèque",
"canvas.locateDismiss": "Retirer le surlignage",
"canvas.deleteBlocked":
"Les assets de projet et les jobs en cours ne peuvent pas être supprimés ici – remplacez-les depuis les assets du projet",
"canvas.deleteFailed": "Impossible de supprimer {count} carte(s)",
+7
View File
@@ -222,6 +222,8 @@ export const ja: Dict = {
"lightbox.download": "ダウンロード",
"lightbox.downloadMedia": "メディアをダウンロード",
"lightbox.fitToScreen": "画面にフィット",
"lightbox.mediaPreview": "メディアプレビュー",
"lightbox.audioUnavailable": "音声を読み込めません",
"lightbox.imagePreview": "画像プレビュー",
"lightbox.imageUnavailable": "画像を表示できません",
"lightbox.next": "次へ",
@@ -305,6 +307,11 @@ export const ja: Dict = {
"canvas.noChatYet": "このキャンバスにはまだチャットがありません",
"canvas.collapseChat": "チャットを折りたたむ",
"canvas.expandChat": "チャットを展開",
"canvas.locateMissing":
"カードが見つかりません。既に削除された可能性があります",
"canvas.located": "探していたカードを表示しました",
"canvas.backToLibrary": "ライブラリに戻る",
"canvas.locateDismiss": "ハイライトを解除",
"canvas.deleteBlocked":
"プロジェクトのアセットや実行中のジョブはここでは削除できません – プロジェクトのアセットから置き換えてください",
"canvas.deleteFailed": "{count} 枚のカードを削除できませんでした",
+6
View File
@@ -221,6 +221,8 @@ export const ko: Dict = {
"lightbox.download": "다운로드",
"lightbox.downloadMedia": "미디어 다운로드",
"lightbox.fitToScreen": "화면에 맞추기",
"lightbox.mediaPreview": "미디어 미리보기",
"lightbox.audioUnavailable": "오디오를 불러올 수 없습니다",
"lightbox.imagePreview": "이미지 미리보기",
"lightbox.imageUnavailable": "이미지를 표시할 수 없습니다",
"lightbox.next": "다음",
@@ -302,6 +304,10 @@ export const ko: Dict = {
"canvas.noChatYet": "이 캔버스에는 아직 채팅이 없습니다",
"canvas.collapseChat": "채팅 접기",
"canvas.expandChat": "채팅 펼치기",
"canvas.locateMissing": "카드를 찾을 수 없어요. 이미 삭제되었을 수 있어요",
"canvas.located": "찾던 카드를 표시했어요",
"canvas.backToLibrary": "라이브러리로 돌아가기",
"canvas.locateDismiss": "강조 끄기",
"canvas.deleteBlocked":
"프로젝트 애셋과 실행 중인 작업은 여기서 삭제할 수 없습니다 – 프로젝트 애셋에서 교체하세요",
"canvas.deleteFailed": "{count}개 카드를 삭제하지 못했습니다",
+6
View File
@@ -216,6 +216,8 @@ export const zh: Dict = {
"lightbox.download": "下载",
"lightbox.downloadMedia": "下载媒体",
"lightbox.fitToScreen": "适应屏幕",
"lightbox.mediaPreview": "素材预览",
"lightbox.audioUnavailable": "音频无法加载",
"lightbox.imagePreview": "图片预览",
"lightbox.imageUnavailable": "图片不可用",
"lightbox.next": "下一个",
@@ -293,6 +295,10 @@ export const zh: Dict = {
"canvas.noChatYet": "此画布尚无对话",
"canvas.collapseChat": "收起对话",
"canvas.expandChat": "展开对话",
"canvas.locateMissing": "没有找到这张卡片,可能已被删除",
"canvas.located": "已定位到要找的卡片",
"canvas.backToLibrary": "返回素材库",
"canvas.locateDismiss": "取消高亮",
"canvas.deleteBlocked":
"项目素材和正在运行的任务无法在此删除 – 请在项目素材中替换它们",
"canvas.deleteFailed": "未能删除 {count} 张卡片",
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount, onDestroy } from 'svelte';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { toStore } from 'svelte/store';
@@ -146,6 +147,99 @@
// internally, so proxies must never reach it.
let nodes = $state.raw<Node[]>([]);
// ---- locate deep-link (?locate=<canvas node id>, HEL-884) -----------
// The library occupancy modal links here; the card is centered, ringed
// and stays identifiable, with a chip to jump back to the material.
let locateNodeId = $state<number | null>(null);
let highlightId = $state<number | null>(null);
let flowPanelEl = $state<HTMLElement | null>(null);
let locateHandled = false;
let locateMissingReported = false;
let locateTimer: ReturnType<typeof setTimeout> | undefined;
onDestroy(() => clearTimeout(locateTimer));
$effect(() => {
if (locateHandled || canvas == null) return;
const param = page.url.searchParams.get('locate');
if (param == null) return;
locateHandled = true;
const nid = Number(param);
if (Number.isFinite(nid) && nid > 0) {
locateNodeId = nid;
clearTimeout(locateTimer);
locateTimer = setTimeout(() => {
highlightId = null;
locateNodeId = null;
}, 20000);
}
replaceUrlParam('locate');
});
/** Center the camera on one card by writing the bound viewport directly.
* Svelte Flow's store.fitView() round-trips the bound nodes array and
* looped the graph-rebuild effect (effect_update_depth_exceeded), so the
* transform is computed from the graph position + measured card box.
* Retries across a few frames — on SPA navigation the pane mounts with
* zero size and the card's DOM box arrives one frame later. */
let centerRetryTimer: ReturnType<typeof setTimeout> | undefined;
onDestroy(() => clearTimeout(centerRetryTimer));
function centerOnLocatedNode(nodeId: number, attempt = 0) {
const panel = flowPanelEl;
const node = $canvasQuery.data?.nodes.find((n) => n.id === nodeId);
if (!panel || !node) return;
if (panel.clientWidth < 2 || panel.clientHeight < 2) {
if (attempt < 30 && locateNodeId === nodeId) {
clearTimeout(centerRetryTimer);
centerRetryTimer = setTimeout(
() => centerOnLocatedNode(nodeId, attempt + 1),
80,
);
}
return;
}
let w = 220;
let h = 200;
const el = panel.querySelector(
`.svelte-flow__node[data-id="cn-${nodeId}"]`,
) as HTMLElement | null;
if (el && el.offsetWidth && el.offsetHeight) {
w = el.offsetWidth;
h = el.offsetHeight;
}
const zoom = Math.min(viewport.zoom > 0 ? viewport.zoom : 1, 1);
viewport = {
zoom,
x: panel.clientWidth / 2 - (node.x + w / 2) * zoom,
y: panel.clientHeight / 2 - (node.y + h / 2) * zoom,
};
}
$effect(() => {
if (locateNodeId == null) return;
const graph = $canvasQuery.data;
if (!graph) return;
if (graph.nodes.some((n) => n.id === locateNodeId)) {
if (highlightId !== locateNodeId) highlightId = locateNodeId;
// Untracked: centering reads viewport.zoom and writes viewport —
// tracked, that read+write pair is a self-retriggering loop.
const nid = locateNodeId;
untrack(() => centerOnLocatedNode(nid));
} else if (
!locateMissingReported &&
!$canvasQuery.isLoading &&
!$canvasQuery.isFetching
) {
locateMissingReported = true;
toast.error(t('canvas.locateMissing'));
locateNodeId = null;
}
});
function backToLibrary() {
if (history.length > 1) history.back();
else goto(resolve('/library'));
}
// ---- media preview (lightbox) ------------------------------------------
// Card clicks open the large preview here; nothing navigates away.
let preview = $state<{
@@ -224,6 +318,7 @@
? 'running'
: (jobs.find((j) => j.id === n.job_id)?.status ?? n.status),
jobId: n.job_id,
located: n.id === highlightId,
onOpenMedia: (kind: 'image' | 'video') =>
openMediaPreview(kind, n.artifact_path, n.title ?? 'Artifact'),
},
@@ -240,6 +335,7 @@
title: n.title ?? 'Untitled',
imagePath: media.imagePath,
videoPath: media.videoPath,
located: n.id === highlightId,
onOpenMedia: (kind: 'image' | 'video') =>
kind === 'video'
? openMediaPreview(
@@ -1150,7 +1246,7 @@
onToggleCollapse={toggleRail}
/>
<main class="flow-panel">
<main class="flow-panel" bind:this={flowPanelEl}>
<div class="titlecard" data-testid="titlecard">
{#if titleEditing}
<input
@@ -1210,6 +1306,32 @@
{/if}
</div>
{#if highlightId != null}
<div class="locate-chip" data-testid="locate-chip">
<span>{t('canvas.located')}</span>
<button
type="button"
class="locate-back"
onclick={backToLibrary}
>
<Icon name="chevron-left" size={14} />
{t('canvas.backToLibrary')}
</button>
<button
type="button"
class="locate-dismiss"
onclick={() => {
clearTimeout(locateTimer);
highlightId = null;
locateNodeId = null;
}}
aria-label={t('canvas.locateDismiss')}
>
×
</button>
</div>
{/if}
<SvelteFlow
bind:nodes
bind:viewport
@@ -1268,6 +1390,7 @@
</div>
</Panel>
{/if}
</SvelteFlow>
</main>
@@ -1398,6 +1521,7 @@
}
.titlecard,
.tidy-btn,
.locate-chip,
.canvas-empty-hint,
.chat-expand {
background: var(--glass);
@@ -1405,6 +1529,62 @@
border: 1px solid var(--line-2);
box-shadow: var(--sh-1);
}
.locate-chip {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 6px 0 14px;
border-color: color-mix(in srgb, var(--gold) 45%, transparent);
border-radius: var(--r-pill);
font-size: 12.5px;
color: var(--tx-1);
white-space: nowrap;
}
.locate-chip > span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.locate-back {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: var(--r-pill);
background: var(--grad-gold);
color: var(--tx-inv);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.locate-back:hover {
box-shadow: var(--sh-2);
}
.locate-dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--tx-3);
font-size: 15px;
cursor: pointer;
}
.locate-dismiss:hover {
color: var(--tx-1);
background: rgb(var(--ink) / 0.06);
}
.titlecard {
position: absolute;
top: 14px;
+344 -10
View File
@@ -1,11 +1,21 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import { SvelteSet } from "svelte/reactivity";
import { libraryApi, assetUrl, type LibraryMediaItem } from "$lib/api";
import {
libraryApi,
canvasApi,
assetUrl,
apiErrorMessage,
type LibraryMediaItem,
type LibraryMediaUsage,
type LibraryUsageCard,
} from "$lib/api";
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
import AttachToProject from "$lib/components/AttachToProject.svelte";
import ImageLightbox from "$lib/components/ImageLightbox.svelte";
@@ -13,6 +23,7 @@
import Icon from "$lib/components/ui/Icon.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import { storageGet, storageSet } from "$lib/storage";
import { toast } from "$lib/toast";
import "./library-v2.css";
const client = useQueryClient();
@@ -33,6 +44,32 @@
fullscreen = $state(false);
const selected = new SvelteSet<string>();
let anchor = $state<string | null>(null);
// Survive navigate-away-and-back (canvas locate round-trip): the original
// selection is restored so the user continues where they left off.
const SELECTION_KEY = "calliope.library.selection";
let selectionRestored = false;
$effect(() => {
if (!items.length) return;
if (selected.size === 0 && !selectionRestored) {
selectionRestored = true;
try {
const saved = JSON.parse(storageGet(SELECTION_KEY) ?? "null") as {
current?: string | null;
paths?: string[];
} | null;
for (const p of saved?.paths ?? [])
if (items.some((i) => i.path === p)) selected.add(p);
if (saved?.current && items.some((i) => i.path === saved.current))
current = saved.current;
} catch {
// corrupted cache — start fresh
}
}
storageSet(
SELECTION_KEY,
JSON.stringify({ current, paths: [...selected] }),
);
});
let meta = $state<
Record<string, { width: number; height: number; duration?: number }>
>({});
@@ -146,6 +183,28 @@
selectAll();
}
}
// Kept reasons the server reports per path — each maps to one user-facing
// explanation so a kept file says why it was kept (HEL-872).
const KEPT_REASONS: Record<string, string> = {
referenced: "已被项目使用",
on_canvas: "仍在画布中使用",
project_owned: "属于项目文件,不归素材库删除",
outside_assets: "不在素材库存储范围内",
unlink_failed: "文件被占用或无权限,删除失败",
other: "无法删除",
};
function keptSummary(kept: { reason?: string }[]) {
const byReason: Record<string, number> = {};
for (const r of kept) {
const key = (r.reason && KEPT_REASONS[r.reason]) ? r.reason : "other";
byReason[key] = (byReason[key] ?? 0) + 1;
}
const parts = Object.entries(byReason).map(
([reason, n]) => `${KEPT_REASONS[reason]} ${n}`,
);
const hint = byReason.on_canvas ? ";画布中的素材请先删除对应卡片" : "";
return `保留 ${kept.length} 个:${parts.join(" · ")}${hint}`;
}
const deleteMutation = createMutation({
mutationFn: async (paths: string[]) => {
const results = [];
@@ -155,18 +214,168 @@
},
onSuccess: (batch) => {
const deleted = batch.reduce((n, r) => n + r.deleted.length, 0),
kept = batch
.flatMap((r) => r.results)
.filter((r) => r.status === "kept").length;
toast.info(
`已删除 ${deleted} 个文件${kept ? ` · 保留 ${kept} 个:被项目使用或无法删除` : ""}`,
);
selected.clear();
kept = batch.flatMap((r) => r.results).filter((r) => r.status === "kept");
if (deleted > 0) {
toast.success(
`已删除 ${deleted} 个文件${kept.length ? ` · ${keptSummary(kept)}` : ""}`,
);
} else {
toast.error(
`未能删除 ${kept.length} 个文件 · ${keptSummary(kept)}`,
);
}
// HEL-884: canvas-held files get the real occupancy report — which
// canvas, which card, and what to do about it.
const onCanvas = [
...new Set(
kept.filter((r) => r.reason === "on_canvas").map((r) => r.path),
),
];
if (onCanvas.length) {
usageData = {};
usageFetched = {};
usageLoading = {};
usageError = {};
usagePaths = onCanvas;
}
// Only files that were really deleted leave the selection; kept ones
// stay checked so a mixed batch never wipes the user's selection
// (HEL-887). Batches are accumulated across the 200-path chunks.
const deletedPaths = new Set(batch.flatMap((r) => r.deleted));
for (const p of deletedPaths) selected.delete(p);
if (current && deletedPaths.has(current)) current = null;
deletePaths = [];
void client.invalidateQueries({ queryKey: ["library-media"] });
},
onError: (e) => toast.error(e.message),
onError: (e) => toast.error(`删除请求失败:${apiErrorMessage(e)}`),
});
// ---- canvas occupancy report (HEL-884) ---------------------------------
// Read-only per-file usage: which canvas card holds the file, in which
// project/session, plus explicit actions (locate / delete card / release).
let usagePaths = $state<string[]>([]);
let usageData = $state<Record<string, LibraryMediaUsage>>({});
let usageLoading = $state<Record<string, boolean>>({});
let usageError = $state<Record<string, string>>({});
let usageFetched = $state<Record<string, boolean>>({});
let pendingAction = $state<{
kind: "delete" | "release";
path: string;
card: LibraryUsageCard;
} | null>(null);
let usageBusy = $state(false);
async function loadUsage(path: string, offset = 0) {
usageLoading = { ...usageLoading, [path]: true };
usageError = { ...usageError, [path]: "" };
try {
const data = await libraryApi.usage(path, offset);
const prev = offset > 0 ? usageData[path] : null;
usageData = {
...usageData,
[path]: prev
? { ...data, usages: [...prev.usages, ...data.usages] }
: data,
};
usageFetched = { ...usageFetched, [path]: true };
} catch (e) {
usageError = { ...usageError, [path]: apiErrorMessage(e) };
} finally {
usageLoading = { ...usageLoading, [path]: false };
}
}
$effect(() => {
for (const p of usagePaths)
if (!usageFetched[p] && !usageLoading[p]) void loadUsage(p);
});
// Entries whose file disappeared (deleted together with its card) leave
// the modal on the next list refresh — the loop closed itself.
$effect(() => {
if (!items.length || !usagePaths.length) return;
const alive = usagePaths.filter((p) => items.some((i) => i.path === p));
if (alive.length !== usagePaths.length) usagePaths = alive;
});
function scopeText(card: LibraryUsageCard) {
if (card.scope.kind === "project")
return card.scope.title
? `项目「${card.scope.title}」`
: "所属项目已不存在";
if (card.scope.kind === "session")
return card.scope.title
? `会话「${card.scope.title}」`
: "所属会话已不存在";
return "独立画布";
}
function keepReasonText(card: LibraryUsageCard) {
switch (card.file_keep_reason) {
case "referenced":
return "文件仍被其他卡片或项目使用,删除卡片后文件保留";
case "project_owned":
return "文件属于项目输出,删除卡片不会删除文件";
case "outside_assets":
return "文件不在素材库删除范围内,删除卡片不会删除文件";
default:
return "删除卡片不会删除文件";
}
}
function locateCard(card: LibraryUsageCard) {
usagePaths = [];
pendingAction = null;
goto(resolve(`/canvas/${card.canvas_id}?locate=${card.node_id}`));
}
async function performUsageAction() {
if (!pendingAction || usageBusy) return;
const { kind, path, card } = pendingAction;
usageBusy = true;
try {
const res = await canvasApi.deleteNode(
card.canvas_id,
card.node_id,
kind === "release",
);
pendingAction = null;
if (kind === "release")
toast.success("已仅移除卡片,原文件保留在素材库");
else if (res.file_deleted) {
// The file left the library with its card — it must also leave the
// selection, exactly like a direct library delete (HEL-887).
selected.delete(path);
if (current === path) current = null;
toast.success("卡片已删除,文件已一并删除");
} else toast.success("卡片已删除,文件保留");
void loadUsage(path);
void client.invalidateQueries({ queryKey: ["library-media"] });
} catch (e) {
toast.error(`处理卡片失败:${apiErrorMessage(e)}`);
} finally {
usageBusy = false;
}
}
async function deleteFreedFile(path: string) {
try {
const res = await libraryApi.deleteMany([path]);
const r = res.results[0];
if (r?.status === "deleted") {
toast.success("文件已删除");
selected.delete(path);
if (current === path) current = null;
usagePaths = usagePaths.filter((p) => p !== path);
} else {
toast.error(`未能删除 · ${KEPT_REASONS[r?.reason ?? "other"] ?? "无法删除"}`);
if (r?.reason === "on_canvas") void loadUsage(path);
}
void client.invalidateQueries({ queryKey: ["library-media"] });
} catch (e) {
toast.error(`删除请求失败:${apiErrorMessage(e)}`);
}
}
function attached(path: string, title: string) {
attachPaths = attachPaths.filter((p) => p !== path);
selected.delete(path);
@@ -470,7 +679,7 @@
open={deletePaths.length > 0}
title={"删除 " + deletePaths.length + " 个文件?"}
onclose={() => (deletePaths = [])}
><p>删除前服务器会再次检查,若文件此刻已被项目使用会自动保留。</p>
><p>删除前服务器会再次检查,仍在画布或项目中使用的文件会自动保留并说明原因。</p>
<div class="delete-preview">
{#each items.filter((i) => deletePaths.includes(i.path)) as i (i.path)}<div>
<SafeMedia
@@ -503,6 +712,131 @@
}}
/>{/if}
</Modal>
<Modal
open={usagePaths.length > 0}
title="画布占用详情"
size="lg"
onclose={() => {
usagePaths = [];
pendingAction = null;
}}
>
{#each usagePaths as path (path)}{@const item = items.find((i) => i.path === path)}{@const usage = usageData[path]}
<section class="usage-file">
<header class="usage-head">
{#if item}<SafeMedia
src={assetUrl(item.path)}
kind={item.kind}
alt={item.name}
controls={false}
/>{/if}
<div class="usage-title">
<b>{item?.name ?? path}</b>
<span>删除被保留,因为仍在画布中使用。逐个处理下面的卡片后即可删除。</span>
</div>
</header>
{#if usageLoading[path] && !usage}<p class="usage-empty">正在核对占用…</p>
{:else if usageError[path]}
<p class="usage-empty usage-error">占用信息读取失败:{usageError[path]}</p>
<Button size="sm" onclick={() => loadUsage(path)}>重试</Button>
{:else if usage}
{#if usage.valid_total === 0}
<div class="usage-free">
<p>当前没有画布卡片占用此文件了,现在可以删除。</p>
<Button
size="sm"
variant="danger"
onclick={() => deleteFreedFile(path)}>删除此文件</Button
>
</div>
{:else}
<ul class="usage-list">
{#each usage.usages as card (card.canvas_id * 1e9 + card.node_id)}
<li class="usage-card">
<div class="u-info">
<b title={card.node_title ?? ""}>「{card.node_title || "未命名卡片"}」</b>
<span class="u-where">
画布「{card.canvas_title}」· {scopeText(card)} · 卡片 #{card.node_id}
</span>
{#if card.job_running}
<span class="u-effect u-warn">生成中,完成后才能处理这张卡</span>
{:else if card.deleting_card_deletes_file}
<span class="u-effect u-danger"
>删除卡片将同时删除文件(这是最后一份引用)</span
>
{:else}
<span class="u-effect">{keepReasonText(card)}</span>
{/if}
</div>
<div class="u-actions">
<Button size="sm" variant="ghost" onclick={() => locateCard(card)}
><Icon name="pin" size={14} />定位</Button
>
{#if !card.job_running}
<Button
size="sm"
variant="danger"
onclick={() =>
(pendingAction = { kind: "delete", path, card })}>删除卡片…</Button
>
<Button
size="sm"
variant="ghost"
onclick={() =>
(pendingAction = { kind: "release", path, card })}
>仅移除卡片</Button
>
{/if}
</div>
</li>
{/each}
</ul>
{#if usage.usages.length < usage.valid_total}
<button
class="usage-more"
onclick={() => loadUsage(path, usage.usages.length)}
>还有 {usage.valid_total - usage.usages.length} 处引用,展开下一批(每次 {usage.limit} 条)</button
>
{/if}
{/if}
{#if usage.invalid_total > 0}
<p class="usage-invalid">
另有 {usage.invalid_total} 条已失效引用(所属画布已不存在),不占用此文件,也无需处理。
</p>
{/if}
{/if}
</section>
{/each}
{#if pendingAction}{@const card = pendingAction.card}
<div class="usage-confirm" role="alertdialog" aria-label="确认处理画布卡片">
{#if pendingAction.kind === "delete"}
<p>
删除画布「{card.canvas_title}」({scopeText(card)})的卡片「{card
.node_title || "未命名卡片"}」?
{#if card.deleting_card_deletes_file}
该文件没有其他引用,删除卡片时会<b>同时删除文件</b>。
{:else}
{keepReasonText(card)}。
{/if}
</p>
{:else}
<p>
仅移除画布「{card.canvas_title}」({scopeText(card)})的卡片「{card
.node_title || "未命名卡片"}」?卡片会从画布消失,<b>原文件保留</b>在素材库,可继续删除或另作他用。
</p>
{/if}
<div class="usage-confirm-actions">
<Button variant="ghost" onclick={() => (pendingAction = null)}>取消</Button>
<Button
variant={pendingAction.kind === "delete" ? "danger" : "secondary"}
loading={usageBusy}
onclick={performUsageAction}
>{pendingAction.kind === "delete" ? "确认删除卡片" : "确认仅移除"}</Button
>
</div>
</div>
{/if}
</Modal>
{#if fullscreen && focused}<ImageLightbox
src={assetUrl(focused.path)}
alt={focused.name}
@@ -630,6 +630,155 @@
object-fit: contain;
}
/* ── Canvas occupancy report (HEL-884) ───────────────────────────── */
.usage-file {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 0;
}
.usage-file + .usage-file {
border-top: 1px solid var(--line-2);
}
.usage-head {
display: flex;
align-items: center;
gap: 10px;
}
.usage-head img,
.usage-head video {
width: 56px;
height: 42px;
border-radius: var(--r-sm);
object-fit: cover;
flex: none;
}
.usage-title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.usage-title b {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-title span {
font-size: 11.5px;
color: var(--tx-3);
}
.usage-empty {
font-size: 12px;
color: var(--tx-2);
}
.usage-error {
color: var(--err);
}
.usage-free {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--ok) 32%, transparent);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--ok) 8%, transparent);
}
.usage-free p {
font-size: 12.5px;
margin: 0;
color: var(--tx-1);
}
.usage-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.usage-card {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
padding: 9px 11px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
}
.u-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.u-info b {
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.u-where {
font-size: 11.5px;
color: var(--tx-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.u-effect {
font-size: 11px;
color: var(--tx-3);
}
.u-effect.u-warn {
color: var(--warn, var(--gold));
}
.u-effect.u-danger {
color: var(--err);
}
.u-actions {
display: flex;
gap: 6px;
flex: none;
}
.usage-more {
border: 0;
background: none;
color: var(--tx-2);
font-size: 12px;
cursor: pointer;
padding: 4px 0;
text-decoration: underline;
text-underline-offset: 3px;
}
.usage-more:hover {
color: var(--tx-1);
}
.usage-invalid {
font-size: 11.5px;
color: var(--tx-3);
margin: 0;
}
.usage-confirm {
margin-top: 12px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--err) 30%, transparent);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--err) 6%, transparent);
}
.usage-confirm p {
margin: 0 0 10px;
font-size: 12.5px;
line-height: 1.55;
color: var(--tx-1);
}
.usage-confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
html[data-motion="full"] .selbar.on {
animation: library-selection var(--t-base) var(--ease-out);
}
@@ -0,0 +1,10 @@
# HEL-871 · 已有参考素材点击预览
- `MediaSlot` / `RefImageGrid` 的已有素材主体改为打开既有 `ImageLightbox`;空槽继续添加,删除为独立按钮,明确替换移至预览工具栏。多图保留选中索引及左右导航,首尾帧按独立槽位查看。
- 灯箱沿用 `SafeMedia`:图片缩放、视频原生手动播放;补齐音频原生播放器及加载失败提示。坏图片仍保留已有路径和更换/移除入口,不再降成空槽。
- 灯箱与 `Drawer` 共用既有 `modalLayers`,最上层处理 Esc/Tab、保留滚动锁、关闭后返回入口;多图切换不丢失最初入口。SlotTray 传入工作流上下文,切换工作流清除旧预览。底栏与管理弹层同时覆盖。
- 没有样式/token、依赖、后端、生成/参数/上传校验/草稿语义变动。七种语言补齐媒体预览和音频失败文案。
验证:前端 check 0 错误 0 警告、lint、test、build 通过;`scripts/hel871/capture.py` 102 项实际挂载检查通过,无 JS 运行错误。真实 HTTP 上传和临时 SQLite,实际 H3 多参考/首尾帧 JSON,四主题×三窗口的底栏/管理弹层/预览证据,基线 344fede 同状态对照。覆盖关闭再看、键盘/焦点、多图顺序、独立替换/移除、格式拒绝、坏图、媒体组与单视频/音频暂停及手动播放、无槽文生图、切工作流、试运行抽屉、素材库共享灯箱。临时实例已清理,未调用真实生成。
公共影响与复现见 `scripts/hel871/README.md`。未分别重建智能体成果、画布、搭景及结果 Inspector 业务数据;共用灯箱通过素材库与输入素材的图片/视频、导航、缩放、焦点测试,其他入口由全量前端检查覆盖。后端无差异,未重复全量后端测试;不合并、不部署。
@@ -0,0 +1,36 @@
# HEL-883:视觉变化取证(未定位到本轮新增视觉回归)
基线 `344fede19833e936582b0fd7d8a85aec28edb59a`,集成头/本卡起点 `2ad5d33c7227136e09c7babbba64254c1ec63236`,目标 `leefer/hel-731-studio-project`。本卡只增加取证工具和记录,产品源码零修改;没有以新设计或整包回退代替定位,也不宣称用户反馈已被解决。
## 定位表
| 核查位置 | 实测与依据 | 结论/处置 |
| --- | --- | --- |
| 线上真实入口/静态包 | `/create?kind=image`、`/library` 实际截图与资源 URL;`start.lI2IEPce.js`;部署包全部 127 文件与服务端实际静态目录逐字节相同 | 未发现这两个入口加载错误部署包。浏览器旧标签页/旧缓存状态未知 |
| 服务器源码与浏览器产物 | 服务端保留的 `calliope-web/src` 多处旧于仓库;28 个当前构建 CSS 与实际服务文件完全相同;本机构建 JS 文件名与部署包不同 | 源码目录不能充当浏览器版本证据。额外下载改前、当前真实部署包独立重放,不以版本标记推定显示一致 |
| `344fede→2ad5d33` | 实际 14 个前端产品文件(含 7 个语言文件),不是卡内概述的 13 个;涉及灯箱、SafeMedia、槽位、Drawer、素材库 | 对新增调用与共享行为做实际挂载回归;不凭无 CSS diff 否认反馈 |
| 创作台静态/输入 | 本机构建与真实部署包分别比较,固定隔离工作流、同空槽、无作品/历史、相同主题/窗口;原定版 HEL-726 SHA 核实;已有槽、管理弹层和灯箱另测 | 所测静态状态未发现新增布局/配色差异。已有→预览为用户明确批准;空→添加、替换、移除、多图、首尾帧保留 |
| 素材库 | 同两份隔离图片/视频,四主题三窗口;旧/新真实部署包比较;线上独立只读截图 | 所测布局未发现新增偏差;删除保留原因文案属于另一已授权修复,不回退 |
| 画布 4 `/canvas/4?session=17` | 从只读 SQLite 导出用户最近项目画布(项目 7,11 个实体,0 产物,0 边);17 仅是隔离测试会话;同节点/位置/保存视口镜像重放 | 与画布 5 不是同一内容范围;没有加入演示节点或生成假过程 |
| 画布 5 `/canvas/5?session=16` | 用户最近沙盒画布,0 卡片,6 条现有消息;只读事件类型汇总保留 | 页面空态有真实数据依据。不能据此认定用户实际进入的就是这个画布 |
| `/canvas` 导航 | 源码按本地活动会话/最新会话选择项目或沙盒,再 ensure 画布;该入口可以 POST,画布 GET 也会 seed | 线上不直接开画布、不请求 seed GET。入口逻辑与上轮基线相同;用户当时 URL、查询、存储键未知 |
| 已批画布样本 | HEL-814 主卡明确“同意”;HTML 与《总管核对补充》原件不改;2 张隔离媒体卡只用于灯箱验证,运行时映射到原样本,12 组图/数表 | 产物宽 220、媒体高 124、缩放控件 40×160、小地图 172×110、助手 320/300 与批准样本吻合;壳层 1px 高度差已有 HEL-818 记录。沙盒胶囊/实际按钮图标和示例项目胶囊属于既有真实状态差异,不做新设计 |
| 共享 ImageLightbox/Drawer | 槽位、嵌套素材管理、试运行抽屉、素材库、画布图片/视频实际打开;焦点圈定/返回、滚动锁/恢复、手动播放、拖动和坐标持久化 | 未发现本轮新增的布局或交互必修项;旧 `opened=false` 未计入任何通过项 |
| 视口冷启动边界 | 首次无本地镜像时,基线与当前均可 fit 到 38%,即使服务端已有另一视口;同一来源的保存镜像存在时正常恢复 | 发现的既有边界,不能证明是用户本次变化原因,未扩大本卡修改画布。独立保留首次重放日志与镜像条件说明 |
## 对照方法与验证结果
- 1440×900、1366×768、1280×720;night/day/grade/bluehour;离线同字体回退;静态比较均为关闭动效,鼠标移出控件。未把本次截图冒充完整/克制动效实测。
- 4 个产物版本(本地基线、本地当前、改前真实部署包、当前真实部署包)×5 个页面状态×12 条件,240 张原图。两组各 60 对比较:本地 50 对逐像素相同、其余至多 88 像素不同;真实部署包 45 对相同、其余至多 74 像素不同(约 0.006%)。差异边界在文字/图标边缘,测得的公共区域几何/计算样式相同;不据此声称全站一致。
- 另附 36 张批准原件截图。创作台只映射空历史/空素材状态,批准样本工作流名称/目录仍是演示数据,不能称整页同数据像素一致。素材库原件也保留演示素材,重点对照已批准布局,不伪造产品能力。
- 画布样本追加 12 组同媒体、同坐标/比例映射,比较画布区域;助手演示对话不纳入裁图。独立保存映射 JSON,原始 HTML/CSS 不改。非逐像素零差承诺。
- `scripts/hel871/capture.py` 当前真实输入链 102/102,通过实际上传、多图/首尾帧、切工作流清预览、替换/移除、图片缩放、视频/音频手动播放、坏媒体、试运行 Drawer 和素材库灯箱;基线脚本复现已有槽点击打开文件选择器。
- 本卡主重放 35/35、JS 错误 0;补充批准样本/视口保存重放 19/19,JS 错误 0。实际断言 `.backdrop` 可见,画布图片与视频均打开;检查 Esc 返回原入口和 body overflow/scroll 恢复、视频手动播放、拖动入库及重载。
- 前端 `check` 0 错 0 警;`lint`、`test`、`build` 通过。构建保留既有三方未使用导入与大包提示;首次干净构建另有生成前 tsconfig 提示,原始日志完整保留,不称构建全零提示。后端无改动,没有重跑后台全量。
- 初次工具错误(素材库选择器、参考映射 CSS 优先级、测试初始化覆盖视口镜像)保留原始尝试日志与失败报告;修正验证工具后再执行,失败不冒充产品通过。
## 限制与交接
用户所述变化的确切位置/当时状态仍未知;已测状态没有足以支持视觉回退的证据,因此产品最小修正为零。不能以本次调查代替用户最终验收,也不保证未经测试的状态无问题。没有合并、部署、改服务器配置/数据/媒体或调用真实生成;未接入占用定位 API,未改智能体双入口或将画布改成流程编辑器。`emil-skills` 在本运行提供的技能与本机已查位置中不可用,本次按已批准样本及 v3 动效约束审查源码,没有用泛化技能重解释定版;完整/克制动态重播未做。
线上浏览器拦截所有非 GET/HEAD 及全部 canvas API;实际拦截两个自动 ComfyUI endpoint-test POST。远端 SQLite 使用 `mode=ro` + `query_only`,只导出选定画布所需行及关联消息/事件汇总,未下载配置或密钥。测试后先核 Multica daemon PID,再停各自子进程;临时库/媒体清除,原始证据保留在任务附件。代码仓库仅提交复现脚本与本文。
+190
View File
@@ -0,0 +1,190 @@
// HEL-872 — real-page library delete regression (isolated API, real build).
// Drives /library with a real browser: single delete, cancel, canvas-held
// retention reason, mixed batch, HTTP-failure retry. Asserts disk + list.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
assert = require("node:assert/strict");
const OUT = "../evidence/hel872",
WEB = "http://127.0.0.1:15272",
API = "http://127.0.0.1:18272";
const phase = process.env.HEL872_PHASE || "after";
const report = { phase, checks: [], errors: [] };
const ok = (name, pass) => {
report.checks.push({ name, pass: !!pass });
assert(pass, name);
console.log("PASS", name);
};
const api = async (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,
});
assert(r.ok, await r.clone().text());
const t = await r.text();
return t ? JSON.parse(t) : null;
};
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const lastToast = (p) =>
p.evaluate(() => {
const els = document.querySelectorAll(".toast, [class*='toast']");
return els.length ? els[els.length - 1].textContent : "";
});
(async () => {
fs.mkdirSync(`${OUT}/${phase}`, { recursive: true });
const names = await api("/api/library/media");
const byName = Object.fromEntries(names.map((i) => [i.name, i.path]));
const free = byName["aaaa-free-upload.png"];
const plain = byName["plain-scratch-out.png"];
const held = byName["held-on-canvas.png"];
ok("seed: three visible library items", !!free && !!plain && !!held);
ok("seed: project-referenced file hidden from list", !names.some((i) => i.name === "hidden-project-ref.png"));
const browser = await chromium.launch({
channel: "chromium", // full build — headless shell lacks host libs
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: `${OUT}/${phase}/video` },
});
const p = await ctx.newPage();
p.on("pageerror", (e) => report.errors.push(e.stack));
p.setDefaultTimeout(12000);
await ctx.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-motion", "off");
});
await p.goto(WEB + "/library");
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor();
await pause(400);
await p.screenshot({ path: `${OUT}/${phase}/01-initial.png` });
// ── cancel leaves everything untouched ─────────────────────────────
await p.getByRole("button", { name: /查看 plain-scratch-out/ }).click();
await p.getByRole("button", { name: "删除此文件" }).click();
await p.getByRole("dialog").getByText("删除 1 个文件?").waitFor();
await p.screenshot({ path: `${OUT}/${phase}/02-confirm-dialog.png` });
await p.getByRole("dialog").getByRole("button", { name: "取消" }).click();
await pause(400);
ok("cancel: modal closed", !(await p.getByRole("dialog").count()));
ok("cancel: file untouched on disk", fs.existsSync(plain));
if (phase === "before") {
// ── defect demo: canvas-held file kept with merged vague reason ──
await p.getByRole("button", { name: /查看 held-on-canvas/ }).click();
await p.getByRole("button", { name: "删除此文件" }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(800);
const toast = await lastToast(p);
await p.screenshot({ path: `${OUT}/${phase}/03-before-held-toast.png` });
ok("before: canvas-held delete reports 0 deleted", /已删除 0 个文件/.test(toast));
ok("before: merged vague reason (defect)", /被项目使用或无法删除/.test(toast));
ok("before: file survives (protection works)", fs.existsSync(held));
} else {
// ── deletable file: cancel already proven; now confirm ──────────
await p.getByRole("button", { name: /查看 plain-scratch-out/ }).click();
await p.getByRole("button", { name: "删除此文件" }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(900);
const toast = await lastToast(p);
await p.screenshot({ path: `${OUT}/${phase}/03-deleted-single.png` });
ok("single delete: success toast", /已删除 1 个文件/.test(toast));
ok("single delete: file really gone from disk", !fs.existsSync(plain));
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor();
ok(
"single delete: gone from list after refresh",
!(await p.getByRole("button", { name: /查看 plain-scratch-out/ }).count()),
);
await p.reload();
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor();
ok(
"single delete: no resurrection after page reload",
!(await p.getByRole("button", { name: /查看 plain-scratch-out/ }).count()),
);
// ── canvas-held file: kept with the accurate reason ─────────────
await p.getByRole("button", { name: /查看 held-on-canvas/ }).click();
await p.getByRole("button", { name: "删除此文件" }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(900);
const heldToast = await lastToast(p);
await p.screenshot({ path: `${OUT}/${phase}/04-held-on-canvas.png` });
ok("held: nothing deleted reported", /未能删除 1 个文件/.test(heldToast));
ok("held: accurate on-canvas reason", /仍在画布中使用 1/.test(heldToast));
ok("held: actionable hint", /请先删除对应卡片/.test(heldToast));
ok("held: file survives on disk", fs.existsSync(held));
await p.getByRole("button", { name: /查看 held-on-canvas/ }).waitFor();
ok("held: still listed (kept, not hidden)", (await p.getByRole("button", { name: /查看 held-on-canvas/ }).count()) === 1);
// ── mixed batch: partial success with per-reason breakdown ──────
await p.getByRole("button", { name: "选择", exact: true }).click();
await p.getByRole("button", { name: /选择 aaaa-free-upload/, exact: true }).click();
await p.getByRole("button", { name: /选择 held-on-canvas/, exact: true }).click();
await p.getByRole("button", { name: "删除", exact: true }).click();
await p.getByRole("dialog").getByText("删除 2 个文件?").waitFor();
await p.screenshot({ path: `${OUT}/${phase}/05-batch-dialog.png` });
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(900);
const batchToast = await lastToast(p);
await p.screenshot({ path: `${OUT}/${phase}/06-batch-result.png` });
ok("batch: one deleted one kept", /已删除 1 个文件/.test(batchToast) && /保留 1 个/.test(batchToast));
ok("batch: kept reason named", /仍在画布中使用 1/.test(batchToast));
ok("batch: free file gone from disk", !fs.existsSync(free));
ok("batch: held file survives", fs.existsSync(held));
ok(
"batch: selection bar cleared after result",
!(await p.getByText("已选 1").count()),
);
// ── HTTP failure: readable error, modal open, retry succeeds ────
await p.reload();
await p.getByRole("button", { name: /查看 held-on-canvas/ }).waitFor();
await p.getByRole("button", { name: /查看 aaaa-free-upload/ }).waitFor().catch(() => {});
// seed one more deletable file through the API for the retry leg
const seeded = await (async () => {
const list = await api("/api/library/media");
return list.find((i) => i.name === "retry-upload.png");
})();
ok("retry: seed file present", !!seeded);
let blocked = 1;
await p.route("**/api/library/media", async (route) => {
if (route.request().method() === "DELETE" && blocked > 0) {
blocked--;
await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "隔离测试注入的暂时故障" }) });
return;
}
await route.continue();
});
await p.getByRole("button", { name: /查看 retry-upload/ }).click();
await p.getByRole("button", { name: "删除此文件" }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(600);
const errToast = await lastToast(p);
await p.screenshot({ path: `${OUT}/${phase}/07-error-toast.png` });
ok("failure: readable error toast", /删除请求失败/.test(errToast) && /隔离测试注入的暂时故障/.test(errToast));
ok("failure: no false success", !/已删除/.test(errToast));
ok("failure: file still on disk", fs.existsSync(seeded.path));
ok("failure: modal stays open for retry", (await p.getByRole("dialog").count()) === 1);
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(900);
const retryToast = await lastToast(p);
await p.screenshot({ path: `${OUT}/${phase}/08-retry-success.png` });
ok("retry: second confirm succeeds", /已删除 1 个文件/.test(retryToast));
ok("retry: file really gone", !fs.existsSync(seeded.path));
await p.unroute("**/api/library/media");
}
await ctx.close();
await browser.close();
fs.writeFileSync(`${OUT}/${phase}/report.json`, JSON.stringify(report, null, 2));
console.log(`HEL-872 ${phase}: ${report.checks.length} checks passed`);
})().catch((e) => {
report.errors.push(String(e));
fs.writeFileSync(`${OUT}/${phase}/report.json`, JSON.stringify(report, null, 2));
console.error(e);
process.exit(1);
});
+373
View File
@@ -0,0 +1,373 @@
// HEL-884 — real-page locate-and-delete loop regression (isolated data).
// Drives /library → kept-on-canvas occupancy modal → locate on the canvas
// (centered + highlighted card + back chip) → per-card delete with accurate
// impact text → release-only (file preserved) → orphan no longer blocks →
// running card blocked → nothing resurrects after reload.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
assert = require("node:assert/strict");
const OUT = "../evidence/hel884";
const WEB = "http://127.0.0.1:15274";
const report = { checks: [], errors: [] };
// HEL884_STRICT=0 records every failure instead of aborting at the first —
// used to capture the full before-fix failure table in one base-build run.
const STRICT = process.env.HEL884_STRICT !== "0";
const ok = (name, pass) => {
report.checks.push({ name, pass: !!pass });
console.log(pass ? "PASS" : "FAIL", name);
if (STRICT) assert(pass, name);
};
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const lastToast = (p) =>
p.evaluate(() => {
const els = document.querySelectorAll(".toast, [class*='toast']");
return els.length ? els[els.length - 1].textContent : "";
});
const usageModal = (p) => p.getByRole("dialog").filter({ hasText: "画布占用详情" });
(async () => {
fs.mkdirSync(OUT, { recursive: true });
const seeded = JSON.parse(fs.readFileSync(`${OUT}/seed-ids.json`, "utf8"));
const browser = await chromium.launch({
channel: "chromium", // full build — headless shell lacks host libs
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: `${OUT}/video` },
});
const p = await ctx.newPage();
p.on("pageerror", (e) => report.errors.push(e.stack));
p.setDefaultTimeout(15000);
await ctx.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-motion", "off");
});
const names = await (await fetch("http://127.0.0.1:18274/api/library/media")).json();
const byName = Object.fromEntries(names.map((i) => [i.name, i.path]));
for (const n of ["locate-main.png", "free-upload.png", "release-only.png",
"orphan-only.png", "running-held.png"])
ok(`seed: ${n} visible in library`, !!byName[n]);
// helper: open the occupancy modal for one file via a real delete attempt
async function openUsage(name) {
await p.getByRole("button", { name: `查看 ${name}` }).click();
await p.getByRole("button", { name: "删除此文件" }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
const modal = usageModal(p);
await modal.waitFor();
await pause(600);
return modal;
}
// ── 1. orphan-only reference never blocks deletion ─────────────────
await p.goto(WEB + "/library");
await p.getByRole("button", { name: /查看 orphan-only/ }).waitFor();
await p.getByRole("button", { name: "选择", exact: true }).click();
await p.getByRole("button", { name: /选择 orphan-only/, exact: true }).click();
await p.getByRole("button", { name: "删除", exact: true }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(900);
ok("orphan: deleted directly (dead reference not occupancy)",
/已删除 1 个文件/.test(await lastToast(p)) && !fs.existsSync(byName["orphan-only.png"]));
await p.getByRole("button", { name: "选择", exact: true }).click(); // leave select mode
// ── 2. kept-on-canvas → occupancy modal with the real locations ────
const modal1 = await openUsage("locate-main.png");
await modal1.getByText("沙盒画布A").waitFor();
await modal1.getByText("项目画布B").waitFor();
await modal1.getByText(/会话「定位沙盒会话」/).waitFor();
await modal1.getByText(/项目「占用项目」/).waitFor();
await modal1.getByText("主占用卡").waitFor();
await modal1.getByText("第二处占用").waitFor();
ok("modal: both usages listed with canvas+scope, not just the first",
(await modal1.locator(".usage-card").count()) === 2);
ok("modal: multi-reference keeps-file note",
(await modal1.getByText(/文件仍被其他卡片或项目使用/).count()) >= 1);
ok("kept: file survives on disk", fs.existsSync(byName["locate-main.png"]));
await p.screenshot({ path: `${OUT}/01-usage-modal.png` });
await modal1.getByRole("button", { name: "关闭对话框" }).click();
await pause(300);
ok("modal: closable", !(await usageModal(p).count()));
// ── 3. locate navigates to the right canvas & card, in view ────────
const modal2 = await openUsage("locate-main.png");
const sandboxRow = modal2.locator(".usage-card", { hasText: "沙盒画布A" });
await sandboxRow.getByRole("button", { name: /定位/ }).click();
await p.waitForURL(new RegExp(`/canvas/${seeded.canvas_a}`));
await p.getByTestId("locate-chip").waitFor();
await p.getByTestId("located-card").waitFor();
await pause(1300);
ok("locate: ?locate param consumed", !p.url().includes("locate="));
const inView = await p.evaluate(() => {
const el = document.querySelector("[data-testid='located-card']");
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight };
});
ok("locate: correct canvas reached", new RegExp(`/canvas/${seeded.canvas_a}`).test(p.url()));
ok("locate: card named 主占用卡 is the located one",
(await p.getByTestId("located-card").textContent()).includes("主占用卡"));
ok("locate: viewport-out card brought into view", inView && inView.x > 0 &&
inView.y > 0 && inView.x + inView.w < inView.vw && inView.y + inView.h < inView.vh);
// HEL-887: the gold ring is a static layer; the pulse breathes on a
// ::before feedback layer animated with transform/opacity only, and only
// under data-motion=full — calm/off (and reduced-motion → off) stay static.
const ring = await p.evaluate(async () => {
const el = document.querySelector("[data-testid='located-card']");
if (!el) return null;
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
// Mirror exactly what reduceMotion.apply() writes per mode — data-reduce=1
// carries a global animation:none kill-switch, so motion alone is not the
// whole state.
const setMode = (mode) => {
const root = document.documentElement;
root.dataset.motion = mode;
root.dataset.reduce = mode === "off" ? "1" : "0";
root.classList.toggle("calm", mode === "calm");
root.classList.toggle("reduce", mode === "off");
};
const staticRing = getComputedStyle(el).boxShadow;
const layerName = () => getComputedStyle(el, "::before").animationName;
setMode("off");
const off = { name: layerName(), opacity: getComputedStyle(el, "::before").opacity };
setMode("calm");
const calm = layerName();
setMode("full");
await raf();
await raf();
const full = layerName();
// Element.getAnimations() skips pseudo targets in Chromium;
// document.getAnimations() carries them with effect.pseudoElement set.
const anims = document.getAnimations()
.filter((a) => a.effect?.target === el && a.effect?.pseudoElement === "::before")
.map((a) => ({
name: a.animationName,
props: [...new Set(a.effect.getKeyframes().flatMap((f) =>
Object.keys(f).filter((k) =>
!["offset", "computedOffset", "easing", "composite"].includes(k))))].sort(),
}));
setMode("off");
return { staticRing, off, calm, full, anims };
});
ok("ring: static gold ring stays on the located card",
!!ring && /0px 0px 0px 3px/.test(ring.staticRing));
ok("ring: pulse layer runs only under motion=full",
!!ring && ring.full === "located-pulse" &&
ring.calm === "none" &&
ring.off.name === "none" && ring.off.opacity === "0");
ok("ring: pulse keyframes animate transform/opacity only",
!!ring && ring.anims.length === 1 &&
ring.anims[0].name === "located-pulse" &&
JSON.stringify(ring.anims[0].props) === JSON.stringify(["opacity", "transform"]));
await p.screenshot({ path: `${OUT}/02-canvas-located.png` });
// ── 4. back chip returns to the library; selection round-trip ──────
await p.getByTestId("locate-chip").getByRole("button", { name: "返回素材库" }).click();
await p.waitForURL(/\/library/);
await pause(500);
ok("back: usage modal not reopened", !(await usageModal(p).count()));
// selection survives a canvas round-trip (persisted selection)
await p.getByRole("button", { name: "选择", exact: true }).click();
await p.getByRole("button", { name: /选择 free-upload/, exact: true }).click();
await p.getByText("已选 1").waitFor();
await p.goto(`${WEB}/canvas/${seeded.canvas_b}`);
await p.getByTestId("titlecard").waitFor();
await p.goBack();
await p.getByRole("button", { name: /查看 free-upload/ }).waitFor();
await p.getByText("已选 1").waitFor();
ok("back: library selection restored after canvas round-trip",
(await p.getByText("已选 1").count()) === 1);
await p.getByRole("button", { name: "取消", exact: true }).click();
// ── 5. delete one card: accurate text, cancel unchanged, confirm ───
const modal3 = await openUsage("locate-main.png");
const projectRow = modal3.locator(".usage-card", { hasText: "项目画布B" });
await projectRow.getByRole("button", { name: "删除卡片…" }).click();
await modal3.getByText(/文件仍被其他卡片或项目使用/).first().waitFor();
await p.screenshot({ path: `${OUT}/03-confirm-keep.png` });
await modal3.getByRole("button", { name: "取消", exact: true }).click();
await pause(400);
ok("cancel: card untouched", (await modal3.getByText("第二处占用").count()) === 1);
await projectRow.getByRole("button", { name: "删除卡片…" }).click();
await modal3.getByRole("button", { name: "确认删除卡片" }).click();
await pause(1300);
ok("card delete: file kept toast (other reference alive)",
/卡片已删除,文件保留/.test(await lastToast(p)));
await modal3.getByText("第二处占用").waitFor({ state: "detached" });
ok("card delete: other usage still listed",
(await modal3.locator(".usage-card", { hasText: "沙盒画布A" }).count()) === 1);
ok("card delete: file survives on disk", fs.existsSync(byName["locate-main.png"]));
await p.screenshot({ path: `${OUT}/04-one-usage-left.png` });
// ── 6. last card: warn the file goes too, then it does ─────────────
const lastRow = modal3.locator(".usage-card", { hasText: "沙盒画布A" });
await lastRow.getByRole("button", { name: "删除卡片…" }).click();
await modal3.getByText(/同时删除文件/).first().waitFor();
await modal3.getByRole("button", { name: "确认删除卡片" }).click();
await pause(1500);
ok("last card: file deleted with the card",
/卡片已删除,文件已一并删除/.test(await lastToast(p)) &&
!fs.existsSync(byName["locate-main.png"]));
await usageModal(p).waitFor({ state: "detached" });
ok("loop closed: modal auto-dismissed (file gone from list)",
!(await usageModal(p).count()));
ok("loop closed: file gone from library list",
!(await p.getByRole("button", { name: /查看 locate-main/ }).count()));
// ── 7. release-only keeps the file, then library delete finishes ───
const modal4 = await openUsage("release-only.png");
await modal4.getByText("沙盒画布A").waitFor();
await modal4.getByRole("button", { name: "仅移除卡片" }).click();
await modal4.getByText(/原文件保留/).waitFor();
await p.screenshot({ path: `${OUT}/05-release-confirm.png` });
await modal4.getByRole("button", { name: "确认仅移除" }).click();
await pause(1300);
ok("release: card removed, file preserved",
/已仅移除卡片,原文件保留/.test(await lastToast(p)) &&
fs.existsSync(byName["release-only.png"]));
await modal4.getByText("当前没有画布卡片占用此文件了,现在可以删除。").waitFor();
await modal4.getByRole("button", { name: "删除此文件" }).click();
await pause(1200);
ok("release: freed file deletable in-modal",
/文件已删除/.test(await lastToast(p)) && !fs.existsSync(byName["release-only.png"]));
// ── 8. running card is visible but not processable ─────────────────
const modal5 = await openUsage("running-held.png");
await modal5.getByText("生成画布C").waitFor();
await modal5.getByText(/生成中,完成后才能处理这张卡/).waitFor();
ok("running: no delete/release action offered",
!(await modal5.getByRole("button", { name: "删除卡片…" }).count()) &&
!(await modal5.getByRole("button", { name: "仅移除卡片" }).count()));
ok("running: file survives", fs.existsSync(byName["running-held.png"]));
await p.screenshot({ path: `${OUT}/06-running-card.png` });
await modal5.getByRole("button", { name: "关闭对话框" }).click();
// ── 9. nothing resurrects after a full reload ──────────────────────
await p.reload();
await p.getByRole("button", { name: /查看 free-upload/ }).waitFor();
ok("reload: locate-main stays gone",
!(await p.getByRole("button", { name: /查看 locate-main/ }).count()));
ok("reload: release-only stays gone",
!(await p.getByRole("button", { name: /查看 release-only/ }).count()));
ok("reload: orphan-only stays gone",
!(await p.getByRole("button", { name: /查看 orphan-only/ }).count()));
ok("reload: untouched control file still listed",
(await p.getByRole("button", { name: /查看 free-upload/ }).count()) === 1);
await p.screenshot({ path: `${OUT}/07-final-library.png` });
// ── 10. canvas A no longer shows the deleted card after refresh ────
await p.goto(`${WEB}/canvas/${seeded.canvas_a}`);
await p.getByTestId("titlecard").waitFor();
await pause(1500);
ok("canvas: deleted card not resurrected",
!(await p.getByText("主占用卡").count()));
await p.screenshot({ path: `${OUT}/08-canvas-after.png` });
// ── 11. HEL-887 mixed delete: kept stays checked, deleted leaves ────
const pickedPaths = () => p.evaluate(() =>
[...document.querySelectorAll(".lt.picked")].map((el) =>
el.querySelector(".bd b")?.textContent ?? ""));
await p.goto(WEB + "/library");
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
await p.getByRole("button", { name: "选择", exact: true }).click();
await p.getByRole("button", { name: /选择 running-held/ }).click();
await p.getByRole("button", { name: /选择 free-upload/ }).click();
await p.getByText("已选 2", { exact: true }).waitFor();
// transport failure: the whole selection must survive for a retry
await p.route("**/api/library/media", async (route) => {
if (route.request().method() === "DELETE") await route.abort("failed");
else await route.continue();
});
await p.getByRole("button", { name: "删除", exact: true }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(700);
ok("retry: failed delete keeps every checked path",
/删除请求失败/.test(await lastToast(p)) &&
(await p.getByText("已选 2", { exact: true }).count()) === 1);
// retry the same modal: deleted leaves the selection, kept stays checked
await p.unroute("**/api/library/media");
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(1400);
ok("mixed: only the deleted path leaves the selection",
/已删除 1 个文件/.test(await lastToast(p)) &&
(await p.getByText("已选 1").count()) === 1 &&
!fs.existsSync(byName["free-upload.png"]) &&
fs.existsSync(byName["running-held.png"]));
report.mixedSelection = await pickedPaths();
// kept selection survives a canvas round-trip and a full refresh
await p.goto(`${WEB}/canvas/${seeded.canvas_b}`);
await p.getByTestId("titlecard").waitFor();
await p.goBack();
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
ok("mixed: kept selection restored after canvas round-trip",
(await p.getByText("已选 1").count()) === 1);
await p.reload();
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
ok("mixed: kept selection restored after full refresh",
(await p.getByText("已选 1").count()) === 1 &&
!(await p.getByRole("button", { name: /查看 free-upload/ }).count()));
if (await p.getByRole("button", { name: "取消", exact: true }).count())
await p.getByRole("button", { name: "取消", exact: true }).click();
// ── 12. HEL-887 invalid-only references still allow in-modal delete ─
const modal6 = await openUsage("mixed-free.png");
await modal6.getByText("混合占用卡").waitFor();
ok("mixed-free: live card listed with the invalid-reference note",
(await modal6.getByText(/另有 1 条已失效引用/).count()) === 1);
await modal6.getByRole("button", { name: "仅移除卡片" }).click();
await modal6.getByRole("button", { name: "确认仅移除" }).click();
await pause(1400);
// last live card released → only a dead reference remains: the direct
// delete must still be offered (invalid_total must not block it)
const freed = await modal6.getByText("当前没有画布卡片占用此文件了,现在可以删除。")
.waitFor({ timeout: 4000 }).then(() => true).catch(() => false);
ok("mixed-free: direct delete offered with only invalid refs left",
freed &&
(await modal6.getByRole("button", { name: "删除此文件" }).count()) === 1 &&
(await modal6.getByText(/另有 1 条已失效引用/).count()) === 1);
await p.screenshot({ path: `${OUT}/09-invalid-only-direct.png` });
if ((await modal6.getByRole("button", { name: "删除此文件" }).count()))
await modal6.getByRole("button", { name: "删除此文件" }).click();
await pause(1300);
ok("mixed-free: file really deleted, invalid refs don't resurrect",
/文件已删除/.test(await lastToast(p)) &&
!fs.existsSync(byName["mixed-free.png"]));
await usageModal(p).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
// ── 13. HEL-887 cross-200-batch delete with a kept file in the batch ─
await p.goto(WEB + "/library");
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
const remaining = (await (await fetch("http://127.0.0.1:18274/api/library/media")).json()).length;
await p.getByRole("button", { name: "选择本组" }).click();
await p.getByText(`已选 ${remaining}`, { exact: true }).waitFor();
ok("batch: 206-path selection crosses the 200-path chunk boundary",
remaining === 206);
await p.getByRole("button", { name: "删除", exact: true }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(2200);
ok("batch: 205 files deleted across both chunks",
/已删除 205 个文件/.test(await lastToast(p)) &&
!fs.existsSync(byName["sel-000.png"]) && !fs.existsSync(byName["sel-204.png"]));
ok("batch: kept file stays checked after the cross-batch delete",
(await p.getByText("已选 1").count()) === 1 &&
fs.existsSync(byName["running-held.png"]));
report.batchSelection = await pickedPaths();
ok("batch: library lists only the kept file afterwards",
(await (await fetch("http://127.0.0.1:18274/api/library/media")).json()).length === 1);
await p.screenshot({ path: `${OUT}/10-final-library.png` });
await ctx.close();
await browser.close();
fs.writeFileSync(`${OUT}/report.json`, JSON.stringify(report, null, 2));
console.log(`HEL-884: ${report.checks.filter((c) => c.pass).length}/${report.checks.length} checks passed`);
if (report.checks.some((c) => !c.pass)) process.exit(1);
})().catch((e) => {
report.errors.push(String(e));
fs.writeFileSync(`${OUT}/report.json`, JSON.stringify(report, null, 2));
console.error(e);
process.exit(1);
});
+148
View File
@@ -0,0 +1,148 @@
"""HEL-872 — own a loopback-only API + static web for one foreground UI check.
Seeds an isolated library (plain upload, plain scratch output, canvas-held
scratch output, project-referenced upload) and drives the real /library page
via e2e/hel872.cjs. HEL872_PHASE=before|after picks which web build to serve;
both phases run against the SAME backend code (base backend for before, fixed
backend for after — the runner is executed from the tree being tested).
"""
import json, os, signal, socket, sqlite3, subprocess, sys, tempfile, time, urllib.request
from pathlib import Path
root = Path(__file__).resolve().parents[1]
os.chdir(root)
phase = os.environ.get("HEL872_PHASE", "after")
build_dir = os.environ.get("HEL872_BUILD", f"calliope-web/build{'-base' if phase == 'before' else ''}")
out = root.parent / "evidence/hel872"
out.mkdir(parents=True, exist_ok=True)
API_PORT, WEB_PORT = 18272, 15272
for port in (API_PORT, WEB_PORT):
with socket.socket() as s:
assert s.connect_ex(("127.0.0.1", port)) != 0, f"Port occupied: {port}"
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da636460f85f0f0002870180eb47ba920000000049454e44"
"ae426082"
) # canonical 1x1 RGBA PNG fixture
processes, handles, cleanup = [], [], []
with tempfile.TemporaryDirectory(prefix="hel872-", dir=out) as tmp:
tmp = Path(tmp)
env = {**os.environ, "PYTHONPATH": str(root / "calliope-backend/src"),
"HEL872_DATA": str(tmp), "HEL872_BUILD": build_dir,
"LD_LIBRARY_PATH": str(out / "browser-libs"),
"PLAYWRIGHT_MODULE_PATH": "/home/xiaomo/.npm-global/lib/node_modules/playwright"}
api_code = f"""
import os
from pathlib import Path
from calliope import config
root = Path({str(tmp)!r})
config.CONFIG_FILE = root / 'settings.json'
config.settings = config.Settings(
data_dir=root / 'data', assets_dir=root / 'data/assets',
llm_base_url='http://127.0.0.1:1/v1', llm_model='isolated',
comfyui_base_url='http://127.0.0.1:1', dry_run=False)
config.settings.ensure_storage_dirs()
config.settings.ensure_llm_profiles()
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(), host='127.0.0.1', port={API_PORT})
"""
web_code = """
const http=require('node:http'),fs=require('node:fs'),path=require('node:path');
const build=path.resolve(process.env.HEL872_BUILD);
http.createServer((req,res)=>{
if(req.url.startsWith('/api/')){
const q=http.request('http://127.0.0.1:%d'+req.url,{method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});
q.on('error',()=>{res.statusCode=502;res.end()});res.on('close',()=>q.destroy());req.pipe(q);return}
let file=path.join(build,new URL(req.url,'http://localhost').pathname);
if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');
res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.jpg':'image/jpeg','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');
fs.createReadStream(file).pipe(res);
}).listen(%d,'127.0.0.1');
""" % (API_PORT, WEB_PORT)
def seed():
base = f"http://127.0.0.1:{API_PORT}"
def post(p, body):
req = urllib.request.Request(base + p, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req, timeout=10))
db = sqlite3.connect(tmp / "data" / "calliope.db")
db.execute("INSERT INTO projects (title, idea, status) VALUES ('Playground Scratch', NULL, 'system')")
db.commit()
pid = db.execute("SELECT id FROM projects WHERE status='system'").fetchone()[0]
up = tmp / "data" / "assets" / "uploads"
scratch_img = tmp / "data" / "assets" / str(pid) / "image"
up.mkdir(parents=True, exist_ok=True)
scratch_img.mkdir(parents=True, exist_ok=True)
(up / "aaaa-free-upload.png").write_bytes(PNG)
(up / "retry-upload.png").write_bytes(PNG)
(up / "hidden-project-ref.png").write_bytes(PNG)
(scratch_img / "plain-scratch-out.png").write_bytes(PNG)
held = scratch_img / "held-on-canvas.png"
held.write_bytes(PNG)
db.execute("INSERT INTO canvas (id, title) VALUES (1, '隔离沙盒画布')")
db.execute(
"INSERT INTO canvas_node (canvas_id, type, title, artifact_path, status)"
" VALUES (1, 'image', '画布卡片', ?, 'done')", (str(held),))
db.commit()
proj = post("/api/projects", {"title": "隔离引用项目", "idea": "hel872"})
db.execute(
"INSERT INTO characters (project_id, name, appearance, portrait_path)"
" VALUES (?, '隔离角色', 'x', ?)", (proj["id"], str(up / "hidden-project-ref.png")))
db.commit()
db.close()
listed = json.load(urllib.request.urlopen(base + "/api/library/media", timeout=10))
print("SEED listed:", [i["name"] for i in listed], flush=True)
try:
for name, cmd in [("api", [sys.executable, "-c", api_code]), ("web", ["node", "-e", web_code])]:
h = (out / f"{phase}-{name}.log").open("w")
handles.append(h)
processes.append(subprocess.Popen(cmd, env=env, stdout=h, stderr=subprocess.STDOUT))
for url in (f"http://127.0.0.1:{API_PORT}/api/health", f"http://127.0.0.1:{WEB_PORT}"):
for attempt in range(150):
try:
urllib.request.urlopen(url, timeout=1)
break
except Exception:
if any(p.poll() is not None for p in processes):
raise RuntimeError(f"Service failed: {name}")
if attempt == 149:
raise
time.sleep(0.1)
seed()
# Minimal API-level repro, logged raw for the evidence bundle:
# list shows the canvas-held file, delete keeps it with the reason.
base = f"http://127.0.0.1:{API_PORT}"
listed = json.load(urllib.request.urlopen(base + "/api/library/media", timeout=10))
held_path = next(i["path"] for i in listed if i["name"] == "held-on-canvas.png")
req = urllib.request.Request(
base + "/api/library/media", method="DELETE",
data=json.dumps({"paths": [held_path]}).encode(),
headers={"Content-Type": "application/json"})
body = json.load(urllib.request.urlopen(req, timeout=10))
(out / f"{phase}-api-repro.json").write_text(json.dumps(
{"listed_held": True, "delete_response": body}, ensure_ascii=False, indent=2))
print("API REPRO:", json.dumps(body, ensure_ascii=False), flush=True)
subprocess.run(["node", "e2e/hel872.cjs"], env=env, check=True)
finally:
daemon = json.loads(subprocess.check_output(["multica", "daemon", "status", "--output", "json"]))["pid"]
for p in processes:
assert p.pid != daemon, "refusing to kill the multica daemon"
if p.poll() is None:
p.send_signal(signal.SIGTERM)
for p in processes:
try:
p.wait(timeout=10)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
cleanup.append({"pid": p.pid, "exit": p.returncode, "daemon_pid": daemon})
for h in handles:
h.close()
(out / f"{phase}-cleanup.json").write_text(json.dumps(
{"children": cleanup, "isolated_data_removed": not Path(tmp).exists()}, indent=2))
print("CLEANUP", cleanup, "isolated data removed:", not Path(tmp).exists(), flush=True)
+272
View File
@@ -0,0 +1,272 @@
"""HEL-884 — isolated loopback API + static web for the locate-and-delete loop.
Seeds a self-contained library (uploads + scratch outputs), two canvases
(sandbox session canvas + project canvas) sharing one file, an orphan
node whose canvas row is gone, a running-job card, a release-only
file, a live+orphan mixed file, and a 205-file batch that crosses the
200-path delete chunk — then drives the real /library and /canvas pages
via e2e/hel884.cjs.
No real LLM/GPU/paid service is called; every byte of media is created here.
"""
import contextlib, json, os, signal, socket, sqlite3, subprocess, sys, tempfile, time
import urllib.error, urllib.parse, urllib.request
from pathlib import Path
root = Path(__file__).resolve().parents[1]
os.chdir(root)
phase = os.environ.get("HEL884_PHASE", "after")
build_dir = os.environ.get("HEL884_BUILD", "calliope-web/build")
out = root.parent / "evidence/hel884"
out.mkdir(parents=True, exist_ok=True)
API_PORT, WEB_PORT = 18274, 15274
for port in (API_PORT, WEB_PORT):
with socket.socket() as s:
assert s.connect_ex(("127.0.0.1", port)) != 0, f"Port occupied: {port}"
# Full-chromium host libs (same shared set previous HEL e2e runs used).
BROWSER_LIBS = os.environ.get("HEL884_BROWSER_LIBS", "")
if not BROWSER_LIBS:
for cand in (
root.parent / "browser-libs",
Path("/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-736-8f7b598afe7a/evidence/browser-libs"),
Path("/home/xiaomo/multica_workspaces/98eedba9-43ac-4b6d-8dc0-af23464d222e/f33c4fd4147e/workdir/browser-libs"),
):
if (cand / "libatk-1.0.so.0").exists():
BROWSER_LIBS = str(cand)
break
assert BROWSER_LIBS, "no browser-libs directory found for full chromium"
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da636460f85f0f0002870180eb47ba920000000049454e44"
"ae426082"
) # canonical 1x1 RGBA PNG fixture
processes, handles, cleanup = [], [], []
def record_cleanup() -> None:
# HEL-887: recorded only AFTER the TemporaryDirectory context exited
# (the isolated dir is really gone) and after every exact child PID was
# waited on. The old in-finally write raced the context exit and always
# logged isolated_data_removed=false. The daemon PID is compared first;
# nothing is ever killed by process name.
isolated = state.get("tmp")
removed = bool(isolated) and not isolated.exists()
(out / f"{phase}-cleanup.json").write_text(json.dumps(
{"children": cleanup, "isolated_dir": str(isolated or ""),
"isolated_data_removed": removed}, indent=2))
print("CLEANUP", cleanup, "isolated data removed:", removed, flush=True)
state: dict = {}
with contextlib.ExitStack() as stack:
# LIFO: the TemporaryDirectory exits (and removes the dir) BEFORE the
# cleanup record callback runs, on success and on any exception.
stack.callback(record_cleanup)
tmp = Path(stack.enter_context(
tempfile.TemporaryDirectory(prefix="hel884-", dir=out)))
state["tmp"] = tmp
env = {**os.environ, "PYTHONPATH": str(root / "calliope-backend/src"),
"HEL884_DATA": str(tmp), "HEL884_BUILD": build_dir,
"LD_LIBRARY_PATH": BROWSER_LIBS,
"PLAYWRIGHT_MODULE_PATH": "/home/xiaomo/.npm-global/lib/node_modules/playwright"}
api_code = f"""
import os
from pathlib import Path
from calliope import config
root = Path({str(tmp)!r})
config.CONFIG_FILE = root / 'settings.json'
config.settings = config.Settings(
data_dir=root / 'data', assets_dir=root / 'data/assets',
llm_base_url='http://127.0.0.1:1/v1', llm_model='isolated',
comfyui_base_url='http://127.0.0.1:1', dry_run=False)
config.settings.ensure_storage_dirs()
config.settings.ensure_llm_profiles()
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(), host='127.0.0.1', port={API_PORT})
"""
web_code = """
const http=require('node:http'),fs=require('node:fs'),path=require('node:path');
const build=path.resolve(process.env.HEL884_BUILD);
http.createServer((req,res)=>{
if(req.url.startsWith('/api/')){
const q=http.request('http://127.0.0.1:%d'+req.url,{method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});
q.on('error',()=>{res.statusCode=502;res.end()});res.on('close',()=>q.destroy());req.pipe(q);return}
let file=path.join(build,new URL(req.url,'http://localhost').pathname);
if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');
res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.jpg':'image/jpeg','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');
fs.createReadStream(file).pipe(res);
}).listen(%d,'127.0.0.1');
""" % (API_PORT, WEB_PORT)
def seed():
base = f"http://127.0.0.1:{API_PORT}"
def post(p, body):
req = urllib.request.Request(base + p, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req, timeout=10))
# API first: the projects POST must not race an open sqlite
# transaction from the seed connection (database-is-locked).
proj = post("/api/projects", {"title": "占用项目", "idea": "hel884"})
db = sqlite3.connect(tmp / "data" / "calliope.db")
db.execute("INSERT INTO projects (title, idea, status)"
" VALUES ('Playground Scratch', NULL, 'system')")
db.commit()
pid = db.execute("SELECT id FROM projects WHERE status='system'").fetchone()[0]
up = tmp / "data" / "assets" / "uploads"
scratch_img = tmp / "data" / "assets" / str(pid) / "image"
up.mkdir(parents=True, exist_ok=True)
scratch_img.mkdir(parents=True, exist_ok=True)
# the tracked file (scratch output) + two control uploads
(scratch_img / "locate-main.png").write_bytes(PNG)
(up / "free-upload.png").write_bytes(PNG)
(up / "release-only.png").write_bytes(PNG)
(up / "orphan-only.png").write_bytes(PNG)
(scratch_img / "running-held.png").write_bytes(PNG)
# sandbox session canvas — card parked far outside the default viewport
cur = db.execute("INSERT INTO agent_sessions (title) VALUES ('定位沙盒会话')")
sid = cur.lastrowid
cur = db.execute("INSERT INTO canvas (agent_session_id, title) VALUES (?, '沙盒画布A')", (sid,))
canvas_a = cur.lastrowid
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '主占用卡', 2400, 1600, ?, 'done')",
(canvas_a, str(scratch_img / "locate-main.png")))
node_a = db.execute("SELECT id FROM canvas_node WHERE canvas_id=?",
(canvas_a,)).fetchone()[0]
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '仅移除卡', 300, 300, ?, 'done')",
(canvas_a, str(up / "release-only.png")))
# project canvas — second reference to the SAME file
cur = db.execute("INSERT INTO canvas (project_id, title) VALUES (?, '项目画布B')", (proj["id"],))
canvas_b = cur.lastrowid
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '第二处占用', 80, 80, ?, 'done')",
(canvas_b, str(scratch_img / "locate-main.png")))
node_b = db.execute("SELECT id FROM canvas_node WHERE canvas_id=?",
(canvas_b,)).fetchone()[0]
# running-job card on its own file (blocked from processing)
cur = db.execute("INSERT INTO jobs (project_id, kind, status) VALUES (?, 'image', 'running')", (pid,))
job_id = cur.lastrowid
cur = db.execute("INSERT INTO agent_sessions (title) VALUES ('生成会话')")
sid_c = cur.lastrowid
cur = db.execute("INSERT INTO canvas (agent_session_id, title) VALUES (?, '生成画布C')", (sid_c,))
canvas_c = cur.lastrowid
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, job_id, status)"
" VALUES (?, 'image', '生成中卡片', 80, 80, ?, ?, 'running')",
(canvas_c, str(scratch_img / "running-held.png"), job_id))
node_c = db.execute("SELECT id FROM canvas_node WHERE canvas_id=?",
(canvas_c,)).fetchone()[0]
# orphan: canvas row removed with FK off, node left behind
cur = db.execute("INSERT INTO canvas (agent_session_id, title) VALUES (?, '幽灵画布')", (sid,))
ghost = cur.lastrowid
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '孤儿卡', 0, 0, ?, 'done')",
(ghost, str(up / "orphan-only.png")))
# HEL-887: mixed-free carries one LIVE card plus one orphan reference
# — releasing the live card leaves only invalid references behind,
# which must still offer the in-modal direct delete.
(up / "mixed-free.png").write_bytes(PNG)
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '混合占用卡', 700, 700, ?, 'done')",
(canvas_a, str(up / "mixed-free.png")))
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '混合孤儿卡', 0, 0, ?, 'done')",
(ghost, str(up / "mixed-free.png")))
# HEL-887: 205 directly-seeded files — one delete attempt over the
# 200-path chunk boundary with a kept file in the second chunk.
for i in range(205):
(up / f"sel-{i:03d}.png").write_bytes(PNG)
db.commit()
db.execute("PRAGMA foreign_keys = OFF")
db.execute("DELETE FROM canvas WHERE id = ?", (ghost,))
db.commit()
db.close()
ids = {"canvas_a": canvas_a, "canvas_b": canvas_b, "canvas_c": canvas_c,
"node_a": node_a, "node_b": node_b, "node_c": node_c,
"session": sid, "scratch_pid": pid}
(out / "seed-ids.json").write_text(json.dumps(ids, ensure_ascii=False, indent=2))
print("SEED", ids, flush=True)
try:
for name, cmd in [("api", [sys.executable, "-c", api_code]), ("web", ["node", "-e", web_code])]:
h = (out / f"{phase}-{name}.log").open("w")
handles.append(h)
processes.append(subprocess.Popen(cmd, env=env, stdout=h, stderr=subprocess.STDOUT))
for url in (f"http://127.0.0.1:{API_PORT}/api/health", f"http://127.0.0.1:{WEB_PORT}"):
for attempt in range(150):
try:
urllib.request.urlopen(url, timeout=1)
break
except Exception:
if any(p.poll() is not None for p in processes):
raise RuntimeError(f"Service failed: {name}")
if attempt == 149:
raise
time.sleep(0.1)
seed()
# API-level evidence, raw JSON into the bundle:
base = f"http://127.0.0.1:{API_PORT}"
listed = json.load(urllib.request.urlopen(base + "/api/library/media", timeout=10))
def path_of(name):
return next(i["path"] for i in listed if i["name"] == name)
usage = json.load(urllib.request.urlopen(
base + f"/api/library/media/usage?path={urllib.parse.quote(path_of('locate-main.png'))}",
timeout=10))
orphan_usage = json.load(urllib.request.urlopen(
base + f"/api/library/media/usage?path={urllib.parse.quote(path_of('orphan-only.png'))}",
timeout=10))
mixed_usage = json.load(urllib.request.urlopen(
base + f"/api/library/media/usage?path={urllib.parse.quote(path_of('mixed-free.png'))}",
timeout=10))
req = urllib.request.Request(
base + "/api/library/media", method="DELETE",
data=json.dumps({"paths": [path_of("orphan-only.png")]}).encode(),
headers={"Content-Type": "application/json"})
orphan_delete = json.load(urllib.request.urlopen(req, timeout=10))
# running-job card refuses direct delete (409) — no resurrection door
ids = json.loads((out / "seed-ids.json").read_text())
req = urllib.request.Request(
base + f"/api/canvas/{ids['canvas_c']}/nodes/{ids['node_c']}", method="DELETE")
try:
urllib.request.urlopen(req, timeout=10)
running_guard = {"status": 200, "body": "UNEXPECTED"}
except urllib.error.HTTPError as exc:
running_guard = {"status": exc.code, "body": exc.read().decode()[:200]}
evidence = {
"listed_names": [i["name"] for i in listed],
"usage_locate_main": usage,
"usage_orphan_only": orphan_usage,
"usage_mixed_free": mixed_usage,
"orphan_delete": orphan_delete,
"running_card_direct_delete": running_guard,
}
(out / f"{phase}-api-evidence.json").write_text(
json.dumps(evidence, ensure_ascii=False, indent=2))
print("API EVIDENCE:", json.dumps(
{k: evidence[k] for k in ("orphan_delete", "running_card_direct_delete")},
ensure_ascii=False), flush=True)
# The evidence leg above really deleted orphan-only.png; re-create it
# so the browser driver starts from the same seeded state (the orphan
# canvas_node row still points at this path — recreating the file
# restores the scenario exactly).
Path(path_of("orphan-only.png")).write_bytes(PNG)
subprocess.run(["node", "e2e/hel884.cjs"], env=env, check=True)
finally:
daemon = json.loads(subprocess.check_output(["multica", "daemon", "status", "--output", "json"]))["pid"]
for p in processes:
assert p.pid != daemon, "refusing to kill the multica daemon"
if p.poll() is None:
p.send_signal(signal.SIGTERM)
for p in processes:
try:
p.wait(timeout=10)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
cleanup.append({"pid": p.pid, "exit": p.returncode, "daemon_pid": daemon})
for h in handles:
h.close()
+13
View File
@@ -0,0 +1,13 @@
HEL-871 已有素材点击预览回归(在仓库根目录运行)。
- `npm --prefix calliope-web ci --no-audit --no-fund`
- `npm --prefix calliope-web run check`(必须零错零警)、`npm --prefix calliope-web run lint`、`npm --prefix calliope-web test`、`npm --prefix calliope-web run build`
- `python3 scripts/hel871/capture.py --backend-python <已有项目依赖的 Python 3.11+> --output <隔离证据目录>`
编排 Python 需已有 Playwright/Pillow;浏览器系统库可通过 `HEL871_BROWSER_LIBS` 指定。后端 Python 使用已有 FastAPI/uvicorn 及项目依赖;不引入新产品依赖。测试占用仅本机 18271/15271,先检查端口空闲,创建临时数据目录,结束时比对 Multica daemon 后终止精确子进程并删除临时数据。ComfyUI/LLM 指向不可用回环端口、队列暂停;不会生成。
`--before --build <基线构建目录>` 可对固定 base 构建采集相同四主题三窗口的底栏/管理弹层,并复现缩略图误开文件选择。默认对当前构建执行实际上传、预览、缩放、焦点圈定/返回、关闭再看、替换、删除、格式拒绝、坏图片、真实 H3 多参考和首尾帧、切工作流、媒体组及单视频/音频原生播放器、无输入工作流、试运行弹层与素材库共享灯箱。上传走真实 HTTP,前端不拦截响应;媒体组为后端既有测试形状,不声称真实供应商生成能力。
产物包含原始截图、Playwright trace/video、HTTP 服务日志及检查 JSON。矩阵只覆盖本次受影响的底栏/管理素材与灯箱;不重拍全站。before/after 并排附件用于核对布局保留,新增预览状态另附。模板媒体为本次合成色卡,非用户素材。
公共组件影响:`Drawer` 接入与 Modal 一致的 modalLayers,避免内层预览 Esc 同时关闭试运行抽屉,并保留嵌套滚动锁和焦点;实际验证第二次 Esc 单独关闭抽屉。`MediaSlot` 仅 SlotTray 使用;`RefImageGrid` 同时用于工作流试运行。`ImageLightbox` 的键盘层级与焦点修复也作用于素材库、创作台结果/Inspector、智能体成果、画布与搭景 CaptureStrip;图片/视频渲染不改,替换按钮仅输入素材调用方显式传入时显示。此卡实际挂载回归覆盖输入槽、试运行与素材库;其余入口通过前端全量 check/lint/build 及共用灯箱回归覆盖,未分别创建业务数据重走整页。
+344
View File
@@ -0,0 +1,344 @@
"""HEL-871: real mounted UI/HTTP regression in disposable loopback-only services."""
import argparse
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.request
import wave
from PIL import Image, ImageDraw
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
p = argparse.ArgumentParser()
p.add_argument('--backend-python', required=True)
p.add_argument('--output', type=Path, required=True)
p.add_argument('--build', type=Path, default=ROOT/'calliope-web/build')
p.add_argument('--before', action='store_true')
a = p.parse_args()
out = a.output.resolve()
out.mkdir(parents=True, exist_ok=True)
phase = 'before' if a.before else 'after'
report = {'phase': phase, 'checks': [], 'errors': [], 'choosers': 0, 'writes': []}
def check(name, value=True):
report['checks'].append({'name': name, 'pass': bool(value)})
assert value, name
print('PASS', name, flush=True)
def verify(page, request, runtime):
def api(path, body):
r = request.post('http://127.0.0.1:18271/api'+path, data=body)
assert r.ok, r.text()
return r.json()
fixtures = ROOT/'calliope-backend/tests/fixtures/comfy_native'
# User-supplied real graphs for multi-reference and first/last frames.
graphs = {name: json.loads((fixtures/file).read_text()) for name, file in [
('refs', 'h3_ref2va_05_API.json'), ('frames', 'h3_i2v_04_API.json')]}
graphs['image'] = {
'1': {'class_type': 'PrimitiveStringMultiline', 'inputs': {'value': '隔离预览测试'}, '_meta': {'title': 'Prompt (Input:prompt)'}},
'2': {'class_type': 'LoadImage', 'inputs': {'image': ''}, '_meta': {'title': 'Image (Input:image)'}},
'3': {'class_type': 'LoadImageMask', 'inputs': {'image': '', 'channel': 'red'}, '_meta': {'title': 'Mask (Input:mask)'}},
'4': {'class_type': 'SaveImage', 'inputs': {'images': ['2', 0]}}
}
graphs['text'] = {'1': graphs['image']['1'], '4': {'class_type': 'SaveImage', 'inputs': {'images': ['1', 0]}}}
# Same generic group shape as backend test_media_groups; no generation claim.
graphs['media'] = {
'1': graphs['image']['1'],
'131': {'class_type': 'MiniMaxH3ReferenceToVideo', 'inputs': {
'prompt': ['1', 0], 'ref_videos.ref_video_0': ['902', 0], 'ref_audios.ref_audio_0': ['903', 0]}},
'902': {'class_type': 'LoadVideo', 'inputs': {'file': 'ref.mp4', 'force_rate': 0}},
'903': {'class_type': 'LoadAudio', 'inputs': {'audio': 'ref.wav'}},
'92': {'class_type': 'SaveVideo', 'inputs': {'video': ['131', 0]}}
}
graphs['media']['1'] = {'class_type': 'PrimitiveStringMultiline', 'inputs': {'value': '隔离媒体预览'}}
graphs['singles'] = {
'1': graphs['image']['1'],
'902': {'class_type': 'LoadVideo', 'inputs': {'file': ''}, '_meta': {'title': 'Video (Input:video)'}},
'903': {'class_type': 'LoadAudio', 'inputs': {'audio': ''}, '_meta': {'title': 'Audio (Input:audio)'}},
'92': {'class_type': 'SaveVideo', 'inputs': {'video': ['902', 0]}}
}
ids = {}
for name, graph in graphs.items():
result = api('/workflows', {'name': 'HEL871 '+name, 'kind': 'image' if name in ['image', 'text'] else 'video', 'workflow_json': graph})
ids[name] = result['id']
(out/'workflows.json').write_text(json.dumps(ids, indent=2))
images = []
for n, color in enumerate(['#247a9b', '#e0ab4d', '#9862ac']):
f = runtime/f'preview-{n}.png'
im = Image.new('RGB', (960, 640), color)
ImageDraw.Draw(im).text((90, 180), f'HEL-871 REFERENCE {n+1}', fill='white')
im.save(f)
images.append(str(f))
audio = runtime/'preview.wav'
with wave.open(str(audio), 'wb') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(8000); w.writeframes(b'\0\0'*16000)
bad = runtime/'broken.png'; bad.write_bytes(b'not an image')
text = runtime/'unsupported.txt'; text.write_text('rejected')
video = str(ROOT/'e2e/tiny.mp4')
def goto(name):
page.goto(f'http://127.0.0.1:15271/create?kind={"image" if name in ["image", "text"] else "video"}&wf={ids[name]}')
page.locator('#desk-prompt').wait_for()
page.wait_for_timeout(200)
def upload(slot, files):
with page.expect_file_chooser() as fc:
slot.locator('button.add').click()
fc.value.set_files(files)
expect(slot.locator('.a-tile-hit')).to_have_count(len(files) if isinstance(files, list) else 1)
expect(slot.locator('.a-tile-ring')).to_have_count(0)
def close():
page.keyboard.press('Escape')
expect(page.locator('.backdrop')).to_have_count(0)
def shot(name, loc=None):
(loc or page).screenshot(path=str(out/f'{name}.png'))
def preview(hit, kind='image', keyboard=None):
before = report['choosers']
hit.focus()
if keyboard: page.keyboard.press(keyboard)
else: hit.click()
expect(page.locator('.backdrop')).to_be_visible()
check(f'{kind} preview without picker', report['choosers'] == before)
return page.locator('.backdrop')
goto('image')
page.locator('#desk-prompt').fill('保留提示词与参数 · HEL871')
slots = page.locator('.media-summary .a-slot')
check('image and mask exactly two wired slots', slots.count() == 2)
upload(slots.nth(0), images[0]); upload(slots.nth(1), images[1])
check('empty slot uses actual upload endpoint', any('/playground/uploads' in x for x in report['writes']))
for width, height in [(1440, 900), (1366, 768), (1280, 720)]:
page.set_viewport_size({'width': width, 'height': height})
for theme in ['night', 'day', 'grade', 'bluehour']:
page.evaluate('(v)=>document.documentElement.dataset.theme=v', theme)
page.wait_for_timeout(80)
name = f'{theme}-{width}'
shot(name+'-compact', page.locator('.media-summary'))
if not a.before:
hit = slots.nth(0).locator('.a-tile-hit')
box = preview(hit, keyboard='Enter')
shot(name+'-preview')
box.get_by_role('button', name='100%', exact=True).click()
check(name+' toolbar zoom stays open', box.locator('.frame.zoomed').count() == 1)
close()
check(name+' close restores focus', hit.evaluate('(e)=>document.activeElement===e'))
page.get_by_role('button', name='管理素材', exact=True).click()
manager = page.locator('.modal-panel')
expect(manager).to_be_visible()
shot(name+'-manager', manager)
if not a.before:
hit = manager.locator('.a-tile-hit').nth(1)
preview(hit, keyboard='Space')
expect(page.locator('.backdrop img')).to_have_attribute('src', manager.locator('.a-tile-hit img').nth(1).get_attribute('src'))
close()
check(name+' escape only closes top preview', manager.is_visible() and hit.evaluate('(e)=>document.activeElement===e'))
page.keyboard.press('Escape')
expect(manager).to_have_count(0)
if a.before:
hit = slots.nth(0).locator('.a-tile-hit')
with page.expect_file_chooser(): hit.click()
check('baseline bug reproduced: filled thumbnail opens file chooser')
return
page.set_viewport_size({'width': 1440, 'height': 900})
hit = slots.nth(0).locator('.a-tile-hit')
page.wait_for_timeout(700)
draft = page.evaluate('JSON.stringify(localStorage)')
original = hit.locator('img').get_attribute('src')
box = preview(hit)
box.get_by_role('button', name='关闭预览', exact=True).click()
expect(box).to_have_count(0)
box = preview(hit)
# Trap focus across both ends.
box.locator('button, a').first.focus(); page.keyboard.press('Shift+Tab')
check('reverse tab trapped', box.evaluate('(e)=>e.contains(document.activeElement)'))
page.keyboard.press('Tab')
check('forward tab trapped', box.evaluate('(e)=>e.contains(document.activeElement)'))
close()
check('preview preserves prompt', page.locator('#desk-prompt').input_value() == '保留提示词与参数 · HEL871')
check('preview does not save draft or reorder values', page.evaluate('JSON.stringify(localStorage)') == draft and hit.locator('img').get_attribute('src') == original)
box = preview(hit)
with page.expect_file_chooser() as fc: box.get_by_role('button', name='替换', exact=True).click()
fc.value.set_files(images[2])
expect(hit.locator('img')).not_to_have_attribute('src', original)
check('explicit single replacement uploads new image')
n = report['choosers']
slots.nth(0).locator('.a-tile-x').click()
expect(slots.nth(0).locator('button.add')).to_be_visible()
check('remove opens neither preview nor picker', page.locator('.backdrop').count() == 0 and report['choosers'] == n)
with page.expect_file_chooser() as fc: slots.nth(0).locator('button.add').click()
fc.value.set_files(str(text))
expect(slots.nth(0).get_by_role('alert')).to_be_visible()
check('upload format validation retained')
upload(slots.nth(0), str(bad))
preview(slots.nth(0).locator('.a-tile-hit'))
expect(page.locator('.backdrop .safe-media-ph')).to_be_visible()
shot('broken-image')
check('broken filled image gives real unavailable message and remains replaceable')
close()
goto('refs')
refs = page.locator('.media-summary .a-slot').first
upload(refs, images[:2])
srcs = refs.locator('.a-tile-hit img').evaluate_all('(es)=>es.map(e=>e.getAttribute("src"))')
box = preview(refs.locator('.a-tile-hit').nth(1))
expect(box.locator('img')).to_have_attribute('src', srcs[1])
page.keyboard.press('ArrowLeft')
expect(box.locator('img')).to_have_attribute('src', srcs[0])
check('multi reference selected image and keyboard navigation correct')
close()
check('navigation retains original invoker focus', refs.locator('.a-tile-hit').nth(1).evaluate('(e)=>document.activeElement===e'))
box = preview(refs.locator('.a-tile-hit').nth(1))
with page.expect_file_chooser() as fc: box.get_by_role('button', name='替换', exact=True).click()
fc.value.set_files(images[2])
expect(refs.locator('.a-tile-hit img').nth(1)).not_to_have_attribute('src', srcs[1])
check('replace changes only selected reference index', refs.locator('.a-tile-hit img').nth(0).get_attribute('src') == srcs[0])
refs.locator('.a-tile-x').nth(0).click()
expect(refs.locator('.a-tile-hit')).to_have_count(1)
check('reference remove preserves remaining order')
preview(refs.locator('.a-tile-hit').first)
# Route navigation while open must discard the old preview.
page.locator('.workflow-pick').filter(has_text='HEL871 frames').evaluate('(e)=>e.click()')
expect(page.locator('.backdrop')).to_have_count(0)
check('switch workflow in mounted desk clears old preview')
goto('frames')
frames = page.locator('.media-summary .a-slot')
expect(frames).to_have_count(2)
upload(frames.nth(0), images[0]); upload(frames.nth(1), images[1])
for i in [0, 1]:
src = frames.nth(i).locator('.a-tile-hit img').get_attribute('src')
box = preview(frames.nth(i).locator('.a-tile-hit'))
expect(box.locator('img')).to_have_attribute('src', src)
close()
shot('first-last-frames', page.locator('.media-summary'))
check('first and last frame slots preview independently')
goto('media')
groups = page.locator('.media-summary .a-slot')
expect(groups).to_have_count(2)
for i, (file, tag) in enumerate([(video, 'video'), (str(audio), 'audio')]):
group = groups.filter(has=page.locator(f'input[accept*="{tag}"]')).first
upload(group, [file])
box = preview(group.locator('.a-tile-hit'), tag)
player = box.locator(tag)
expect(player).to_be_visible()
check(tag+' native controls, paused by default', player.evaluate('(e)=>e.controls && e.paused && !e.autoplay'))
player.focus(); page.keyboard.press('Space')
expect(player).to_have_js_property('paused', False)
check(tag+' keyboard manual playback works')
shot(tag+'-manual-play')
close()
goto('singles')
singles = page.locator('.media-summary .a-slot')
expect(singles).to_have_count(2)
for i, (file, tag) in enumerate([(video, 'video'), (str(audio), 'audio')]):
slot = singles.filter(has=page.locator(f'input[accept*="{tag}"]')).first
upload(slot, file)
box = preview(slot.locator('.a-tile-hit'), tag)
expect(box.locator(tag)).to_be_visible()
check('single '+tag+' correct player and paused', box.locator(tag).evaluate('(e)=>e.controls && e.paused'))
close()
goto('text')
expect(page.locator('.media-summary')).to_have_count(0)
check('text to image has no invented input slot')
# Other mounted caller of RefImageGrid: workflow test dialog.
page.goto('http://127.0.0.1:15271/workflows')
row = page.locator('tbody tr').filter(has_text='HEL871 refs')
row.locator('button.mg-click').click()
page.get_by_role('button', name='试运行', exact=True).click()
form = page.locator('.wft-form .a-slot').first
expect(form).to_be_visible()
upload(form, images[:2])
box = preview(form.locator('.a-tile-hit').nth(1))
close()
check('workflow test dialog preview restores nested focus', form.locator('.a-tile-hit').nth(1).evaluate('(e)=>document.activeElement===e'))
shot('workflow-test-dialog')
page.keyboard.press('Escape')
expect(form).to_have_count(0)
check('drawer closes on second escape and restores body scroll', page.evaluate('document.body.style.overflow') != 'hidden')
# Existing shared lightbox caller: library navigation, zoom, focus, manual video.
page.goto('http://127.0.0.1:15271/library')
tile = page.locator('.lt').filter(has=page.locator('img')).first
tile.dblclick()
box = page.locator('.backdrop')
expect(box).to_be_visible()
expect(box.get_by_role('button', name='替换', exact=True)).to_have_count(0)
box.get_by_role('button', name='100%', exact=True).click()
expect(box.locator('.frame.zoomed')).to_be_visible()
page.keyboard.press('ArrowRight')
close()
check('library shared lightbox navigation zoom and close retained')
check('no generation, model or prompt-tool requests', not any(any(part in url for part in ['/generate', '/test-run', '/prompt-tools']) for url in report['writes']))
check('no JavaScript runtime errors', not report['errors'])
for port in [18271, 15271]:
with socket.socket() as probe:
assert probe.connect_ex(('127.0.0.1', port)) != 0, f'Occupied port: {port}'
children = []
handles = []
with tempfile.TemporaryDirectory(prefix='runtime-', dir=out) as temp:
runtime = Path(temp)
env = {**os.environ, 'PYTHONPATH': str(ROOT/'calliope-backend/src'), 'HEL871_DATA': temp}
bootstrap = '''import os
from pathlib import Path
from calliope import config
root=Path(os.environ['HEL871_DATA'])
config.CONFIG_FILE=root/'settings.json'
config.settings=config.Settings(data_dir=root/'data',assets_dir=root/'data/assets',llm_base_url='http://127.0.0.1:1/v1',comfyui_base_url='http://127.0.0.1:1',dry_run=False)
config.settings.ensure_storage_dirs()
from calliope.queue.manager import queue_manager
queue_manager.paused=True
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(),host='127.0.0.1',port=18271)
'''
web = (ROOT/'e2e/hel818-web.cjs').read_text().replace('18247', '18271').replace('15173', '15271').replace("path.resolve('calliope-web/build')", json.dumps(str(a.build.resolve())))
try:
for name, cmd in [('api', [a.backend_python, '-c', bootstrap]), ('web', ['node', '-e', web])]:
h = (out/f'{name}.log').open('w'); handles.append(h)
children.append(subprocess.Popen(cmd, env=env, stdout=h, stderr=subprocess.STDOUT, cwd=ROOT))
for url in ['http://127.0.0.1:18271/api/health', 'http://127.0.0.1:15271']:
for n in range(150):
try:
urllib.request.urlopen(url, timeout=1); break
except OSError:
if any(c.poll() is not None for c in children): raise RuntimeError('service startup failure')
if n == 149: raise
time.sleep(.1)
with sync_playwright() as pw:
browser = pw.chromium.launch(args=['--no-sandbox'], env={**os.environ, 'LD_LIBRARY_PATH': os.environ.get('HEL871_BROWSER_LIBS', '/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu')})
context = browser.new_context(viewport={'width': 1440, 'height': 900}, record_video_dir=str(out/'video'))
context.add_init_script("localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-motion','off')")
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page = context.new_page()
page.set_default_timeout(10000)
page.on('pageerror', lambda e: report['errors'].append(str(e)))
page.on('filechooser', lambda _: report.update(choosers=report['choosers']+1))
page.on('request', lambda r: report['writes'].append(r.url) if r.method in ['POST', 'PATCH', 'PUT', 'DELETE'] else None)
try:
verify(page, context.request, runtime)
except Exception:
page.screenshot(path=str(out/'failure.png'))
(out/'failure.html').write_text(page.content())
raise
finally:
context.tracing.stop(path=str(out/'trace.zip'))
context.close(); browser.close()
finally:
daemon = json.loads(subprocess.check_output(['multica', 'daemon', 'status', '--output', 'json']))['pid']
for child in children:
assert child.pid != daemon
if child.poll() is None: child.send_signal(signal.SIGTERM)
for child in children:
try: child.wait(timeout=10)
except subprocess.TimeoutExpired: child.kill(); child.wait()
for h in handles: h.close()
report['cleanup'] = [{'pid': c.pid, 'exit': c.returncode, 'daemon': daemon} for c in children]
(out/'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2))
report['isolated_runtime_removed'] = not runtime.exists()
(out/'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2))
print('PASS cleanup', report['cleanup'], flush=True)
+28
View File
@@ -0,0 +1,28 @@
"""Pair unmodified before/after UI captures; preserve originals and their hashes."""
import argparse
import hashlib
import json
from pathlib import Path
from PIL import Image, ImageDraw
p = argparse.ArgumentParser()
p.add_argument('--evidence', type=Path, required=True)
a = p.parse_args()
out = a.evidence/'comparison'
out.mkdir(exist_ok=True)
manifest = []
for theme in ['night', 'day', 'grade', 'bluehour']:
for width in [1440, 1366, 1280]:
for state in ['compact', 'manager']:
name = f'{theme}-{width}-{state}.png'
files = [a.evidence/phase/name for phase in ['before', 'after']]
images = [Image.open(f).convert('RGB') for f in files]
canvas = Image.new('RGB', (sum(im.width for im in images), max(im.height for im in images)+28), '#202020')
draw = ImageDraw.Draw(canvas)
x = 0
for label, im in zip(['BASE 344fede', 'HEL-871'], images):
draw.text((x+8, 8), label, fill='white')
canvas.paste(im, (x, 28)); x += im.width
canvas.save(out/name)
manifest.append({'file': name, 'sha256': [hashlib.sha256(f.read_bytes()).hexdigest() for f in files]})
(out/'manifest.json').write_text(json.dumps(manifest, indent=2))
+13
View File
@@ -0,0 +1,13 @@
HEL-883 视觉取证;产品代码零修改。
先读 `docs/changelogs/2026-10-26-hel883-visual-investigation.md`。需要环境已有 Python/Playwright/Pillow、后端依赖、Chromium 系统库、Node;不新增产品依赖。
1. 使用 Multica CLI 下载批准 HEL-726、HEL-814 HTML 和 HEL-837 第二版附件;按取证报告核 SHA,保留原件不改。
2. `read_canvas.py` 仅适用于本卡已授权测试站,默认画布 4,`--board 5` 选择另一画布。通过 `ssh root@192.168.200.15 python3 - --board 4 < scripts/hel883/read_canvas.py` 只读导出到证据目录 `production-board4.json`;另一份存 `production-board5.json`。不读取配置/密钥,也不使用可能 seed 的线上画布 GET。
3. 构建冻结 `344fede` 与当前头。下载卡内已核验的改前/当前部署包到本地解压,分别指向真实静态根目录。不要在服务器部署或重建。
4. `python3 scripts/hel883/capture.py --output <证据目录> --baseline <基线构建> --deployed <当前部署包根> --previous <改前部署包根> --references <原件目录>`。原件目录需包含 `canvas-sample.html`;`--only-reference` 只执行部署包同状态样本比较和画布灯箱/持久化回归。
5. `readonly.py --output <证据目录> --references <原件目录>` 采线上 `/create?kind=image`、`/library` 两个只读入口与批准原件矩阵;原件目录另含 `夜片场-多主题交互样本.html`、`extracted/HEL-837-交互样本-v2.html`。线上所有非只读及 canvas API 都被拦截。
`capture.py` 占用仅本地 18383/15383,开跑先验证端口空闲,暂停隔离队列、LLM/ComfyUI 指向不可用回环地址;唯一媒体夹具明确标注非用户画布,不用于“丰富”产品内容。进程在前台 finally 回收,终止前比对 daemon;隔离目录由 TemporaryDirectory 清理,原始报告/截图/日志保留。参考映射 JSON 含两张测试媒体的截图数据,不含凭据。
输入槽完整回归继续复用 `scripts/hel871/capture.py`,需要单独执行;它的构建、真实上传、焦点/嵌套 Drawer 验证见原 README。源码与部署产物不必共享构建哈希;本卡额外以部署包 127/127 文件与服务端实际静态目录一致证明取证对象。
+216
View File
@@ -0,0 +1,216 @@
"""Foreground, loopback-only before/current audit and selected production replay.
Needs existing Playwright/Pillow/backend dependencies. Production export is read
only; write/drag assertions run exclusively against this disposable database.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import sqlite3
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from PIL import Image, ImageChops
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
p = argparse.ArgumentParser()
p.add_argument('--output', type=Path, required=True)
p.add_argument('--baseline', type=Path, required=True)
p.add_argument('--references', type=Path, required=True)
p.add_argument('--deployed', type=Path, required=True)
p.add_argument('--previous', type=Path, required=True)
p.add_argument('--only-reference', action='store_true')
a = p.parse_args()
out = a.output.resolve(); out.mkdir(parents=True, exist_ok=True)
report = {'checks': [], 'errors': [], 'geometry': [], 'comparisons': [], 'writes': []}
API = 'http://127.0.0.1:18383'
WEB = 'http://127.0.0.1:15383'
def check(name, value):
report['checks'].append({'name': name, 'pass': bool(value)})
print('PASS' if value else 'FAIL', name, flush=True)
assert value, name
def api(path, body=None, method=None):
r = urllib.request.Request(API + '/api' + path,
data=None if body is None else json.dumps(body).encode(),
headers={'Content-Type': 'application/json'},
method=method or ('GET' if body is None else 'POST'))
with urllib.request.urlopen(r, timeout=10) as response:
return json.load(response)
def geometry(page):
return page.evaluate('''()=>Object.fromEntries(['.desk','.stage','.composer','.media-summary','.library-layout','.flow-panel','.chat-panel','.svelte-flow__controls','.svelte-flow__minimap'].map(s=>{let e=document.querySelector(s);if(!e)return [s,null];let r=e.getBoundingClientRect(),c=getComputedStyle(e);return [s,{x:r.x,y:r.y,w:r.width,h:r.height,background:c.backgroundColor,color:c.color,font:c.font,padding:c.padding}]}))''')
def seed(runtime):
db = sqlite3.connect(runtime/'data/calliope.db')
# Import only the two explicitly selected boards; no credentials/config or
# unrelated user/project records are copied from the server.
for board in [4, 5]:
snap = json.loads((out/f'production-board{board}.json').read_text())
for table in ['projects','agent_sessions','characters','locations','items','canvas','canvas_node','canvas_edge','agent_messages']:
for row in snap.get(table, []):
columns = ','.join('"'+k+'"' for k in row)
db.execute('INSERT INTO '+table+' ('+columns+') VALUES ('+','.join('?' for _ in row)+')', list(row.values()))
# An empty, explicitly isolated session for the selected project's UI.
db.execute("INSERT INTO agent_sessions(id,title,project_id) VALUES(17,'隔离项目会话',7)")
assets = runtime/'data/assets/uploads'; assets.mkdir(parents=True,exist_ok=True)
for src, dst in [(ROOT/'calliope-web/static/refract/coast.jpg',assets/'HEL883-fixture.jpg'),(ROOT/'e2e/tiny.mp4',assets/'HEL883-fixture.mp4')]:
shutil.copyfile(src,dst)
db.execute("INSERT INTO agent_sessions(id,title) VALUES(18,'HEL883 共享灯箱隔离回归')")
db.execute("INSERT INTO canvas(id,title,agent_session_id,viewport_json) VALUES(6,'HEL883 测试夹具(非用户画布)',18,?)", (json.dumps({'x':0,'y':0,'zoom':1}),))
for kind,x in [('image',40),('video',320)]:
db.execute("INSERT INTO canvas_node(canvas_id,type,title,x,y,status,artifact_path) VALUES(6,?,'HEL883 灯箱夹具',?,140,'done',?)",(kind,x,str(assets/('HEL883-fixture.jpg' if kind=='image' else 'HEL883-fixture.mp4'))))
db.commit();db.close()
graph = {'1': {'class_type':'PrimitiveStringMultiline','inputs':{'value':'HEL883 同状态视觉核对'},'_meta':{'title':'Prompt (Input:prompt)'}}, '2': {'class_type':'LoadImage','inputs':{'image':''},'_meta':{'title':'Image (Input:image)'}},'3':{'class_type':'SaveImage','inputs':{'images':['2',0]}}}
return api('/workflows', {'name':'HEL883 隔离图生图','kind':'image','workflow_json':graph})['id']
def matrix(browser,wf):
phases=[('base',a.baseline.resolve()),('current',ROOT/'calliope-web/build'),('previous',a.previous.resolve()),('deployed',a.deployed.resolve())]
if a.only_reference: phases=phases[-1:]
for phase, build in phases:
(out/'active-build.txt').write_text(str(build))
context = browser.new_context(viewport={'width':1440,'height':900})
context.add_init_script("localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-theme','night')")
mirrors={str(board):json.loads((out/f'production-board{board}.json').read_text())['canvas'][0]['viewport_json'] for board in [4,5]}
mirrors['6']=json.dumps({'x':0,'y':0,'zoom':1})
context.add_init_script('Object.entries('+json.dumps(mirrors)+').forEach(([id,v])=>{if(!localStorage.getItem("calliope.canvas.viewport."+id))localStorage.setItem("calliope.canvas.viewport."+id,v)})')
context.route('https://**/*',lambda r:r.abort())
page = context.new_page();page.set_default_timeout(12000)
page.on('pageerror',lambda e:report['errors'].append(str(e)))
page.on('request',lambda r:report['writes'].append({'phase':phase,'method':r.method,'path':r.url.split('15383')[-1]}) if r.method not in ['GET','HEAD'] else None)
for label,path,ready in [('create',f'/create?kind=image&wf={wf}','#desk-prompt'),('library','/library','.lt'),('board4','/canvas/4?session=17','.canvas-card'),('board5','/canvas/5?session=16','.flow-panel'),('fixture','/canvas/6?session=18','.canvas-card')]:
if a.only_reference and label!='fixture': continue
page.goto(WEB+path);page.locator(ready).first.wait_for()
page.wait_for_timeout(900)
for width,height in [(1440,900),(1366,768),(1280,720)]:
page.set_viewport_size({'width':width,'height':height})
for theme in ['night','day','grade','bluehour']:
page.evaluate('(t)=>document.documentElement.dataset.theme=t',theme)
page.mouse.move(1,1);page.wait_for_timeout(200)
name=f'{label}-{theme}-{width}'
page.screenshot(path=str(out/f'{phase}-{name}.png'),animations='disabled')
if phase=='deployed' and label=='fixture':
from reference import capture
capture(page,context,a.references/'canvas-sample.html',out,theme,width)
report['geometry'].append({'phase':phase,'name':name,'url':page.url,'theme':theme,'motion':page.locator('html').get_attribute('data-motion'),'geometry':geometry(page)})
check(f'{phase} {label} mounted',True)
if phase=='deployed':
for kind in ['image','video']:
page.goto(WEB+'/canvas/6?session=18')
hit=page.locator(f'.artifact-node[data-kind="{kind}"] .media-btn')
hit.wait_for();hit.focus();scroll=page.evaluate('({x:scrollX,y:scrollY,overflow:document.body.style.overflow})')
page.keyboard.press('Enter')
box=page.locator('.backdrop');expect(box).to_be_visible()
check(f'canvas {kind} lightbox actually opened',box.count()==1)
check(f'canvas {kind} body locked',page.evaluate('document.body.style.overflow')=='hidden')
if kind=='video':
video=box.locator('video');expect(video).to_be_visible()
video.evaluate('(v)=>v.play()');page.wait_for_timeout(150)
check('canvas video manual playback',video.evaluate('(v)=>!v.paused && v.currentTime>0'))
video.evaluate('(v)=>v.pause()')
else:
expect(box.locator('img')).to_be_visible()
box.get_by_role('button',name='100%',exact=True).click()
expect(box.locator('.frame.zoomed')).to_be_visible()
box.locator('button,a').first.focus();page.keyboard.press('Shift+Tab')
check(f'{kind} tab stays inside preview',box.evaluate('(e)=>e.contains(document.activeElement)'))
page.screenshot(path=str(out/f'canvas-{kind}-opened.png'))
page.keyboard.press('Escape');expect(box).to_have_count(0)
check(f'canvas {kind} focus restored',hit.evaluate('(e)=>e===document.activeElement'))
check(f'canvas {kind} scroll restored',scroll==page.evaluate('({x:scrollX,y:scrollY,overflow:document.body.style.overflow})'))
header=page.locator('.artifact-node[data-kind="image"] header')
before=next(n for n in api('/canvas/6')['nodes'] if n['type']=='image')
b=header.bounding_box();page.mouse.move(b['x']+80,b['y']+12);page.mouse.down();page.mouse.move(b['x']+160,b['y']+72,steps=10);page.mouse.up();page.wait_for_timeout(800)
after=next(n for n in api('/canvas/6')['nodes'] if n['id']==before['id'])
check('canvas drag persists coordinates in isolated DB',abs(after['x']-before['x'])>30 and abs(after['y']-before['y'])>30)
page.reload();header.wait_for();page.wait_for_timeout(500)
check('canvas drag retained after reload',next(n for n in api('/canvas/6')['nodes'] if n['id']==before['id'])['x']==after['x'])
zoom_before=json.loads(api('/canvas/6')['canvas']['viewport_json'])
page.locator('.svelte-flow__controls-button').first.click()
page.wait_for_timeout(800)
saved=json.loads(api('/canvas/6')['canvas']['viewport_json'])
check('canvas zoom saves to isolated DB',saved['zoom']!=zoom_before['zoom'])
check('canvas viewport mirror matches backend',json.loads(page.evaluate("localStorage.getItem('calliope.canvas.viewport.6')"))==saved)
page.reload();page.locator('.zoom-value').wait_for();page.wait_for_timeout(500)
check('canvas viewport restored after reload',page.locator('.zoom-value').inner_text()==str(round(saved['zoom']*100))+'%')
context.close()
if a.only_reference: return
for current in sorted(list(out.glob('current-*.png'))+list(out.glob('deployed-*.png'))):
base=out/(current.name.replace('current-','base-',1) if current.name.startswith('current-') else current.name.replace('deployed-','previous-',1))
b,c=Image.open(base).convert('RGB'),Image.open(current).convert('RGB')
diff=ImageChops.difference(b,c)
pixels=sum(1 for x in diff.get_flattened_data() if x!=(0,0,0))
report['comparisons'].append({'name':current.stem,'different_pixels':pixels,'total_pixels':b.width*b.height,'bbox':diff.getbbox()})
pair=Image.new('RGB',(b.width*2,b.height));pair.paste(b,(0,0));pair.paste(c,(b.width,0));pair.save(out/('compare-'+current.name))
for port in [18383,15383]:
with socket.socket() as s:assert s.connect_ex(('127.0.0.1',port))!=0
proc=None;web_proc=None
with tempfile.TemporaryDirectory(prefix='hel883-',dir=out) as temp:
runtime=Path(temp)
code='''from pathlib import Path
from calliope import config
root=Path(TEMP)
config.CONFIG_FILE=root/'settings.json'
config.settings=config.Settings(data_dir=root/'data',assets_dir=root/'data/assets',llm_base_url='http://127.0.0.1:1/v1',comfyui_base_url='http://127.0.0.1:1',dry_run=False)
config.settings.ensure_storage_dirs()
from calliope.queue.manager import queue_manager
queue_manager.paused=True
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(),host='127.0.0.1',port=18383)
'''.replace('TEMP',repr(temp))
with (out/'replay-api.log').open('w') as log:
try:
proc=subprocess.Popen(['python3','-c',code],env={**os.environ,'PYTHONPATH':str(ROOT/'calliope-backend/src')},stdout=log,stderr=subprocess.STDOUT)
for attempt in range(100):
try: api('/health');break
except OSError:
if proc.poll() is not None: raise RuntimeError('API startup failure')
time.sleep(.1)
wf=seed(runtime)
(out/'active-build.txt').write_text(str(a.baseline.resolve()))
web=(ROOT/'e2e/hel818-web.cjs').read_text().replace('18247','18383').replace('15173','15383').replace("const build=path.resolve('calliope-web/build');", "const marker="+json.dumps(str(out/'active-build.txt'))+";").replace(" let file=path.join(build", " const build=fs.readFileSync(marker,'utf8'); let file=path.join(build")
web_proc=subprocess.Popen(['node','-e',web],stdout=log,stderr=subprocess.STDOUT)
for attempt in range(100):
try: urllib.request.urlopen(WEB,timeout=1);break
except OSError:time.sleep(.1)
with sync_playwright() as pw:
browser=pw.chromium.launch(args=['--no-sandbox'],env={**os.environ,'LD_LIBRARY_PATH':'/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu'})
try: matrix(browser,wf)
finally: browser.close()
check('no JS runtime errors',not report['errors'])
check('no generation or model call',not any(any(t in r['path'] for t in ['/generate','/prompt-tools','/agent/stream']) for r in report['writes']))
finally:
if web_proc:
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
assert web_proc.pid!=daemon
web_proc.terminate();web_proc.wait(timeout=10)
report['web_cleanup']={'pid':web_proc.pid,'daemon_pid':daemon,'exit':web_proc.returncode}
if proc:
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
assert proc.pid!=daemon
if proc.poll() is None:proc.send_signal(signal.SIGTERM)
try:proc.wait(timeout=10)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
report['cleanup']={'pid':proc.pid,'daemon_pid':daemon,'exit':proc.returncode}
(out/'replay-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
report['runtime_removed']=not runtime.exists()
(out/'replay-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
+29
View File
@@ -0,0 +1,29 @@
"""Read only the selected board and its renderable rows; never call seeding GET."""
import argparse, json, sqlite3
p=argparse.ArgumentParser()
p.add_argument("--board",type=int,choices=[4,5],default=4)
a=p.parse_args()
c = sqlite3.connect('file:/opt/aigc-home/data/calliope.db?mode=ro', uri=True)
c.execute('PRAGMA query_only=ON')
c.row_factory = sqlite3.Row
one = lambda sql, args=(): [dict(r) for r in c.execute(sql, args)]
board = one('SELECT * FROM canvas WHERE id=?',(a.board,))[0]
nodes = one('SELECT * FROM canvas_node WHERE canvas_id=?',(a.board,))
out = {'canvas': [board], 'canvas_node': nodes,
'canvas_edge': one('SELECT * FROM canvas_edge WHERE canvas_id=?',(a.board,))}
if board['project_id']:
out['projects'] = one('SELECT id,title,status,idea FROM projects WHERE id=?', (board['project_id'],))
for table, kind in [('characters', 'character'), ('locations', 'location'), ('items', 'item')]:
ids = [n['entity_id'] for n in nodes if n['entity_type'] == kind]
if ids:
cols = ['id','project_id','name'] + (['portrait_path','sheet_path'] if kind=='character' else ['reference_image_path'])
out[table] = one('SELECT '+','.join(cols)+' FROM '+table+' WHERE id IN ('+','.join('?' for _ in ids)+')', ids)
sessions = one('SELECT id,title,project_id FROM agent_sessions WHERE project_id=? ORDER BY updated_at DESC LIMIT 1', (board['project_id'],))
if not board['project_id']:
sessions = one('SELECT id,title,project_id FROM agent_sessions WHERE id=?', (board['agent_session_id'],))
out['agent_sessions'] = sessions
if sessions:
sid = sessions[0]['id']
out['agent_messages'] = one('SELECT session_id,role,content,agent_name FROM agent_messages WHERE session_id=? ORDER BY id', (sid,))
out['event_summary'] = one('SELECT type,count(*) as count FROM agent_events WHERE session_id=? GROUP BY type', (sid,))
print(json.dumps(out,ensure_ascii=False))
+49
View File
@@ -0,0 +1,49 @@
"""Capture read-only online entry evidence and approved original reference states."""
import argparse
import hashlib
import json
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
p=argparse.ArgumentParser()
p.add_argument('--output',type=Path,required=True)
p.add_argument('--references',type=Path,required=True)
a=p.parse_args();out=a.output.resolve();refs=a.references.resolve()
report={'blocked':[],'online':[],'reference':[],'errors':[]}
with sync_playwright() as pw:
b=pw.chromium.launch(args=['--no-sandbox'],env={**os.environ,'LD_LIBRARY_PATH':'/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu'})
try:
ctx=b.new_context(viewport={'width':1440,'height':900})
ctx.add_init_script("localStorage.setItem('aigc-theme','night');localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-lang','zh')")
def guard(route):
r=route.request
if r.method not in ['GET','HEAD'] or '/api/canvas' in r.url or not r.url.startswith('http://192.168.200.15:8080/'):
report['blocked'].append({'method':r.method,'url':r.url});route.abort()
else:route.continue_()
ctx.route('**/*',guard)
page=ctx.new_page();page.on('pageerror',lambda e:report['errors'].append(str(e)))
for name,path,ready in [('create','/create?kind=image','#desk-prompt'),('library','/library','.lib-body')]:
page.goto('http://192.168.200.15:8080'+path);page.locator(ready).wait_for();page.wait_for_timeout(1000)
page.screenshot(path=str(out/f'online-{name}-night-1440.png'))
report['online'].append({'name':name,'url':page.url,'theme':page.locator('html').get_attribute('data-theme'),'motion':page.locator('html').get_attribute('data-motion'),'resources':page.evaluate('performance.getEntriesByType("resource").filter(r=>r.initiatorType==="script"||r.initiatorType==="link").map(r=>r.name)')})
ctx.close()
ctx=b.new_context();ctx.route('https://**/*',lambda r:r.abort());s=ctx.new_page()
originals=[('desk',refs/'夜片场-多主题交互样本.html','?still=1#create/image'),('library',refs/'extracted/HEL-837-交互样本-v2.html','?still=1#library'),('canvas',refs/'canvas-sample.html','?chrome=0&reduce=1')]
for label,file,query in originals:
for width,height in [(1440,900),(1366,768),(1280,720)]:
s.set_viewport_size({'width':width,'height':height})
for theme in ['night','day','grade','bluehour']:
s.goto(file.as_uri()+query)
s.evaluate('(t)=>document.documentElement.dataset.theme=t',theme)
if label=='desk':
# Map only the empty-result state, never alter reference CSS.
s.evaluate("st.hist.image=[];st.fills={};st.viewing=null;renderCreate()")
s.wait_for_timeout(300)
s.screenshot(path=str(out/f'approved-{label}-{theme}-{width}.png'),animations='disabled')
tokens=s.evaluate("()=>Object.fromEntries(['--bg-0','--bg-1','--bg-2','--tx-1','--tx-2','--board-bg','--board-dot','--rail','--head'].map(t=>[t,getComputedStyle(document.documentElement).getPropertyValue(t).trim()]))")
report['reference'].append({'label':label,'theme':theme,'width':width,'file_sha256':hashlib.sha256(file.read_bytes()).hexdigest(),'tokens':tokens,'state':'empty result/history' if label=='desk' else 'original demonstration data'})
ctx.close()
finally:b.close()
(out/'readonly-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
print('online entries',len(report['online']),'approved screenshots',len(report['reference']),'JS errors',report['errors'])
+43
View File
@@ -0,0 +1,43 @@
"""Map fixture content into an untouched approved canvas HTML at runtime.
Called by capture.py while the real isolated page is mounted. Changes only
fixture text, media, positions and selection; original CSS/file stay intact.
"""
import base64
import json
def capture(page, context, reference, out, theme, width):
sample=context.new_page()
sample.set_viewport_size(page.viewport_size)
sample.goto(reference.resolve().as_uri()+'?theme='+theme+'&chrome=0&reduce=1')
data=page.evaluate('''()=>{let b=document.querySelector('.flow-panel').getBoundingClientRect();return {title:document.querySelector('.title-btn').textContent.trim(),zoom:document.querySelector('.zoom-value').textContent,cards:[...document.querySelectorAll('.canvas-card')].map(e=>{let r=e.getBoundingClientRect();return {title:e.querySelector('.title').textContent,kind:e.dataset.kind,x:r.x-b.x,y:r.y-b.y,scale:r.width/parseFloat(getComputedStyle(e).width),foot:e.querySelector('footer').innerText,image:e.querySelector('img')?.src,selected:e.classList.contains('a-in-frame')}})}}''')
data['video']='data:image/png;base64,'+base64.b64encode(page.locator('.canvas-card video').screenshot()).decode()
data['minimap']='data:image/png;base64,'+base64.b64encode(page.locator('.svelte-flow__minimap svg').screenshot()).decode()
sample.evaluate('''m=>{
let templates=[...document.querySelectorAll('.card.artifact')].map(e=>e.cloneNode(true));
document.querySelector('#cards').replaceChildren();
for(let [i,d] of m.cards.entries()){
let e=templates[d.kind==='video'?1:0];e.classList.remove('running','sel','on');
e.style.setProperty('left',d.x+'px','important');e.style.setProperty('top',d.y+'px','important');e.style.transform='scale('+d.scale+')';e.style.transformOrigin='top left';
e.querySelector('.t').textContent=d.title;e.querySelector('.pill').textContent=d.kind;
let image=document.createElement('img');image.src=d.image||m.video;image.style.cssText='display:block;width:100%;height:100%;object-fit:cover';
let media=e.querySelector('.media');media.replaceChildren(image);let badge=document.createElement('span');badge.className='kind';badge.textContent=d.kind;media.append(badge);
let foot=e.querySelector('.cfoot');foot.replaceChildren();let grow=document.createElement('span');grow.className='grow';let num=document.createElement('span');num.className='mono';num.textContent=d.foot;foot.append(grow,num);document.querySelector('#cards').append(e);
}
document.querySelector('.tname').firstChild.nodeValue=m.title;
document.querySelector('.titlecard .saved').remove();
document.querySelector('.proj-chip').textContent='Sandbox';document.querySelector('.unlink').remove();
document.querySelector('.zoomv').textContent=m.zoom;
let mini=document.createElement('img');mini.src=m.minimap;mini.style.cssText='width:100%;height:100%;display:block';document.querySelector('.minimap .mm').replaceChildren(mini);document.querySelector('.minimap .mmtag')?.remove();
document.querySelector('.demo-badge')?.remove();
}''',data)
sample.wait_for_timeout(150)
name=f'{theme}-{width}'
page.locator('.flow-panel').screenshot(path=str(out/f'product-board-{name}.png'))
sample.locator('.board').screenshot(path=str(out/f'approved-mapped-board-{name}.png'))
geometry={}
for label,p,selectors in [('product',page,['.artifact-node','.artifact-node .media','.svelte-flow__controls','.svelte-flow__minimap','.chat-panel']),('approved',sample,['.card.artifact','.card.artifact .media','.ctl','.minimap','#chat'])]:
geometry[label]=p.evaluate('''ss=>ss.map(s=>{let e=document.querySelector(s);if(!e)return {selector:s};let r=e.getBoundingClientRect(),c=getComputedStyle(e);return {selector:s,w:r.width,h:r.height,color:c.color,background:c.backgroundColor,font:c.font,border:c.border,borderRadius:c.borderRadius}})''',selectors)
(out/f'approved-mapping-{name}.json').write_text(json.dumps({'data':data,'geometry':geometry,'scope':'Same isolated image/video fixture mapped into approved board; chat excluded from board crops. No original file or stylesheet modified.'},ensure_ascii=False,indent=2))
sample.close()