HEL-840: add isolated end-to-end evidence and reproducible review kit
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -46,3 +46,6 @@ prototype/
|
||||
|
||||
# --- Reference clones (vendored for porting only) ---
|
||||
reference/
|
||||
|
||||
# HEL-840 reproducible QA outputs are delivered as attachments.
|
||||
/evidence/hel840/
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
HEL-840:第二版布局与提示词工具
|
||||
|
||||
本卡从 `leefer/hel-731-studio-project@415fb782362b2ac5b725e312ecbdbc9a782e9aa3` 分支施工。批准包 SHA256:`dfe02579065200e8f0edaa3114709fc44286f232fdaa76c34acd3f3814523508`。原件不改;截图中的项目、媒体、档案均为独立夹具,不编译进产品。
|
||||
|
||||
实现边界
|
||||
|
||||
- 素材库:筛选 / 网格 / 常驻详情;浏览器真实媒体元数据和 onerror;搜索、排序、时间分组、密度、完整/铺满、多选、键盘及复用全屏预览。批量存入复用既有 attach 接口,图片分别成为素材,视频明确选择不同场景;成功后刷新排除。删除仍由后端最终检查引用,分批遵守原 200 项限制。
|
||||
- 工作室:继续创作、四个紧凑入口、项目主区、待确认/最近成果/队列右栏。复用 AProjectCard,保留改名/删除;只读 cover_path 和真实统计。队列汇总明确最近 20 条范围。
|
||||
- 模型连接:统一列表、常驻详情、单一状态胶囊,中文错误和原始响应折叠,本浏览器测试记录。必填满足即可保存,测试不会保存配置。五智能体计数不混入提示词角色。ComfyUI 显示现有版本/设备字段;队列/显存未知。
|
||||
- 设置:提示词模型只读;唯一编辑处为模型分工,单键增量保存 translate/polish,深链高亮对应行。没有第二个模型库或第二份配置。
|
||||
- 新端点仅 `POST /api/prompt-tools/transform`。复用 Settings 角色解析与 LLMClient;请求开始捕获档案,纠正重试也使用同一档案,避免并发改配置使返回的模型身份失真。翻译双向/显式换向,润色同语言;预览不写原文,不创建任务。
|
||||
- 共享提示词控件接创作台正向/反向/视频及工作台风格/素材/镜头编辑框;普通聊天不接。seq、文本递增版本和上下文递增版本共同保护,包含实际项目/来源字段身份及工作流选择 epoch。A→B→A、取消、切换、在途编辑的迟到结果均失效;结果后编辑需二次确认;5 秒撤销核对替换版本和上下文。
|
||||
- 技术片段按完整表达式校验,含 LoRA、嵌套权重及参数值,纠正一次仍不合格返回错误而无结果。为保证权重归属,识别到的完整权重括号(包含其内词语)逐字保留;其余正文翻译/润色。普通专名和裸数不作逐字承诺。
|
||||
- 独立断连监测不依赖 token 到达;取消协同取消上游任务并调用实际 `close()`,覆盖慢流、无首块和阻塞回退。45 秒间隔、120 秒总时长。修复既有 LLMClient 在流式 401/403 上读取未消费响应体、遮蔽原始状态的问题。关闭连接不等于供应商停止生成/计费。
|
||||
|
||||
视觉与动效
|
||||
|
||||
采用原有颜色/字体/圆角/动效 token,新增批准的几何 token;共享页头样式抽到唯一来源。创作台外壳/舞台不改,结果条沿现有 grid 重排。小窗角标仅防换行和给工作流名省略,不改变外壳。双环源码未变。
|
||||
|
||||
| 宽度 | 工作室右栏 | 素材筛选 / 详情 | 模型详情 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1440 | 320 | 220 / 360 | 400 |
|
||||
| 1366 | 296 | 200 / 300 | 360 |
|
||||
| 1280 | 280 | 184 / 288 | 360 |
|
||||
|
||||
19 个批准的颜色/字体/几何 token × 12 组合,原 HTML 与产品 CSS 求值一致(228 项);实际产品 DOM 单独保留。60 张产品矩阵均无横溢;三个窗口生成按钮下沿分别 875/743/695,首屏可见。九组窗口/动效测试均验证结果条关闭恢复作品尺寸、作品和底座留距 ≥47px。
|
||||
|
||||
原稿与应用的媒体、文案、地址、计数不同,不能声称逐像素相同。对照查看器保留原件哈希、数据映射及独立产品截图;字体族一致,Linux Chromium 与原 Edge 的字体光栅化由总工在部署环境重拍核对。
|
||||
|
||||
自测与复现
|
||||
|
||||
- `npm run lint`、`check`、`test`、`build`;check 零错零警。未新增产品依赖。
|
||||
- 相关后端套件 66 项通过,LLM 既有流/回退套件 19 项通过;提示词套件最终 34 项复测通过(含实际 HTTP 慢流/无首块/阻塞回退取消、档案并发切换、配置重新加载、删除回退)。日志分别交付,不重复累计测试数量。
|
||||
- 隔离的真实前端→FastAPI→LLMClient→HTTP 假供应商,浏览器不拦截响应。60 张四主题三窗口×五页矩阵;45 张交互状态截图,含默认地址未启动、全部提示词状态、三个工作台面板、真实媒体失败/空目录/多选/存入移出、项目空态。原 39 图中的 X01/X02 是原型规范导航,不给产品新增该入口。
|
||||
- 浏览器最后一轮 48 项断言通过,脚本错误 0。另交 1440×900 录屏和九组作品开关几何、三档双环定帧/样式记录。
|
||||
- 独立服务进程实际停止后重启:translate/polish 指派保留,重启后真实 transform 请求分别路由到此前指定的不同假模型。
|
||||
- `scripts/hel840/README.md`、capture.py、serve_fixture.py、exercise.py、motion.py、restart_config.py、compare_reference.py 供总工独立复拍。脚本仅操作显式指定的隔离目录。超时场景把测试进程总时钟缩到 1.5 秒,产品常量仍 120 秒。
|
||||
- 不调用真实付费模型/GPU;夹具队列暂停,模型及 ComfyUI 测试均为本地假服务。未合并、未部署、未改现网数据/密钥/配置。
|
||||
|
||||
兼容与回退
|
||||
|
||||
无数据库迁移、无新配置顶级键,仅扩展角色白名单。现有档案、生成队列、attach/引用删除安全接口保持。总工部署前应同时快照 SQLite、calliope_config.json 和旧部署包,并记录实际构建入口/提交;这是独立部署步骤。本卡保留基线代码和隔离验收快照。回退代码时保留兼容的新数据库/配置写入,不能默认拿历史 DB/配置覆盖当前数据。
|
||||
|
||||
未测:真实收费供应商的译文质量和断连后计费、真实 GPU 生成、测试服务器最终部署画面。以上由独立审核/获准后的部署验收承接。
|
||||
@@ -0,0 +1,18 @@
|
||||
HEL-840 isolated verification
|
||||
|
||||
- Product build: `cd calliope-web && npm ci && npm run check && npm run lint && npm test && npm run build`.
|
||||
- Backend: Python >=3.11, install existing project `[dev]` dependencies in `calliope-backend/.venv`; run `calliope-backend/.venv/bin/python -m pytest calliope-backend/tests/test_prompt_tools.py -q`.
|
||||
- Capture: run `python3 scripts/hel840/capture.py --output ./evidence/hel840/final`. The orchestrating Python needs the existing Playwright test runtime; the server uses `calliope-backend/.venv/bin/python`.
|
||||
- Chromium runtime libraries may be supplied via `HEL840_BROWSER_LIBS`; these are local test prerequisites, not product dependencies.
|
||||
- `serve_fixture.py` creates only the explicitly named fixture directory. Never point it at existing user data. It serves the **built product**, real FastAPI router and real LLMClient, with an HTTP fake provider. Browser responses are not mocked.
|
||||
- Fake images and project rows are deterministic QA data and never compiled into product. Approved original samples are separate and untouched.
|
||||
- `capture.py` owns and cleans its exact child process after comparing the workspace daemon PID. It must finish in foreground; no service handoff is provided.
|
||||
|
||||
Additional verification:
|
||||
|
||||
- `python3 scripts/hel840/capture.py --output ./evidence/hel840/states --states-only` runs the full interaction assertions without the 60-image matrix.
|
||||
- `python3 scripts/hel840/capture.py --output ./evidence/hel840/motion --motion-only` records the nine viewport/motion combinations and both rings at 1440×900 video resolution.
|
||||
- `python3 scripts/hel840/restart_config.py --output ./evidence/hel840/restart` stops and restarts two exact owned server processes and checks persisted assignments plus actual transform routing.
|
||||
- `python3 scripts/hel840/compare_reference.py --reference /path/to/approved-v2.html --output ./evidence/hel840/tokens.json` compares token values; it does not claim screenshot pixel equality.
|
||||
|
||||
The approved original HTML and PNGs must be supplied unchanged from the approved attachment. Keep originals, mapping and actual application captures separate. For cancellation/timeout screenshots the isolated fixture accelerates the total timeout to 1.5 seconds; the product uses 120 seconds. Complete recognized weighted expressions are preserved verbatim so their weights cannot move to another operand. Closing a request does not promise that a supplier stops charging.
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Reproducible screenshots of built product against isolated real backend/fake HTTP LLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
p.add_argument("--quick", action="store_true")
|
||||
p.add_argument("--states-only", action="store_true")
|
||||
p.add_argument("--motion-only", action="store_true")
|
||||
args = p.parse_args()
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
data = out / "fixture"
|
||||
data.mkdir(exist_ok=True)
|
||||
log = (out / "server.log").open("w")
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(ROOT / "calliope-backend/.venv/bin/python"),
|
||||
str(ROOT / "scripts/hel840/serve_fixture.py"),
|
||||
"--data",
|
||||
str(data),
|
||||
"--port",
|
||||
"18740",
|
||||
],
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=ROOT,
|
||||
)
|
||||
(out / "owned-process.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pid": proc.pid,
|
||||
"purpose": "isolated fixture backend",
|
||||
"cleanup": "foreground capture.py finally terminates exact child",
|
||||
}
|
||||
)
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError((out / "server.log").read_text()[-4000:])
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"http://127.0.0.1:18740/api/health", timeout=1
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError("backend not ready")
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox"],
|
||||
env={
|
||||
**os.environ,
|
||||
"LD_LIBRARY_PATH": os.environ.get(
|
||||
"HEL840_BROWSER_LIBS",
|
||||
str(
|
||||
ROOT
|
||||
/ "evidence/hel840/browser-runtime/libs/usr/lib/x86_64-linux-gnu"
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
context = browser.new_context(
|
||||
viewport={"width": 1440, "height": 900},
|
||||
record_video_dir=str(out / "recordings"),
|
||||
record_video_size={"width": 1440, "height": 900},
|
||||
)
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
themes = (
|
||||
[]
|
||||
if args.states_only or args.motion_only
|
||||
else ["night"]
|
||||
if args.quick
|
||||
else ["night", "day", "grade", "bluehour"]
|
||||
)
|
||||
sizes = [(1440, 900)] if args.quick else [(1440, 900), (1366, 768), (1280, 720)]
|
||||
metrics = []
|
||||
for theme in themes:
|
||||
for width, height in sizes:
|
||||
page.set_viewport_size({"width": width, "height": height})
|
||||
page.goto("http://127.0.0.1:18740/settings")
|
||||
page.evaluate(
|
||||
'(theme)=>localStorage.setItem("aigc-theme",theme)', theme
|
||||
)
|
||||
for label, route in [
|
||||
("01-工作室", "/"),
|
||||
("02-素材库", "/library"),
|
||||
("03-模型连接", "/connections?tab=llm"),
|
||||
("04-创作台翻译结果", "/image"),
|
||||
("05-设置", "/settings"),
|
||||
]:
|
||||
page.goto("http://127.0.0.1:18740" + route)
|
||||
page.wait_for_timeout(700)
|
||||
if label.startswith("03"):
|
||||
for n in range(2):
|
||||
page.locator(".mrow").nth(n).get_by_role(
|
||||
"button", name="测试", exact=True
|
||||
).click()
|
||||
page.locator(".mrow").nth(n).locator(".spill.ok").wait_for()
|
||||
page.get_by_role("button", name="编辑", exact=True).nth(
|
||||
1
|
||||
).click()
|
||||
page.wait_for_timeout(150)
|
||||
if label.startswith("04"):
|
||||
page.locator("#desk-prompt").wait_for()
|
||||
page.locator("#desk-prompt").fill(
|
||||
"一只猫,柔和侧光,<lora:cat:0.7>,(neon:1.2)"
|
||||
)
|
||||
page.locator(".pt-buttons").first.get_by_role(
|
||||
"button"
|
||||
).first.click()
|
||||
page.locator(".pt-res").wait_for()
|
||||
folder = out / "matrix" / theme
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
page.screenshot(path=str(folder / f"{label}-{width}x{height}.png"))
|
||||
metrics.append(
|
||||
{
|
||||
"theme": theme,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"page": label,
|
||||
"dom": page.evaluate(
|
||||
"""()=>{const rect=s=>{const n=document.querySelector(s);if(!n)return null;const r=n.getBoundingClientRect(),c=getComputedStyle(n);return {x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom,font:c.font,fontFamily:c.fontFamily,color:c.color,background:c.backgroundColor}};return {overflow:document.documentElement.scrollWidth>innerWidth,theme:document.documentElement.dataset.theme,main:rect('main'),resume:rect('.resume'),library:rect('.lib2'),filter:rect('.mg-side'),detail:rect('.lib-detail'),models:rect('.mdl'),modelDetail:rect('.mdl-detail'),band:rect('.ptband'),dock:rect('.studio-form'),media:rect('.stage-media .print'),generate:rect('.gen')}}"""
|
||||
),
|
||||
}
|
||||
)
|
||||
(out / "geometry.json").write_text(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2)
|
||||
)
|
||||
if args.motion_only:
|
||||
from motion import record_motion
|
||||
|
||||
record_motion(page, data, out)
|
||||
elif not args.quick:
|
||||
from exercise import exercise
|
||||
|
||||
exercise(page, data, out)
|
||||
(out / "browser-errors.json").write_text(
|
||||
json.dumps(errors, ensure_ascii=False, indent=2)
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"screenshots": len(metrics),
|
||||
"browser_errors": errors,
|
||||
"output": str(out),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
context.close()
|
||||
browser.close()
|
||||
finally:
|
||||
# Compare against the workspace daemon before terminating only our own child.
|
||||
raw = subprocess.check_output(
|
||||
["multica", "daemon", "status", "--output", "json"], text=True
|
||||
)
|
||||
(out / "daemon-status.json").write_text(raw)
|
||||
daemon = json.loads(raw)
|
||||
|
||||
def pids(obj):
|
||||
if isinstance(obj, dict):
|
||||
return [v for k, v in obj.items() if k in ("pid", "process_id")] + [
|
||||
n for v in obj.values() for n in pids(v)
|
||||
]
|
||||
if isinstance(obj, list):
|
||||
return [n for v in obj for n in pids(v)]
|
||||
return []
|
||||
|
||||
if proc.pid not in pids(daemon):
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
log.close()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Compare approved original tokens with product CSS; screenshots remain separate artifacts."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--reference", type=Path, required=True)
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
args = p.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
tokens = [
|
||||
"--bg-0",
|
||||
"--bg-1",
|
||||
"--bg-2",
|
||||
"--tx-1",
|
||||
"--tx-2",
|
||||
"--tx-3",
|
||||
"--gold",
|
||||
"--sc-image",
|
||||
"--sc-video",
|
||||
"--sc-manage",
|
||||
"--font",
|
||||
"--fs-body",
|
||||
"--studio-side",
|
||||
"--lib-filter",
|
||||
"--lib-detail",
|
||||
"--lib-tile-min",
|
||||
"--mdl-detail",
|
||||
"--mdl-list-row",
|
||||
"--ptband-max-h",
|
||||
]
|
||||
css = (root / "calliope-web/static/themes.css").read_text() + (
|
||||
root / "calliope-web/src/routes/app.css"
|
||||
).read_text()
|
||||
results = []
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox"],
|
||||
env={
|
||||
**os.environ,
|
||||
"LD_LIBRARY_PATH": os.environ.get(
|
||||
"HEL840_BROWSER_LIBS",
|
||||
str(
|
||||
root
|
||||
/ "evidence/hel840/browser-runtime/libs/usr/lib/x86_64-linux-gnu"
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
reference = browser.new_page()
|
||||
product = browser.new_page()
|
||||
reference.goto(args.reference.resolve().as_uri())
|
||||
product.set_content(
|
||||
"<!doctype html><style>" + css + "</style><body>CSS token probe</body>"
|
||||
)
|
||||
for theme in ["night", "day", "grade", "bluehour"]:
|
||||
for width, height in [(1440, 900), (1366, 768), (1280, 720)]:
|
||||
entry = {"theme": theme, "width": width, "height": height}
|
||||
for name, page in [("original", reference), ("product_css", product)]:
|
||||
page.set_viewport_size({"width": width, "height": height})
|
||||
page.evaluate(
|
||||
"(theme)=>document.documentElement.dataset.theme=theme", theme
|
||||
)
|
||||
entry[name] = page.evaluate(
|
||||
"(tokens)=>Object.fromEntries(tokens.map(t=>[t,getComputedStyle(document.documentElement).getPropertyValue(t).trim()]))",
|
||||
tokens,
|
||||
)
|
||||
entry["differences"] = {
|
||||
key: {
|
||||
"original": entry["original"][key],
|
||||
"product_css": entry["product_css"][key],
|
||||
}
|
||||
for key in tokens
|
||||
if "".join(entry["original"][key].split())
|
||||
!= "".join(entry["product_css"][key].split())
|
||||
}
|
||||
results.append(entry)
|
||||
browser.close()
|
||||
args.output.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"method": "Approved original browser CSS vs product source CSS evaluation; actual product DOM measurements are in geometry.json. No pixel equality is claimed for different media/text.",
|
||||
"entries": results,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"entries": len(results),
|
||||
"differences": sum(len(r["differences"]) for r in results),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Interaction assertions use the product UI and live isolated backend, without response interception."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
def exercise(page, data: Path, out: Path):
|
||||
state_dir = out / "states"
|
||||
state_dir.mkdir(exist_ok=True)
|
||||
checks = []
|
||||
control = data / "control.json"
|
||||
base = "http://127.0.0.1:18740"
|
||||
|
||||
def mode(**kw):
|
||||
control.write_text(json.dumps(kw))
|
||||
|
||||
def save(name):
|
||||
page.screenshot(path=str(state_dir / (name + ".png")))
|
||||
checks.append(name)
|
||||
print("verified " + name, flush=True)
|
||||
|
||||
def desk():
|
||||
mode()
|
||||
page.goto(base + "/image")
|
||||
page.locator("#desk-prompt").wait_for()
|
||||
|
||||
def input(text):
|
||||
page.locator("#desk-prompt").fill(text)
|
||||
|
||||
def translate():
|
||||
page.locator(".pt-buttons").first.get_by_role("button").first.click()
|
||||
|
||||
def result():
|
||||
page.locator(".pt-res").wait_for()
|
||||
|
||||
def close():
|
||||
page.get_by_role("button", name="关闭提示词结果").click()
|
||||
|
||||
def button(label):
|
||||
return page.locator(".ptband").get_by_role("button", name=label, exact=True)
|
||||
|
||||
page.set_viewport_size({"width": 1440, "height": 900})
|
||||
desk()
|
||||
save("S00-创作台基准")
|
||||
original = "一只猫,<lora:cat:0.7>,(neon:1.2)"
|
||||
input(original)
|
||||
translate()
|
||||
result()
|
||||
expect(page.locator("#desk-prompt")).to_have_value(original)
|
||||
save("S01-翻译预览-原文未改")
|
||||
button("换向重译").click()
|
||||
result()
|
||||
expect(page.locator(".pt-dir")).to_contain_text("英→中")
|
||||
save("S02-显式换向")
|
||||
close()
|
||||
page.locator(".pt-buttons").first.get_by_role("button").nth(1).click()
|
||||
result()
|
||||
expect(page.locator(".pt-dir")).to_have_text("同语言润色")
|
||||
save("S03-润色结果")
|
||||
close()
|
||||
mode(delay=0.8)
|
||||
translate()
|
||||
expect(page.locator(".pt-wait")).to_be_visible()
|
||||
save("S04-处理中可取消")
|
||||
button("取消").click()
|
||||
page.wait_for_timeout(950)
|
||||
expect(page.locator(".ptband")).to_have_count(0)
|
||||
expect(page.locator("#desk-prompt")).to_have_value(original)
|
||||
save("S05-取消迟到丢弃")
|
||||
translate()
|
||||
input("改动后的文本")
|
||||
input(original)
|
||||
page.wait_for_timeout(950)
|
||||
expect(page.locator(".pt-res")).to_have_count(0)
|
||||
expect(page.locator(".pt-confirm")).to_contain_text("结果已丢弃")
|
||||
save("S06-在途ABA编辑丢弃")
|
||||
mode()
|
||||
button("按当前文本重新处理").click()
|
||||
result()
|
||||
input("结果出现后新输入")
|
||||
save("S07-结果后编辑")
|
||||
button("替换").click()
|
||||
expect(button("仍然替换")).to_be_visible()
|
||||
expect(page.locator("#desk-prompt")).to_have_value("结果出现后新输入")
|
||||
save("S08-替换二次确认")
|
||||
button("仍然替换").click()
|
||||
expect(page.locator(".ptband")).to_have_count(0)
|
||||
save("S09-替换撤销")
|
||||
page.get_by_role("button", name="撤销", exact=True).click()
|
||||
expect(page.locator("#desk-prompt")).to_have_value("结果出现后新输入")
|
||||
input(original)
|
||||
translate()
|
||||
result()
|
||||
button("替换").click()
|
||||
input("新的文本")
|
||||
input(
|
||||
"A cat in soft side lighting, cinematic composition, <lora:cat:0.7>, (neon:1.2)"
|
||||
)
|
||||
page.get_by_role("button", name="撤销", exact=True).click()
|
||||
expect(
|
||||
page.get_by_text("内容已变化或已切换,未自动恢复", exact=True)
|
||||
).to_be_visible()
|
||||
save("S10-编辑后撤销被拒")
|
||||
input(original)
|
||||
mode(delay=0.8)
|
||||
translate()
|
||||
page.locator(".workflow-pick").first.click()
|
||||
page.locator(".workflow-pick").last.click()
|
||||
page.wait_for_timeout(950)
|
||||
expect(page.locator(".ptband")).to_have_count(0)
|
||||
save("S11-工作流ABA切换失效")
|
||||
mode(status=401)
|
||||
translate()
|
||||
page.locator(".pt-error").wait_for()
|
||||
expect(page.locator(".pt-error")).to_contain_text("密钥")
|
||||
save("S12-认证失败真实服务响应")
|
||||
close()
|
||||
mode(delay=3)
|
||||
translate()
|
||||
page.locator(".pt-error").wait_for()
|
||||
expect(page.locator(".pt-error")).to_contain_text("超时")
|
||||
save("S13-总超时")
|
||||
close()
|
||||
mode(broken=True)
|
||||
translate()
|
||||
page.locator(".pt-error").wait_for()
|
||||
expect(page.locator(".pt-error")).to_contain_text("技术片段")
|
||||
expect(page.locator(".pt-res")).to_have_count(0)
|
||||
save("S14-片段破坏纠正后仍失败")
|
||||
close()
|
||||
mode()
|
||||
page.locator("#desk-negative").fill("模糊,低画质")
|
||||
page.locator(".pt-buttons").nth(1).get_by_role("button").first.click()
|
||||
result()
|
||||
expect(page.locator(".pt-h")).to_contain_text("反向")
|
||||
save("S15-反向提示词")
|
||||
page.goto(base + "/video")
|
||||
page.locator("#desk-prompt").fill("镜头缓慢推进")
|
||||
translate()
|
||||
result()
|
||||
save("S16-视频提示词")
|
||||
page.set_viewport_size({"width": 1280, "height": 720})
|
||||
close()
|
||||
page.locator(".pt-buttons").first.get_by_role("button").nth(1).click()
|
||||
result()
|
||||
save("S17-小窗润色")
|
||||
close()
|
||||
mode(delay=0.8)
|
||||
translate()
|
||||
save("S18-小窗处理中")
|
||||
button("取消").click()
|
||||
mode(status=401)
|
||||
translate()
|
||||
page.locator(".pt-error").wait_for()
|
||||
save("S19-小窗失败")
|
||||
close()
|
||||
save("S20-小窗基准")
|
||||
mode()
|
||||
page.set_viewport_size({"width": 1440, "height": 900})
|
||||
# Empty/long and the three editable workbench panels use the same real control.
|
||||
desk()
|
||||
input("")
|
||||
expect(
|
||||
page.locator(".pt-buttons").first.get_by_role("button").first
|
||||
).to_be_disabled()
|
||||
input("猫" * 20001)
|
||||
translate()
|
||||
page.locator(".pt-error").wait_for()
|
||||
expect(page.locator(".pt-error")).to_contain_text("20000")
|
||||
checks.append("empty-and-too-long")
|
||||
settings = page.request.get(base + "/api/settings").json()
|
||||
page.goto(base + "/settings")
|
||||
page.get_by_role("button", name="去模型分工修改", exact=True).click()
|
||||
expect(page.locator("#role-translate")).to_be_visible()
|
||||
save("M01-设置跳转高亮")
|
||||
select = page.locator("#role-translate select")
|
||||
select.select_option("fixture-b")
|
||||
page.wait_for_timeout(300)
|
||||
page.locator("#role-polish select").select_option("fixture-a")
|
||||
page.wait_for_timeout(300)
|
||||
page.reload()
|
||||
page.locator("#role-polish select").wait_for()
|
||||
expect(page.locator("#role-translate select")).to_have_value("fixture-b")
|
||||
expect(page.locator("#role-polish select")).to_have_value("fixture-a")
|
||||
checks.append("independent-assignments-after-reload")
|
||||
page.request.post(
|
||||
base + "/api/settings",
|
||||
data={"agent_llm_assignments": settings["agent_llm_assignments"]},
|
||||
)
|
||||
profile_list = [
|
||||
{k: p[k] for k in ["id", "name", "model", "base_url"]}
|
||||
for p in settings["llm_profiles"]
|
||||
]
|
||||
unavailable = {**profile_list[0], "base_url": "http://127.0.0.1:1/v1"}
|
||||
page.request.post(base + "/api/settings", data={"llm_profiles": [unavailable]})
|
||||
page.reload()
|
||||
page.locator(".mrow").wait_for()
|
||||
expect(page.locator(".mrow")).to_have_count(1)
|
||||
page.locator(".mrow").first.get_by_role("button", name="测试", exact=True).click()
|
||||
expect(page.locator(".mrow .spill")).to_contain_text("连接失败")
|
||||
save("M02-仅默认档案")
|
||||
page.goto(base + "/image")
|
||||
page.locator("#desk-prompt").fill("一只猫")
|
||||
translate()
|
||||
page.locator(".pt-error").wait_for()
|
||||
expect(page.locator(".pt-error")).to_contain_text("连接失败")
|
||||
save("S12b-默认地址未启动")
|
||||
|
||||
page.request.post(
|
||||
base + "/api/settings",
|
||||
data={
|
||||
"llm_profiles": profile_list,
|
||||
"agent_llm_assignments": settings["agent_llm_assignments"],
|
||||
},
|
||||
)
|
||||
page.goto(base + "/connections?tab=llm")
|
||||
page.locator(".mrow").first.wait_for()
|
||||
mode(status=401)
|
||||
page.locator(".mrow").first.get_by_role("button", name="测试", exact=True).click()
|
||||
expect(page.locator(".mdl-alert")).to_be_visible()
|
||||
save("M03-模型测试失败")
|
||||
page.get_by_role("button", name="添加模型", exact=True).click()
|
||||
page.locator(".md-b input").nth(0).fill("本地不先测试")
|
||||
page.locator(".md-b input").nth(1).fill("http://127.0.0.1:1/v1")
|
||||
page.locator(".md-b input").nth(2).fill("local")
|
||||
expect(page.get_by_role("button", name="保存", exact=True)).to_be_enabled()
|
||||
save("M04-未测试可以保存")
|
||||
page.goto(base + "/connections?tab=comfy")
|
||||
page.locator(".mrow").first.wait_for()
|
||||
page.locator(".mrow").first.get_by_role("button", name="测试", exact=True).click()
|
||||
expect(page.locator(".mrow .spill")).to_contain_text("已连接")
|
||||
page.locator(".mrow").first.get_by_role("button", name="编辑", exact=True).click()
|
||||
save("M05-Comfy-未知如实显示")
|
||||
page.goto(base + "/settings")
|
||||
page.get_by_role("button", name="去模型分工修改").wait_for()
|
||||
save("P01-设置只读工具卡")
|
||||
mode()
|
||||
for stage in ["style", "assets", "shot"]:
|
||||
page.goto(base + f"/workbench/1?stage={stage}")
|
||||
page.get_by_role("button", name="编辑", exact=True).click()
|
||||
page.locator(".prompt-editor textarea").first.wait_for()
|
||||
before = page.locator(".prompt-editor textarea").first.input_value()
|
||||
page.locator(".prompt-editor .pt-buttons").first.get_by_role(
|
||||
"button"
|
||||
).first.click()
|
||||
result()
|
||||
expect(page.locator(".prompt-editor textarea").first).to_have_value(before)
|
||||
save("workbench-" + stage + "-shared-control")
|
||||
# Library: native loaded metadata, selection, full screen, safe delete, attach refresh.
|
||||
mode()
|
||||
page.goto(base + "/library")
|
||||
page.locator(".lt").first.wait_for()
|
||||
page.locator(".lt .ck").nth(0).click(force=True)
|
||||
page.locator(".lt .ck").nth(1).click(force=True)
|
||||
expect(page.locator(".selbar")).to_be_visible()
|
||||
save("L01-多选选择条")
|
||||
page.locator(".selbar").get_by_role("button", name="删除", exact=True).click()
|
||||
page.get_by_role("dialog").wait_for()
|
||||
save("L02-删除确认")
|
||||
page.get_by_role("dialog").get_by_role("button", name="取消", exact=True).click()
|
||||
page.locator(".selbar").get_by_role("button", name="取消", exact=True).click()
|
||||
video = page.locator(".lt").filter(has_text="视频样本.webm")
|
||||
video.locator(".mat").click()
|
||||
expect(page.locator(".kv")).to_contain_text("640 × 360")
|
||||
save("L03-视频元数据")
|
||||
page.locator(".lt").filter(has_text="真实读取失败.png").locator(".mat").click()
|
||||
expect(page.locator(".kv")).to_contain_text("未知")
|
||||
save("L04-真实读取失败")
|
||||
page.locator(".lt").filter(has_text="样本素材-01.png").locator(".mat").dblclick()
|
||||
page.get_by_role("dialog").wait_for()
|
||||
save("L05-全屏预览")
|
||||
page.keyboard.press("Escape")
|
||||
page.get_by_role("textbox", name="文件名查找").fill("no-match")
|
||||
save("L06a-筛选空态")
|
||||
uploads = data / "assets" / "uploads"
|
||||
hidden = data / "assets" / "uploads-empty-state-backup"
|
||||
uploads.rename(hidden)
|
||||
try:
|
||||
page.reload()
|
||||
expect(page.get_by_text("还没有素材", exact=True)).to_be_visible()
|
||||
save("L06-真正空目录")
|
||||
finally:
|
||||
hidden.rename(uploads)
|
||||
page.reload()
|
||||
page.locator(".lt").first.wait_for()
|
||||
page.locator(".lt").filter(has_text="样本素材-01.png").locator(".mat").click()
|
||||
page.locator(".ld-f").get_by_role("button", name="存入项目").click()
|
||||
page.get_by_role("dialog").get_by_role(
|
||||
"button", name="添加到项目", exact=True
|
||||
).click()
|
||||
page.get_by_role("dialog").locator("select").first.select_option("1")
|
||||
save("L07-存入项目")
|
||||
page.get_by_role("dialog").get_by_role("button", name="添加", exact=True).click()
|
||||
expect(page.locator(".lt").filter(has_text="样本素材-01.png")).to_have_count(0)
|
||||
save("L08-存入后刷新移出")
|
||||
for n in range(7):
|
||||
page.request.post(
|
||||
base + "/api/projects",
|
||||
data={
|
||||
"title": "一段很长很长的项目标题,用来核验多行省略和边界 · "
|
||||
+ str(n + 1)
|
||||
},
|
||||
)
|
||||
page.goto(base + "/")
|
||||
page.locator(".pg2").wait_for()
|
||||
save("W01-多项目长标题")
|
||||
import sqlite3
|
||||
|
||||
source = sqlite3.connect(data / "fixture.db")
|
||||
backup = sqlite3.connect(data / "before-empty.db")
|
||||
source.backup(backup)
|
||||
backup.close()
|
||||
source.close()
|
||||
for project in page.request.get(base + "/api/projects").json():
|
||||
response = page.request.delete(base + "/api/projects/" + str(project["id"]))
|
||||
assert response.ok
|
||||
page.reload()
|
||||
expect(page.get_by_text("开始你的第一个片子", exact=True)).to_be_visible()
|
||||
save("W02-真正无项目空态")
|
||||
# Theme/motion state preservation and double ring unchanged: actual UI transitions.
|
||||
import re
|
||||
|
||||
desk()
|
||||
input("保留我的输入")
|
||||
page.locator("#desk-negative").fill("保留反向")
|
||||
for theme in ["夜片场", "日光棚", "调色间", "蓝调时刻"]:
|
||||
page.get_by_role("button", name="主题", exact=True).click()
|
||||
page.get_by_role("dialog").get_by_role("button", name=re.compile(theme)).click()
|
||||
for motion in ["完整", "克制", "关闭"]:
|
||||
page.get_by_role("dialog").get_by_role(
|
||||
"button", name=motion, exact=True
|
||||
).click()
|
||||
expect(page.locator("#desk-prompt")).to_have_value("保留我的输入")
|
||||
expect(page.locator("#desk-negative")).to_have_value("保留反向")
|
||||
page.keyboard.press("Escape")
|
||||
checks.append("four-themes-three-motion-preserve-both-prompts")
|
||||
|
||||
desk()
|
||||
input("保留我的输入")
|
||||
page.goto(base + "/agents")
|
||||
page.wait_for_timeout(400)
|
||||
for motion in ["full", "calm", "off"]:
|
||||
page.evaluate('(motion)=>localStorage.setItem("aigc-motion",motion)', motion)
|
||||
page.reload()
|
||||
page.wait_for_timeout(500)
|
||||
save("motion-" + motion + "-agent-rings")
|
||||
control.write_text("{}")
|
||||
(out / "interaction-checks.json").write_text(
|
||||
json.dumps(checks, ensure_ascii=False, indent=2)
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Record all approved viewport/motion combinations against actual product controls."""
|
||||
|
||||
import json
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
def record_motion(page, data, out):
|
||||
base = "http://127.0.0.1:18740"
|
||||
checks = []
|
||||
screenshots = out / "motion"
|
||||
screenshots.mkdir(exist_ok=True)
|
||||
(data / "control.json").write_text("{}")
|
||||
|
||||
def geometry():
|
||||
return page.evaluate(
|
||||
"""()=>{const media=document.querySelector('.stage-media .print').getBoundingClientRect(),dock=document.querySelector('.studio-form').getBoundingClientRect(),button=document.querySelector('.gen').getBoundingClientRect();return {mediaHeight:media.height,mediaBottom:media.bottom,dockTop:dock.top,gap:dock.top-media.bottom,generateBottom:button.bottom,viewport:innerHeight}}"""
|
||||
)
|
||||
|
||||
for width, height in [(1440, 900), (1366, 768), (1280, 720)]:
|
||||
page.set_viewport_size({"width": width, "height": height})
|
||||
for mode, label in [("full", "完整"), ("calm", "克制"), ("off", "关闭")]:
|
||||
page.goto(base + "/image")
|
||||
page.locator(".stage-media .print img").wait_for()
|
||||
page.locator("#desk-prompt").fill("保留我的输入,柔和侧光")
|
||||
page.get_by_role("button", name="主题", exact=True).click()
|
||||
page.get_by_role("dialog").get_by_role(
|
||||
"button", name=label, exact=True
|
||||
).click()
|
||||
page.keyboard.press("Escape")
|
||||
before = geometry()
|
||||
page.locator(".pt-buttons").first.get_by_role("button").first.click()
|
||||
page.locator(".pt-res").wait_for()
|
||||
opened = geometry()
|
||||
expect(page.locator("#desk-prompt")).to_have_value("保留我的输入,柔和侧光")
|
||||
page.screenshot(path=str(screenshots / f"{width}-{mode}-result.png"))
|
||||
page.get_by_role("button", name="关闭提示词结果").click()
|
||||
page.wait_for_timeout(100)
|
||||
after = geometry()
|
||||
assert opened["gap"] >= 47 and opened["generateBottom"] <= height
|
||||
assert abs(before["mediaHeight"] - after["mediaHeight"]) <= 1
|
||||
checks.append(
|
||||
{
|
||||
"viewport": [width, height],
|
||||
"mode": mode,
|
||||
"before": before,
|
||||
"opened": opened,
|
||||
"after": after,
|
||||
}
|
||||
)
|
||||
print(f"verified motion {width} {mode}", flush=True)
|
||||
page.set_viewport_size({"width": 1440, "height": 900})
|
||||
for mode, label in [("full", "完整"), ("calm", "克制"), ("off", "关闭")]:
|
||||
page.goto(base + "/agents")
|
||||
page.get_by_role("button", name="主题", exact=True).click()
|
||||
page.get_by_role("dialog").get_by_role("button", name=label, exact=True).click()
|
||||
page.keyboard.press("Escape")
|
||||
# Full mode restarts only on a new page/task trigger by the approved existing policy.
|
||||
page.reload()
|
||||
page.wait_for_timeout(1000)
|
||||
assert page.locator(".orb .ring").count() == 2
|
||||
rings = page.locator(".orb .ring").evaluate_all(
|
||||
"(nodes)=>nodes.map(n=>({animation:getComputedStyle(n).animationName,duration:getComputedStyle(n).animationDuration,display:getComputedStyle(n).display}))"
|
||||
)
|
||||
page.screenshot(path=str(screenshots / f"agent-rings-{mode}.png"))
|
||||
checks.append({"agent": mode, "rings": rings})
|
||||
(out / "motion-checks.json").write_text(
|
||||
json.dumps(checks, ensure_ascii=False, indent=2)
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Assemble separate approved originals, product evidence and reproducible metadata."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--approved", type=Path, required=True)
|
||||
parser.add_argument("--evidence", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
evidence = args.evidence.resolve()
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
kit = output / "review"
|
||||
kit.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def copy(source, target):
|
||||
target = kit / target
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
for p in (args.approved / "四主题三窗口").rglob("*.png"):
|
||||
copy(p, Path("originals") / p.relative_to(args.approved / "四主题三窗口"))
|
||||
for name in [
|
||||
"HEL-837-视觉规范-v2.md",
|
||||
"HEL-838-技术方案-v2.txt",
|
||||
"整合说明.txt",
|
||||
"HEL-837-交互样本-v2.html",
|
||||
]:
|
||||
copy(args.approved / name, Path("originals") / name)
|
||||
for p in (evidence / "delivery/matrix").rglob("*.png"):
|
||||
copy(p, Path("product") / p.relative_to(evidence / "delivery/matrix"))
|
||||
for p in (evidence / "default-verification/states").glob("*.png"):
|
||||
copy(p, Path("states") / p.name)
|
||||
for p in (evidence / "motion-hd/motion").glob("*.png"):
|
||||
copy(p, Path("motion") / p.name)
|
||||
for run, name in [("default-verification", "interactions"), ("motion-hd", "motion-hd")]:
|
||||
for i, p in enumerate((evidence / run / "recordings").glob("*.webm")):
|
||||
copy(p, Path("recordings") / f"{name}-{i}.webm")
|
||||
for run, name in [
|
||||
("delivery", "geometry.json"),
|
||||
("delivery", "token-comparison.json"),
|
||||
("default-verification", "interaction-checks.json"),
|
||||
("motion-hd", "motion-checks.json"),
|
||||
("restart", "restart-check.json"),
|
||||
]:
|
||||
copy(evidence / run / name, Path("metadata") / name)
|
||||
for name in [
|
||||
"lint-840.log",
|
||||
"check-840.log",
|
||||
"webtest-840.log",
|
||||
"build-840.log",
|
||||
"regression-840.log",
|
||||
"llm-regression-840.log",
|
||||
"test-840-py311.log",
|
||||
"ruff-840.log",
|
||||
"capture-840.log",
|
||||
"default-ui-840.log",
|
||||
"motion-840.log",
|
||||
]:
|
||||
copy(root / name, Path("logs") / name)
|
||||
for run in ["delivery", "default-verification", "motion-hd"]:
|
||||
copy(evidence / run / "server.log", Path("logs") / f"{run}-server.log")
|
||||
copy(
|
||||
evidence / run / "fixture/provider.jsonl",
|
||||
Path("logs") / f"{run}-provider.jsonl",
|
||||
)
|
||||
copy(evidence / "restart/restart-server.log", Path("logs/restart-server.log"))
|
||||
for p in (root / "scripts/hel840").glob("*"):
|
||||
if p.is_file():
|
||||
copy(p, Path("reproduction/scripts/hel840") / p.name)
|
||||
copy(root / "docs/changelogs/2026-10-24-hel840-v2.md", Path("实现与自测说明.md"))
|
||||
# Fake-only snapshots; never include daemon roster/state or installed configuration.
|
||||
for p in (evidence / "default-verification/fixture/assets/uploads").glob("*"):
|
||||
if p.is_file():
|
||||
copy(p, Path("reproduction/fixture/assets/uploads") / p.name)
|
||||
for name in ["before-empty.db", "calliope_config.json"]:
|
||||
copy(
|
||||
evidence / "default-verification/fixture" / name,
|
||||
Path("reproduction/fixture") / name,
|
||||
)
|
||||
head = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=root, text=True
|
||||
).strip()
|
||||
base = "415fb782362b2ac5b725e312ecbdbc9a782e9aa3"
|
||||
patch = subprocess.check_output(["git", "diff", "--binary", base, head], cwd=root)
|
||||
(kit / "reproduction/changes.patch").write_bytes(patch)
|
||||
manifest = {
|
||||
"head": head,
|
||||
"base": base,
|
||||
"branch": subprocess.check_output(
|
||||
["git", "branch", "--show-current"], cwd=root, text=True
|
||||
).strip(),
|
||||
"approved_archive_sha256": "dfe02579065200e8f0edaa3114709fc44286f232fdaa76c34acd3f3814523508",
|
||||
"counts": {
|
||||
"original_matrix": 60,
|
||||
"product_matrix": 60,
|
||||
"interaction_states": len(list((kit / "states").glob("*.png"))),
|
||||
"motion_stills": len(list((kit / "motion").glob("*.png"))),
|
||||
},
|
||||
"data_mapping": {
|
||||
"projects": "5 fixture projects matching loaded/covered/placeholder/completed states; W01 adds 7 long titles; W02 deletes only fixture projects via real API.",
|
||||
"models": "3 fixture profiles; first 2 probed by real settings test API; third untested. Translation follows active, polish assigned separately.",
|
||||
"media": "Generated QA files decoded in browser; one valid 69-byte PNG, corrupt PNG, and real WebM. No product hardcoded samples.",
|
||||
"queue": "20 real fixture DB rows, paused worker; 12 done plus running/pending/failed/cancelled. No GPU calls.",
|
||||
"originals": "Approved design originals remain unchanged; media/text/address/latency data differs, so pixel equality is not claimed.",
|
||||
},
|
||||
"files": {},
|
||||
}
|
||||
for p in kit.rglob("*"):
|
||||
if p.is_file():
|
||||
manifest["files"][str(p.relative_to(kit))] = hashlib.sha256(
|
||||
p.read_bytes()
|
||||
).hexdigest()
|
||||
(kit / "metadata/manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||
)
|
||||
(kit / "index.html").write_text(
|
||||
"""<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>HEL-840 原件 / 实际产品对照</title><style>body{margin:20px;background:#10131a;color:#eee;font:14px system-ui}select{margin:8px;padding:8px}main{display:grid;grid-template-columns:1fr 1fr;gap:12px}img{width:100%;border:1px solid #454b58}a{color:#a8d7ff}p{max-width:1100px;line-height:1.7}h2{font-size:15px}</style><h1>HEL-840 · 原件与实际产品</h1><p>左:批准原稿;右:本地构建实际产品,经真实前后端与 HTTP 假服务取数。项目、图片、地址和计数为分别保留的数据,不声称逐像素相同。对比布局、样式和操作状态;实际 DOM、字体/颜色、token 核对、交互和进程重启记录见 metadata,脚本见 reproduction。最终部署由总工独立重拍。</p><select id="theme"><option>night</option><option>day</option><option>grade</option><option>bluehour</option></select><select id="size"><option>1440x900</option><option>1366x768</option><option>1280x720</option></select><select id="page"><option>01-工作室</option><option>02-素材库</option><option>03-模型连接</option><option>04-创作台翻译结果</option><option>05-设置</option></select><main><section><h2>批准原件</h2><img id="original"></section><section><h2>实际产品</h2><img id="product"></section></main><p><a href="metadata/manifest.json">版本 / 数据映射 / 哈希清单</a> · <a href="metadata/geometry.json">实际产品 DOM</a> · <a href="metadata/token-comparison.json">原件 token 核对</a> · <a href="metadata/interaction-checks.json">48 项交互断言</a> · <a href="metadata/motion-checks.json">九组动效 / 双环记录</a> · <a href="metadata/restart-check.json">真实进程重启</a></p><script>function show(){const path=theme.value+'/'+page.value+'-'+size.value+'.png';original.src='originals/'+path;product.src='product/'+path}document.querySelectorAll('select').forEach(s=>s.onchange=show);show()</script></html>""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for path, source in [
|
||||
(output / "HEL-840-原样本与实际产品证据.zip", kit),
|
||||
(output / "HEL-840-前端构建.zip", root / "calliope-web/build"),
|
||||
]:
|
||||
with zipfile.ZipFile(
|
||||
path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||
) as archive:
|
||||
for p in source.rglob("*"):
|
||||
if p.is_file():
|
||||
archive.write(p, p.relative_to(source))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"head": head,
|
||||
"counts": manifest["counts"],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": p.name,
|
||||
"bytes": p.stat().st_size,
|
||||
"sha256": hashlib.sha256(p.read_bytes()).hexdigest(),
|
||||
}
|
||||
for p in output.glob("*.zip")
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Actual server stop/restart and role-routing persistence, isolated from user data."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
base = "http://127.0.0.1:18740"
|
||||
log = (out / "restart-server.log").open("w")
|
||||
owned = []
|
||||
|
||||
|
||||
def request(method, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
base + path,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def start(resume=False):
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(root / "calliope-backend/.venv/bin/python"),
|
||||
str(root / "scripts/hel840/serve_fixture.py"),
|
||||
"--data",
|
||||
str(out / "fixture"),
|
||||
*(["--resume"] if resume else []),
|
||||
],
|
||||
cwd=root,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
owned.append(proc)
|
||||
for _ in range(100):
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError("fixture backend exited")
|
||||
try:
|
||||
request("GET", "/api/health")
|
||||
return proc
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError("fixture readiness timeout")
|
||||
|
||||
|
||||
def stop(proc):
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
status = json.loads(
|
||||
subprocess.check_output(
|
||||
["multica", "daemon", "status", "--output", "json"], text=True
|
||||
)
|
||||
)
|
||||
|
||||
def pids(obj):
|
||||
if isinstance(obj, dict):
|
||||
return [v for k, v in obj.items() if k in ("pid", "process_id")] + [
|
||||
n for v in obj.values() for n in pids(v)
|
||||
]
|
||||
if isinstance(obj, list):
|
||||
return [n for v in obj for n in pids(v)]
|
||||
return []
|
||||
|
||||
assert proc.pid not in pids(status)
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
|
||||
|
||||
try:
|
||||
first = start()
|
||||
request(
|
||||
"POST", "/api/settings", {"agent_llm_assignments": {"translate": "fixture-b"}}
|
||||
)
|
||||
request("POST", "/api/settings", {"agent_llm_assignments": {"polish": "fixture-a"}})
|
||||
saved = request("GET", "/api/settings")["agent_llm_assignments"]
|
||||
assert saved["translate"] == "fixture-b" and saved["polish"] == "fixture-a"
|
||||
stop(first)
|
||||
second = start(True)
|
||||
loaded = request("GET", "/api/settings")["agent_llm_assignments"]
|
||||
assert loaded == saved
|
||||
translation = request(
|
||||
"POST", "/api/prompt-tools/transform", {"action": "translate", "text": "一只猫"}
|
||||
)
|
||||
polish = request(
|
||||
"POST", "/api/prompt-tools/transform", {"action": "polish", "text": "一只猫"}
|
||||
)
|
||||
assert (
|
||||
translation["model"]["id"] == "fixture-b"
|
||||
and polish["model"]["id"] == "fixture-a"
|
||||
)
|
||||
result = {
|
||||
"before_pid": first.pid,
|
||||
"after_pid": second.pid,
|
||||
"assignments": loaded,
|
||||
"translation_model": translation["model"],
|
||||
"polish_model": polish["model"],
|
||||
"passed": True,
|
||||
}
|
||||
(out / "restart-check.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2)
|
||||
)
|
||||
print(
|
||||
json.dumps({"passed": True, "before_pid": first.pid, "after_pid": second.pid})
|
||||
)
|
||||
finally:
|
||||
for proc in owned:
|
||||
stop(proc)
|
||||
log.close()
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Local isolated product instance + HTTP fake LLM. Never touches deployment data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from calliope import config
|
||||
from calliope.db import get_db, migrate_db
|
||||
from calliope.main import create_app
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", required=True, type=Path)
|
||||
parser.add_argument("--port", type=int, default=18740)
|
||||
parser.add_argument("--resume", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = args.data.resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
config.CONFIG_FILE = root / "calliope_config.json"
|
||||
s = config.settings
|
||||
prior_assignments = None
|
||||
if args.resume:
|
||||
s.load_config_file()
|
||||
prior_assignments = dict(s.agent_llm_assignments)
|
||||
s.data_dir = root
|
||||
s.assets_dir = root / "assets"
|
||||
s.assets_dir.mkdir(exist_ok=True)
|
||||
s.db_name = "fixture.db"
|
||||
s.dry_run = True
|
||||
s.agent_shell_enabled = False
|
||||
s.comfyui_endpoints = [
|
||||
{
|
||||
"id": "fixture-comfy",
|
||||
"name": "本地隔离假服务",
|
||||
"base_url": "http://127.0.0.1:1",
|
||||
"auth_type": "none",
|
||||
"auth_token": "",
|
||||
}
|
||||
]
|
||||
s.comfyui_active_id = "fixture-comfy"
|
||||
|
||||
|
||||
# Fake provider is intentionally a real HTTP boundary; product code is unchanged.
|
||||
class Fake(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
payload = json.dumps(
|
||||
{
|
||||
"system": {"comfyui_version": "fixture-0.3"},
|
||||
"devices": [{"name": "Fixture GPU"}],
|
||||
}
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_POST(self):
|
||||
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
control_path = root / "control.json"
|
||||
control = json.loads(control_path.read_text()) if control_path.exists() else {}
|
||||
with (root / "provider.jsonl").open("a") as f:
|
||||
# No prompt or secret logging: actual model, direction instruction, stream and timestamps only.
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
"at": time.time(),
|
||||
"model": payload.get("model"),
|
||||
"stream": payload.get("stream"),
|
||||
"phase": "start",
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
if control.get("blocking") and payload.get("stream"):
|
||||
self.send_response(405)
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(control.get("status", 200))
|
||||
self.send_header(
|
||||
"Content-Type",
|
||||
"text/event-stream" if payload.get("stream") else "application/json",
|
||||
)
|
||||
self.end_headers()
|
||||
if control.get("status", 200) != 200:
|
||||
return
|
||||
text = next(
|
||||
(m["content"] for m in payload["messages"] if m["role"] == "user"), "ping"
|
||||
)
|
||||
from calliope.routers.prompt_tools import fragments
|
||||
|
||||
protected = fragments(text)
|
||||
instruction = payload["messages"][0]["content"]
|
||||
result = (
|
||||
"一只猫,柔和侧光,电影质感"
|
||||
if "from English to Chinese" in instruction
|
||||
else "A cat in soft side lighting, cinematic composition"
|
||||
) + (" · 润色" if "Polish" in instruction else "")
|
||||
if protected:
|
||||
result += ", " + ", ".join(protected)
|
||||
if control.get("broken"):
|
||||
result = "Broken output"
|
||||
delay = control.get("delay", 0.15)
|
||||
try:
|
||||
time.sleep(delay)
|
||||
if payload.get("stream"):
|
||||
for piece in [result[: len(result) // 2], result[len(result) // 2 :]]:
|
||||
self.wfile.write(
|
||||
(
|
||||
"data: "
|
||||
+ json.dumps({"choices": [{"delta": {"content": piece}}]})
|
||||
+ "\n\n"
|
||||
).encode()
|
||||
)
|
||||
self.wfile.flush()
|
||||
time.sleep(control.get("chunk_delay", 0.05))
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
else:
|
||||
self.wfile.write(
|
||||
json.dumps({"choices": [{"message": {"content": result}}]}).encode()
|
||||
)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
with (root / "provider.jsonl").open("a") as f:
|
||||
f.write(json.dumps({"at": time.time(), "phase": "disconnected"}) + "\n")
|
||||
|
||||
|
||||
provider = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
|
||||
thread = threading.Thread(target=provider.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
s.llm_profiles = [
|
||||
{
|
||||
"id": "fixture-a",
|
||||
"name": "隔离翻译模型",
|
||||
"model": "fixture-translate",
|
||||
"base_url": f"http://127.0.0.1:{provider.server_port}/v1",
|
||||
"api_key": "",
|
||||
},
|
||||
{
|
||||
"id": "fixture-b",
|
||||
"name": "隔离润色模型",
|
||||
"model": "fixture-polish",
|
||||
"base_url": f"http://127.0.0.1:{provider.server_port}/v1",
|
||||
"api_key": "",
|
||||
},
|
||||
]
|
||||
s.comfyui_endpoints[0]["base_url"] = f"http://127.0.0.1:{provider.server_port}"
|
||||
s.llm_profiles.append(
|
||||
{
|
||||
"id": "fixture-c",
|
||||
"name": "本地候选模型",
|
||||
"model": "fixture-local",
|
||||
"base_url": "http://127.0.0.1:1/v1",
|
||||
"api_key": "",
|
||||
}
|
||||
)
|
||||
s.llm_active_id = "fixture-a"
|
||||
s.agent_llm_assignments = {
|
||||
"translate": None,
|
||||
"polish": "fixture-b",
|
||||
"style": "fixture-b",
|
||||
"video": "fixture-b",
|
||||
}
|
||||
s.apply_active_llm()
|
||||
if prior_assignments is not None:
|
||||
s.agent_llm_assignments = prior_assignments
|
||||
s.save_config_file()
|
||||
asyncio.run(migrate_db(s.db_path))
|
||||
from calliope.routers import prompt_tools
|
||||
|
||||
prompt_tools.TOTAL_TIMEOUT = 1.5 # Accelerated total clock only in isolated QA process.
|
||||
# A fixture-only deterministic media set, separate from product source and approved originals.
|
||||
uploads = s.assets_dir / "uploads"
|
||||
uploads.mkdir(exist_ok=True)
|
||||
for index in range(14):
|
||||
path = uploads / f"样本素材-{index + 1:02}.png"
|
||||
im = Image.new(
|
||||
"RGB",
|
||||
(960 if index % 2 else 640, 640 if index % 2 else 960),
|
||||
(28 + index * 8, 45 + index * 6, 60 + index * 5),
|
||||
)
|
||||
draw = ImageDraw.Draw(im)
|
||||
draw.ellipse((80, 80, 400, 400), fill=(190, 145, 80))
|
||||
draw.rectangle((100, 430, 530, 530), fill=(40, 110, 125))
|
||||
im.save(path)
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (640, 360), (34, 75, 95)).save(buffer, format="JPEG")
|
||||
ffmpeg = Path.home() / ".cache/ms-playwright/ffmpeg-1011/ffmpeg-linux"
|
||||
if ffmpeg.exists():
|
||||
subprocess.run(
|
||||
[
|
||||
str(ffmpeg),
|
||||
"-y",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-r",
|
||||
"12",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-c:v",
|
||||
"libvpx",
|
||||
"-f",
|
||||
"webm",
|
||||
str(uploads / "视频样本.webm"),
|
||||
],
|
||||
input=buffer.getvalue() * 24,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
(uploads / "真实读取失败.png").write_bytes(b"bad media")
|
||||
Image.new("RGB", (1, 1), (255, 0, 0)).save(uploads / "小于1KB可解码.png")
|
||||
conn = get_db(s.db_path)
|
||||
if not conn.execute("SELECT 1 FROM projects LIMIT 1").fetchone():
|
||||
for index in range(5):
|
||||
title = [
|
||||
"雾港来信",
|
||||
"夏日站台",
|
||||
"城市漫游者",
|
||||
"旧照片里的猫",
|
||||
"HEL690 冒烟项目",
|
||||
][index]
|
||||
cover = (
|
||||
str(uploads / f"样本素材-{index + 1:02}.png")
|
||||
if index not in [2, 4]
|
||||
else None
|
||||
)
|
||||
pid = conn.execute(
|
||||
"INSERT INTO projects(title,idea,cover_path) VALUES(?,?,?)",
|
||||
(title, "隔离验收夹具", cover),
|
||||
).lastrowid
|
||||
for n in range(3 if index == 0 else 1):
|
||||
conn.execute(
|
||||
"INSERT INTO characters(project_id,name) VALUES(?,?)",
|
||||
(pid, "角色 " + str(n + 1)),
|
||||
)
|
||||
for n in range(12 if index == 0 else 3):
|
||||
conn.execute(
|
||||
"INSERT INTO scenes(project_id,order_index,heading) VALUES(?,?,?)",
|
||||
(pid, n + 1, "场景 " + str(n + 1)),
|
||||
)
|
||||
for key in ["script", "style", "storyboard", "assets", "shot"]:
|
||||
conn.execute(
|
||||
"INSERT INTO project_stages(project_id,stage_key,status,content_json) VALUES(?,?,?,?)",
|
||||
(
|
||||
pid,
|
||||
key,
|
||||
"confirmed"
|
||||
if (index == 3 or (index == 0 and key in ["script", "style"]))
|
||||
else "awaiting_confirm"
|
||||
if (
|
||||
index == 0
|
||||
and key == "storyboard"
|
||||
or index == 1
|
||||
and key == "style"
|
||||
)
|
||||
else "todo",
|
||||
"{}",
|
||||
),
|
||||
)
|
||||
for index, kind in enumerate(["image", "image", "video"]):
|
||||
workflow = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "一只猫,柔和侧光"},
|
||||
"_meta": {"title": "Prompt (Input:prompt)"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "模糊,低画质"},
|
||||
"_meta": {"title": "Negative (Input:negative)"},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage" if kind == "image" else "SaveVideo",
|
||||
"inputs": {"images": ["1", 0]},
|
||||
"_meta": {"title": "Output (Output:" + kind + ")"},
|
||||
},
|
||||
}
|
||||
from calliope.models.schemas import WorkflowCreate
|
||||
from calliope.routers.workflows import create_workflow
|
||||
|
||||
conn.commit()
|
||||
asyncio.run(
|
||||
create_workflow(
|
||||
WorkflowCreate(
|
||||
name="隔离工作流 " + str(index + 1),
|
||||
kind=kind,
|
||||
workflow_json=workflow,
|
||||
usage="text2image" if kind == "image" else "text2video",
|
||||
)
|
||||
)
|
||||
)
|
||||
content_by_stage = {
|
||||
"style": {
|
||||
"directions": [
|
||||
{
|
||||
"id": "s1",
|
||||
"name": "柔光",
|
||||
"description": "隔离夹具",
|
||||
"prompt": "一只猫,柔和侧光",
|
||||
"negative_prompt": "模糊",
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"name": "暮色",
|
||||
"description": "隔离夹具",
|
||||
"prompt": "夜景中的猫",
|
||||
"negative_prompt": "",
|
||||
},
|
||||
]
|
||||
},
|
||||
"assets": {
|
||||
"assets": [
|
||||
{
|
||||
"id": "a1",
|
||||
"kind": "character",
|
||||
"name": "猫",
|
||||
"description": "隔离夹具",
|
||||
"prompt": "一只猫,柔和侧光",
|
||||
"negative_prompt": "模糊",
|
||||
}
|
||||
]
|
||||
},
|
||||
"shot": {
|
||||
"shots": [
|
||||
{
|
||||
"id": "v1",
|
||||
"shot_no": "S1",
|
||||
"description": "隔离夹具",
|
||||
"prompt": "镜头缓慢推进",
|
||||
"duration_sec": 5,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
for key, content in content_by_stage.items():
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?,status='awaiting_confirm' WHERE project_id=1 AND stage_key=?",
|
||||
(json.dumps(content), key),
|
||||
)
|
||||
scratch = conn.execute(
|
||||
"INSERT INTO projects(title,status) VALUES('隔离独立创作','system')"
|
||||
).lastrowid
|
||||
for index in range(20):
|
||||
status = (
|
||||
"done"
|
||||
if index < 12
|
||||
else [
|
||||
"running",
|
||||
"pending",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
][index - 12]
|
||||
)
|
||||
kind = "video" if index % 2 else "image"
|
||||
output = (
|
||||
str(uploads / "视频样本.webm")
|
||||
if kind == "video"
|
||||
else str(uploads / f"样本素材-{index % 14 + 1:02}.png")
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs(project_id,kind,status,payload_json,output_paths_json,workflow_id) VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
scratch,
|
||||
kind,
|
||||
status,
|
||||
json.dumps(
|
||||
{
|
||||
"source": "studio-" + kind,
|
||||
"input_values": {"1": "一只猫,柔和侧光", "2": "模糊"},
|
||||
}
|
||||
),
|
||||
json.dumps([output] if status == "done" else []),
|
||||
2 if kind == "image" else 3,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
(root / "runtime.json").write_text(
|
||||
json.dumps(
|
||||
{"port": args.port, "provider_port": provider.server_port, "data": str(root)}
|
||||
)
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
queue_manager.paused = (
|
||||
True # Keep seeded QA queue states stable; never execute GPU jobs.
|
||||
)
|
||||
try:
|
||||
uvicorn.run(
|
||||
create_app(Path(__file__).resolve().parents[2] / "calliope-web" / "build"),
|
||||
host="127.0.0.1",
|
||||
port=args.port,
|
||||
log_level="warning",
|
||||
)
|
||||
finally:
|
||||
provider.shutdown()
|
||||
thread.join()
|
||||
provider.server_close()
|
||||
Reference in New Issue
Block a user