HEL-883: audit deployed visuals and mount canvas lightbox regressions

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-10-26 23:04:14 +08:00
co-authored by multica-agent
parent 2ad5d33c72
commit c3039343ca
6 changed files with 386 additions and 0 deletions
@@ -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,再停各自子进程;临时库/媒体清除,原始证据保留在任务附件。代码仓库仅提交复现脚本与本文。
+13
View File
@@ -0,0 +1,13 @@
HEL-883 视觉取证;产品代码零修改。
先读 `docs/changelogs/2026-10-26-hel883-visual-investigation.md`。需要环境已有 Python/Playwright/Pillow、后端依赖、Chromium 系统库、Node;不新增产品依赖。
1. 使用 Multica CLI 下载批准 HEL-726、HEL-814 HTML 和 HEL-837 第二版附件;按取证报告核 SHA,保留原件不改。
2. `read_canvas.py` 仅适用于本卡已授权测试站,默认画布 4,`--board 5` 选择另一画布。通过 `ssh root@192.168.200.15 python3 - --board 4 < scripts/hel883/read_canvas.py` 只读导出到证据目录 `production-board4.json`;另一份存 `production-board5.json`。不读取配置/密钥,也不使用可能 seed 的线上画布 GET。
3. 构建冻结 `344fede` 与当前头。下载卡内已核验的改前/当前部署包到本地解压,分别指向真实静态根目录。不要在服务器部署或重建。
4. `python3 scripts/hel883/capture.py --output <证据目录> --baseline <基线构建> --deployed <当前部署包根> --previous <改前部署包根> --references <原件目录>`。原件目录需包含 `canvas-sample.html`;`--only-reference` 只执行部署包同状态样本比较和画布灯箱/持久化回归。
5. `readonly.py --output <证据目录> --references <原件目录>` 采线上 `/create?kind=image`、`/library` 两个只读入口与批准原件矩阵;原件目录另含 `夜片场-多主题交互样本.html`、`extracted/HEL-837-交互样本-v2.html`。线上所有非只读及 canvas API 都被拦截。
`capture.py` 占用仅本地 18383/15383,开跑先验证端口空闲,暂停隔离队列、LLM/ComfyUI 指向不可用回环地址;唯一媒体夹具明确标注非用户画布,不用于“丰富”产品内容。进程在前台 finally 回收,终止前比对 daemon;隔离目录由 TemporaryDirectory 清理,原始报告/截图/日志保留。参考映射 JSON 含两张测试媒体的截图数据,不含凭据。
输入槽完整回归继续复用 `scripts/hel871/capture.py`,需要单独执行;它的构建、真实上传、焦点/嵌套 Drawer 验证见原 README。源码与部署产物不必共享构建哈希;本卡额外以部署包 127/127 文件与服务端实际静态目录一致证明取证对象。
+216
View File
@@ -0,0 +1,216 @@
"""Foreground, loopback-only before/current audit and selected production replay.
Needs existing Playwright/Pillow/backend dependencies. Production export is read
only; write/drag assertions run exclusively against this disposable database.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import sqlite3
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from PIL import Image, ImageChops
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
p = argparse.ArgumentParser()
p.add_argument('--output', type=Path, required=True)
p.add_argument('--baseline', type=Path, required=True)
p.add_argument('--references', type=Path, required=True)
p.add_argument('--deployed', type=Path, required=True)
p.add_argument('--previous', type=Path, required=True)
p.add_argument('--only-reference', action='store_true')
a = p.parse_args()
out = a.output.resolve(); out.mkdir(parents=True, exist_ok=True)
report = {'checks': [], 'errors': [], 'geometry': [], 'comparisons': [], 'writes': []}
API = 'http://127.0.0.1:18383'
WEB = 'http://127.0.0.1:15383'
def check(name, value):
report['checks'].append({'name': name, 'pass': bool(value)})
print('PASS' if value else 'FAIL', name, flush=True)
assert value, name
def api(path, body=None, method=None):
r = urllib.request.Request(API + '/api' + path,
data=None if body is None else json.dumps(body).encode(),
headers={'Content-Type': 'application/json'},
method=method or ('GET' if body is None else 'POST'))
with urllib.request.urlopen(r, timeout=10) as response:
return json.load(response)
def geometry(page):
return page.evaluate('''()=>Object.fromEntries(['.desk','.stage','.composer','.media-summary','.library-layout','.flow-panel','.chat-panel','.svelte-flow__controls','.svelte-flow__minimap'].map(s=>{let e=document.querySelector(s);if(!e)return [s,null];let r=e.getBoundingClientRect(),c=getComputedStyle(e);return [s,{x:r.x,y:r.y,w:r.width,h:r.height,background:c.backgroundColor,color:c.color,font:c.font,padding:c.padding}]}))''')
def seed(runtime):
db = sqlite3.connect(runtime/'data/calliope.db')
# Import only the two explicitly selected boards; no credentials/config or
# unrelated user/project records are copied from the server.
for board in [4, 5]:
snap = json.loads((out/f'production-board{board}.json').read_text())
for table in ['projects','agent_sessions','characters','locations','items','canvas','canvas_node','canvas_edge','agent_messages']:
for row in snap.get(table, []):
columns = ','.join('"'+k+'"' for k in row)
db.execute('INSERT INTO '+table+' ('+columns+') VALUES ('+','.join('?' for _ in row)+')', list(row.values()))
# An empty, explicitly isolated session for the selected project's UI.
db.execute("INSERT INTO agent_sessions(id,title,project_id) VALUES(17,'隔离项目会话',7)")
assets = runtime/'data/assets/uploads'; assets.mkdir(parents=True,exist_ok=True)
for src, dst in [(ROOT/'calliope-web/static/refract/coast.jpg',assets/'HEL883-fixture.jpg'),(ROOT/'e2e/tiny.mp4',assets/'HEL883-fixture.mp4')]:
shutil.copyfile(src,dst)
db.execute("INSERT INTO agent_sessions(id,title) VALUES(18,'HEL883 共享灯箱隔离回归')")
db.execute("INSERT INTO canvas(id,title,agent_session_id,viewport_json) VALUES(6,'HEL883 测试夹具(非用户画布)',18,?)", (json.dumps({'x':0,'y':0,'zoom':1}),))
for kind,x in [('image',40),('video',320)]:
db.execute("INSERT INTO canvas_node(canvas_id,type,title,x,y,status,artifact_path) VALUES(6,?,'HEL883 灯箱夹具',?,140,'done',?)",(kind,x,str(assets/('HEL883-fixture.jpg' if kind=='image' else 'HEL883-fixture.mp4'))))
db.commit();db.close()
graph = {'1': {'class_type':'PrimitiveStringMultiline','inputs':{'value':'HEL883 同状态视觉核对'},'_meta':{'title':'Prompt (Input:prompt)'}}, '2': {'class_type':'LoadImage','inputs':{'image':''},'_meta':{'title':'Image (Input:image)'}},'3':{'class_type':'SaveImage','inputs':{'images':['2',0]}}}
return api('/workflows', {'name':'HEL883 隔离图生图','kind':'image','workflow_json':graph})['id']
def matrix(browser,wf):
phases=[('base',a.baseline.resolve()),('current',ROOT/'calliope-web/build'),('previous',a.previous.resolve()),('deployed',a.deployed.resolve())]
if a.only_reference: phases=phases[-1:]
for phase, build in phases:
(out/'active-build.txt').write_text(str(build))
context = browser.new_context(viewport={'width':1440,'height':900})
context.add_init_script("localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-theme','night')")
mirrors={str(board):json.loads((out/f'production-board{board}.json').read_text())['canvas'][0]['viewport_json'] for board in [4,5]}
mirrors['6']=json.dumps({'x':0,'y':0,'zoom':1})
context.add_init_script('Object.entries('+json.dumps(mirrors)+').forEach(([id,v])=>{if(!localStorage.getItem("calliope.canvas.viewport."+id))localStorage.setItem("calliope.canvas.viewport."+id,v)})')
context.route('https://**/*',lambda r:r.abort())
page = context.new_page();page.set_default_timeout(12000)
page.on('pageerror',lambda e:report['errors'].append(str(e)))
page.on('request',lambda r:report['writes'].append({'phase':phase,'method':r.method,'path':r.url.split('15383')[-1]}) if r.method not in ['GET','HEAD'] else None)
for label,path,ready in [('create',f'/create?kind=image&wf={wf}','#desk-prompt'),('library','/library','.lt'),('board4','/canvas/4?session=17','.canvas-card'),('board5','/canvas/5?session=16','.flow-panel'),('fixture','/canvas/6?session=18','.canvas-card')]:
if a.only_reference and label!='fixture': continue
page.goto(WEB+path);page.locator(ready).first.wait_for()
page.wait_for_timeout(900)
for width,height in [(1440,900),(1366,768),(1280,720)]:
page.set_viewport_size({'width':width,'height':height})
for theme in ['night','day','grade','bluehour']:
page.evaluate('(t)=>document.documentElement.dataset.theme=t',theme)
page.mouse.move(1,1);page.wait_for_timeout(200)
name=f'{label}-{theme}-{width}'
page.screenshot(path=str(out/f'{phase}-{name}.png'),animations='disabled')
if phase=='deployed' and label=='fixture':
from reference import capture
capture(page,context,a.references/'canvas-sample.html',out,theme,width)
report['geometry'].append({'phase':phase,'name':name,'url':page.url,'theme':theme,'motion':page.locator('html').get_attribute('data-motion'),'geometry':geometry(page)})
check(f'{phase} {label} mounted',True)
if phase=='deployed':
for kind in ['image','video']:
page.goto(WEB+'/canvas/6?session=18')
hit=page.locator(f'.artifact-node[data-kind="{kind}"] .media-btn')
hit.wait_for();hit.focus();scroll=page.evaluate('({x:scrollX,y:scrollY,overflow:document.body.style.overflow})')
page.keyboard.press('Enter')
box=page.locator('.backdrop');expect(box).to_be_visible()
check(f'canvas {kind} lightbox actually opened',box.count()==1)
check(f'canvas {kind} body locked',page.evaluate('document.body.style.overflow')=='hidden')
if kind=='video':
video=box.locator('video');expect(video).to_be_visible()
video.evaluate('(v)=>v.play()');page.wait_for_timeout(150)
check('canvas video manual playback',video.evaluate('(v)=>!v.paused && v.currentTime>0'))
video.evaluate('(v)=>v.pause()')
else:
expect(box.locator('img')).to_be_visible()
box.get_by_role('button',name='100%',exact=True).click()
expect(box.locator('.frame.zoomed')).to_be_visible()
box.locator('button,a').first.focus();page.keyboard.press('Shift+Tab')
check(f'{kind} tab stays inside preview',box.evaluate('(e)=>e.contains(document.activeElement)'))
page.screenshot(path=str(out/f'canvas-{kind}-opened.png'))
page.keyboard.press('Escape');expect(box).to_have_count(0)
check(f'canvas {kind} focus restored',hit.evaluate('(e)=>e===document.activeElement'))
check(f'canvas {kind} scroll restored',scroll==page.evaluate('({x:scrollX,y:scrollY,overflow:document.body.style.overflow})'))
header=page.locator('.artifact-node[data-kind="image"] header')
before=next(n for n in api('/canvas/6')['nodes'] if n['type']=='image')
b=header.bounding_box();page.mouse.move(b['x']+80,b['y']+12);page.mouse.down();page.mouse.move(b['x']+160,b['y']+72,steps=10);page.mouse.up();page.wait_for_timeout(800)
after=next(n for n in api('/canvas/6')['nodes'] if n['id']==before['id'])
check('canvas drag persists coordinates in isolated DB',abs(after['x']-before['x'])>30 and abs(after['y']-before['y'])>30)
page.reload();header.wait_for();page.wait_for_timeout(500)
check('canvas drag retained after reload',next(n for n in api('/canvas/6')['nodes'] if n['id']==before['id'])['x']==after['x'])
zoom_before=json.loads(api('/canvas/6')['canvas']['viewport_json'])
page.locator('.svelte-flow__controls-button').first.click()
page.wait_for_timeout(800)
saved=json.loads(api('/canvas/6')['canvas']['viewport_json'])
check('canvas zoom saves to isolated DB',saved['zoom']!=zoom_before['zoom'])
check('canvas viewport mirror matches backend',json.loads(page.evaluate("localStorage.getItem('calliope.canvas.viewport.6')"))==saved)
page.reload();page.locator('.zoom-value').wait_for();page.wait_for_timeout(500)
check('canvas viewport restored after reload',page.locator('.zoom-value').inner_text()==str(round(saved['zoom']*100))+'%')
context.close()
if a.only_reference: return
for current in sorted(list(out.glob('current-*.png'))+list(out.glob('deployed-*.png'))):
base=out/(current.name.replace('current-','base-',1) if current.name.startswith('current-') else current.name.replace('deployed-','previous-',1))
b,c=Image.open(base).convert('RGB'),Image.open(current).convert('RGB')
diff=ImageChops.difference(b,c)
pixels=sum(1 for x in diff.get_flattened_data() if x!=(0,0,0))
report['comparisons'].append({'name':current.stem,'different_pixels':pixels,'total_pixels':b.width*b.height,'bbox':diff.getbbox()})
pair=Image.new('RGB',(b.width*2,b.height));pair.paste(b,(0,0));pair.paste(c,(b.width,0));pair.save(out/('compare-'+current.name))
for port in [18383,15383]:
with socket.socket() as s:assert s.connect_ex(('127.0.0.1',port))!=0
proc=None;web_proc=None
with tempfile.TemporaryDirectory(prefix='hel883-',dir=out) as temp:
runtime=Path(temp)
code='''from pathlib import Path
from calliope import config
root=Path(TEMP)
config.CONFIG_FILE=root/'settings.json'
config.settings=config.Settings(data_dir=root/'data',assets_dir=root/'data/assets',llm_base_url='http://127.0.0.1:1/v1',comfyui_base_url='http://127.0.0.1:1',dry_run=False)
config.settings.ensure_storage_dirs()
from calliope.queue.manager import queue_manager
queue_manager.paused=True
from calliope.main import create_app
import uvicorn
uvicorn.run(create_app(),host='127.0.0.1',port=18383)
'''.replace('TEMP',repr(temp))
with (out/'replay-api.log').open('w') as log:
try:
proc=subprocess.Popen(['python3','-c',code],env={**os.environ,'PYTHONPATH':str(ROOT/'calliope-backend/src')},stdout=log,stderr=subprocess.STDOUT)
for attempt in range(100):
try: api('/health');break
except OSError:
if proc.poll() is not None: raise RuntimeError('API startup failure')
time.sleep(.1)
wf=seed(runtime)
(out/'active-build.txt').write_text(str(a.baseline.resolve()))
web=(ROOT/'e2e/hel818-web.cjs').read_text().replace('18247','18383').replace('15173','15383').replace("const build=path.resolve('calliope-web/build');", "const marker="+json.dumps(str(out/'active-build.txt'))+";").replace(" let file=path.join(build", " const build=fs.readFileSync(marker,'utf8'); let file=path.join(build")
web_proc=subprocess.Popen(['node','-e',web],stdout=log,stderr=subprocess.STDOUT)
for attempt in range(100):
try: urllib.request.urlopen(WEB,timeout=1);break
except OSError:time.sleep(.1)
with sync_playwright() as pw:
browser=pw.chromium.launch(args=['--no-sandbox'],env={**os.environ,'LD_LIBRARY_PATH':'/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu'})
try: matrix(browser,wf)
finally: browser.close()
check('no JS runtime errors',not report['errors'])
check('no generation or model call',not any(any(t in r['path'] for t in ['/generate','/prompt-tools','/agent/stream']) for r in report['writes']))
finally:
if web_proc:
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
assert web_proc.pid!=daemon
web_proc.terminate();web_proc.wait(timeout=10)
report['web_cleanup']={'pid':web_proc.pid,'daemon_pid':daemon,'exit':web_proc.returncode}
if proc:
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
assert proc.pid!=daemon
if proc.poll() is None:proc.send_signal(signal.SIGTERM)
try:proc.wait(timeout=10)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
report['cleanup']={'pid':proc.pid,'daemon_pid':daemon,'exit':proc.returncode}
(out/'replay-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
report['runtime_removed']=not runtime.exists()
(out/'replay-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
+29
View File
@@ -0,0 +1,29 @@
"""Read only the selected board and its renderable rows; never call seeding GET."""
import argparse, json, sqlite3
p=argparse.ArgumentParser()
p.add_argument("--board",type=int,choices=[4,5],default=4)
a=p.parse_args()
c = sqlite3.connect('file:/opt/aigc-home/data/calliope.db?mode=ro', uri=True)
c.execute('PRAGMA query_only=ON')
c.row_factory = sqlite3.Row
one = lambda sql, args=(): [dict(r) for r in c.execute(sql, args)]
board = one('SELECT * FROM canvas WHERE id=?',(a.board,))[0]
nodes = one('SELECT * FROM canvas_node WHERE canvas_id=?',(a.board,))
out = {'canvas': [board], 'canvas_node': nodes,
'canvas_edge': one('SELECT * FROM canvas_edge WHERE canvas_id=?',(a.board,))}
if board['project_id']:
out['projects'] = one('SELECT id,title,status,idea FROM projects WHERE id=?', (board['project_id'],))
for table, kind in [('characters', 'character'), ('locations', 'location'), ('items', 'item')]:
ids = [n['entity_id'] for n in nodes if n['entity_type'] == kind]
if ids:
cols = ['id','project_id','name'] + (['portrait_path','sheet_path'] if kind=='character' else ['reference_image_path'])
out[table] = one('SELECT '+','.join(cols)+' FROM '+table+' WHERE id IN ('+','.join('?' for _ in ids)+')', ids)
sessions = one('SELECT id,title,project_id FROM agent_sessions WHERE project_id=? ORDER BY updated_at DESC LIMIT 1', (board['project_id'],))
if not board['project_id']:
sessions = one('SELECT id,title,project_id FROM agent_sessions WHERE id=?', (board['agent_session_id'],))
out['agent_sessions'] = sessions
if sessions:
sid = sessions[0]['id']
out['agent_messages'] = one('SELECT session_id,role,content,agent_name FROM agent_messages WHERE session_id=? ORDER BY id', (sid,))
out['event_summary'] = one('SELECT type,count(*) as count FROM agent_events WHERE session_id=? GROUP BY type', (sid,))
print(json.dumps(out,ensure_ascii=False))
+49
View File
@@ -0,0 +1,49 @@
"""Capture read-only online entry evidence and approved original reference states."""
import argparse
import hashlib
import json
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
p=argparse.ArgumentParser()
p.add_argument('--output',type=Path,required=True)
p.add_argument('--references',type=Path,required=True)
a=p.parse_args();out=a.output.resolve();refs=a.references.resolve()
report={'blocked':[],'online':[],'reference':[],'errors':[]}
with sync_playwright() as pw:
b=pw.chromium.launch(args=['--no-sandbox'],env={**os.environ,'LD_LIBRARY_PATH':'/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu'})
try:
ctx=b.new_context(viewport={'width':1440,'height':900})
ctx.add_init_script("localStorage.setItem('aigc-theme','night');localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-lang','zh')")
def guard(route):
r=route.request
if r.method not in ['GET','HEAD'] or '/api/canvas' in r.url or not r.url.startswith('http://192.168.200.15:8080/'):
report['blocked'].append({'method':r.method,'url':r.url});route.abort()
else:route.continue_()
ctx.route('**/*',guard)
page=ctx.new_page();page.on('pageerror',lambda e:report['errors'].append(str(e)))
for name,path,ready in [('create','/create?kind=image','#desk-prompt'),('library','/library','.lib-body')]:
page.goto('http://192.168.200.15:8080'+path);page.locator(ready).wait_for();page.wait_for_timeout(1000)
page.screenshot(path=str(out/f'online-{name}-night-1440.png'))
report['online'].append({'name':name,'url':page.url,'theme':page.locator('html').get_attribute('data-theme'),'motion':page.locator('html').get_attribute('data-motion'),'resources':page.evaluate('performance.getEntriesByType("resource").filter(r=>r.initiatorType==="script"||r.initiatorType==="link").map(r=>r.name)')})
ctx.close()
ctx=b.new_context();ctx.route('https://**/*',lambda r:r.abort());s=ctx.new_page()
originals=[('desk',refs/'夜片场-多主题交互样本.html','?still=1#create/image'),('library',refs/'extracted/HEL-837-交互样本-v2.html','?still=1#library'),('canvas',refs/'canvas-sample.html','?chrome=0&reduce=1')]
for label,file,query in originals:
for width,height in [(1440,900),(1366,768),(1280,720)]:
s.set_viewport_size({'width':width,'height':height})
for theme in ['night','day','grade','bluehour']:
s.goto(file.as_uri()+query)
s.evaluate('(t)=>document.documentElement.dataset.theme=t',theme)
if label=='desk':
# Map only the empty-result state, never alter reference CSS.
s.evaluate("st.hist.image=[];st.fills={};st.viewing=null;renderCreate()")
s.wait_for_timeout(300)
s.screenshot(path=str(out/f'approved-{label}-{theme}-{width}.png'),animations='disabled')
tokens=s.evaluate("()=>Object.fromEntries(['--bg-0','--bg-1','--bg-2','--tx-1','--tx-2','--board-bg','--board-dot','--rail','--head'].map(t=>[t,getComputedStyle(document.documentElement).getPropertyValue(t).trim()]))")
report['reference'].append({'label':label,'theme':theme,'width':width,'file_sha256':hashlib.sha256(file.read_bytes()).hexdigest(),'tokens':tokens,'state':'empty result/history' if label=='desk' else 'original demonstration data'})
ctx.close()
finally:b.close()
(out/'readonly-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
print('online entries',len(report['online']),'approved screenshots',len(report['reference']),'JS errors',report['errors'])
+43
View File
@@ -0,0 +1,43 @@
"""Map fixture content into an untouched approved canvas HTML at runtime.
Called by capture.py while the real isolated page is mounted. Changes only
fixture text, media, positions and selection; original CSS/file stay intact.
"""
import base64
import json
def capture(page, context, reference, out, theme, width):
sample=context.new_page()
sample.set_viewport_size(page.viewport_size)
sample.goto(reference.resolve().as_uri()+'?theme='+theme+'&chrome=0&reduce=1')
data=page.evaluate('''()=>{let b=document.querySelector('.flow-panel').getBoundingClientRect();return {title:document.querySelector('.title-btn').textContent.trim(),zoom:document.querySelector('.zoom-value').textContent,cards:[...document.querySelectorAll('.canvas-card')].map(e=>{let r=e.getBoundingClientRect();return {title:e.querySelector('.title').textContent,kind:e.dataset.kind,x:r.x-b.x,y:r.y-b.y,scale:r.width/parseFloat(getComputedStyle(e).width),foot:e.querySelector('footer').innerText,image:e.querySelector('img')?.src,selected:e.classList.contains('a-in-frame')}})}}''')
data['video']='data:image/png;base64,'+base64.b64encode(page.locator('.canvas-card video').screenshot()).decode()
data['minimap']='data:image/png;base64,'+base64.b64encode(page.locator('.svelte-flow__minimap svg').screenshot()).decode()
sample.evaluate('''m=>{
let templates=[...document.querySelectorAll('.card.artifact')].map(e=>e.cloneNode(true));
document.querySelector('#cards').replaceChildren();
for(let [i,d] of m.cards.entries()){
let e=templates[d.kind==='video'?1:0];e.classList.remove('running','sel','on');
e.style.setProperty('left',d.x+'px','important');e.style.setProperty('top',d.y+'px','important');e.style.transform='scale('+d.scale+')';e.style.transformOrigin='top left';
e.querySelector('.t').textContent=d.title;e.querySelector('.pill').textContent=d.kind;
let image=document.createElement('img');image.src=d.image||m.video;image.style.cssText='display:block;width:100%;height:100%;object-fit:cover';
let media=e.querySelector('.media');media.replaceChildren(image);let badge=document.createElement('span');badge.className='kind';badge.textContent=d.kind;media.append(badge);
let foot=e.querySelector('.cfoot');foot.replaceChildren();let grow=document.createElement('span');grow.className='grow';let num=document.createElement('span');num.className='mono';num.textContent=d.foot;foot.append(grow,num);document.querySelector('#cards').append(e);
}
document.querySelector('.tname').firstChild.nodeValue=m.title;
document.querySelector('.titlecard .saved').remove();
document.querySelector('.proj-chip').textContent='Sandbox';document.querySelector('.unlink').remove();
document.querySelector('.zoomv').textContent=m.zoom;
let mini=document.createElement('img');mini.src=m.minimap;mini.style.cssText='width:100%;height:100%;display:block';document.querySelector('.minimap .mm').replaceChildren(mini);document.querySelector('.minimap .mmtag')?.remove();
document.querySelector('.demo-badge')?.remove();
}''',data)
sample.wait_for_timeout(150)
name=f'{theme}-{width}'
page.locator('.flow-panel').screenshot(path=str(out/f'product-board-{name}.png'))
sample.locator('.board').screenshot(path=str(out/f'approved-mapped-board-{name}.png'))
geometry={}
for label,p,selectors in [('product',page,['.artifact-node','.artifact-node .media','.svelte-flow__controls','.svelte-flow__minimap','.chat-panel']),('approved',sample,['.card.artifact','.card.artifact .media','.ctl','.minimap','#chat'])]:
geometry[label]=p.evaluate('''ss=>ss.map(s=>{let e=document.querySelector(s);if(!e)return {selector:s};let r=e.getBoundingClientRect(),c=getComputedStyle(e);return {selector:s,w:r.width,h:r.height,color:c.color,background:c.backgroundColor,font:c.font,border:c.border,borderRadius:c.borderRadius}})''',selectors)
(out/f'approved-mapping-{name}.json').write_text(json.dumps({'data':data,'geometry':geometry,'scope':'Same isolated image/video fixture mapped into approved board; chat excluded from board crops. No original file or stylesheet modified.'},ensure_ascii=False,indent=2))
sample.close()