diff --git a/.gitignore b/.gitignore index 5782304..bff9488 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ models/ *.swp .DS_Store Thumbs.db + +# Independent web demo +web/node_modules/ +web/dist/ diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..d7edc78 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +qa-artifacts/ diff --git a/web/QA.md b/web/QA.md new file mode 100644 index 0000000..67963ff --- /dev/null +++ b/web/QA.md @@ -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 数据。证据随本卡附件交付,不以本地路径代替交付。 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..11e28e3 --- /dev/null +++ b/web/README.md @@ -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 。 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ed71487 --- /dev/null +++ b/web/index.html @@ -0,0 +1 @@ +CyberGril · 与晚晚相处
diff --git a/web/nginx.conf.example b/web/nginx.conf.example new file mode 100644 index 0000000..9376692 --- /dev/null +++ b/web/nginx.conf.example @@ -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; +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..7d2179a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1166 @@ +{ + "name": "cybergril-rain-room", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cybergril-rain-room", + "version": "0.1.0", + "dependencies": { + "pixi.js": "^8.0.0" + }, + "devDependencies": { + "typescript": "~5.9.3", + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", + "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", + "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", + "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", + "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", + "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", + "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", + "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", + "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", + "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", + "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", + "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", + "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", + "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", + "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", + "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", + "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", + "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", + "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", + "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", + "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", + "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", + "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", + "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", + "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", + "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", + "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/earcut": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz", + "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pixi.js": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.22.0.tgz", + "integrity": "sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.15", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b5317e8 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/assets/rain-room.jpg b/web/public/assets/rain-room.jpg new file mode 100644 index 0000000..c244de4 Binary files /dev/null and b/web/public/assets/rain-room.jpg differ diff --git a/web/public/assets/rain-room.webp b/web/public/assets/rain-room.webp new file mode 100644 index 0000000..3eefc52 Binary files /dev/null and b/web/public/assets/rain-room.webp differ diff --git a/web/src/app/shell.ts b/web/src/app/shell.ts new file mode 100644 index 0000000..a9a12fb --- /dev/null +++ b/web/src/app/shell.ts @@ -0,0 +1,14 @@ +// Static application chrome only. All message/model content uses textContent. +export const shell = ` + +
☾ CyberGril 晚晚
她的时间
+

演示版 · 性格与记忆引擎未接入

+ +

+ +

晚晚

在这里 · 场景演示

+

今晚 · 雨夜相处

波形演示 · 无语音播放
+ +
正在探测模型…
0 / 500文字 / 语音

语音录入与 TTS 未接入;附件功能未接入。

+

雨夜设置

氛围预设 · 未接真实情绪引擎

系统减少动效时自动静态,触屏关闭视差。

默认静音,仅主动开启后播放。

+`; diff --git a/web/src/app/style.css b/web/src/app/style.css new file mode 100644 index 0000000..315dd54 --- /dev/null +++ b/web/src/app/style.css @@ -0,0 +1,1015 @@ +:root { + --scene-night-deep:#16233c; + --scene-city-blue:#305481; + --scene-city-mid:#5a76a2; + --scene-tower:#2b456b; + --scene-glass-blue:#718fc2; + --scene-amber:#e8a45c; + --scene-amber-soft:#f2c894; + --scene-wood:#59361d; + --scene-cream:#e8d9c8; + --scene-blanket:#362928; + --ink-primary:#f2efe9; + --ink-secondary:rgba(242,239,233,.64); + --ink-tertiary:rgba(242,239,233,.4); + --ink-disabled:rgba(242,239,233,.28); + --accent:#e8a45c; + --accent-hover:#f2b879; + --accent-pressed:#c9853f; + --accent-ink:#1a1208; + --glass-bg:rgba(14,18,30,.55); + --glass-bg-strong:rgba(14,18,30,.72); + --glass-border:rgba(255,255,255,.08); + --glass-highlight:rgba(255,255,255,.14); + --bubble-user:rgba(120,144,180,.2); + --bubble-user-border:rgba(255,255,255,.07); + --bubble-her:rgba(255,255,255,.06); + --bubble-failed:rgba(200,90,80,.14); + --state-danger:#e07a6a; + --font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Source Han Sans SC",system-ui,-apple-system,sans-serif; + --fs-caption:12px; + --fs-small:13px; + --fs-body:14px; + --fs-chat:15px; + --fs-nav:15px; + --fs-title:20px; + --fs-brand:16px; + --fs-time:13px; + --space-1:4px; + --space-2:8px; + --space-3:12px; + --space-4:16px; + --space-5:20px; + --space-6:24px; + --space-8:32px; + --space-10:40px; + --r-panel:24px; + --r-bubble:16px; + --r-card:16px; + --r-control:12px; + --r-pill:999px; + --shadow-panel:0 24px 80px rgba(0,0,0,.45); + --shadow-pop:0 8px 24px rgba(0,0,0,.35); + --glow-accent:0 0 12px rgba(232,164,92,.45); + --glass-blur:blur(28px) saturate(1.2); + --z-scene-far:0; + --z-scene-rain:1; + --z-scene-near:2; + --z-caption:10; + --z-nav:10; + --z-topbar:20; + --z-panel:30; + --z-toast:40; + --ease-out:cubic-bezier(.23,1,.32,1); + --ease-in-out:cubic-bezier(.77,0,.175,1); + --ease-drawer:cubic-bezier(.32,.72,0,1); + --lamp-opacity:.12; + --panel-width:clamp(360px,30vw,460px); + --panel-top:64px; + --panel-inset:24px; + --scene-height:330px; + --drawer-height:30dvh; +} +* { + box-sizing:border-box +} +[hidden] { + display:none!important +} +html,body { + margin:0; + min-height:100%; + background:var(--scene-night-deep); + color:var(--ink-primary); + font-family:var(--font-sans); + font-size:var(--fs-body); + letter-spacing:.02em +} +body { + height:100dvh; + overflow:hidden +} +button,input,textarea,select { + font:inherit; + color:inherit +} +button { + cursor:pointer; + border:1px solid transparent; + background:transparent; + border-radius:var(--r-control); + padding:var(--space-2) var(--space-3); + transition:transform 160ms var(--ease-out),opacity 160ms var(--ease-out) +} +button:active { + transform:scale(.94); + opacity:.7 +} +button:disabled { + color:var(--ink-disabled); + cursor:default +} +button[aria-disabled=true] { + color:var(--ink-disabled) +} +button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible { + outline:2px solid var(--accent); + outline-offset:3px +} +p,h1,h2 { + margin:0 +} +small { + font-size:var(--fs-caption); + color:var(--ink-secondary) +} +h1 { + font-size:var(--fs-title); + font-weight:500 +} +h2 { + font-size:var(--fs-title); + font-weight:500 +} +.glass { + background:var(--glass-bg); + border:1px solid var(--glass-border); + border-radius:var(--r-panel); + backdrop-filter:var(--glass-blur); + -webkit-backdrop-filter:var(--glass-blur); + box-shadow:inset 0 1px 0 var(--glass-highlight),var(--shadow-panel) +} +.scene { + position:fixed; + inset:0; + z-index:var(--z-scene-far); + pointer-events:none; + overflow:hidden +} +.scene picture,.scene img { + position:absolute; + width:100%; + height:100%; + object-fit:cover; + object-position:32% 40% +} +.shade { + position:absolute; + inset:0; + background:linear-gradient(180deg,var(--glass-bg-strong),transparent 17%,transparent 70%,var(--glass-bg-strong)) +} +#effects { + position:absolute; + inset:-12px; + mask-image:linear-gradient(90deg,transparent 42%,black 54%,black 92%,transparent); + will-change:transform +} +#effects canvas { + width:100%; + height:100%; + display:block +} +.lamp { + position:absolute; + inset:0; + background:radial-gradient(ellipse at 18% 35%,var(--scene-amber),transparent 50%); + opacity:var(--lamp-opacity) +} +.css-rain { + display:none; + position:absolute; + inset:-25%; + left:48%; + opacity:.25; + background:repeating-linear-gradient(100deg,transparent 0 70px,var(--scene-glass-blue) 71px,transparent 72px 170px); + animation:rain 9s linear infinite +} +.light-trail { + position:absolute; + left:55%; + top:56%; + width:20%; + height:1px; + background:linear-gradient(90deg,transparent,var(--scene-amber-soft),transparent); + opacity:.4; + animation:trail 14s linear infinite +} +.dust { + position:absolute; + left:17%; + top:27%; + width:18%; + height:34%; + opacity:.25; + background:radial-gradient(circle at 10% 20%,var(--scene-amber-soft) 0 1px,transparent 2px),radial-gradient(circle at 75% 40%,var(--scene-amber-soft) 0 1px,transparent 2px),radial-gradient(circle at 30% 80%,var(--scene-amber-soft) 0 1px,transparent 2px); + background-size:49px 79px; + animation:dust 16s linear infinite +} +header { + height:52px; + padding:0 28px; + position:fixed; + inset:0 0 auto; + z-index:var(--z-topbar); + display:flex; + align-items:center; + justify-content:space-between +} +.brand { + font-size:var(--fs-brand); + font-weight:600; + letter-spacing:.06em; + display:flex; + align-items:center; + gap:var(--space-3) +} +.brand>span:last-child { + font-weight:400; + letter-spacing:.02em +} +.moon { + font-size:26px; + color:var(--accent) +} +.clock { + display:flex; + align-items:center; + gap:var(--space-4); + font-size:var(--fs-time); + color:var(--ink-secondary) +} +time { + font-variant-numeric:tabular-nums; + color:var(--ink-primary) +} +.clock button { + font-size:20px +} +.badge { + position:fixed; + left:32px; + top:66px; + z-index:var(--z-caption); + font-size:var(--fs-caption); + color:var(--ink-secondary) +} +nav { + position:fixed; + left:24px; + top:60%; + z-index:var(--z-nav); + display:flex; + flex-direction:column; + gap:var(--space-3) +} +nav button { + text-align:left; + font-size:var(--fs-nav); + color:var(--ink-secondary); + position:relative +} +nav button[aria-current] { + color:var(--ink-primary) +} +nav button[aria-current]:before { + content:""; + width:6px; + height:6px; + border-radius:50%; + background:var(--accent); + position:absolute; + left:-4px; + top:calc(50% - 3px) +} +nav small { + border:1px solid var(--glass-border); + border-radius:var(--r-pill); + padding:1px 5px; + margin-left:var(--space-1) +} +.caption { + white-space:pre-line; + position:fixed; + left:32px; + bottom:40px; + color:var(--ink-secondary); + line-height:1.8; + z-index:var(--z-caption) +} +.panel { + position:fixed; + right:var(--panel-inset); + top:var(--panel-top); + bottom:var(--panel-inset); + width:var(--panel-width); + z-index:var(--z-panel); + padding:var(--space-5); + display:flex; + flex-direction:column; + gap:var(--space-4); + min-height:0; + transition:transform 240ms var(--ease-out),opacity 240ms var(--ease-out) +} +.panel-head { + display:flex; + align-items:center; + justify-content:space-between; + min-height:56px +} +.panel-head p { + color:var(--ink-secondary); + font-size:var(--fs-small); + margin-top:var(--space-1) +} +.presence { + display:inline-block; + width:8px; + height:8px; + background:var(--accent); + border-radius:50%; + box-shadow:var(--glow-accent); + margin-right:var(--space-1); + animation:presence 2.4s var(--ease-in-out) infinite +} +.panel-head button { + font-size:24px +} +.handle { + display:none +} +.view { + flex:1; + display:flex; + flex-direction:column; + min-height:0; + gap:var(--space-4); + position:relative +} +#messages { + flex:1; + overflow:auto; + overscroll-behavior:contain; + scrollbar-width:thin; + scrollbar-color:var(--glass-highlight) transparent; + min-height:0; + padding-right:var(--space-1) +} +.date { + text-align:center; + font-size:var(--fs-caption); + color:var(--ink-tertiary); + margin:var(--space-3) 0 var(--space-6) +} +.message { + max-width:78%; + width:fit-content; + background:var(--bubble-her); + border:1px solid var(--glass-border); + border-radius:var(--r-bubble); + padding:var(--space-3) var(--space-4); + margin-bottom:var(--space-4); + overflow-wrap:anywhere; + animation:appear 180ms var(--ease-out) +} +.message p { + white-space:pre-wrap; + font-size:var(--fs-chat); + line-height:1.6 +} +.message>small { + display:block; + font-size:10px; + color:var(--ink-tertiary); + margin-bottom:var(--space-1) +} +.message.user { + margin-left:auto; + background:var(--bubble-user); + border-color:var(--bubble-user-border); + border-bottom-right-radius:6px +} +.message.pending { + opacity:.55 +} +.message:has(.error) { + background:var(--bubble-failed) +} +.error { + color:var(--state-danger); + font-size:var(--fs-caption); + margin-top:var(--space-2) +} +.error button { + color:var(--accent); + padding:var(--space-1) var(--space-2) +} +#bottom { + position:absolute; + bottom:140px; + left:30%; + background:var(--glass-bg-strong); + color:var(--accent); + box-shadow:var(--shadow-pop) +} +.wave-card { + display:flex; + align-items:center; + gap:var(--space-2); + flex-wrap:wrap +} +.wave-card>button { + color:var(--accent); + border-color:var(--accent); + border-radius:var(--r-pill); + height:32px; + width:32px; + padding:0 +} +.wave-bars { + height:20px; + display:flex; + gap:2px; + align-items:center +} +.wave-bars i { + width:2px; + height:var(--bar-height); + background:var(--ink-secondary); + border-radius:var(--r-pill) +} +.wave-bars.playing i { + animation:wave 700ms var(--ease-in-out) infinite alternate; + animation-delay:var(--delay) +} +.wave-card>small { + font-size:10px; + color:var(--ink-tertiary); + width:100%; + padding-left:40px +} +.moment { + display:flex; + align-items:center; + gap:var(--space-3); + background:var(--bubble-her); + padding:var(--space-2); + border-radius:var(--r-card); + min-height:64px; + text-align:left; + flex-shrink:0 +} +.moment>span:last-child { + margin-left:auto; + font-size:24px; + color:var(--ink-secondary) +} +.thumb { + width:44px; + height:44px; + border-radius:var(--r-control); + background:url('/assets/rain-room.webp') 15% 50% / auto 90px +} +.moment small { + line-height:1.8; + color:var(--ink-tertiary) +} +.compose-area { + flex-shrink:0 +} +.mode-row { + display:flex; + justify-content:space-between; + align-items:center; + font-size:10px; + color:var(--ink-tertiary); + margin-bottom:var(--space-2); + gap:var(--space-1) +} +.mode-row button { + padding:var(--space-1); + color:var(--ink-secondary); + font-size:10px +} +form { + display:flex; + gap:var(--space-1); + align-items:center; + border:1px solid var(--glass-border); + background:var(--glass-bg-strong); + border-radius:var(--r-pill); + padding:var(--space-1); + min-height:48px +} +form:focus-within { + border-color:var(--accent); + box-shadow:var(--glow-accent) +} +form button svg { + width:18px; + height:18px; + fill:none; + stroke:currentColor; + stroke-width:1.5; + stroke-linecap:round; + stroke-linejoin:round; + vertical-align:middle +} +form button { + padding:0; + width:32px; + height:36px; + flex-shrink:0 +} +textarea { + flex:1; + min-width:0; + resize:none; + max-height:80px; + border:0; + outline:none!important; + background:transparent; + line-height:1.6; + font-size:var(--fs-body); + padding:var(--space-2) 0 +} +textarea::placeholder { + color:var(--ink-tertiary) +} +#send { + border-radius:var(--r-pill); + background:var(--accent); + color:var(--accent-ink); + font-size:24px; + width:36px +} +#voice { + border:1.5px solid var(--accent); + border-radius:var(--r-pill); + width:32px; + height:32px; + box-shadow:var(--glow-accent) +} +.compose-meta { + display:flex; + justify-content:space-between; + align-items:center; + color:var(--ink-tertiary); + font-size:var(--fs-caption); + margin-top:var(--space-2) +} +.compose-meta button { + padding:0; + color:var(--accent) +} +#voice-note { + font-size:10px; + color:var(--ink-tertiary); + margin-top:var(--space-2) +} +#chat-status { + font-size:10px; + color:var(--ink-secondary); + min-height:14px; + margin-top:var(--space-1) +} +#restore { + position:fixed; + right:24px; + bottom:24px; + z-index:var(--z-panel); + padding:var(--space-4); + color:var(--accent) +} +.chat-hidden .panel { + opacity:0; + transform:translateX(calc(100% + 24px)); + pointer-events:none; + visibility:hidden +} +#preview-view { + overflow:auto; + line-height:1.8 +} +#preview-view h2 { + margin:var(--space-5) 0 +} +#preview-content>p { + margin:var(--space-4) 0; + padding:var(--space-4); + background:var(--bubble-her); + border-radius:var(--r-card) +} +#preview-content small { + color:var(--accent) +} +dialog { + position:fixed; + top:64px; + right:var(--panel-inset); + bottom:auto; + left:auto; + margin:0; + width:var(--panel-width); + color:var(--ink-primary); + padding:var(--space-6); + max-height:calc(100dvh - 88px); + overflow:auto +} +dialog::backdrop { + background:var(--glass-bg-strong) +} +.dialog-head { + display:flex; + justify-content:space-between; + align-items:center; + margin-bottom:var(--space-5) +} +.dialog-head button { + font-size:24px +} +dialog p { + color:var(--ink-secondary); + font-size:var(--fs-small); + line-height:1.6 +} +dialog label { + display:flex; + flex-direction:column; + gap:var(--space-3); + margin:var(--space-5) 0 +} +dialog select { + background:var(--scene-night-deep); + border:1px solid var(--glass-highlight); + padding:var(--space-3); + border-radius:var(--r-control) +} +.choices { + display:flex; + flex-wrap:wrap; + gap:var(--space-2); + margin:var(--space-4) 0 +} +.choices button { + border-color:var(--glass-highlight); + font-size:var(--fs-small) +} +.choices button[aria-pressed=true] { + color:var(--accent); + border-color:var(--accent) +} +dialog .audio-label { + flex-direction:row; + align-items:center +} +input { + accent-color:var(--accent) +} +#toast { + position:fixed; + left:50%; + bottom:var(--space-6); + transform:translateX(-50%); + max-width:calc(100vw - 32px); + width:max-content; + padding:var(--space-3) var(--space-5); + z-index:var(--z-toast); + font-size:var(--fs-small); + pointer-events:none +} +@keyframes rain { + to { + transform:translateY(25%) + } +} +@keyframes trail { + from { + transform:translateX(-50%); + opacity:.1 + } + 50% { + opacity:.5 + } + to { + transform:translateX(80%); + opacity:.1 + } +} +@keyframes dust { + 50% { + transform:translate(8px,-14px); + opacity:.15 + } +} +@keyframes presence { + 50% { + opacity:.5 + } +} +@keyframes wave { + to { + transform:scaleY(.3) + } +} +@keyframes appear { + from { + transform:translateY(8px); + opacity:0 + } + to { + transform:translateY(0); + opacity:1 + } +} +body[data-renderer=css] .css-rain { + display:block +} +body[data-motion=static] .scene * { + animation:none!important +} +body[data-motion=static] #effects { + transform:none!important +} +body[data-motion=static] .light-trail,body[data-motion=static] .dust { + display:none +} +.paused *,.paused *::before,.paused *::after { + animation-play-state:paused!important +} +@media(hover:hover) and (pointer:fine) { + button:hover { + color:var(--accent-hover); + border-color:var(--glass-highlight) + } + nav button:hover { + transform:translateX(-2px) + } + #send:hover { + background:var(--accent-hover); + color:var(--accent-ink) + } +} +@supports not (backdrop-filter:blur(1px)) { + .glass { + background:var(--glass-bg-strong) + } +} +@media(min-width:768px) and (max-width:1279px) { + :root { + --panel-width:340px; + --panel-inset:16px + } + nav { + left:16px + } + .caption { + left:24px + } +} +@media(min-width:768px) and (max-width:1279px) and (orientation:portrait) { + .scene img { + object-position:44% 40% + } +} +@media(max-width:767px) { + dialog { + top:auto; + bottom:0; + right:0; + left:0; + width:100%; + max-width:100%; + max-height:55dvh; + border-radius:var(--r-panel) var(--r-panel) 0 0; + } + :root { + --panel-inset:0px; + --panel-top:auto + } + header { + height:44px; + padding:0 var(--space-4) + } + .brand { + font-size:var(--fs-body); + gap:var(--space-2) + } + .clock { + font-size:0; + gap:var(--space-2) + } + .clock time { + font-size:var(--fs-time) + } + nav { + top:48px; + left:var(--space-3); + right:var(--space-3); + flex-direction:row; + gap:var(--space-2) + } + nav button { + font-size:var(--fs-small); + padding:var(--space-2) + } + nav button[aria-current]:before { + width:24px; + height:2px; + left:var(--space-2); + top:auto; + bottom:0 + } + .badge { + left:var(--space-5); + top:96px; + font-size:10px + } + .scene img { + object-position:30% 30% + } + .panel { + width:100%; + height:var(--drawer-height); + bottom:0; + border-radius:var(--r-panel) var(--r-panel) 0 0; + padding:var(--space-3) var(--space-4) max(var(--space-3),env(safe-area-inset-bottom)); + gap:var(--space-2) + } + .handle { + display:block; + position:absolute; + top:0; + left:calc(50% - 40px); + width:80px; + height:20px; + padding:0; + touch-action:none + } + .handle span { + display:block; + background:var(--glass-highlight); + width:36px; + height:4px; + border-radius:var(--r-pill); + margin:auto + } + .panel-head { + min-height:32px; + padding-top:var(--space-1) + } + .panel-head h1 { + font-size:var(--fs-brand) + } + .panel-head p { + display:none + } + .date,.moment,.wave-card { + display:none + } + .compose-meta { + margin-top:var(--space-1); + font-size:10px + } + #voice-note { + font-size:9px; + margin-top:var(--space-1) + } + .message { + padding:var(--space-2) var(--space-3); + margin-bottom:var(--space-2) + } + .message p { + font-size:var(--fs-body) + } + .message>small { + font-size:9px + } + .mode-row { + margin-bottom:var(--space-1) + } + .caption { + left:var(--space-5); + bottom:calc(var(--drawer-height) + 20px); + font-size:var(--fs-small) + } + body.expanded { + --drawer-height:55dvh + } + .expanded .caption { + display:none + } + .expanded .moment { + display:flex + } + .expanded .date { + display:block + } + .panel-head button { + padding:0 var(--space-2) + } + #bottom { + bottom:var(--space-3); + left:30% + } + .expanded #bottom { + bottom:80px + } + #toast { + bottom:16px + } + .chat-hidden .panel { + transform:translateY(100%) + } + #restore { + bottom:var(--space-4); + right:var(--space-4) + } +} +/* Short screens and a visible software keyboard get a scrollable scene above chat. */ +@media(max-width:950px) and (max-height:600px) { + body { + height:auto; + min-height:100dvh; + overflow:auto + } + .scene { + position:absolute; + height:var(--scene-height); + bottom:auto + } + .scene img { + object-position:30% 30% + } + header { + position:absolute + } + .badge { + position:absolute; + top:calc(var(--scene-height) - 24px) + } + nav { + position:absolute; + top:48px; + left:var(--space-3); + flex-direction:row + } + .caption { + display:none + } + .panel { + position:relative; + top:auto; + right:auto; + bottom:auto; + width:min(100%,680px); + height:480px; + margin:var(--scene-height) auto 0; + border-radius:var(--r-panel) var(--r-panel) 0 0 + } + .chat-hidden .panel { + display:none + } + #restore { + position:relative; + margin-top:var(--scene-height) + } + #toast { + bottom:12px + } +} +body.keyboard { + height:auto; + overflow:auto +} +body.keyboard .scene { + position:absolute; + height:var(--scene-height); + bottom:auto +} +body.keyboard header,body.keyboard nav,body.keyboard .badge { + position:absolute +} +body.keyboard .panel { + position:relative; + top:auto; + right:auto; + bottom:auto; + margin-top:var(--scene-height); + height:480px; + width:100% +} +body.keyboard .badge { + top:calc(var(--scene-height) - 24px) +} +body.keyboard .caption { + display:none +} +@media(prefers-reduced-motion:reduce) { + *,*::before,*::after { + animation:none!important; + transition:opacity 200ms!important + } + #effects { + transform:none!important + } +} diff --git a/web/src/demo/script.ts b/web/src/demo/script.ts new file mode 100644 index 0000000..d61e5ed --- /dev/null +++ b/web/src/demo/script.ts @@ -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]; +} diff --git a/web/src/engine/audio.ts b/web/src/engine/audio.ts new file mode 100644 index 0000000..85d430a --- /dev/null +++ b/web/src/engine/audio.ts @@ -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(); } } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..e4efc97 --- /dev/null +++ b/web/src/main.ts @@ -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 $ = (id: string) => document.getElementById(id) as T; +$('app').innerHTML = shell; +$('caption').textContent = caption.join('\n'); +$('moment-title').textContent = momentTitle; +let toastTimer: ReturnType; +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 = $('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() { + $('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) { + 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; $('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; $('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(); $('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('[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('[data-view]')) button.onclick = () => showView(button.dataset.view!); +$('moment').onclick = () => showView('daily'); +$('attachment').onclick = () => toast('附件功能未接入,本轮只支持文字。'); +$('voice').onclick = () => toast('语音录入与 TTS 未接入,不会启动麦克风。'); +const settings = $('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); +}); +$('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 */ } +$('volume').value = String(audio.volume * 100); +$('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 = '浏览器无法启动音频,雨声保持关闭'; } +}; +$('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 | 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'); }; diff --git a/web/src/net/llm.ts b/web/src/net/llm.ts new file mode 100644 index 0000000..b28193a --- /dev/null +++ b/web/src/net/llm.ts @@ -0,0 +1,77 @@ +import type { ChatMessage } from '../state/contracts'; +export async function probe(): Promise { + 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 { + 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 | undefined = setTimeout(expire, 8000); + let reader: ReadableStreamDefaultReader | 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((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); + } +} diff --git a/web/src/scene/scene.ts b/web/src/scene/scene.ts new file mode 100644 index 0000000..00a9b08 --- /dev/null +++ b/web/src/scene/scene.ts @@ -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); + }; +} diff --git a/web/src/state/contracts.ts b/web/src/state/contracts.ts new file mode 100644 index 0000000..b14aec2 --- /dev/null +++ b/web/src/state/contracts.ts @@ -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 } diff --git a/web/tests/browser_check.py b/web/tests/browser_check.py new file mode 100644 index 0000000..634dcef --- /dev/null +++ b/web/tests/browser_check.py @@ -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":"中文"}}]}\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', '' 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(ta-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)) diff --git a/web/tests/lifecycle_check.py b/web/tests/lifecycle_check.py new file mode 100644 index 0000000..84ba1a3 --- /dev/null +++ b/web/tests/lifecycle_check.py @@ -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)) diff --git a/web/tests/network_check.py b/web/tests/network_check.py new file mode 100644 index 0000000..a9352d6 --- /dev/null +++ b/web/tests/network_check.py @@ -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)) diff --git a/web/tests/production_check.py b/web/tests/production_check.py new file mode 100644 index 0000000..e2422fe --- /dev/null +++ b/web/tests/production_check.py @@ -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)) diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..6fd0535 --- /dev/null +++ b/web/tsconfig.json @@ -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" + ] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..38fa5d7 --- /dev/null +++ b/web/vite.config.ts @@ -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 } });