Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93df1c55c1 | ||
|
|
e88dc0ae40 | ||
|
|
679969bc5b | ||
|
|
a175fe57dd | ||
|
|
433fd719f1 | ||
|
|
c3039343ca | ||
|
|
2ad5d33c72 | ||
|
|
e81f191e64 | ||
|
|
5163230433 | ||
|
|
e08b32c064 | ||
|
|
344fede198 | ||
|
|
a8efdd3a66 | ||
|
|
796dff27ad | ||
|
|
4cda754eab | ||
|
|
49dc71b727 |
@@ -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 ")):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?.();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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} 枚のカードを削除できませんでした",
|
||||
|
||||
@@ -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}개 카드를 삭제하지 못했습니다",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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 及共用灯箱回归覆盖,未分别创建业务数据重走整页。
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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 文件与服务端实际静态目录一致证明取证对象。
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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'])
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user