From c3039343ca6500507851dd11f37b8ba8a4e4d502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=98?= <4846a27a-ba87-49c9-939d-7275f63efcc7@agents.multica.local> Date: Mon, 26 Oct 2026 23:04:14 +0800 Subject: [PATCH] HEL-883: audit deployed visuals and mount canvas lightbox regressions Co-authored-by: multica-agent --- .../2026-10-26-hel883-visual-investigation.md | 36 +++ scripts/hel883/README.md | 13 ++ scripts/hel883/capture.py | 216 ++++++++++++++++++ scripts/hel883/read_canvas.py | 29 +++ scripts/hel883/readonly.py | 49 ++++ scripts/hel883/reference.py | 43 ++++ 6 files changed, 386 insertions(+) create mode 100644 docs/changelogs/2026-10-26-hel883-visual-investigation.md create mode 100644 scripts/hel883/README.md create mode 100644 scripts/hel883/capture.py create mode 100644 scripts/hel883/read_canvas.py create mode 100644 scripts/hel883/readonly.py create mode 100644 scripts/hel883/reference.py diff --git a/docs/changelogs/2026-10-26-hel883-visual-investigation.md b/docs/changelogs/2026-10-26-hel883-visual-investigation.md new file mode 100644 index 0000000..3219c35 --- /dev/null +++ b/docs/changelogs/2026-10-26-hel883-visual-investigation.md @@ -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,再停各自子进程;临时库/媒体清除,原始证据保留在任务附件。代码仓库仅提交复现脚本与本文。 diff --git a/scripts/hel883/README.md b/scripts/hel883/README.md new file mode 100644 index 0000000..d8edff3 --- /dev/null +++ b/scripts/hel883/README.md @@ -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 文件与服务端实际静态目录一致证明取证对象。 diff --git a/scripts/hel883/capture.py b/scripts/hel883/capture.py new file mode 100644 index 0000000..c2d1154 --- /dev/null +++ b/scripts/hel883/capture.py @@ -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)) diff --git a/scripts/hel883/read_canvas.py b/scripts/hel883/read_canvas.py new file mode 100644 index 0000000..4478132 --- /dev/null +++ b/scripts/hel883/read_canvas.py @@ -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)) diff --git a/scripts/hel883/readonly.py b/scripts/hel883/readonly.py new file mode 100644 index 0000000..7baadaf --- /dev/null +++ b/scripts/hel883/readonly.py @@ -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']) diff --git a/scripts/hel883/reference.py b/scripts/hel883/reference.py new file mode 100644 index 0000000..ce14509 --- /dev/null +++ b/scripts/hel883/reference.py @@ -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()