feat(web): add interactive rain room companion demo
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -48,3 +48,7 @@ models/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Independent web demo
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
qa-artifacts/
|
||||
@@ -0,0 +1,64 @@
|
||||
# HEL-700 施工与自测记录
|
||||
|
||||
基准 `1e9b08a`;交付分支 `hel-700-rain-room`。实现范围仅 `web/` 与根 `.gitignore`。未部署,未改 Python 核心、配置、测试或远端服务。
|
||||
|
||||
## 实现
|
||||
|
||||
- `src/app/`:真实 DOM 导航、玻璃面板、消息、输入、片刻/命盘预览、设置,样式 token 统一在 `style.css`。系统减少动效、触屏、抽屉和矮屏各有适配;设置在桌面右侧、手机底部最多55dvh,避开脸部。
|
||||
- `src/scene/scene.ts`:Pixi 仅允许 WebGL 后端并校验类型,独立 Canvas2D、CSS、静态降级;雨幕、水痕高光、粒子、光轨及指针视差。原始带 UI 参考图没有放入网页。
|
||||
- `src/engine/audio.ts`:用户主动开启程序化雨声、音量、隐藏暂停及恢复。语音和附件只解释未接入,不采集设备。
|
||||
- `src/net/llm.ts`:同源探测、UTF-8 SSE 分包、2/8/40 秒超时、取消、保留流式内容、手动重试;空响应与截断均报错。模型内容作为文本节点渲染。
|
||||
- `src/demo/`:演示台词、片刻、场景文案和预设唯一来源;`state/contracts.ts` 镜像 Python State/Utterance 字段。
|
||||
- `vite.config.ts` 开发/preview 同源代理;`nginx.conf.example` 是交总工的生产模板,没有执行服务器配置。
|
||||
|
||||
## 命令与结果
|
||||
|
||||
从仓库根:
|
||||
|
||||
```sh
|
||||
npm ci --prefix web
|
||||
npm run typecheck --prefix web
|
||||
npm run build --prefix web
|
||||
python3 -m pytest -q
|
||||
CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/browser_check.py
|
||||
CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/network_check.py
|
||||
CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/lifecycle_check.py
|
||||
CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/production_check.py
|
||||
```
|
||||
|
||||
浏览器脚本使用宿主已有 Python Playwright,不增加 npm 依赖;需要宿主可用的浏览器图形运行库。每个脚本前台启动并最终关闭自己的 Vite 子进程。严格 TS/noUnused、构建、锁文件重装均通过;既有 Python 测试 **78 passed**。未新增独立 linter 依赖,另做 `git diff --check` 与调试残留检查。
|
||||
|
||||
浏览器:Linux 无头 Chromium 151.0.7922.34,桌面 1600×900、1280×800,平板 768×1024,手机视口 390×844 收起/展开/聚焦、844×390 横屏。截图均为实际页面,生产截图前缀 `production-`。
|
||||
|
||||
- 主交互回归 32 项通过:Enter/Shift+Enter、组合输入保护、500 字、流式期间编辑、取消/重试、来源标注、预览、静态/轻量、减少动效、语音说明、折叠恢复、XSS 文本显示等。
|
||||
- 生命周期回归 13 项通过:真实 `WEBGL_lose_context` 丢失;屏蔽 WebGL API 转独立 Canvas2D,屏蔽 Canvas2D 转 CSS;20 FPS 注入触发自动逐级到静态;雨声隐藏暂停/回来恢复;长消息内部滚动和返回底部;抽屉拖动/键盘;软键盘布局分支。
|
||||
- 网络故障 8 项通过:逐字节 UTF-8/CRLF 分包、网络错误、取消、空响应、坏 SSE、首包 8001ms 超时、整包 40000ms 超时、探测 2000ms 超时。
|
||||
- 真实内网模型经开发同源代理完成回复,单次完整回复约 1408ms;生产 preview `/v1/models` 返回 200。实时失败未转换成演示台词,已有部分输出不会自动重试。
|
||||
- 构建产物 smoke 7 项通过,无未处理页面异常。
|
||||
|
||||
## 包体与性能(实际采样,不扩大适用范围)
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 全部 JS gzip 合计(Python gzip 默认级别9) | 269538 字节;低于 300KB 目标 |
|
||||
| JS 原始合计 | 931348 字节;Pixi 主块约 904KB,Vite 大块提示仍存在 |
|
||||
| 素材合计(WebP + JPEG 回退) | 593708 字节;低于 1.5MB |
|
||||
| 首屏 WebP / JPEG 回退 | 267992 / 325716 字节 |
|
||||
| 4 秒帧采样 | 198 帧;帧间隔 P95 33.40ms;未观测到 >50ms long task |
|
||||
| 场景 rAF 回调 | 379 次,P95 0.740ms,最长 2.497ms |
|
||||
| 浏览器主线程任务 | 3052 次,最长 10.304ms;3 次 >8ms |
|
||||
|
||||
**8ms 全主线程任务目标尚未完全达到。** 超出任务来自浏览器 `ScheduledActionSendBeginMainFrame`(含合成/布局),不是只统计业务 JS。保留完整 `chromium-main-thread-trace.json`,不以无 long task 替代 8ms 证明。快速连续键击字符串完整保留。该采样使用本机软件图形后端,不能代表低端安卓。
|
||||
|
||||
## 限制与待审核项
|
||||
|
||||
- Firefox 尝试启动,但宿主缺 libgtk/libXcursor 等运行库;未完成 Firefox、Edge、Safari/iOS 或低端安卓真机测试。
|
||||
- 中文输入法通过浏览器 composition 事件测试保护逻辑;未使用桌面系统真实输入法人工验收。软键盘布局通过 visualViewport 压缩事件模拟,未做手机系统键盘真机测试。
|
||||
- 隐藏/恢复通过 document visibility 事件和隐藏值注入,验证场景、CSS 和实际 AudioContext 状态;没有冒充真实设备后台生命周期测试。
|
||||
- 真正 WebGL 上下文丢失有实测;低帧率/不可用 API 和坏 SSE 使用明确的故障注入,不声称真实硬件故障。
|
||||
- 运行时依赖审计 0 漏洞。授权的 Vite 5.4.21 / esbuild 开发依赖仍有 1 high + 1 moderate 通告(详见 npm audit 原始附件);没有擅自升级授权之外的大版本。开发与 preview 只绑定本机;生产只交静态 dist 与代理模板。后续升级需总管确认版本范围。
|
||||
- nginx 模板未经远端部署验证;未接 ASR、TTS、Python 性格/记忆/生活/排盘;人物与城市是一体素材,不宣称独立人物动画。
|
||||
|
||||
## 证据索引
|
||||
|
||||
`browser-report.json`、`network-report.json`、`lifecycle-report.json`、`production-report.json` 为原始结果;PNG 包含主场景、发送/取消/失败、预览、各档降级与所有适配截图;trace 为 Chromium DevTools timeline 数据。证据随本卡附件交付,不以本地路径代替交付。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 雨夜相处演示
|
||||
|
||||
独立前端,对应 HEL-700,基准 `1e9b08a`。不连接 Python 性格、记忆、生活、排盘或语音引擎。
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm ci
|
||||
npm run dev
|
||||
npm run typecheck
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
开发与 preview 均提供同源 `/v1` 代理。生产由总工按 `nginx.conf.example` 合入已有站点配置,禁止把 Vite 开发服务作为生产服务。模板不选择端口、不修改服务器。模型配置集中在 `src/net/llm.ts` 的请求和探测中。
|
||||
|
||||
目录边界:`app/` 真实 DOM 与视觉 token;`scene/` 只读取独立场景时间与氛围;`engine/audio.ts` 管理用户主动开启的程序雨声;`net/llm.ts` 管理探测、SSE、超时与取消;`demo/` 是演示台词与片刻的唯一来源;`state/contracts.ts` 镜像 Python State、Utterance 字段并定义前端消息类型。没有共享场景/输入状态。
|
||||
|
||||
降级:全特效首先创建 Pixi WebGL,创建失败或上下文丢失切到独立 Canvas2D;Canvas 创建失败切 CSS 雨幕;连续 4 秒采样低于 30 FPS 逐级降低,CSS 仍低帧率则静态。轻量直接用 Canvas2D,静态或系统减少动效关闭环境与波形动画。用户手动选择档位可重新尝试。`body[data-renderer]` 标出实际渲染器,`#effects[data-running]` 标出运行状态,供审核检查。素材始终静态,只有雨幕与光膜位移。手机不读设备传感器。
|
||||
|
||||
2 秒模型探测失败进入明确标注的演示模式。实时请求首个内容片段 8 秒超时,整包 40 秒超时。错误、截断、取消保留已有内容,不自动重试;只有用户点击重试才重置该失败回复。已产生后续消息时要求重新发送,防止重试混入过期上下文。模型内容始终 `textContent` 渲染。演示消息不会进入实时模型上下文。不会把模型错误转换为角色台词。
|
||||
|
||||
雨声每次打开页面保持关闭;用户开启后,页面隐藏会暂停,返回按当次选择恢复。音量保存在 localStorage(存储不可用不影响使用)。设置中的预设仅修改场景灯光和雨密度,界面暖色 token 不变。
|
||||
|
||||
手机默认抽屉 30dvh,展开 55dvh;横屏、矮屏或软键盘压缩可视区时切成上下可滚动布局。手柄支持点击/键盘与上下拖动。语音和附件只显示说明,不触发设备授权。
|
||||
|
||||
浏览器回归用本机已有的 Python Playwright(不增加 npm 依赖):
|
||||
|
||||
```sh
|
||||
python3 web/tests/browser_check.py
|
||||
```
|
||||
|
||||
其余网络故障、生命周期与生产脚本见 `QA.md`,上述测试命令从仓库根执行。脚本启动并最终关闭本地 Vite 服务,输出到执行时指定的 `CYBERGRIL_ARTIFACT_DIR`(默认 `web/qa-artifacts`)。截图、采样和实际覆盖情况见任务卡交付附件。
|
||||
|
||||
Pixi API 按官方 Application / Graphics 文档实现:https://pixijs.com/8.x/guides/components/application ,https://pixijs.com/8.x/guides/components/scene-objects/graphics 。
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#16233c"><title>CyberGril · 与晚晚相处</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
@@ -0,0 +1,17 @@
|
||||
# Template only. Integrator selects the existing server and release root.
|
||||
# Insert these locations in the deployment owner's server block.
|
||||
location / {
|
||||
root /srv/cybergril/current;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /v1/ {
|
||||
proxy_pass http://192.168.200.11:8731;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 45s;
|
||||
proxy_send_timeout 45s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
}
|
||||
Generated
+1166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cybergril-rain-room",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixi.js": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 318 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
@@ -0,0 +1,14 @@
|
||||
// Static application chrome only. All message/model content uses textContent.
|
||||
export const shell = `
|
||||
<div class="scene" aria-hidden="true"><picture><source srcset="/assets/rain-room.webp" type="image/webp"><img src="/assets/rain-room.jpg" alt=""></picture><div class="lamp"></div><div id="effects"></div><div class="css-rain"></div><div class="light-trail"></div><div class="dust"></div><div class="shade"></div></div>
|
||||
<header><div class="brand"><span class="moon">☾</span> CyberGril <span>晚晚</span></div><div class="clock">她的时间 <time id="time"></time><button id="settings-open" aria-label="设置">⚙</button></div></header>
|
||||
<p class="badge">演示版 · 性格与记忆引擎未接入</p>
|
||||
<nav aria-label="主导航"><button data-view="chat" aria-current="page">相处</button><button data-view="daily">她的日常 <small>预览</small></button><button data-view="fate">命盘 <small>预览</small></button></nav>
|
||||
<p id="caption" class="caption"></p>
|
||||
<button id="restore" class="glass" hidden>与晚晚说说话 ↗</button>
|
||||
<section class="panel glass" aria-label="晚晚对话"><button id="drawer" class="handle" aria-label="展开对话" aria-expanded="false"><span></span></button><div class="panel-head"><div><h1>晚晚</h1><p><i class="presence"></i> 在这里 <small>· 场景演示</small></p></div><button id="collapse" aria-label="收起对话">···</button></div>
|
||||
<div id="chat-view" class="view"><div id="messages" role="log" aria-label="对话记录" aria-live="off"><p class="date">今晚 · 雨夜相处</p></div><button id="bottom" hidden>↓ 回到底部</button><div class="wave-card"><button id="wave" aria-label="切换波形演示" aria-pressed="false">▷</button><span class="wave-bars" aria-hidden="true"></span><small>波形演示 · 无语音播放</small></div><button id="moment" class="moment"><span class="thumb" aria-hidden="true"></span><span><small>今天的片刻 · 演示</small><br><span id="moment-title"></span></span><span>›</span></button></div>
|
||||
<div id="preview-view" class="view" hidden><h2 id="preview-title"></h2><div id="preview-content"></div><small>轻量预览 · 非最终版</small></div>
|
||||
<div class="compose-area"><div class="mode-row"><span id="connection" role="status">正在探测模型…</span><button id="mode" disabled>正在检测…</button></div><form id="compose"><button type="button" id="attachment" aria-disabled="true" aria-label="附件未接入,查看说明"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8 13 6-6a3 3 0 0 1 4 4l-8 8a5 5 0 0 1-7-7l9-9"/></svg></button><textarea id="input" rows="1" maxlength="500" placeholder="和她说点什么…" aria-label="和她说点什么"></textarea><button type="button" id="voice" aria-disabled="true" aria-describedby="voice-note" aria-label="语音未接入,查看说明"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5m-4 0h8"/></svg></button><button id="send" type="submit" aria-label="发送消息">↑</button></form><div class="compose-meta"><span id="count">0 / 500</span><span>文字 / 语音</span><button id="cancel" hidden>取消回应</button></div><p id="voice-note">语音录入与 TTS 未接入;附件功能未接入。</p><p id="chat-status" role="status"></p></div></section>
|
||||
<dialog id="settings" class="glass"><div class="dialog-head"><h2>雨夜设置</h2><button id="settings-close" aria-label="关闭设置">×</button></div><p>氛围预设 · 未接真实情绪引擎</p><div class="choices" id="presets"></div><label>动效档位<select id="quality"><option value="full">全特效 · WebGL 优先</option><option value="lite">轻量 · Canvas2D 优先</option><option value="static">静态</option></select></label><small>系统减少动效时自动静态,触屏关闭视差。</small><label class="audio-label"><input id="rain-sound" type="checkbox"> 程序化雨声</label><label>雨声音量<input id="volume" type="range" min="0" max="100" value="25"></label><p id="audio-status" role="status">默认静音,仅主动开启后播放。</p></dialog>
|
||||
<div id="toast" class="glass" role="status" hidden></div>`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
export const caption = ['窗外下雨了。', '她刚读完一本书的一页。'];
|
||||
export const momentTitle = '雨声 · 阅读';
|
||||
export const opening = ['窗外又下雨了。', '刚好读完一页书。你今天过得怎么样?'];
|
||||
export const moments = [ ['20:36', '把台灯调暗了一点,留一盏暖光给这一页。'], ['21:02', '读到一句喜欢的话,停下来听了一会儿雨。'], ['21:08', '书还摊在膝上,城市在窗外慢慢亮着。'] ];
|
||||
export const presets = [ { name: '雨夜阅读', density: 1, warmth: .12 }, { name: '暖灯小憩', density: .55, warmth: .22 }, { name: '静听蓝城', density: 1.4, warmth: .04 } ];
|
||||
export function reply(text: string, turn: number): string {
|
||||
if (/书|读/.test(text)) return '这段演示里,我正翻到书中描写雨夜的那一页。你最近有读到喜欢的句子吗?';
|
||||
if (/累|忙|难过/.test(text)) return '那就先歇一会儿吧。不急着把今天说完,也可以从一件小事慢慢说起。';
|
||||
if (/雨|晚|好/.test(text)) return '雨落在窗上的声音很轻。你那边的夜晚是什么样的?';
|
||||
return ['这是一段预设的演示对话。你可以试着和我聊聊书、雨夜,或者今天的心情。', '把今天留一点在这里吧。现在显示的是演示台词,尚未接入性格与记忆引擎。'][turn % 2];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class RainAudio {
|
||||
private context?: AudioContext;
|
||||
private gain?: GainNode;
|
||||
enabled = false;
|
||||
volume = .25;
|
||||
async set(enabled: boolean, volume = this.volume) {
|
||||
this.enabled = enabled; this.volume = volume;
|
||||
if (enabled && !this.context) {
|
||||
this.context = new AudioContext();
|
||||
const buffer = this.context.createBuffer(1, this.context.sampleRate * 3, this.context.sampleRate);
|
||||
const values = buffer.getChannelData(0);
|
||||
for (let i = 0; i < values.length; i++) values[i] = Math.random() * 2 - 1;
|
||||
const source = this.context.createBufferSource(); source.buffer = buffer; source.loop = true;
|
||||
const filter = this.context.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 1200;
|
||||
this.gain = this.context.createGain();
|
||||
source.connect(filter).connect(this.gain).connect(this.context.destination); source.start();
|
||||
}
|
||||
if (this.gain && this.context) this.gain.gain.setTargetAtTime(volume * .3, this.context.currentTime, .1);
|
||||
if (this.context) {
|
||||
if (enabled && !document.hidden) await this.context.resume();
|
||||
else await this.context.suspend();
|
||||
}
|
||||
}
|
||||
async visibility() { if (this.context) { if (document.hidden) await this.context.suspend(); else if (this.enabled) await this.context.resume(); } }
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import './app/style.css';
|
||||
import { shell } from './app/shell';
|
||||
import { opening, moments, presets, reply, caption, momentTitle } from './demo/script';
|
||||
import { probe, stream, demoStream } from './net/llm';
|
||||
import { Scene } from './scene/scene';
|
||||
import { RainAudio } from './engine/audio';
|
||||
import type { ChatMessage, FxLevel } from './state/contracts';
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
$('app').innerHTML = shell;
|
||||
$('caption').textContent = caption.join('\n');
|
||||
$('moment-title').textContent = momentTitle;
|
||||
let toastTimer: ReturnType<typeof setTimeout>;
|
||||
function toast(text: string) { $('toast').textContent = text; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { $('toast').hidden = true; }, 5000); }
|
||||
const scene = new Scene($('effects'), toast);
|
||||
void scene.set('full');
|
||||
const audio = new RainAudio();
|
||||
const history: ChatMessage[] = [];
|
||||
let live = false, modeChosen = false, probing = true, active: AbortController | undefined, composing = false, follow = true, turn = 0;
|
||||
let view = 'chat';
|
||||
const input = $<HTMLTextAreaElement>('input');
|
||||
const messages = $('messages');
|
||||
function scroll() { if (follow) messages.scrollTop = messages.scrollHeight; }
|
||||
new ResizeObserver(() => { if (follow) scroll(); }).observe(messages);
|
||||
messages.addEventListener('scroll', () => { follow = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 70; $('bottom').hidden = follow; });
|
||||
$('bottom').onclick = () => { follow = true; scroll(); $('bottom').hidden = true; };
|
||||
function bubble(message: ChatMessage) {
|
||||
const article = document.createElement('article'); article.className = `message ${message.role}`;
|
||||
const label = document.createElement('small'); label.textContent = message.source === 'live' ? '实时模型' : '演示对话 · 非实时模型';
|
||||
const text = document.createElement('p'); text.textContent = message.text;
|
||||
article.append(label, text); messages.append(article); scroll(); return { article, text };
|
||||
}
|
||||
for (const text of opening) { const message: ChatMessage = { role: 'assistant', text, source: 'demo', complete: true }; history.push(message); bubble(message); }
|
||||
function modeLabel() {
|
||||
$<HTMLButtonElement>('mode').disabled = false;
|
||||
$('connection').textContent = live ? '实时模型 · 已连接' : '演示对话 · 非实时模型';
|
||||
$('mode').textContent = live ? '切换演示' : '检测并连接模型';
|
||||
}
|
||||
void probe().then(ok => { probing = false; if (!modeChosen) { live = ok; modeLabel(); if (!ok) toast('内网 LLM 不可达,已进入明确标注的演示模式'); } });
|
||||
$('mode').onclick = async () => {
|
||||
if (active) { toast('请先取消当前回应'); return; }
|
||||
modeChosen = true;
|
||||
if (live) { live = false; modeLabel(); return; }
|
||||
$('connection').textContent = '正在探测模型…';
|
||||
const ok = await probe(); live = ok; modeLabel(); if (!ok) toast('模型不可达,继续使用演示模式');
|
||||
};
|
||||
async function respond(user: ChatMessage, existing?: ChatMessage, node?: ReturnType<typeof bubble>) {
|
||||
const source = user.source;
|
||||
const response: ChatMessage = existing ?? { role: 'assistant', text: '', source, complete: false };
|
||||
if (!existing) history.push(response);
|
||||
response.text = ''; response.complete = false;
|
||||
const elements = node ?? bubble(response);
|
||||
elements.text.textContent = '回应中…'; elements.article.classList.add('pending');
|
||||
elements.article.querySelector('.error')?.remove();
|
||||
active = new AbortController(); const controller = active;
|
||||
$('cancel').hidden = false; $<HTMLButtonElement>('send').disabled = true;
|
||||
$('chat-status').textContent = source === 'live' ? '正在等待模型回应…' : '正在显示演示对话…';
|
||||
try {
|
||||
const delta = (text: string) => { response.text += text; elements.text.textContent = response.text; scroll(); };
|
||||
if (source === 'live') await stream(history.slice(0, history.indexOf(response)), controller.signal, delta);
|
||||
else await demoStream(reply(user.text, turn++), controller.signal, delta);
|
||||
response.complete = true; $('chat-status').textContent = '回应已完成';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '网络连接失败';
|
||||
if (!response.text) elements.text.textContent = '未收到回复。';
|
||||
const row = document.createElement('div'); row.className = 'error';
|
||||
const label = document.createElement('span'); label.textContent = message;
|
||||
const retry = document.createElement('button'); retry.textContent = '重试';
|
||||
retry.onclick = () => {
|
||||
if (active) return toast('请先结束当前回应');
|
||||
// Keep retry context deterministic; no concurrent/newer turn is duplicated.
|
||||
if (history[history.length - 1] !== response) return toast('已有后续对话,请重新发送需要重试的内容');
|
||||
void respond(user, response, elements);
|
||||
};
|
||||
row.append(label, retry); elements.article.append(row); $('chat-status').textContent = message;
|
||||
} finally {
|
||||
elements.article.classList.remove('pending');
|
||||
if (active === controller) active = undefined;
|
||||
$('cancel').hidden = true; $<HTMLButtonElement>('send').disabled = false; scroll();
|
||||
}
|
||||
}
|
||||
$('compose').onsubmit = event => {
|
||||
event.preventDefault();
|
||||
if (active || composing || !input.value.trim()) return;
|
||||
if (probing) { toast('模型检测中,请稍候'); return; }
|
||||
const text = input.value.trim(); if ([...text].length > 500) { toast('最多输入500字'); return; }
|
||||
const message: ChatMessage = { role: 'user', text, source: live ? 'live' : 'demo', complete: true };
|
||||
history.push(message); follow = true; bubble(message); input.value = ''; count();
|
||||
void respond(message);
|
||||
};
|
||||
function count() { $('count').textContent = `${[...input.value].length} / 500`; }
|
||||
input.oninput = count;
|
||||
input.addEventListener('compositionstart', () => { composing = true; });
|
||||
input.addEventListener('compositionend', () => { composing = false; count(); });
|
||||
input.onkeydown = event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !composing && event.keyCode !== 229) {
|
||||
event.preventDefault(); $<HTMLFormElement>('compose').requestSubmit();
|
||||
}
|
||||
};
|
||||
$('cancel').onclick = () => active?.abort();
|
||||
function cancel() { active?.abort(); }
|
||||
$('collapse').onclick = () => { cancel(); document.body.classList.add('chat-hidden'); $('restore').hidden = false; };
|
||||
$('restore').onclick = () => { document.body.classList.remove('chat-hidden'); $('restore').hidden = true; requestAnimationFrame(scroll); };
|
||||
const drawer = $('drawer');
|
||||
function expand(expanded: boolean) { document.body.classList.toggle('expanded', expanded); drawer.setAttribute('aria-expanded', String(expanded)); drawer.setAttribute('aria-label', expanded ? '收起抽屉' : '展开对话'); }
|
||||
let dragged = false;
|
||||
drawer.onclick = () => { if (dragged) { dragged = false; return; } expand(!document.body.classList.contains('expanded')); };
|
||||
let startY = 0;
|
||||
drawer.onpointerdown = e => { dragged = false; startY = e.clientY; drawer.setPointerCapture(e.pointerId); };
|
||||
drawer.onpointerup = e => { if (Math.abs(e.clientY - startY) > 15) { dragged = true; expand(e.clientY < startY); } };
|
||||
function showView(next: string) {
|
||||
cancel(); view = next;
|
||||
document.body.classList.remove('chat-hidden'); $('restore').hidden = true;
|
||||
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-view]')) {
|
||||
if (button.dataset.view === next) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
|
||||
}
|
||||
$('chat-view').hidden = next !== 'chat'; $('preview-view').hidden = next === 'chat';
|
||||
const content = $('preview-content'); content.replaceChildren();
|
||||
$('preview-title').textContent = next === 'daily' ? '她的日常' : '命盘';
|
||||
if (next === 'daily') {
|
||||
for (const [time, text] of moments) { const p = document.createElement('p'); const small = document.createElement('small'); small.textContent = `${time} · 演示片刻`; const body = document.createElement('p'); body.textContent = text; p.append(small, body); content.append(p); }
|
||||
} else { const p = document.createElement('p'); p.textContent = '星轨尚未点亮。命盘将呈现角色的稳定底色,本轮尚未接入排盘引擎,不提供运势推演。'; content.append(p); }
|
||||
if (next !== 'chat') expand(true); else { follow = true; requestAnimationFrame(scroll); }
|
||||
}
|
||||
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-view]')) button.onclick = () => showView(button.dataset.view!);
|
||||
$('moment').onclick = () => showView('daily');
|
||||
$('attachment').onclick = () => toast('附件功能未接入,本轮只支持文字。');
|
||||
$('voice').onclick = () => toast('语音录入与 TTS 未接入,不会启动麦克风。');
|
||||
const settings = $<HTMLDialogElement>('settings');
|
||||
$('settings-open').onclick = () => settings.showModal(); $('settings-close').onclick = () => settings.close();
|
||||
settings.addEventListener('click', e => { if (e.target === settings && (e.clientX < settings.getBoundingClientRect().left || e.clientX > settings.getBoundingClientRect().right || e.clientY < settings.getBoundingClientRect().top || e.clientY > settings.getBoundingClientRect().bottom)) settings.close(); });
|
||||
presets.forEach((preset, index) => {
|
||||
const button = document.createElement('button'); button.textContent = preset.name; button.setAttribute('aria-pressed', String(index === 0));
|
||||
button.onclick = () => { scene.preset(index); for (const child of $('presets').children) child.setAttribute('aria-pressed', String(child === button)); toast(`氛围已切换为${preset.name}`); }; $('presets').append(button);
|
||||
});
|
||||
$<HTMLSelectElement>('quality').onchange = event => { const level = (event.target as HTMLSelectElement).value as FxLevel; void scene.set(level); toast(matchMedia('(prefers-reduced-motion: reduce)').matches ? '系统已开启减少动效,场景保持静态' : '动效档位已切换'); };
|
||||
try { audio.volume = Number(localStorage.getItem('rain-volume') ?? .25); } catch { /* storage optional */ }
|
||||
$<HTMLInputElement>('volume').value = String(audio.volume * 100);
|
||||
$<HTMLInputElement>('rain-sound').onchange = async event => {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
try { await audio.set(checkbox.checked); $('audio-status').textContent = checkbox.checked ? '程序化雨声已开启' : '雨声已关闭'; }
|
||||
catch { checkbox.checked = false; audio.enabled = false; $('audio-status').textContent = '浏览器无法启动音频,雨声保持关闭'; }
|
||||
};
|
||||
$<HTMLInputElement>('volume').oninput = event => { const volume = Number((event.target as HTMLInputElement).value) / 100; void audio.set(audio.enabled, volume).catch(() => toast('音频不可用')); try { localStorage.setItem('rain-volume', String(volume)); } catch { /* optional */ } };
|
||||
const bars = document.querySelector('.wave-bars')!;
|
||||
for (let i = 0; i < 48; i++) { const bar = document.createElement('i'); bar.style.setProperty('--bar-height', `${4 + Math.sin(i * 1.8) ** 2 * 14}px`); bar.style.setProperty('--delay', `${i * -0.09}s`); bars.append(bar); }
|
||||
$('wave').onclick = () => { const on = $('wave').getAttribute('aria-pressed') !== 'true'; $('wave').setAttribute('aria-pressed', String(on)); $('wave').textContent = on ? 'Ⅱ' : '▷'; bars.classList.toggle('playing', on); };
|
||||
function clock() { $('time').textContent = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date()); }
|
||||
let clockTimer: ReturnType<typeof setInterval> | undefined;
|
||||
function scheduleClock() { clearInterval(clockTimer); clock(); if (!document.hidden) clockTimer = setInterval(clock, 30000); }
|
||||
scheduleClock();
|
||||
document.addEventListener('visibilitychange', () => { void audio.visibility().catch(() => {}); scheduleClock(); });
|
||||
function viewport() {
|
||||
const height = window.visualViewport?.height ?? innerHeight;
|
||||
document.documentElement.style.setProperty('--visible-height', `${height}px`);
|
||||
document.body.classList.toggle('keyboard', height < innerHeight * .78);
|
||||
if (document.body.classList.contains('keyboard') && document.activeElement === input) requestAnimationFrame(() => input.scrollIntoView({ block: 'end' }));
|
||||
}
|
||||
window.visualViewport?.addEventListener('resize', viewport); viewport();
|
||||
input.onfocus = () => { if (view !== 'chat') showView('chat'); };
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ChatMessage } from '../state/contracts';
|
||||
export async function probe(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/v1/models', { signal: AbortSignal.timeout(2000) });
|
||||
const body = await response.json();
|
||||
return response.ok && body.data?.some((m: { id: string }) => m.id === 'halogen-qwen3.8-flash-next');
|
||||
} catch { return false; }
|
||||
}
|
||||
// SSE is decoded across arbitrary UTF-8 / line boundaries. Completion is required;
|
||||
// a truncated stream preserves visible output and offers explicit manual retry.
|
||||
export async function stream(messages: ChatMessage[], signal: AbortSignal, delta: (s: string) => void): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort(signal.reason);
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
if (signal.aborted) cancel();
|
||||
let timeout = false;
|
||||
const expire = () => { timeout = true; controller.abort(); };
|
||||
const total = setTimeout(expire, 40000);
|
||||
let first: ReturnType<typeof setTimeout> | undefined = setTimeout(expire, 8000);
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
const response = await fetch('/v1/chat/completions', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
|
||||
body: JSON.stringify({ model: 'halogen-qwen3.8-flash-next', stream: true, max_tokens: 600,
|
||||
messages: [{ role: 'system', content: '你叫晚晚,简短温和地用中文交流,每次不超过150字。不得声称拥有记忆或情绪引擎,不得声称已接入语音或真实生活引擎。' },
|
||||
...messages.filter(m => m.complete && m.source === 'live').slice(-16).map(m => ({ role: m.role, content: m.text }))] })
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error(`模型服务返回 ${response.status}`);
|
||||
reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', done = false, received = false;
|
||||
const line = (s: string) => {
|
||||
if (!s.startsWith('data:')) return;
|
||||
const value = s.slice(5).trim();
|
||||
if (!value) return;
|
||||
if (value === '[DONE]') { done = true; return; }
|
||||
const event = JSON.parse(value);
|
||||
if (event.error) throw new Error('模型服务返回错误');
|
||||
const choice = event.choices?.[0];
|
||||
if (choice?.delta?.content) {
|
||||
received = true; clearTimeout(first); first = undefined;
|
||||
delta(choice.delta.content);
|
||||
}
|
||||
if (choice?.finish_reason) done = true;
|
||||
};
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
buffer += decoder.decode(chunk.value, { stream: !chunk.done });
|
||||
const lines = buffer.split('\n'); buffer = lines.pop() ?? '';
|
||||
for (const item of lines) line(item.trimEnd());
|
||||
if (chunk.done) { if (buffer) line(buffer); break; }
|
||||
}
|
||||
if (!done) throw new Error('流式连接中断,已收到的内容保留');
|
||||
if (!received) throw new Error('模型未返回正文,请手动重试');
|
||||
} catch (error) {
|
||||
if (timeout) throw new Error('模型响应超时,请手动重试');
|
||||
if (signal.aborted) throw new Error('已取消,已收到的内容保留');
|
||||
if (error instanceof SyntaxError) throw new Error('流式数据格式错误,已收到的内容保留');
|
||||
if (error instanceof TypeError) throw new Error('网络连接失败,请检查模型服务后重试');
|
||||
throw error instanceof Error ? error : new Error('网络连接失败');
|
||||
} finally {
|
||||
clearTimeout(total); clearTimeout(first);
|
||||
signal.removeEventListener('abort', cancel);
|
||||
await reader?.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
export async function demoStream(text: string, signal: AbortSignal, delta: (s: string) => void) {
|
||||
for (const character of text) {
|
||||
if (signal.aborted) throw new Error('已取消,已收到的内容保留');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cancel = () => { clearTimeout(timer); reject(new Error('已取消,已收到的内容保留')); };
|
||||
const timer = setTimeout(() => { signal.removeEventListener('abort', cancel); resolve(); }, 40);
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
});
|
||||
delta(character);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FxLevel } from '../state/contracts';
|
||||
import { presets } from '../demo/script';
|
||||
interface Renderer { draw(t: number, density: number): void; destroy(): void }
|
||||
type Tier = 'webgl' | 'canvas' | 'css' | 'static';
|
||||
// Pure timeline coordinates: the scene never reads or writes chat state.
|
||||
function drops(t: number, count: number, w: number, h: number, line: (x: number, y: number, length: number, alpha: number) => void) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const speed = 48 + (i * 37 % 110);
|
||||
line((i * 137.51 % w), ((i * 87.37 + t * speed) % (h + 70)) - 35, 9 + i % 24, .1 + i % 5 * .045);
|
||||
}
|
||||
}
|
||||
export class Scene {
|
||||
private renderer?: Renderer;
|
||||
private frame = 0;
|
||||
private generation = 0;
|
||||
private tier: Tier = 'static';
|
||||
private level: FxLevel = 'full';
|
||||
private time = 0;
|
||||
private last = 0;
|
||||
private sample = 0;
|
||||
private frames = 0;
|
||||
private slow = 0;
|
||||
private reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
private mood = presets[0];
|
||||
private px = 0; private py = 0; private x = 0; private y = 0;
|
||||
constructor(private host: HTMLElement, private notify: (text: string) => void) {
|
||||
this.reduced.addEventListener('change', () => { void this.set(this.level); });
|
||||
document.addEventListener('visibilitychange', () => this.visibility());
|
||||
window.addEventListener('resize', () => { void this.configure(this.tier); });
|
||||
window.addEventListener('pointermove', e => {
|
||||
if (e.pointerType === 'mouse' && matchMedia('(pointer:fine)').matches) {
|
||||
this.px = (e.clientX / innerWidth - .5) * -12;
|
||||
this.py = (e.clientY / innerHeight - .5) * -12;
|
||||
}
|
||||
});
|
||||
}
|
||||
async set(level: FxLevel) {
|
||||
this.level = level;
|
||||
await this.configure(this.reduced.matches || level === 'static' ? 'static' : level === 'lite' ? 'canvas' : 'webgl');
|
||||
}
|
||||
preset(index: number) {
|
||||
this.mood = presets[index];
|
||||
document.documentElement.style.setProperty('--lamp-opacity', String(this.mood.warmth));
|
||||
}
|
||||
private async configure(tier: Tier) {
|
||||
const version = ++this.generation;
|
||||
cancelAnimationFrame(this.frame); this.frame = 0;
|
||||
this.renderer?.destroy(); this.renderer = undefined;
|
||||
this.host.replaceChildren();
|
||||
this.tier = tier; this.last = 0; this.sample = 0; this.frames = 0; this.slow = 0;
|
||||
const w = Math.min(innerWidth, 1920), h = Math.min(innerHeight, 1080);
|
||||
let renderer: Renderer | undefined;
|
||||
try {
|
||||
if (tier === 'webgl') {
|
||||
const { Application, Graphics, RendererType } = await import('pixi.js');
|
||||
if (version !== this.generation) return;
|
||||
const app = new Application();
|
||||
try { await app.init({ width: w, height: h, resolution: 1, backgroundAlpha: 0, preference: ['webgl'], autoStart: false, antialias: false }); if (app.renderer.type !== RendererType.WEBGL) throw new Error('WebGL renderer unavailable'); }
|
||||
catch (error) { try { app.destroy(true); } catch { /* incomplete initialization */ } throw error; }
|
||||
if (version !== this.generation) { app.destroy(true); return; }
|
||||
const graphics = new Graphics(); app.stage.addChild(graphics);
|
||||
const contextLost = (e: Event) => {
|
||||
e.preventDefault(); this.notify('图形上下文丢失,已切换轻量雨幕'); void this.configure('canvas');
|
||||
};
|
||||
app.canvas.addEventListener('webglcontextlost', contextLost);
|
||||
this.host.append(app.canvas);
|
||||
renderer = { draw: (t, density) => {
|
||||
graphics.clear();
|
||||
drops(t, Math.round(180 * density), w, h, (x, y, length) => {
|
||||
graphics.moveTo(x, y).lineTo(x - 2, y + length);
|
||||
});
|
||||
graphics.stroke({ color: 0x718fc2, alpha: .24, width: 1 });
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const x = i * 101.3 % w, y = ((i * 137 + t * 12) % h);
|
||||
graphics.ellipse(x, y, 1.5, 4).fill({ color: 0xc9ddff, alpha: .25 + Math.sin(t + i) * .12 });
|
||||
}
|
||||
app.renderer.render(app.stage);
|
||||
}, destroy: () => { app.canvas.removeEventListener('webglcontextlost', contextLost); app.destroy(true, { children: true }); } };
|
||||
} else if (tier === 'canvas') {
|
||||
const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas2D unavailable');
|
||||
this.host.append(canvas);
|
||||
renderer = { draw: (t, density) => {
|
||||
ctx.clearRect(0, 0, w, h); ctx.lineWidth = 1;
|
||||
drops(t, Math.round(80 * density), w, h, (x, y, length, alpha) => {
|
||||
ctx.strokeStyle = `rgba(113,143,194,${alpha})`; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - 2, y + length); ctx.stroke();
|
||||
});
|
||||
}, destroy: () => canvas.remove() };
|
||||
}
|
||||
} catch {
|
||||
if (version !== this.generation) return;
|
||||
this.notify(tier === 'webgl' ? 'WebGL 不可用,已切换 Canvas2D' : 'Canvas2D 不可用,已切换 CSS 雨幕');
|
||||
await this.configure(tier === 'webgl' ? 'canvas' : 'css'); return;
|
||||
}
|
||||
if (version !== this.generation) { renderer?.destroy(); return; }
|
||||
this.renderer = renderer;
|
||||
document.body.dataset.renderer = tier;
|
||||
document.body.dataset.motion = tier === 'static' ? 'static' : 'active';
|
||||
this.visibility();
|
||||
}
|
||||
private visibility() {
|
||||
cancelAnimationFrame(this.frame); this.frame = 0; this.last = 0;
|
||||
document.body.classList.toggle('paused', document.hidden);
|
||||
this.host.dataset.running = String(!document.hidden && this.tier !== 'static');
|
||||
if (!document.hidden && this.tier !== 'static') this.frame = requestAnimationFrame(this.tick);
|
||||
}
|
||||
private tick = (now: number) => {
|
||||
const dt = this.last ? now - this.last : 16.7; this.last = now;
|
||||
this.time += Math.min(dt, 50) / 1000;
|
||||
this.x += (this.px - this.x) * .08; this.y += (this.py - this.y) * .08;
|
||||
if (matchMedia('(pointer:fine)').matches) this.host.style.transform = `translate(${this.x}px,${this.y}px)`;
|
||||
this.renderer?.draw(this.time, this.mood.density);
|
||||
this.sample += dt; this.frames++;
|
||||
if (this.sample >= 4000) {
|
||||
this.slow = this.frames / (this.sample / 1000) < 30 ? this.slow + 1 : 0;
|
||||
this.sample = 0; this.frames = 0;
|
||||
if (this.slow) {
|
||||
const next = this.tier === 'webgl' ? 'canvas' : this.tier === 'canvas' ? 'css' : 'static';
|
||||
this.notify('设备帧率较低,已自动降低场景动效'); void this.configure(next); return;
|
||||
}
|
||||
}
|
||||
this.frame = requestAnimationFrame(this.tick);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Exact data fields from Python contracts; these types do not connect the engine.
|
||||
export type Presence = 'listening' | 'speaking' | 'away' | 'asleep';
|
||||
export interface State { ts: number; v: number; a: number; d: number; e: number; mood_word: string; presence: Presence; drivers: readonly number[] }
|
||||
export type SpeechTag = 'laughter' | 'breath' | 'sigh' | 'pause' | 'cry' | 'hesitate';
|
||||
export interface Utterance { text: string; speech_tags: readonly SpeechTag[]; emotion_intent: string | null; withheld: string | null; interruptible: boolean }
|
||||
export type FxLevel = 'full' | 'lite' | 'static';
|
||||
export interface ChatMessage { role: 'user' | 'assistant'; text: string; source: 'live' | 'demo'; complete: boolean }
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Foreground browser regression using preinstalled Python Playwright; no npm test dependency."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR', ROOT / 'qa-artifacts'))
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
PORT = sock.getsockname()[1]
|
||||
BASE = f'http://127.0.0.1:{PORT}'
|
||||
log = open(OUT / 'vite.log', 'w')
|
||||
server = subprocess.Popen(['node', 'node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', str(PORT), '--strictPort'], cwd=ROOT, stdout=log, stderr=log)
|
||||
report = {'checks': [], 'errors': [], 'browser': '', 'samples': {}}
|
||||
|
||||
def check(name, condition):
|
||||
assert condition, name
|
||||
report['checks'].append(name)
|
||||
print('PASS', name, flush=True)
|
||||
|
||||
def screenshot(page, name):
|
||||
page.screenshot(path=str(OUT / (name + '.png')), full_page=True)
|
||||
|
||||
try:
|
||||
for _ in range(60):
|
||||
try:
|
||||
urllib.request.urlopen(BASE, timeout=.5)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(.1)
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=['--no-sandbox', '--enable-unsafe-swiftshader'])
|
||||
report['browser'] = browser.version
|
||||
page = browser.new_page(viewport={'width': 1600, 'height': 900}, device_scale_factor=1)
|
||||
page.on('pageerror', lambda e: report['errors'].append(str(e)))
|
||||
# Unreachable probe is a deterministic offline case, not a test-only app mode.
|
||||
page.route('**/v1/models', lambda route: route.abort())
|
||||
page.goto(BASE)
|
||||
page.wait_for_function("document.querySelector('#connection').textContent.includes('非实时模型')")
|
||||
page.wait_for_timeout(5100)
|
||||
check('unreachable probe selects labelled demo', '演示' in page.locator('#connection').inner_text())
|
||||
check('real DOM + permanent engine disclosure', page.locator('.badge').is_visible() and page.locator('.message p').count() == 2)
|
||||
check('renderer starts', page.locator('body').get_attribute('data-renderer') in ['webgl', 'canvas'])
|
||||
screenshot(page, 'desktop-1600')
|
||||
page.locator('#input').fill('中文输入法')
|
||||
page.locator('#input').dispatch_event('compositionstart')
|
||||
page.locator('#input').press('Enter')
|
||||
check('IME Enter does not submit', page.locator('.message.user').count() == 0)
|
||||
page.locator('#input').dispatch_event('compositionend')
|
||||
page.locator('#input').press('Shift+Enter')
|
||||
check('Shift Enter preserves newline', '\n' in page.locator('#input').input_value())
|
||||
page.locator('#input').fill('今天有点累')
|
||||
page.locator('#input').press('Enter')
|
||||
page.locator('#cancel').wait_for(state='visible')
|
||||
page.locator('#input').fill('流式中继续快速输入,不应丢字')
|
||||
screenshot(page, 'desktop-stream-focus')
|
||||
check('input stays editable during stream', page.locator('#input').input_value() == '流式中继续快速输入,不应丢字')
|
||||
page.locator('#cancel').click()
|
||||
page.locator('.error').wait_for()
|
||||
check('cancel keeps partial and offers retry', '已取消' in page.locator('.error').inner_text())
|
||||
screenshot(page, 'desktop-cancel-retry')
|
||||
page.locator('.error button').click()
|
||||
page.locator('#cancel').wait_for(state='hidden', timeout=10000)
|
||||
check('manual retry succeeds without duplicate user turn', page.locator('.message.user').count() == 1 and page.locator('.error').count() == 0)
|
||||
page.locator('#input').fill('长' * 500)
|
||||
check('500 character input available', page.locator('#input').input_value() == '长' * 500)
|
||||
page.locator('#input').fill('')
|
||||
page.locator('#voice').click(force=True)
|
||||
check('unavailable voice explains itself', '不会启动麦克风' in page.locator('#toast').inner_text())
|
||||
page.locator('#collapse').click()
|
||||
check('collapse restores conversation entry', page.locator('#restore').is_visible() and not page.locator('.panel').is_visible())
|
||||
page.locator('#restore').click()
|
||||
page.locator('[data-view=daily]').click()
|
||||
check('daily has explicitly marked demo moments', page.locator('#preview-content small').count() == 3)
|
||||
screenshot(page, 'daily-preview')
|
||||
page.locator('[data-view=fate]').click()
|
||||
check('fate is preview only', '未接入排盘引擎' in page.locator('#preview-content').inner_text())
|
||||
page.locator('[data-view=chat]').click()
|
||||
page.locator('#settings-open').click()
|
||||
original = page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--accent')")
|
||||
page.get_by_role('button', name='静听蓝城', exact=True).click()
|
||||
check('preset keeps warm UI accent', original == page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--accent')"))
|
||||
page.locator('#quality').select_option('static')
|
||||
page.wait_for_function("document.body.dataset.renderer === 'static'")
|
||||
check('static stops scene', page.locator('#effects').get_attribute('data-running') == 'false')
|
||||
page.locator('#quality').select_option('lite')
|
||||
page.wait_for_function("document.body.dataset.renderer === 'canvas'")
|
||||
check('lite uses independent Canvas2D', page.locator('#effects canvas').count() == 1)
|
||||
page.locator('#rain-sound').check()
|
||||
check('rain only starts on user action', '已开启' in page.locator('#audio-status').inner_text())
|
||||
page.locator('#volume').fill('50')
|
||||
page.locator('#rain-sound').uncheck()
|
||||
page.locator('#settings-close').click()
|
||||
page.set_viewport_size({'width':1280,'height':800})
|
||||
screenshot(page,'desktop-1280')
|
||||
page.set_viewport_size({'width':390,'height':844})
|
||||
page.evaluate("document.body.classList.remove('expanded')")
|
||||
screenshot(page,'mobile-collapsed')
|
||||
check('mobile default drawer near 30 percent', abs(page.locator('.panel').bounding_box()['height'] / 844 - .30) < .02)
|
||||
page.locator('#drawer').click()
|
||||
screenshot(page,'mobile-expanded')
|
||||
check('mobile expansion at or below 55 percent', page.locator('.panel').bounding_box()['y'] >= 844*.45-1)
|
||||
page.locator('#input').focus()
|
||||
screenshot(page,'mobile-focus')
|
||||
page.set_viewport_size({'width':844,'height':390})
|
||||
screenshot(page,'mobile-landscape')
|
||||
check('landscape is scrollable scene above chat', page.locator('.panel').bounding_box()['y'] >= 300)
|
||||
check('no horizontal overflow', page.evaluate('document.documentElement.scrollWidth <= innerWidth'))
|
||||
page.set_viewport_size({'width':1600,'height':900})
|
||||
page.emulate_media(reduced_motion='reduce')
|
||||
page.wait_for_function("document.body.dataset.renderer === 'static'")
|
||||
screenshot(page,'reduced-motion')
|
||||
check('reduced motion enforces static', page.locator('#effects').get_attribute('data-running') == 'false')
|
||||
page.emulate_media(reduced_motion='no-preference')
|
||||
page.wait_for_function("document.querySelector('#effects').dataset.running === 'true'")
|
||||
# Visibility lifecycle event injected because headless tabs do not reliably hide.
|
||||
page.evaluate("Object.defineProperty(document, 'hidden', {configurable:true,get:()=>true}); document.dispatchEvent(new Event('visibilitychange'))")
|
||||
check('visibility handler pauses scene and CSS', page.locator('#effects').get_attribute('data-running') == 'false' and 'paused' in page.locator('body').get_attribute('class'))
|
||||
page.evaluate("Object.defineProperty(document, 'hidden', {configurable:true,get:()=>false}); document.dispatchEvent(new Event('visibilitychange'))")
|
||||
check('visibility handler restores selected animation', page.locator('#effects').get_attribute('data-running') == 'true')
|
||||
# True API availability failure injection, no app query override.
|
||||
for name, blocked, expected in [('webgl-fallback', ['webgl','webgl2','experimental-webgl'], 'canvas'), ('canvas-fallback', ['webgl','webgl2','experimental-webgl','2d'], 'css')]:
|
||||
ctx = browser.new_context(viewport={'width':1280,'height':800})
|
||||
ctx.add_init_script("const original=HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext=function(type,...args){if("+json.dumps(blocked)+".includes(type))return null;return original.call(this,type,...args)}")
|
||||
p = ctx.new_page(); p.route('**/v1/models', lambda route: route.abort()); p.goto(BASE)
|
||||
p.wait_for_function(f"document.body.dataset.renderer === '{expected}'",timeout=12000)
|
||||
check(name, True); screenshot(p,name); ctx.close()
|
||||
# Live mode cases use model-compatible SSE fixtures.
|
||||
page.unroute('**/v1/models')
|
||||
page.route('**/v1/models', lambda route: route.fulfill(json={'data':[{'id':'halogen-qwen3.8-flash-next'}]}))
|
||||
page.locator('#mode').click()
|
||||
page.wait_for_function("document.querySelector('#connection').textContent.includes('已连接')")
|
||||
def completion(route):
|
||||
report['last_request'] = route.request.post_data_json
|
||||
route.fulfill(content_type='text/event-stream', body='data: {"choices":[{"delta":{"content":"<script>模型文本</script>中文"}}]}\r\n\r\ndata: [DONE]\r\n\r\n')
|
||||
page.route('**/v1/chat/completions', completion)
|
||||
page.locator('#input').fill('实时测试'); page.locator('#send').click()
|
||||
page.wait_for_function("document.querySelector('#chat-status').textContent === '回应已完成'")
|
||||
check('live SSE renders unsafe-looking text as text', '<script>模型文本</script>' in page.locator('.message.assistant').last.inner_text() and page.locator('.message script').count()==0)
|
||||
check('live request excludes demo replies', all('窗外又下雨' not in m['content'] for m in report['last_request']['messages']))
|
||||
page.unroute('**/v1/chat/completions')
|
||||
page.route('**/v1/chat/completions', lambda route: route.fulfill(status=503,body='Unavailable'))
|
||||
page.locator('#input').fill('失败测试'); page.locator('#send').click(); page.locator('.error').wait_for()
|
||||
check('upstream error visible with retry', '503' in page.locator('.error').inner_text())
|
||||
screenshot(page,'live-failure')
|
||||
page.unroute('**/v1/chat/completions')
|
||||
page.route('**/v1/chat/completions', completion)
|
||||
page.locator('.error button').click(); page.wait_for_function("document.querySelector('#chat-status').textContent === '回应已完成'")
|
||||
check('live retry clears failure', page.locator('.error').count()==0)
|
||||
page.unroute('**/v1/chat/completions')
|
||||
page.route('**/v1/chat/completions', lambda route: route.fulfill(content_type='text/event-stream',body='data: {"choices":[{"delta":{"content":"已经收到的一部分"}}]}\n\n'))
|
||||
page.locator('#input').fill('中断测试'); page.locator('#send').click(); page.locator('.error').wait_for()
|
||||
check('truncated SSE keeps partial text', '已经收到的一部分' in page.locator('.message.assistant').last.inner_text() and '中断' in page.locator('.error').inner_text())
|
||||
# Browser scheduling sample with chat input during independent animation.
|
||||
page.locator('#settings-open').click(); page.locator('#quality').select_option('full'); page.locator('#settings-close').click()
|
||||
page.wait_for_timeout(1000)
|
||||
sample = page.evaluate("""async () => {const gaps=[],tasks=[]; const po=new PerformanceObserver(l=>tasks.push(...l.getEntries().map(e=>e.duration)));po.observe({type:'longtask'});let last=performance.now();await new Promise(resolve=>{const end=last+4000;function frame(t){gaps.push(t-last);last=t;if(t<end)requestAnimationFrame(frame);else resolve()}requestAnimationFrame(frame)});po.disconnect();gaps.sort((a,b)=>a-b);return {frames:gaps.length,intervalP95:gaps[Math.floor(gaps.length*.95)],longTasks:tasks,renderer:document.body.dataset.renderer}}""")
|
||||
report['samples']['chromium1600'] = sample
|
||||
check('no unhandled page errors', not report['errors'])
|
||||
browser.close()
|
||||
# Firefox scope is reported accurately if the installed browser cannot launch.
|
||||
try:
|
||||
firefox = pw.firefox.launch()
|
||||
fp = firefox.new_page(viewport={'width':1280,'height':800})
|
||||
fp.route('**/v1/models',lambda route:route.abort());fp.goto(BASE); fp.wait_for_timeout(1000)
|
||||
screenshot(fp,'firefox-1280'); report['firefox'] = firefox.version
|
||||
check('Firefox shell usable',fp.locator('#input').is_visible());firefox.close()
|
||||
except Exception as exc:
|
||||
report['firefox_unavailable'] = str(exc)[:500]
|
||||
finally:
|
||||
server.terminate()
|
||||
server.wait(timeout=10)
|
||||
log.close()
|
||||
(OUT/'browser-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Context loss, slow-frame degradation, drawer/keyboard and browser main-thread trace."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from playwright.sync_api import sync_playwright
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
OUT=Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR',ROOT/'qa-artifacts'));OUT.mkdir(parents=True,exist_ok=True)
|
||||
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
log=open(OUT/'lifecycle-vite.log','w')
|
||||
server=subprocess.Popen(['node','node_modules/vite/bin/vite.js','--host','127.0.0.1','--port',str(port),'--strictPort'],cwd=ROOT,stdout=log,stderr=log)
|
||||
report={'checks':[]}
|
||||
def check(name,value):
|
||||
assert value,name
|
||||
report['checks'].append(name);print('PASS',name,flush=True)
|
||||
try:
|
||||
for _ in range(60):
|
||||
try:urllib.request.urlopen(base,timeout=.5);break
|
||||
except Exception:time.sleep(.1)
|
||||
with sync_playwright() as pw:
|
||||
browser=pw.chromium.launch(args=['--no-sandbox','--enable-unsafe-swiftshader'])
|
||||
ctx=browser.new_context(viewport={'width':1600,'height':900})
|
||||
# Capture actual AudioContext state without exposing anything in application code.
|
||||
ctx.add_init_script("const Original=AudioContext;window.AudioContext=class extends Original{constructor(...args){super(...args);window.testAudio=this}}")
|
||||
page=ctx.new_page();page.route('**/v1/models',lambda r:r.abort());page.goto(base)
|
||||
page.wait_for_function("document.body.dataset.renderer==='webgl'")
|
||||
page.evaluate("document.querySelector('#effects canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()")
|
||||
page.wait_for_function("document.body.dataset.renderer==='canvas'")
|
||||
check('real WebGL context loss switches independent canvas',True)
|
||||
page.locator('#settings-open').click();page.locator('#quality').select_option('full');page.locator('#settings-close').click()
|
||||
page.wait_for_function("document.body.dataset.renderer==='webgl'")
|
||||
page.set_viewport_size({'width':1280,'height':800});page.wait_for_timeout(300)
|
||||
check('resize does not report false context loss',page.locator('body').get_attribute('data-renderer')=='webgl')
|
||||
page.locator('#settings-open').click();page.locator('#rain-sound').check();page.locator('#settings-close').click()
|
||||
check('audio context runs only after explicit enable',page.evaluate("testAudio.state==='running'"))
|
||||
page.evaluate("Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'))")
|
||||
page.wait_for_function("testAudio.state==='suspended'")
|
||||
check('hidden audio suspends',True)
|
||||
page.evaluate("Object.defineProperty(document,'hidden',{configurable:true,get:()=>false});document.dispatchEvent(new Event('visibilitychange'))")
|
||||
page.wait_for_function("testAudio.state==='running'")
|
||||
check('visible audio restores user selection',True)
|
||||
page.locator('#settings-open').click();page.locator('#rain-sound').uncheck();page.locator('#settings-close').click()
|
||||
# Deterministic large model body validates internal scroll and safe text.
|
||||
page.unroute('**/v1/models');page.route('**/v1/models',lambda r:r.fulfill(json={'data':[{'id':'halogen-qwen3.8-flash-next'}]}))
|
||||
page.locator('#mode').click();page.wait_for_function("document.querySelector('#connection').textContent.includes('已连接')")
|
||||
body='data: '+json.dumps({'choices':[{'delta':{'content':'长消息。'*400}}]},ensure_ascii=False)+'\n\ndata: [DONE]\n\n'
|
||||
page.route('**/v1/chat/completions',lambda r:r.fulfill(content_type='text/event-stream',body=body))
|
||||
page.locator('#input').fill('请返回长消息');page.locator('#send').click();page.wait_for_function("document.querySelector('#chat-status').textContent==='回应已完成'")
|
||||
check('long streamed message follows bottom',page.evaluate("(()=>{const e=document.querySelector('#messages');return e.scrollHeight-e.scrollTop-e.clientHeight<70})()"))
|
||||
page.locator('#messages').evaluate('(e)=>{e.scrollTop=0}')
|
||||
page.wait_for_timeout(100)
|
||||
check('scrolling up exposes return-to-bottom',page.locator('#bottom').is_visible())
|
||||
page.locator('#bottom').click()
|
||||
check('return-to-bottom restores follow',page.evaluate("(()=>{const e=document.querySelector('#messages');return e.scrollHeight-e.scrollTop-e.clientHeight<70})()"))
|
||||
page.set_viewport_size({'width':390,'height':844});page.evaluate("document.body.classList.remove('expanded')")
|
||||
box=page.locator('#drawer').bounding_box()
|
||||
page.mouse.move(box['x']+40,box['y']+10);page.mouse.down();page.mouse.move(box['x']+40,box['y']-80,steps=5);page.mouse.up();page.wait_for_timeout(100)
|
||||
check('drawer drag expands without click reversal',page.locator('#drawer').get_attribute('aria-expanded')=='true')
|
||||
page.locator('#drawer').press('Space')
|
||||
check('drawer keyboard toggles',page.locator('#drawer').get_attribute('aria-expanded')=='false')
|
||||
page.locator('#input').focus()
|
||||
page.evaluate("Object.defineProperty(visualViewport,'height',{configurable:true,value:400});visualViewport.dispatchEvent(new Event('resize'))")
|
||||
check('software keyboard handler uses scrollable stacked layout','keyboard' in page.locator('body').get_attribute('class') and page.locator('.panel').bounding_box()['y']>=300)
|
||||
page.locator('#input').scroll_into_view_if_needed();page.screenshot(path=str(OUT/'mobile-keyboard-layout.png'),full_page=True)
|
||||
page.evaluate("delete visualViewport.height;visualViewport.dispatchEvent(new Event('resize'))")
|
||||
page.set_viewport_size({'width':1600,'height':900});page.wait_for_timeout(1000)
|
||||
cdp=ctx.new_cdp_session(page);events=[]
|
||||
cdp.on('Tracing.dataCollected',lambda data:events.extend(data['value']))
|
||||
cdp.send('Tracing.start',{'categories':'devtools.timeline,toplevel','transferMode':'ReportEvents'})
|
||||
page.locator('#input').fill('')
|
||||
page.locator('#input').press_sequentially('持续快速打字同时场景运行。'*6,delay=8)
|
||||
page.wait_for_timeout(3000)
|
||||
cdp.send('Tracing.end')
|
||||
page.wait_for_timeout(1000)
|
||||
(OUT/'chromium-main-thread-trace.json').write_text(json.dumps({'traceEvents':events}))
|
||||
raf=sorted(e['dur']/1000 for e in events if e.get('name')=='FireAnimationFrame' and 'dur' in e)
|
||||
main_ids={(e['pid'],e['tid']) for e in events if e.get('name')=='thread_name' and e.get('args',{}).get('name')=='CrRendererMain'}
|
||||
tasks=sorted(e['dur']/1000 for e in events if (e.get('pid'),e.get('tid')) in main_ids and e.get('cat')=='toplevel' and 'dur' in e)
|
||||
report['trace']={'rafCount':len(raf),'rafMaxMs':max(raf,default=0),'rafP95Ms':raf[int(len(raf)*.95)] if raf else None,'mainTaskCount':len(tasks),'mainTaskMaxMs':max(tasks,default=0),'mainTaskOver8ms':sum(t>8 for t in tasks),'scope':'Linux headless Chromium, software WebGL; not low-end Android'}
|
||||
check('rapid keystrokes survive live scene',page.locator('#input').input_value()=='持续快速打字同时场景运行。'*6)
|
||||
# Inject a 20 FPS rAF clock, exercising the real 4-second rolling sampler.
|
||||
slowctx=browser.new_context(viewport={'width':1280,'height':800})
|
||||
slowctx.add_init_script("window.requestAnimationFrame=cb=>setTimeout(()=>cb(performance.now()),50);window.cancelAnimationFrame=id=>clearTimeout(id)")
|
||||
sp=slowctx.new_page();sp.route('**/v1/models',lambda r:r.abort());sp.goto(base)
|
||||
sp.wait_for_function("document.body.dataset.renderer==='static'",timeout=20000)
|
||||
check('slow renderer automatically descends WebGL Canvas CSS static',True)
|
||||
slowctx.close();browser.close()
|
||||
finally:
|
||||
server.terminate();server.wait(timeout=10);log.close();(OUT/'lifecycle-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Actual same-origin upstream check + deterministic stream faults with real timeout durations."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from playwright.sync_api import sync_playwright
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR', ROOT / 'qa-artifacts'))
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
log=open(OUT/'network-vite.log','w')
|
||||
server=subprocess.Popen(['node','node_modules/vite/bin/vite.js','--host','127.0.0.1','--port',str(port),'--strictPort'],cwd=ROOT,stdout=log,stderr=log)
|
||||
report={}
|
||||
try:
|
||||
for _ in range(60):
|
||||
try: urllib.request.urlopen(base,timeout=.5);break
|
||||
except Exception:time.sleep(.1)
|
||||
with sync_playwright() as pw:
|
||||
browser=pw.chromium.launch(args=['--no-sandbox','--enable-unsafe-swiftshader'])
|
||||
page=browser.new_page()
|
||||
page.goto(base)
|
||||
report['real_upstream']=page.evaluate("""async()=>{const {probe,stream}=await import('/src/net/llm.ts'); const reachable=await probe(); if(!reachable)return {reachable:false,via:'same-origin /v1'}; const start=performance.now();let text='';try{await stream([{role:'user',text:'你好,请用一句话打招呼。',complete:true,source:'live'}],new AbortController().signal,s=>text+=s);return {reachable:true,complete:true,text,ms:performance.now()-start}}catch(e){return {reachable:true,complete:false,text,error:e.message,ms:performance.now()-start}}}""")
|
||||
print('Real upstream:',json.dumps(report['real_upstream'],ensure_ascii=False),flush=True)
|
||||
report['faults']=page.evaluate("""async()=>{
|
||||
const {stream,probe}=await import('/src/net/llm.ts'); const original=window.fetch;
|
||||
window.fetch=async(url,options={})=>{
|
||||
if(String(url).endsWith('/models'))return new Promise((_,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('abort','AbortError'))));
|
||||
const mode=JSON.parse(options.body).messages.at(-1).content;
|
||||
if(mode==='network')throw new TypeError('Failed to fetch');
|
||||
let timer,interval;
|
||||
const body=new ReadableStream({start(c){
|
||||
const send=s=>c.enqueue(new TextEncoder().encode(s));
|
||||
const delta=s=>'data: '+JSON.stringify({choices:[{delta:{content:s}}]})+'\\r\\n\\r\\n';
|
||||
options.signal.addEventListener('abort',()=>{clearTimeout(timer);clearInterval(interval);c.error(new DOMException('abort','AbortError'))},{once:true});
|
||||
if(mode==='first-timeout')return;
|
||||
if(mode==='total-timeout'){send(delta('保留首段'));interval=setInterval(()=>send('data: {"choices":[{"delta":{}}]}\\n\\n'),1000);return;}
|
||||
if(mode==='cancel'){send(delta('取消前已显示'));return;}
|
||||
if(mode==='empty'){send('data: [DONE]\\n\\n');c.close();return;}
|
||||
if(mode==='invalid'){send(delta('格式错误前的内容'));send('data: invalid-json\\n\\n');c.close();return;}
|
||||
const bytes=new TextEncoder().encode(delta('中文🌧️,分包正文')+'data: [DONE]\\r\\n\\r\\n');let i=0;
|
||||
const emit=()=>{if(i===bytes.length){c.close();return}c.enqueue(bytes.slice(i,i+1));i++;timer=setTimeout(emit,1)};emit();
|
||||
},cancel(){clearTimeout(timer);clearInterval(interval)}});
|
||||
return new Response(body,{headers:{'Content-Type':'text/event-stream'}});
|
||||
};
|
||||
try{
|
||||
const run=async mode=>{const start=performance.now();const controller=new AbortController();let text='',error=null;if(mode==='cancel')setTimeout(()=>controller.abort(),100);try{await stream([{role:'user',text:mode,complete:true,source:'live'}],controller.signal,s=>text+=s)}catch(e){error=e.message}return {mode,text,error,ms:Math.round(performance.now()-start)}};
|
||||
const faults=await Promise.all(['utf8','network','cancel','empty','invalid','first-timeout','total-timeout'].map(run));
|
||||
const start=performance.now(),reachable=await probe();faults.push({mode:'probe-timeout',reachable,ms:Math.round(performance.now()-start)});return faults;
|
||||
}finally{window.fetch=original}
|
||||
}""")
|
||||
faults={r['mode']:r for r in report['faults']}
|
||||
assert faults['utf8']['text']=='中文🌧️,分包正文' and faults['utf8']['error'] is None
|
||||
assert '网络连接失败' in faults['network']['error']
|
||||
assert faults['cancel']['text']=='取消前已显示' and '已取消' in faults['cancel']['error']
|
||||
assert '未返回正文' in faults['empty']['error']
|
||||
assert faults['invalid']['text']=='格式错误前的内容' and '格式错误' in faults['invalid']['error']
|
||||
assert 7900<=faults['first-timeout']['ms']<10000 and '超时' in faults['first-timeout']['error']
|
||||
assert 39900<=faults['total-timeout']['ms']<43000 and faults['total-timeout']['text']=='保留首段'
|
||||
assert 1900<=faults['probe-timeout']['ms']<4000 and not faults['probe-timeout']['reachable']
|
||||
report['passed']=True
|
||||
print('PASS UTF8 fragmentation, network error, empty reply, invalid SSE, cancel, 8s first-content timeout, 40s total timeout, 2s probe timeout',flush=True)
|
||||
browser.close()
|
||||
finally:
|
||||
server.terminate();server.wait(timeout=10);log.close()
|
||||
(OUT/'network-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Built-output smoke and clean screenshots. Preview proxy is not a deployed nginx test."""
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from playwright.sync_api import sync_playwright
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
OUT=Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR',ROOT/'qa-artifacts'));OUT.mkdir(parents=True,exist_ok=True)
|
||||
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
log=open(OUT/'preview.log','w')
|
||||
server=subprocess.Popen(['node','node_modules/vite/bin/vite.js','preview','--host','127.0.0.1','--port',str(port),'--strictPort'],cwd=ROOT,stdout=log,stderr=log)
|
||||
report={'checks':[],'errors':[]}
|
||||
try:
|
||||
for _ in range(60):
|
||||
try:urllib.request.urlopen(base,timeout=.5);break
|
||||
except Exception:time.sleep(.1)
|
||||
with sync_playwright() as pw:
|
||||
browser=pw.chromium.launch(args=['--no-sandbox','--enable-unsafe-swiftshader'])
|
||||
page=browser.new_page(viewport={'width':1600,'height':900})
|
||||
page.on('pageerror',lambda e:report['errors'].append(str(e)))
|
||||
page.goto(base);page.wait_for_function("document.body.dataset.renderer==='webgl'");page.wait_for_timeout(5200)
|
||||
report['proxy']=page.evaluate("async()=>{try{const r=await fetch('/v1/models',{signal:AbortSignal.timeout(2000)});return {status:r.status,body:await r.json()}}catch(e){return {error:e.message}}}")
|
||||
def shot(name):page.screenshot(path=str(OUT/f'production-{name}.png'),full_page=True)
|
||||
shot('desktop-1600')
|
||||
page.locator('#settings-open').click();shot('desktop-settings')
|
||||
assert page.locator('#settings').bounding_box()['x']>=1100
|
||||
page.locator('#settings-close').click()
|
||||
report['checks'].append('desktop settings align with right panel outside character')
|
||||
page.set_viewport_size({'width':1280,'height':800});page.wait_for_timeout(300);shot('desktop-1280')
|
||||
page.set_viewport_size({'width':768,'height':1024});page.wait_for_timeout(300);shot('tablet-portrait')
|
||||
page.set_viewport_size({'width':390,'height':844});page.wait_for_timeout(300);shot('mobile-collapsed')
|
||||
page.locator('#drawer').click();shot('mobile-expanded')
|
||||
page.locator('#settings-open').click();shot('mobile-settings')
|
||||
assert page.locator('#settings').bounding_box()['y']>=844*.45-1
|
||||
page.locator('#presets button').last.click()
|
||||
page.locator('#quality').select_option('lite')
|
||||
page.locator('#rain-sound').check();page.locator('#rain-sound').uncheck()
|
||||
page.locator('#settings-close').click()
|
||||
report['checks'].append('mobile settings preserve face area and all scrolled controls work')
|
||||
assert page.locator('.panel').bounding_box()['y']>=844*.45-1
|
||||
report['checks'].append('built mobile drawer preserves top 45 percent face area')
|
||||
page.locator('#input').focus();shot('mobile-input-focus')
|
||||
page.set_viewport_size({'width':844,'height':390});page.wait_for_timeout(300);shot('mobile-landscape')
|
||||
assert page.locator('.panel').bounding_box()['y']>=300
|
||||
report['checks'].append('built landscape layout is scene above conversation')
|
||||
page.set_viewport_size({'width':1600,'height':900});page.emulate_media(reduced_motion='reduce');page.wait_for_function("document.body.dataset.renderer==='static'");shot('reduced-motion')
|
||||
report['checks'].append('built reduced-motion is static')
|
||||
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
||||
assert not report['errors']
|
||||
report['checks'].append('production output has no unhandled errors or horizontal overflow')
|
||||
browser.close()
|
||||
files=list((ROOT/'dist').rglob('*.js'))
|
||||
report['sizes']={'jsGzipBytes':sum(len(gzip.compress(p.read_bytes())) for p in files),'jsRawBytes':sum(p.stat().st_size for p in files),'assetsBytes':sum(p.stat().st_size for p in (ROOT/'public/assets').iterdir()),'visibleWebpBytes':(ROOT/'public/assets/rain-room.webp').stat().st_size,'jpegFallbackBytes':(ROOT/'public/assets/rain-room.jpg').stat().st_size}
|
||||
assert report['sizes']['jsGzipBytes']<=300000 and report['sizes']['assetsBytes']<=1500000 and report['sizes']['visibleWebpBytes']<=900000
|
||||
report['checks'].append('compressed JS and material size budgets met')
|
||||
report['passed']=True
|
||||
print(json.dumps(report,ensure_ascii=False,indent=2),flush=True)
|
||||
finally:
|
||||
server.terminate();server.wait(timeout=10);log.close();(OUT/'production-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite';
|
||||
const proxy = { '/v1': { target: 'http://192.168.200.11:8731', changeOrigin: true } };
|
||||
export default defineConfig({ server: { proxy }, preview: { proxy } });
|
||||
Reference in New Issue
Block a user