Compare commits

...
Author SHA1 Message Date
白栖知andmultica-agent ad6a3b19ae HEL-811: 保全 HEL-799 v2 原稿并升版 v1.9(交流静图认可入正式来源)
- prototypes/hel-799/ 逐字节保全(23 文件+PROVENANCE 哈希清单;独立重建与交付 HTML 逐字节一致)
- sources 新增 comment-evidence-shared-space-20261023.json 与 shared-space-static-acceptance-20261023.md(原话经平台逐条核验)
- baseline/current/changes/coverage 升版 v1.9:VIS-011 边界注明交流静图认可,严格限定范围
- 新增 docs/requirements/handoff-HEL-811.json(总工机械校验版;原作者 handoff 原样保留)
- check_requirements --base-ref origin/main 与 --handoff 通过;git diff --check 干净

Co-authored-by: multica-agent <github@multica.ai>
2026-10-23 15:47:04 +08:00
leefer 3a986726d8 Merge pull request 'HEL-697: 视觉总监接手整体视觉与代码,记录需求v1.8' (#7) from agent/agent/6f5a7d8d7cb6 into main 2026-10-22 23:05:35 +08:00
白栖知andmultica-agent 81d7cb523d HEL-697: record restored director execution and runtime change provenance
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 21:18:13 +08:00
白栖知andmultica-agent c6dd174253 HEL-697: record director handoff and confirmed authentication blocker
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:53:20 +08:00
白栖知andmultica-agent 94ba2b654e HEL-697: assign visual design and implementation to visual director
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:50:54 +08:00
leefer 4242f8377e Merge pull request 'HEL-697: 完整需求跨任务继承与验收约束' (#5) from agent/agent/5004131dd07f into main 2026-10-22 19:50:12 +08:00
白栖知andmultica-agent 97aaefe5fa HEL-697: record task migration and requirement checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 19:39:13 +08:00
白栖知andmultica-agent 159253a12f HEL-697: preserve complete requirements and enforce task handoffs
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 19:33:51 +08:00
leefer 4984293998 Merge HEL-748: 完整前端与演示适配
独立审核通过,白栖知已验收前端演示版;主卡继续推进真实功能接入。
2026-10-22 11:28:51 +08:00
白栖知andmultica-agent 6c3b09e693 HEL-748: implement S1 frontend with explicit local demo
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 00:56:18 +08:00
90 changed files with 14930 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# CyberGril 项目必读规则
白栖知已于主卡 HEL-697 评论 `01a1635b-b47c-79ed-ac66-9408c7905af9` 批准需求继承办法。
## 开始任何工作前
1. 读取 `docs/requirements/README.md`、`current.md`、`coverage.md`,以及本次相关的原始来源。完整需求不能被任务卡摘录替代。记录所用版本和读取时的仓库提交。
2. 项目现行需求入口优先于旧 `docs/SPEC.md`、历史设计稿、样机、代码现状与任务卡中的旧指令。明确的用户新决定才能覆盖旧决定,并须更新需求变更记录。遇到冲突先核对原话,无法解决才向总管报告具体问题,不擅自降低要求。
3. 所有 CyberGril 任务应归属 CyberGril 项目,并包含需求入口、版本、关联要求编号、交付与验收、未完成项及后续承接、需求变更来源。现有历史卡的迁移不授权重启已停止的试验或重新执行已交付工作。
## 施工和交接
- 白栖知于主卡评论 `01a163a5-e2a7-7ac8-afdb-991868e721bc` 明确:本项目整体视觉由视觉总监接手设计与实际落地,职责不止审核把关。施工员无法实现对应效果时,由视觉总监直接编写相关代码。本轮对话及沉浸界面交由视觉总监亲自负责;总管负责协调、核对需求和交付,不再以自己的视觉原型替代它接手。此项是CyberGril项目分工,不修改队员在其他项目的全局职责、模型或认证。
- 使用 `docs/requirements/task-handoff-template.md`,写出本次交付及完整需求的关联;完整当前需求始终有效。阶段不做的内容列明承接,不删除需求。
- 需求、技术方案、阶段范围、实现状态、用户验收分开记录。不得把智能体建议、技术困难、模型登录失败或某阶段通过当作修改需求的授权。
- 不用“当前一两句”以外的长消息滚动主界面替代已确认的交流体验。长回复需完整可读,具体分句等方案先通过动态小样验证。
- 不用固定模板长文冒充 CG-014 的模型诠释自由、CG-015 的自主初始习惯与成长。出生底色不变;后天成长另记,不得因 UI 修复重算已有伴侣。
- 演示数据与真实能力严格区分。未分享生活不得借列表、搜索、摘要或旧链接泄露;真实服务须在响应前裁剪,前端过滤不是权限控制。
- 保留已有伴侣、名字、生日、聊天与记忆;写入测试使用隔离数据。用户认可界面小样前不替换现有正式界面。当前产品 PR#4 未获合并授权。
## 修改需求和审核
修改 `baseline.json` 中的要求时,须在 `changes.json` 和 `changes.md` 中记录白栖知明确批准的原话来源、旧要求、新要求及影响;原始来源只增补,不篡改。先核实来源作者和语义,不能用智能体确认代替。
交付前执行:
```bash
python3 scripts/check_requirements.py --base-ref origin/main
python3 scripts/check_requirements.py --base-ref origin/main --handoff <本次交接JSON文件>
```
检查脚本只发现结构缺项、来源损坏和编号失配。审核人仍须逐项核对原意、实际画面及动态过程;未实际验证真机或 Safari 就明确注明。前端演示通过、工程测试通过、用户阶段验收和整项目完成是不同事实。
比较前先获取真实目标分支;若本卡目标不是 main,使用实际目标引用。初次引入基准时目标没有基准文件,由独立审核核对整包来源;以后需求有变必须升版、追加新的用户授权,历史来源和变更记录只增不改。项目尚未配置自动CI合并门禁,当前由交付与审核执行本检查,不能声称平台已经强制拦截合并。
主任务 HEL-697 持续追踪完整功能,不因局部通过而归档。平台交付路由遵守当次运行指令,总管负责向白栖知汇报。
+6
View File
@@ -4,6 +4,8 @@
不是聊天机器人。区别在于:**它在你不说话的时候也在运行**。
设计、施工与审核先读 [完整需求入口](docs/requirements/README.md) 和 [项目必读规则](AGENTS.md)。后续逐项确认的需求优先于本仓库早期构想;演示通过不代表整个项目完成。
---
## 先看一眼
@@ -306,3 +308,7 @@ pytest -k degradation -v # 降级机制
## 许可
私有项目,未授权。
## S1 前端演示
独立的 React / TypeScript 前端位于 `web/`,运行 `cd web && npm ci && npm run dev` 后从普通入口进入明确标识的本机演示。真实后台、语音、视觉和备份尚未接通;不自动连接模型或现有服务。安装、构建和测试命令见 [web/README.md](web/README.md),实现与后续接线见 [docs/frontend/IMPLEMENTATION.md](docs/frontend/IMPLEMENTATION.md)。
+1
View File
@@ -0,0 +1 @@
screenshots/
+48
View File
@@ -0,0 +1,48 @@
HEL-748 · S1 实现与接线说明
本轮范围是可构建的完整前端及离线演示,不部署,不实现 B1–B3。基于正式仓库 main 的 `1e9b08aed81b4ebf163721232a8d65707b1c3183` 新建任务分支;检出时工作区干净,无仓库内额外 AGENTS,无平台关联 PR。未改 Python 核心、既有测试、服务配置、SSH 信任、端口、GPU 或模型。
依据为本卡《整套设计验收与施工裁定-v1》、HEL-746 R2、HEL-738 结构与接口边界和需求 v1.6。名字、关系不预选、未分享即隐藏三项裁定覆盖旧原型。首建底色明确为示例,真实排盘待接已有 Python 编译器,不在浏览器重写八字规则。
主要目录与职责:
- `web/src/app/`:入口、首建守卫、导航、全局 UI 偏好、返回/主动/断线演示。
- `web/src/manage/`:四步首建;只读身份;关系/城市/服务/显示草稿;保存取消失败;外观标识;演示导出与同身份恢复。
- `web/src/chat/`:文字草稿、IME 与快捷键、字幕/记录、取消重试、模拟语音/观察授权及结束。
- `web/src/life/`:生活与四类回忆、双向关联、原位保留、手机详情推入、分页和深链守卫。
- `web/src/domain/`:模型、首建校验、公开视图。所有可见事件、回忆、消息、摘要统一经过 `publicView`,未知关联默认拒绝;详情也从公开视图查找。
- `web/src/adapters/`:独立可替换聊天适配器、turn_id/epoch 门槛、AbortController、本地持久化、严格 JSON 重建校验。
- `web/src/demo/`:固定场景数据,与页面和传输分离。
- `web/src/stage/`:背景空间与 `FallbackPresenter`。Presenter 只消费 SceneDesc,不写生活;参考图片失效也有文字降级,不黑屏。
- `web/src/shared/`:复用按钮、图标、字段、状态和带焦点边界的确认弹层。
- `web/src/styles/`:R2 单一 Token 和组件样式来源;没有第三方 UI 皮肤或叠加覆盖样式表。
两主题使用相同 DOM 和布局;人物拱窗、交谈展开、阅读面板与手机断点沿用获批样式。静帧仅呈现参考人物/房间,不渲染整页图片。语音/观察不访问 `getUserMedia`、不调用 ASR/TTS、不发外部请求。演示天气固定并标注模拟;当前视觉标签来自演示记录公开视图。
F1–F9 走法:
| 流程 | 入口与边界 |
|---|---|
| F1 | 无本机档案 → 四步创建 → 名字/成年生日/关系/勾选校验 → 本机持久化 → 首晚空生活 |
| F2 | 普通入口说明未接服务 → 明确演示入口;重开直接回到同一位她 |
| F3 | 演示场景 → 模拟两天后返回;只从已分享数据生成摘要,不生成后台生活 |
| F4 | 演示场景 → 主动消息;来源是已分享事件,回她定位聊天并保留来源区域 |
| F5 | 交流麦克风 → 模拟授权 → 倾听界面;文字发送推进思考/说话,可打断/结束;无真实收音 |
| F6 | 演示断线 → 暂停发送,保留草稿,结束模拟语音、取消旧轮;恢复失败可重试,不自动重发 |
| F7 | 设置城市 → 搜索/选择 → 保存成功/失败/重试;不调用天气 API |
| F8 | 本地演示素材 JSON → 结构检查/模拟对比/确认;只更新外观标识,真实媒体导入待后续 |
| F9 | 本地演示 JSON 下载/同身份校验/输入确认/先导出再恢复;服务器备份明确未接通 |
取消与错误:新轮、手动取消、切离交流会 abort 当前模拟回复;事件还需同时匹配 turn_id 与 epoch,非协作适配器的迟到消息同样被门槛拒绝。断线 resync 提升 epoch;恢复不自动续接旧轮。管理保存切区/关区会 abort;草稿保留,取消恢复上次提交值;存储损坏不进入新的首建覆盖。导入只执行 JSON.parse 后按固定 schema 重建;不执行脚本、不作为 HTML、不加载媒体/服务 URL。
后续接线清单(当前均未提供):
1. B1 同源 API 与 WS:hello/sync、游标和持久 epoch、能力声明、HTTP AbortSignal。真实模式不得直接替换成未经审定的 LLM URL。
2. 服务端授权与隐私:必须在响应前裁剪未分享记录、未知引用、回忆和消息及摘要;本轮前端过滤不构成服务器权限控制。
3. 接已有 Python 生辰预览/只读档案、真实关系更新;身份 ID、底色指纹由服务端负责。
4. B2/B3 后台生活、主动性、记忆与增长层;前端只订阅 LifeScene/事件,不从素材反编生活。
5. S2 流式文字;S3 ASR/TTS 二进制协议、session_id/seq 隔离、权限与实际中断;当前没有真实音频流,因此不声称达到延迟指标。
6. S4 生活/回忆服务分页;S5 城市/天气、素材包校验和完整服务器备份/自动备份;演示 JSON 的 MIME 和结构不是媒体真伪校验。
7. S6 视觉摘要时效与 HTTPS、真实权限;Presenter 的视频/音频/口型驱动器可注入,当前只交静帧降级。
测试部署由总工审核后执行。建议在独立目录安装构建,将 `web/dist/` 作为静态站点挂到总工选定的未占用测试端口;正常路径 `/`,演示 `/?demo=1`。本轮未占用固定服务端口,也未更改 8010/18080/18081 或现网。部署若换 origin,浏览器本地演示资料不会自动迁移,可通过同身份演示 JSON 手动恢复。
+11
View File
@@ -0,0 +1,11 @@
HEL-748: S1 完整前端与本机演示(不含后端或部署)
原仓库只有 Python 核心,没有产品前端。新增独立 `web/` React/TypeScript/Vite 工程,将获批 HEL-746 R2 的两主题、相处/交流/日子/管理和 F1–F9 演示落地为可构建组件。
创建必须输入名字并亲自选关系,刷新保留同一位档案;未分享生活及未知引用统一隐藏,包括详情、回忆、聊天和离开摘要。普通入口说明服务未接通,只有显式 `?demo=1` 进入本机演示;导入只解析校验 JSON,不执行 HTML/脚本、不请求外部媒体或服务。轮次/epoch/AbortSignal 隔离旧回复,管理草稿按分区保存或取消,恢复需同身份、输入确认并先导出。
验证:TypeScript、ESLint、Vite 生产构建、11 项领域测试、原有 Python 78 项测试;实际 Chromium 在 1440×900、1280×800、390×844 走查两主题及关键失败/恢复路径。浏览器记录与接线清单见 `docs/frontend/`。真实手机/Safari、真实服务和性能指标未测。
本轮仅 S1。没有更改 Python 核心、现网配置或服务端口,没有部署/合并,没有接入 LLM/ASR/TTS/视觉/后台循环/真实备份。Presenter 使用标识清楚的参考静帧。审查通过后由总工负责独立测试部署。
目标 base: main (`1e9b08aed81b4ebf163721232a8d65707b1c3183`)
+33
View File
@@ -0,0 +1,33 @@
HEL-748 S1 · 实际验证记录 · 2026-10-22(北京时间)
环境:Ubuntu 22.04;Node 24.16.0 / npm 11.13.0;Chromium 151.0.7922.34(Python Playwright、无头真实浏览器);Python 3.11.16 独立 `.venv`。浏览器直接加载 Vite 生产产物,测试脚本启动随机 loopback HTTP 端口并在结束时关闭。
| 检查 | 实际结果 |
|---|---|
| `npm run typecheck` | 通过 |
| `npm run lint` | 通过,无调试输出残留 |
| `npm test` | 11 项通过:隐私公开视图/未知引用、首建拦截、持久化失败与损坏、恢复身份/结构/脚本边界、turn/epoch/Abort 隔离、HTTP 标识兼容 |
| `npm run build` | 通过,41 个模块;JS 197.50KB / gzip 64.67KB,CSS 61.20KB / gzip 13.05KB |
| Python 既有测试 | `.venv/bin/python -m pytest -q`:78 项通过(Python 3.11.16);默认 Python 3.10.12 亦通过 78 项 |
| 运行时依赖检查 | `npm audit --omit=dev --registry=https://registry.npmjs.org` 返回 0 漏洞;只检查生产依赖 |
| 三尺寸主走查 | 1440×900、1280×800、390×844,两套主题;16 组结果通过,88 次本机请求,无未捕获浏览器错误、无外部服务请求 |
| 末次手机复测 | 390×844 两主题重跑,包含保存错误说明进入可见区域的断言;结果另存 `browser-results-390.json` |
| 导入与草稿专项 | 7 组通过,见 `import-results.json` |
主走查是本轮新增的真实操作脚本,未引用原型自检数字:
- 普通入口有未接服务说明,不能直接进入伪在线状态;正常入口没有演示控制台。
- 首建空名字、未成年生日、未选关系、未勾选均拦截;关系无预选;创建模拟失败资料保留,可重试;创建后刷新不再首建,名字统一使用。
- 两套主题下首页、交流、生活详情、回忆关联返回、管理表单、保存取消与关系二次确认;主题刷新仍保留。
- 未分享列表项、详情深链、回忆、聊天引用、离开摘要不可见;未知引用默认隐藏。原始本机演示数据仍含专门的隐私测试条目,展示使用过滤后的公开视图,不能把这一点当作服务端授权。
- 手动取消回复、退出交流后的旧回复不复活;发送失败重试;中文输入法 Enter 不误发,Shift+Enter 换行,Enter 发送;用户/导入的 HTML 字符串只显示文本。
- 模拟语音的结束控件在手机视口内,文字输入常驻;观察为无采集模拟;断线暂停发送、草稿保留、失败重试;城市保存失败保留修改、重试同步顶栏。
- 离开摘要经过隐私过滤;主动消息有已分享事件来源,回她定位记录。
专项覆盖:同一位档案恢复、异人阻断、输入“恢复”与恢复前下载;非法素材清单拒绝;素材 URL/服务测试不发网络请求;换外观标识不改身份;切换分区取消尚未完成的保存;保存城市不提交服务草稿;长列表 18+12 分页、聊天返回精确保留滚动位置;坏存储不自动覆盖;模拟 `randomUUID` 缺失时创建与聊天仍能完成。
本轮实际发现并修正:表单下拉框可访问标签混入选项文字;SVG 图标视口/命名空间;手机固定操作区域与内容关系;管理分区误带其他草稿提交的风险;保存反馈在手机滚动区内不够可见;局域网 HTTP 的标识生成兼容性。手机列表/详情推入需要先按返回才能操作列表,这是获批的布局行为,测试脚本已按实际用户路径操作。
截图为本轮 DOM 实际捕获,共 45 张 PNG;`screenshots/` 不进入前端构建或 Git,随任务评论附件打包交付。包含三尺寸的两主题首页/交流记录/生活详情/管理,以及首建、创建失败、发送失败、语音、断线失败、保存失败、离开返回。截图不代表动效性能实测。
未测:真实手机硬件、Safari、触屏键盘/安全区、真实麦克风/摄像头/ASR/TTS/天气/服务器记忆与备份、真实媒体/口型、后台常驻生活、FPS/延迟/长时稳定性。上述能力当前均未接线,不声称完成。
+12
View File
@@ -0,0 +1,12 @@
{
"checks": [
"390: ordinary entry, creation validation/failure/retry and reopen",
"390: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"390: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"390: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"390: privacy-filtered away summary and proactive message",
"No uncaught browser errors, external requests, microphone or camera requests"
],
"errors": [],
"requests": 36
}
+22
View File
@@ -0,0 +1,22 @@
{
"checks": [
"1440: ordinary entry, creation validation/failure/retry and reopen",
"1440: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"1440: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"1440: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"1440: privacy-filtered away summary and proactive message",
"1280: ordinary entry, creation validation/failure/retry and reopen",
"1280: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"1280: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"1280: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"1280: privacy-filtered away summary and proactive message",
"390: ordinary entry, creation validation/failure/retry and reopen",
"390: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"390: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"390: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"390: privacy-filtered away summary and proactive message",
"No uncaught browser errors, external requests, microphone or camera requests"
],
"errors": [],
"requests": 88
}
+9
View File
@@ -0,0 +1,9 @@
[
"Reject foreign identity; same-identity restore requires typed confirmation and pre-restore export",
"Invalid manifests rejected; external media never loaded; appearance confirm/cancel preserves identity; service test has no network",
"Changing section aborts outstanding save; saving city does not commit an unsaved service draft",
"Imported model text rendered as text, never as HTML",
"Long timeline paginates 18 then 12 and retains exact scroll position after chat return",
"Corrupt persistence never falls back to overwriting first creation",
"Creation and chat work when secure-context randomUUID is unavailable"
]
+36
View File
@@ -0,0 +1,36 @@
# CyberGril 需求入口
当前完整基准:**v1.8**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
| 文件 | 用途 |
| --- | --- |
| [current.md](current.md) | 59条完整有效要求:原31条产品决定、13条视觉、3条补充、6条用户整改、5条执行约束、1条交付边界;另列未定项 |
| [baseline.json](baseline.json) | 同一份要求的结构化编号、边界和来源 |
| [coverage.md](coverage.md) / [coverage.json](coverage.json) | 实现与验收状态,未完成项目继续保留 |
| [changes.md](changes.md) / [changes.json](changes.json) | 后续修改的用户授权及影响 |
| [task-handoff-template.md](task-handoff-template.md) | 每张卡的必填交接项 |
| [sources/](sources/) | 原始v1.6、完整初轮和视觉访谈、后续裁定、获批设计及分批方案 |
| [source-manifest.json](source-manifest.json) | 来源文件校验值,防止历史来源被静默改写 |
| [implementation-verification.md](implementation-verification.md) | 本次实际写入、旧卡迁移与正反向验证记录 |
`sources/comment-evidence.json` 保存初版125条来源评论(保留原样)的身份、时间、原话和父评论;正文中的编号可追溯回主卡。原始初轮访谈与完整视觉访谈均用分页取齐,后续材料按关联决定保存。其余来源文档保留原样;其中旧进度、技术推荐、旧待定项不能自动成为当前事实。已经被后续明确决定覆盖的内容,见 current.md 和 changes.md。
原仓库 `docs/SPEC.md` 是初始构想,不能盖过后续逐项确认:例如旧VRM路线、允许经历改写出生核心等,均不能用于覆盖当前要求。
## 当前返工与交付边界
用户对当前前端的**对话显示效果**和**整体沉浸界面**不满意。本轮先完善需求约束,再制作独立样图及可操作动态小样;两主题、电脑/手机、长句与身份、历史展开、交流开合和空间过渡都须能查看。小样未认可前不替换正式界面。
完整产品仍需按获批批次接入生活、记忆、主动联系、语音、天气/素材/备份及有条件的视觉观察。R1已有档案与文字能力不能视为全部要求完成。嘴型研究独立,不阻塞完整界面设计。
## 检查与人工审核
先获取实际目标分支,再运行 `python3 scripts/check_requirements.py --base-ref origin/main`(实际目标不是 main 时替换引用);有交接JSON时加 `--handoff <文件>`。检查会对照目标阻止原地改需求却不升版、不追加新用户授权,且保留历史来源和变更记录。初次引入基准时由独立审核核对整包来源。此检查不证明画面好看、评论语义构成授权或用户已经满意。人工审核必须阅读原话,体验验收仍由白栖知决定。
当前没有配置仓库自动CI合并门禁;上述命令作为交付和独立审核的必做项,不声称平台已强制拦截所有修改。
v1.8新增来源见 `sources/comment-evidence-visual-role-20261022.json`,最新分工见GOV-005。
后续以该入口所记录的最新版本为准,不把v1.7永久钉死为永不可新增的文档。每次新版本必须记录用户明确裁定,保留旧版来源与覆盖关系。
+751
View File
@@ -0,0 +1,751 @@
{
"schema_version": 1,
"version": "1.9",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.8按用户明确指令新增视觉总监接手及必要代码职责,原58项和已批准边界继续有效,不证明功能已实现或已验收。",
"requirements": [
{
"id": "CG-001",
"category": "产品",
"topic": "需求对齐与开工边界",
"conclusion": "先逐项问清需求,形成统一记录和方案;对单项建议的同意,仅用于确认该项,不能扩大成全部功能的开工授权。",
"boundary": "",
"source_comment_ids": [
"01a15722-86fc-7c70-9409-d2e9b8cb041f",
"01a157ab-209c-7c8f-956c-40ebb3340f4e"
]
},
{
"id": "CG-002",
"category": "产品",
"topic": "写实视觉风格",
"conclusion": "人物采用照片、电影中真人的写实质感。首版日常画面采用CG-029的人物与房间成品视频,按活动、天气和时段选片;不再要求浏览器每帧用独立人物透明层与房间合成。",
"boundary": "原仓库的VRM/建模默认路线和Alina候选,不作为当前已认可的外观方向。 真人照片般的效果仍为首选;新增条件备选见CG-027,不把讨论二维视为立即放弃真人路线。 独立控制的产品目标由统一状态和对应素材组合实现;物理分层实现要求已由CG-029覆盖。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a15717-b34f-72ac-939f-d81c90f58920",
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a158c5-a25f-742b-975d-6af1ce8ff6b6",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-003",
"category": "产品",
"topic": "动作与交谈的混合路线",
"conclusion": "预生成场景和同一人物的日常动作、服装素材;根据实际生活状态选择、衔接。交谈实时产生内容与语音,口型和局部反应需实际验证;后台可逐步补充素材。",
"boundary": "首版不承诺即时生成任意新全身动作。现场自由生成仍是用户的理想方向,属于后续增强目标。重生成任务应给交谈让出资源。 当前首版主验证路线明确为CG-029;具体交谈驱动器和真实表现仍待测试。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-004",
"category": "产品",
"topic": "交流镜头",
"conclusion": "日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。",
"boundary": "具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730"
]
},
{
"id": "CG-005",
"category": "产品",
"topic": "工程外观与自备素材",
"conclusion": "工程阶段由总管安排选择人物外观;后续支持用户按标准自行生成图片或视频,替换人物外观。该能力从首版设计时预留。",
"boundary": "具体素材包规范和导入流程尚未定稿;一张图片本身不等于已有全部动态动作。最终外观仍经过样图验收。 工程外观须遵循CG-025的新要求;素材替换能力与仅换外观、不重建伴侣的CG-006保持不变。 首轮工程素材由小队制作,不以用户先备齐视频为前提,见CG-030。",
"source_comment_ids": [
"01a1572c-18c8-7872-94b0-c05c1b24b5c7",
"01a1572d-2c55-777c-a63f-a7c74c31c070",
"01a1573f-aedf-7d08-91a7-482b2a94f782",
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-006",
"category": "产品",
"topic": "换外观保留同一个她",
"conclusion": "换素材仅改变外观,保留原有八字底色、性格积累、记忆和关系;创建新人物与换外观是不同概念。",
"boundary": "首版随后收缩为仅一个伴侣;这里不授权实现多个伴侣或并行运行。",
"source_comment_ids": [
"01a1573f-aedf-7d08-91a7-482b2a94f782",
"01a15749-7530-71b4-9157-b9fa7011254b"
]
},
{
"id": "CG-007",
"category": "产品",
"topic": "首版仅一个伴侣",
"conclusion": "首版只能创建、使用一个伴侣,暂不做多个伴侣及切换;支持数据备份。",
"boundary": "覆盖此前已讨论并同意的多伴侣保留、切换方向;单伴侣下的清空或重新创建规则未定。",
"source_comment_ids": [
"01a15749-7530-71b4-9157-b9fa7011254b"
]
},
{
"id": "CG-008",
"category": "产品",
"topic": "后台生活与连续性",
"conclusion": "用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续。画面、对话、记忆应对应同一份生活状态。",
"boundary": "没有承诺服务器关机仍能运行;停机后的恢复、补算规则另待设计。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a15749-7530-71b4-9157-b9fa7011254b",
"01a15751-ded1-7328-80ae-39967d2b3821"
]
},
{
"id": "CG-009",
"category": "产品",
"topic": "备份与恢复",
"conclusion": "每天自动备份;支持随时手动完整导出到自己的电脑,并从备份恢复同一位伴侣。覆盖八字、性格资料、对话、记忆、关系、生活进度;手动完整导出带上所用人物、场景素材及恢复所需设定。",
"boundary": "自动副本留在服务器,首版暂不增加云盘接入;保留份数、空间策略、恢复校验等工程细节尚未定稿。",
"source_comment_ids": [
"01a15749-fc88-7295-8350-dccfb0168b70",
"01a1574f-9e0e-7bf4-b158-3f88a4c1988a"
]
},
{
"id": "CG-010",
"category": "产品",
"topic": "连续语音与文字",
"conclusion": "用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。",
"source_comment_ids": [
"01a15750-2cf9-7f23-9269-0f150d2c5e61",
"01a15750-e061-733b-9d80-38459d306b97"
]
},
{
"id": "CG-011",
"category": "产品",
"topic": "主动联系范围",
"conclusion": "首版只在网页内主动联系:语音开启时可以主动开口,未开启时留文字消息;关闭网页后保留消息,回来再看。主动分享应有自己的生活经历作为依据。",
"boundary": "首版不接手机推送或其他聊天软件;具体频率可在方案中结合原仓库参数制定。",
"source_comment_ids": [
"01a15751-670e-7187-b94a-cea70154df3b",
"01a15751-ded1-7328-80ae-39967d2b3821"
]
},
{
"id": "CG-012",
"category": "产品",
"topic": "生辰创建方式",
"conclusion": "使用她自己的出生年月日和时辰;创建时可自动生成,也允许用户在确认前手动指定。确认后作为人格底色依据,换外观不会改变。",
"boundary": "",
"source_comment_ids": [
"01a15752-4cfe-7cd7-a4a3-8a8f8143f943",
"01a15754-b879-73b6-b367-b105898d7107"
]
},
{
"id": "CG-013",
"category": "产品",
"topic": "八字底色不可编辑",
"conclusion": "八字带来的性格底色是系统级资料,创建后只读;每轮重新提供给模型并预留上下文空间。可用结构化指标配合简短解释表达,关系提示词不得覆盖底色。",
"boundary": "程序保证资料不被改写;模型能否长期遵守仍需验证,不能把写成数字或系统提示词等同于已解决漂移。",
"source_comment_ids": [
"01a15771-3a93-7518-9b23-489509520d77",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-014",
"category": "产品",
"topic": "性格诠释空间",
"conclusion": "性格描述保留适度抽象和解释空间,让模型形成有延续性的个性化诠释;同样底色允许不同表现与成长,不给每次回答规定唯一标准答案。",
"boundary": "不能将本项误记成‘用户可修改八字底色’,也不能将随机、无缘由地反复变换人设当作个性。",
"source_comment_ids": [
"01a15763-1fc5-7f36-bf43-722e8755703c",
"01a15763-e729-7aa2-a752-7b95fb69372c",
"01a15771-3a93-7518-9b23-489509520d77",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-015",
"category": "产品",
"topic": "初始习惯与后天成长",
"conclusion": "创建时带少量模型自主形成的喜好和小习惯;以后通过生活、相处逐渐丰富。成长经历单独记录,保留最初的八字底色。",
"boundary": "原仓库重大经历可微调核心的旧例外,按此讨论改为另存成长层,不据旧文直接修改出生底色。",
"source_comment_ids": [
"01a15763-e729-7aa2-a752-7b95fb69372c",
"01a15765-2fa8-7494-b0b8-7a0054500556",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-016",
"category": "产品",
"topic": "关系可配置、可调整",
"conclusion": "朋友或恋人、称呼、相处边界等关系设定通过独立提示词表达,相处过程中也可明确修改;保留原有记忆和互动形成的信任,不改写过去。",
"boundary": "没有确定默认必须初识或必须情侣;通用人物提示词编辑器并未获确认,允许修改的范围是关系设定。",
"source_comment_ids": [
"01a1576a-5ffe-73fb-b6f1-2aeee98a5782",
"01a15771-3a93-7518-9b23-489509520d77",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-017",
"category": "产品",
"topic": "首版场景范围",
"conclusion": "先集中做好一个高层卧室,以成品视频表现对应天气、昼夜、光线以及少量服装和日常动作;由统一状态按条件选片,保留后续扩展入口。",
"boundary": "其他房间和外出场景后续扩展;素材数量先结合试片结果定,不将早期示例数量作为现行承诺。 当前唯一房间明确为CG-026所述高层卧室。 浏览器人物/环境物理分层要求由CG-029覆盖;活动、天气等逻辑状态仍独立管理,不用选片篡改生活记录。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a15775-89f4-7ec8-89eb-b3554fb05d19",
"01a1577a-e852-7551-ace8-0b2028db9ebb",
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-018",
"category": "产品",
"topic": "电脑优先、兼顾手机",
"conclusion": "主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。",
"boundary": "",
"source_comment_ids": [
"01a1577b-b65c-787a-b436-b0a74c38fea4",
"01a1577c-d10f-7513-8f58-62b3b387240a"
]
},
{
"id": "CG-019",
"category": "产品",
"topic": "素材工具与新增费用",
"conclusion": "首版先使用已有工具和本地机器制作图片、视频素材,暂不新增付费云端生成接口;若实测不够,再列明方案效果和费用,由用户决定。",
"boundary": "此项是素材制作的费用边界,不能误解为禁止使用已提供的外接LLM或用户自行部署的小模型服务。",
"source_comment_ids": [
"01a1577d-7203-7b2b-bf24-f880f56750cf",
"01a1577e-96f6-72c7-b66b-6c43779fc5f0"
]
},
{
"id": "CG-020",
"category": "产品",
"topic": "首版内网访问",
"conclusion": "首版在部署服务器所在内网使用,电脑和手机在该内网访问。",
"boundary": "外出访问后续扩展。摄像头和麦克风所需可信访问方式仍要在工程方案中落实。",
"source_comment_ids": [
"01a1577f-0707-7ac6-9c0f-aef2e73ba108",
"01a15780-0fd0-7438-beee-4947f4c3c928"
]
},
{
"id": "CG-021",
"category": "产品",
"topic": "按任务选模型、视觉外接",
"conclusion": "该用小模型的地方用小模型;状态和时间推进用程序,记忆等后台任务可用小模型,主模型负责主对话和较复杂理解。摄像头用小视觉模型前处理,部署由用户在其他设备负责,项目通过接口接入;用户另有4080显卡。",
"boundary": "平时把带时间和不确定项的观察摘要交给主模型,必要时保留原图复核途径。具体小模型、部署参数、接口地址和性能尚未定版。",
"source_comment_ids": [
"01a157a5-1668-723d-af23-732a612b30b9",
"01a157a6-b158-7ba5-8a3a-1d19920890ac",
"01a157a7-c9f2-7133-8340-9b497f12f7e7"
]
},
{
"id": "CG-022",
"category": "产品",
"topic": "首版摄像头范围",
"conclusion": "加入可开关的视觉观察,先聚焦人在不在、明显物品、房间环境变化;快速挥手、点头等连续动作放到后续。",
"boundary": "观察有时效,不能把旧画面继续当作眼前事实。刷新间隔和延迟以小视觉服务实测为准,5—10秒只是先前建议,不是固定性能保证。",
"source_comment_ids": [
"01a157a6-b158-7ba5-8a3a-1d19920890ac",
"01a157a7-c9f2-7133-8340-9b497f12f7e7"
]
},
{
"id": "CG-023",
"category": "产品",
"topic": "天气、昼夜与作息",
"conclusion": "用户手动选择一个真实城市,房间天气、昼夜和生活作息跟随当地节奏;城市可自行更换,无需每次打开重新定位。",
"boundary": "具体城市、天气来源及跨城市切换细节仍待设置或工程设计。",
"source_comment_ids": [
"01a157a8-510f-7d3d-9880-670dc9ae4d22",
"01a157a8-aeab-7611-9695-74afbc2dec02"
]
},
{
"id": "CG-024",
"category": "产品",
"topic": "第一道验收及后续顺序",
"conclusion": "先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。",
"boundary": "小样通过仅表示该阶段通过,不等于记忆、后台生活、摄像头或备份已全部实现和通过验收。 用户本次确认v2静态方向并授权继续最小动态验证;不等于动态或完整功能已通过,也不扩大为全量施工及任意模型安装授权。 本次同意CG-029/030后进入已确认方案的关键技术验证;旧小样通过不等于新实时交谈、衔接或完整功能通过。 CG-031明确完整前端设计不以嘴型技术验证通过为前置条件;设计样图、交互原型与真实功能接通须分别标明,不能把设计推进等同于全部后台施工获准。",
"source_comment_ids": [
"01a157a9-bd18-7533-9b21-e5f4c3a90d7c",
"01a157ab-209c-7c8f-956c-40ebb3340f4e",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-025",
"category": "产品",
"topic": "当前工程人物外观",
"conclusion": "成年女性,年龄观感在20岁左右,冷白皮,圆鹅蛋脸,中短发,气质清纯干净。用户指出上一版人物太老、太路人,要求重新设计。",
"boundary": "用户已于2026年10月20日15:17(北京时间)认可v2人物静态方向。CG-002的写实风格、CG-006外观与伴侣身份分离保持;不代表分层和跨视频身份连续性已验证。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-026",
"category": "产品",
"topic": "当前房间设定",
"conclusion": "高层卧室,设置慵懒沙发和女性化装饰元素,整体温暖温馨。",
"boundary": "用户已认可v2高层卧室静态方向,四张v2图为当前视觉基准。CG-017的一间房、人物与环境独立、天气昼夜变化范围保持;分层几何、光照切换效果仍需实测。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-027",
"category": "产品",
"topic": "真人优先与二维备用",
"conclusion": "优先争取真人照片般的效果;如果真人路线无法达到所需效果、损害沉浸感,再考虑二维动画。",
"boundary": "真人优先、二维为条件备用保持。首版具体主验证路线现由CG-029确认;交谈身体范围按CG-028。真人转二维的具体判据和二维风格尚未定稿,不自动切换。",
"source_comment_ids": [
"01a158c5-a25f-742b-975d-6af1ce8ff6b6",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-028",
"category": "产品",
"topic": "首版交谈的实时表现与预设身体动作",
"conclusion": "首版优先让口型、表情和目光自然;身体主要使用少数预设动作,不要求肩膀、手势根据每句话实时生成新的变化。",
"boundary": "这是首版范围选择,不是禁止预设肩部/手部动作或要求人物身体完全静止。具体模型能支持哪些表情、目光和动作仍待实测;没有测试证明现有硬件绝对无法实时驱动肩部或手势。此项同意不代表选定成品视频架构、具体模型、安装、付费API或新施工。 后续用户另于01a1599b-f137-71cc-8af2-a265499b4b6e同意CG-029/030方案,不把这两次确认混成同一授权。",
"source_comment_ids": [
"01a158c7-f16f-7b98-844e-77271f16d6d8",
"01a158d7-1d0c-764c-8a4c-35359b4f5d8a",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-029",
"category": "产品",
"topic": "成品日常视频与本地实时交谈主路线",
"conclusion": "首版优先验证:人物与房间一起制作成品日常视频,按真实时间、天气及她的实际活动选片;交流时自然转入同一人物的实时半身交谈画面,结束后回到日常活动。人格、记忆、关系与后台生活由本地核心系统管理。",
"boundary": "替代首版浏览器每帧独立人物抠像合成的实施要求;天气、服装或活动变化需要对应视频与自然过渡,保持同脸、服装和光线连续。只确认架构方向,LiveTalking/MuseTalk/Ditto等具体驱动器仍需实际选型;不承诺任意新身体动作,不默认采用云端收费服务,也不代表全部功能已完成。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a158d9-2f3a-72f2-a911-5f58adf074c7",
"01a1597e-ef9e-7cfa-9456-1a0d4e248430",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-030",
"category": "产品",
"topic": "首批视频制作与后续用户自备素材",
"conclusion": "首轮工程素材由总管安排小队使用现有本地资源制作,以已确认的人物与高层卧室为基准;用户不需要先自行准备整套视频。后续提供模板、制作规范与导入检查,用户可自行生成替换外观。",
"boundary": "先验证少量日常、过渡和交谈素材的完整流程,再扩充服装/天气/动作;具体数量和格式待测试。生成有筛选、重试和处理成本,不承诺一张照片自动得到全套合格素材;换素材不改变八字、记忆和关系。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1597d-f505-7481-a9ee-62e025fbfa2f",
"01a1597e-ef9e-7cfa-9456-1a0d4e248430",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-031",
"category": "产品",
"topic": "完整前端以产品功能和使用流程为设计依据,人物与卧室样图只作视觉参考",
"conclusion": "完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。",
"boundary": "完整界面的布局、导航、内容层级、交互与视觉语言应围绕产品使用流程独立设计,不能被旧样图构图锁定,也不把成品视频策略扩张为所有界面必须全屏视频。嘴型同步作为独立技术研究,不作为完整前端设计开工的前置验收。此项确认设计依据与范围,不代表已认可任何完整页面稿,不授权直接实现所有后台功能或自动改换模型;此前素材仍可作参考,不能误记成需要删除旧素材或推翻全部既有需求。",
"source_comment_ids": [
"01a15e8d-91ab-739e-84f9-fc4026bfcd70",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "VIS-001",
"category": "视觉",
"topic": "两人的数字空间",
"conclusion": "整体像只属于两人的数字空间,人物、交流、回忆融在其中。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-002",
"category": "视觉",
"topic": "整体气质",
"conclusion": "静谧梦幻、安静亲密,有空间深度和柔和微光。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-003",
"category": "视觉",
"topic": "多主题的一致结构",
"conclusion": "切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-004",
"category": "视觉",
"topic": "两套首轮主题",
"conclusion": "靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-005",
"category": "视觉",
"topic": "空间优先的首页",
"conclusion": "首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-006",
"category": "视觉",
"topic": "背景层次",
"conclusion": "光雾、景深与若隐若现的景物融合;具体画面通过样图验证。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-007",
"category": "视觉",
"topic": "空间动效",
"conclusion": "平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-008",
"category": "视觉",
"topic": "透光浮层",
"conclusion": "内容采用透光浮层,背后保留空间光影,文字清晰可读。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-009",
"category": "视觉",
"topic": "轻盈文字",
"conclusion": "现代轻盈、字形简洁、标题克制,正文易读。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-010",
"category": "视觉",
"topic": "微光强调",
"conclusion": "少量微光集中于关键操作和选中状态,两主题各有强调色。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-011",
"category": "视觉",
"topic": "人物独立视窗",
"conclusion": "人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。交流状态静图已按评论 01a1668f-5810-7524-b72f-33574a32c7d6 认可为 HEL-798 共同空间构图;相处状态、过渡、雾光、手机与真实人物动态仍待验收。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-012",
"category": "视觉",
"topic": "悬浮导航",
"conclusion": "底部小巧透光导航,图标配简短文字,选中亮起微光。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-013",
"category": "视觉",
"topic": "交流呈现",
"conclusion": "交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "AM-001",
"category": "补充裁定",
"topic": "未分享生活不可见",
"conclusion": "等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "AM-002",
"category": "补充裁定",
"topic": "关系由用户选择",
"conclusion": "创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "AM-003",
"category": "补充裁定",
"topic": "名字由用户起",
"conclusion": "首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "UX-001",
"category": "用户整改",
"topic": "手机对话可用",
"conclusion": "手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-002",
"category": "用户整改",
"topic": "对话文字动效",
"conclusion": "文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-003",
"category": "用户整改",
"topic": "滚动条主题一致",
"conclusion": "记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-004",
"category": "用户整改",
"topic": "出生资料与底色深度",
"conclusion": "创建与资料显示公历、农历、时辰及四柱说明;性格不能只用话多、直接、焦虑等浅标签概括。深度不能变成固定模板人设,仍须遵守 CG-013/014/015 的诠释自由、初始个性、成长分离。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-005",
"category": "用户整改",
"topic": "交流身份清楚",
"conclusion": "用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-006",
"category": "用户整改",
"topic": "完整界面功能保留",
"conclusion": "按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "GOV-001",
"category": "执行约束",
"topic": "完整需求跨任务继承",
"conclusion": "所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-002",
"category": "执行约束",
"topic": "只有用户能改需求",
"conclusion": "只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-003",
"category": "执行约束",
"topic": "对照和验收",
"conclusion": "每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-004",
"category": "执行约束",
"topic": "本轮返工范围",
"conclusion": "先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "DEL-001",
"category": "交付批次",
"topic": "演示与真实接入边界",
"conclusion": "前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。",
"boundary": "接入方案中的候选服务、声音、摄像头和硬件操作仍须满足各自前置与授权,不能从批次批准推断全部候选已经批准。",
"source_comment_ids": [
"01a161a4-842f-7c26-9be0-1812b720976a",
"01a1629c-732f-7559-8417-6f2ea05c7151"
],
"source_document": "sources/integration-plan-v1.md"
},
{
"id": "GOV-005",
"category": "执行分工",
"topic": "视觉总监接手整体视觉及必要代码",
"conclusion": "CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。",
"boundary": "总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。",
"source_comment_ids": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc"
]
}
],
"open_items": [
{
"id": "T-01",
"topic": "八字映射细则",
"detail": "具体规则表、权重、命理解释覆盖深度与验证样例尚未定稿。用户没有直接确认‘只保留明确量化项、排除所有抽象描述’;后续已明确保留性格诠释空间。"
},
{
"id": "T-03",
"topic": "素材包标准与具体数量",
"detail": "导入模板、尺寸、帧率、背景处理、动作节点、口型预处理、预览与回退方式等需结合方案、小样定稿。未承诺固定服装/动作数量或一张参考图自动生成整套合格视频。"
},
{
"id": "T-04",
"topic": "模型与速度",
"detail": "Qwen3-VL-4B、2B和此前提到的视频/口型方案都是候选;4080部署、真实摄像头、语音与人物同时运行尚未实测。既有接口单图测试的1.5秒/7秒不代表4080速度或端到端语音延迟。"
},
{
"id": "T-05",
"topic": "外接服务接入",
"detail": "4080视觉服务地址、具体模型、输入输出能力,以及现有ASR/TTS的流式和打断能力,需后续核实或由用户提供。部署视觉小模型由用户负责。"
},
{
"id": "T-06",
"topic": "运行与恢复细节",
"detail": "单伴侣重新创建/清空、服务器停机后的状态恢复、备份保留份数和空间策略尚未定稿;不能用多伴侣旧结论补全。"
},
{
"id": "T-07",
"topic": "天气和访问细节",
"detail": "具体城市、天气数据来源、缺失天气素材时的呈现方式、内网可信HTTPS方案等留待工程设计。"
},
{
"id": "T-08",
"topic": "实现完成情况",
"detail": "本记录是需求确认台账,不证明功能已实现。旧演示的部署和测试结果不能作为新写实动态路线的验收结果。"
},
{
"id": "T-09",
"topic": "真人呈现路线与交谈动态边界",
"detail": "CG-029已选定成品日常视频配本地实时半身交谈,CG-030确定首批由小队制作。待验证具体驱动器、同脸/服装/光线连续、音频/口型/头部表情实际能力、打断、日常与交谈往返、素材制作成本及本机并发/恢复;真人转二维的条件仍待明确。云端收费服务未选定。"
},
{
"id": "T-02",
"topic": "关系界面余项",
"detail": "初始关系由用户选择已由 AM-002 确定,旧待定结论被覆盖;自由提示词不能被用于修改出生底色。"
},
{
"id": "T-10",
"topic": "当前一两句的长回复呈现",
"detail": "分句、停留和前后查看细节在独立动态小样中验证,不冒充用户已选具体方案。"
}
],
"precedence": "用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。"
}
+92
View File
@@ -0,0 +1,92 @@
{
"entries": [
{
"version": "1.7",
"approval_comment_id": "01a1635b-b47c-79ed-ac66-9408c7905af9",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"previous_record": "sources/product-decisions-v1.6.json",
"summary": "整合原31项、已确认视觉与后续裁定,增加跨卡继承约束;只落实用户同意的方案,不把实现现状写成需求。",
"affected_ids": [
"AM-001",
"AM-002",
"AM-003",
"CG-001",
"CG-002",
"CG-003",
"CG-004",
"CG-005",
"CG-006",
"CG-007",
"CG-008",
"CG-009",
"CG-010",
"CG-011",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-017",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-022",
"CG-023",
"CG-024",
"CG-025",
"CG-026",
"CG-027",
"CG-028",
"CG-029",
"CG-030",
"CG-031",
"DEL-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"UX-001",
"UX-002",
"UX-003",
"UX-004",
"UX-005",
"UX-006",
"VIS-001",
"VIS-002",
"VIS-003",
"VIS-004",
"VIS-005",
"VIS-006",
"VIS-007",
"VIS-008",
"VIS-009",
"VIS-010",
"VIS-011",
"VIS-012",
"VIS-013"
],
"impact": "约束新旧卡及独立界面小样;不改产品数据、模型与现有服务。"
},
{
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "新增GOV-005:整体视觉交给视觉总监接手,不止把关;施工员不能实现时由视觉总监直接写代码。",
"affected_ids": [
"GOV-005"
],
"impact": "本项目本轮视觉设计、动效与相应代码责任转给视觉总监;不改现网、数据、全局模型或认证。"
},
{
"version": "1.9",
"approval_comment_id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "交流状态静态视觉基准更新:白栖知认可 HEL-798 共同空间交流静图(1600×1000,SHA256 a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a),在 VIS-011 边界注明。",
"affected_ids": [
"VIS-011"
],
"impact": "仅交流状态静图获认可:不用独立人物框,环境铺满四边,前景为用户一侧桌沿/茶杯/烛灯,对话在视线下方。相处状态、过渡与动态、雾光、手机、真实人物动态与完整功能仍待验收;不永久取消 VIS-011 在其他状态的原规则,不把完整前端锁定为全屏照片或视频(CG-031 继续有效)。来源:sources/comment-evidence-shared-space-20261023.json 与 sources/shared-space-static-acceptance-20261023.md(原话经总工 HEL-811 对照平台逐条核验)。"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# 需求变更记录
v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入口,新增白栖知批准的跨任务继承办法。批准评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`;所批方案原文在 `sources/governance-proposal-approved.md`。
原始 v1.6 原样保留。后续生活披露、初始关系和名字的覆盖见 current.md 的 AM-001–003 及对应来源,不回写历史。旧文档中的历史进度不作为现在的事实。
以后每次改需求,记录:原要求、新要求、白栖知明确原话及评论编号、涉及编号、影响范围。智能体建议、实现困难、阶段通过均不能替代用户授权。检查脚本只能检查材料完整性,审核者仍必须核对原话。
## v1.8:视觉总监接手
白栖知明确裁定:整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。旧分工为总管因环境失败接手制作、后续视觉总监只把关;现按GOV-005交视觉总监亲自设计与落地,必要时直接写代码。本次不改变产品功能与原验收边界。
## v1.9:交流状态静态视觉基准更新
白栖知看过 HEL-798 共同空间样图后明确认可:**“静态效果我认可”**
来源评论:`01a1668f-5810-7524-b72f-33574a32c7d6`(获认可图 `hel-798-face-to-face-moon.png`,1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`)。
旧要求(VIS-011):人物平时作为空间中独立视窗;交流时自然放大。新认可:交流状态(靛蓝月光、电脑 1600×1000)的静态画面不再用独立人物框,环境铺满四边,前景为用户一侧桌沿/茶杯/烛灯,对话在近处视线下方;VIS-011 条文保留,边界注明该更新。
影响范围:仅交流状态静图。相处状态、过渡与动态、雾光、手机、真实人物动态与完整功能仍待验收;不永久取消 VIS-011 其他状态原规则,不把完整前端锁定为全屏照片或视频(CG-031 继续有效)。原话与适用范围见 `sources/comment-evidence-shared-space-20261023.json`、`sources/shared-space-static-acceptance-20261023.md`(原话经总工 HEL-811 对照平台逐条核验)。
+366
View File
@@ -0,0 +1,366 @@
{
"baseline_version": "1.9",
"items": [
{
"requirement_id": "CG-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-002",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-003",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-004",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-005",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-006",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-007",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-008",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-009",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-010",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-011",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-012",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-013",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-014",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-015",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-016",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-017",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-018",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-019",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-020",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-021",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-022",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-023",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-024",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-025",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-026",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-027",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-028",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-029",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-030",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-031",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "VIS-001",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-002",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-003",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-004",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-005",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-006",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-007",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-008",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-009",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-010",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-011",
"status": "交流静图(HEL-798月光构图)已获用户认可;相处、过渡、雾光、手机与整体动态仍待用户验收",
"evidence": [
"docs/requirements/changes.json v1.9(01a1668f)",
"prototypes/hel-799/(HEL-811 独立复核通过)"
],
"next_step": "HEL-799 动态小样交白栖知体验验收;相处/雾光/手机与真实素材按主卡承接"
},
{
"requirement_id": "VIS-012",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-013",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "AM-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "AM-002",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "AM-003",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-001",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-002",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-003",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-004",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-005",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-006",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-001",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-002",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-003",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-004",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "DEL-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-005",
"status": "用户恢复原运行时后,视觉总监已在HEL-793真实运行;设计与代码由其亲自负责",
"evidence": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc",
"01a163be-0bbc-7a74-8dc1-a3bcee89cc58",
"running:01a163bc-9134-75e4-aa28-71c6371ff692"
],
"next_step": "视觉总监交实际样图和动态代码;总管按完整需求核对后交用户验收,保持用户设定的运行时"
}
]
}
+67
View File
@@ -0,0 +1,67 @@
# 完整需求对照表
此表不使用“代码存在”代替用户验收;当前所有未闭合要求继续有效。
| 要求 | 当前状态 | 后续承接 |
| --- | --- | --- |
| CG-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-002 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-004 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-005 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-007 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-008 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-009 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-010 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-011 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-012 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-013 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-014 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-015 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-016 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-017 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-018 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-019 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-020 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-021 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-022 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-023 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-024 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-025 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-026 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-027 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-028 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-029 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-030 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-031 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| VIS-001 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-002 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-003 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-004 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-005 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-006 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-007 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-008 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-009 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-010 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-011 | 交流静图(HEL-798月光构图)已获用户认可;相处、过渡、雾光、手机与整体动态仍待用户验收 | HEL-799 动态小样(总工 HEL-811 已独立复核)交白栖知体验验收 |
| VIS-012 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-013 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| AM-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| AM-002 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| AM-003 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-001 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-002 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-004 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-005 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-001 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-002 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-003 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-004 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| DEL-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-005 | 用户恢复原运行时,视觉总监已在HEL-793真实运行 | 由其交实际设计与代码,保持用户配置 |
详细证据位于 `coverage.json`;空证据表示尚无本基准下整项合格证据,不表示需求取消。
+562
View File
@@ -0,0 +1,562 @@
# CyberGril 完整当前需求 · v1.8
完整要求;版本1.8保留原完整要求并新增用户明确的视觉总监接手分工,不证明已实现或已验收。
## 阅读与效力
用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。
已确认的产品、视觉与补充要求全文如下;实现情况见 `coverage.md`,未定事项不视为已确认。旧方案的阶段状态、技术推荐和旧样机结论仅作历史证据。
## CG-001 · 需求对齐与开工边界
先逐项问清需求,形成统一记录和方案;对单项建议的同意,仅用于确认该项,不能扩大成全部功能的开工授权。
来源评论:`01a15722-86fc-7c70-9409-d2e9b8cb041f`、`01a157ab-209c-7c8f-956c-40ebb3340f4e`。
## CG-002 · 写实视觉风格
人物采用照片、电影中真人的写实质感。首版日常画面采用CG-029的人物与房间成品视频,按活动、天气和时段选片;不再要求浏览器每帧用独立人物透明层与房间合成。
边界:原仓库的VRM/建模默认路线和Alina候选,不作为当前已认可的外观方向。 真人照片般的效果仍为首选;新增条件备选见CG-027,不把讨论二维视为立即放弃真人路线。 独立控制的产品目标由统一状态和对应素材组合实现;物理分层实现要求已由CG-029覆盖。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a15717-b34f-72ac-939f-d81c90f58920`、`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a158c5-a25f-742b-975d-6af1ce8ff6b6`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-003 · 动作与交谈的混合路线
预生成场景和同一人物的日常动作、服装素材;根据实际生活状态选择、衔接。交谈实时产生内容与语音,口型和局部反应需实际验证;后台可逐步补充素材。
边界:首版不承诺即时生成任意新全身动作。现场自由生成仍是用户的理想方向,属于后续增强目标。重生成任务应给交谈让出资源。 当前首版主验证路线明确为CG-029;具体交谈驱动器和真实表现仍待测试。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-004 · 交流镜头
日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。
边界:具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`。
## CG-005 · 工程外观与自备素材
工程阶段由总管安排选择人物外观;后续支持用户按标准自行生成图片或视频,替换人物外观。该能力从首版设计时预留。
边界:具体素材包规范和导入流程尚未定稿;一张图片本身不等于已有全部动态动作。最终外观仍经过样图验收。 工程外观须遵循CG-025的新要求;素材替换能力与仅换外观、不重建伴侣的CG-006保持不变。 首轮工程素材由小队制作,不以用户先备齐视频为前提,见CG-030。
来源评论:`01a1572c-18c8-7872-94b0-c05c1b24b5c7`、`01a1572d-2c55-777c-a63f-a7c74c31c070`、`01a1573f-aedf-7d08-91a7-482b2a94f782`、`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-006 · 换外观保留同一个她
换素材仅改变外观,保留原有八字底色、性格积累、记忆和关系;创建新人物与换外观是不同概念。
边界:首版随后收缩为仅一个伴侣;这里不授权实现多个伴侣或并行运行。
来源评论:`01a1573f-aedf-7d08-91a7-482b2a94f782`、`01a15749-7530-71b4-9157-b9fa7011254b`。
## CG-007 · 首版仅一个伴侣
首版只能创建、使用一个伴侣,暂不做多个伴侣及切换;支持数据备份。
边界:覆盖此前已讨论并同意的多伴侣保留、切换方向;单伴侣下的清空或重新创建规则未定。
来源评论:`01a15749-7530-71b4-9157-b9fa7011254b`。
## CG-008 · 后台生活与连续性
用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续。画面、对话、记忆应对应同一份生活状态。
边界:没有承诺服务器关机仍能运行;停机后的恢复、补算规则另待设计。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a15749-7530-71b4-9157-b9fa7011254b`、`01a15751-ded1-7328-80ae-39967d2b3821`。
## CG-009 · 备份与恢复
每天自动备份;支持随时手动完整导出到自己的电脑,并从备份恢复同一位伴侣。覆盖八字、性格资料、对话、记忆、关系、生活进度;手动完整导出带上所用人物、场景素材及恢复所需设定。
边界:自动副本留在服务器,首版暂不增加云盘接入;保留份数、空间策略、恢复校验等工程细节尚未定稿。
来源评论:`01a15749-fc88-7295-8350-dccfb0168b70`、`01a1574f-9e0e-7bf4-b158-3f88a4c1988a`。
## CG-010 · 连续语音与文字
用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`。
## CG-011 · 主动联系范围
首版只在网页内主动联系:语音开启时可以主动开口,未开启时留文字消息;关闭网页后保留消息,回来再看。主动分享应有自己的生活经历作为依据。
边界:首版不接手机推送或其他聊天软件;具体频率可在方案中结合原仓库参数制定。
来源评论:`01a15751-670e-7187-b94a-cea70154df3b`、`01a15751-ded1-7328-80ae-39967d2b3821`。
## CG-012 · 生辰创建方式
使用她自己的出生年月日和时辰;创建时可自动生成,也允许用户在确认前手动指定。确认后作为人格底色依据,换外观不会改变。
来源评论:`01a15752-4cfe-7cd7-a4a3-8a8f8143f943`、`01a15754-b879-73b6-b367-b105898d7107`。
## CG-013 · 八字底色不可编辑
八字带来的性格底色是系统级资料,创建后只读;每轮重新提供给模型并预留上下文空间。可用结构化指标配合简短解释表达,关系提示词不得覆盖底色。
边界:程序保证资料不被改写;模型能否长期遵守仍需验证,不能把写成数字或系统提示词等同于已解决漂移。
来源评论:`01a15771-3a93-7518-9b23-489509520d77`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-014 · 性格诠释空间
性格描述保留适度抽象和解释空间,让模型形成有延续性的个性化诠释;同样底色允许不同表现与成长,不给每次回答规定唯一标准答案。
边界:不能将本项误记成‘用户可修改八字底色’,也不能将随机、无缘由地反复变换人设当作个性。
来源评论:`01a15763-1fc5-7f36-bf43-722e8755703c`、`01a15763-e729-7aa2-a752-7b95fb69372c`、`01a15771-3a93-7518-9b23-489509520d77`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-015 · 初始习惯与后天成长
创建时带少量模型自主形成的喜好和小习惯;以后通过生活、相处逐渐丰富。成长经历单独记录,保留最初的八字底色。
边界:原仓库重大经历可微调核心的旧例外,按此讨论改为另存成长层,不据旧文直接修改出生底色。
来源评论:`01a15763-e729-7aa2-a752-7b95fb69372c`、`01a15765-2fa8-7494-b0b8-7a0054500556`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-016 · 关系可配置、可调整
朋友或恋人、称呼、相处边界等关系设定通过独立提示词表达,相处过程中也可明确修改;保留原有记忆和互动形成的信任,不改写过去。
边界:没有确定默认必须初识或必须情侣;通用人物提示词编辑器并未获确认,允许修改的范围是关系设定。
来源评论:`01a1576a-5ffe-73fb-b6f1-2aeee98a5782`、`01a15771-3a93-7518-9b23-489509520d77`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-017 · 首版场景范围
先集中做好一个高层卧室,以成品视频表现对应天气、昼夜、光线以及少量服装和日常动作;由统一状态按条件选片,保留后续扩展入口。
边界:其他房间和外出场景后续扩展;素材数量先结合试片结果定,不将早期示例数量作为现行承诺。 当前唯一房间明确为CG-026所述高层卧室。 浏览器人物/环境物理分层要求由CG-029覆盖;活动、天气等逻辑状态仍独立管理,不用选片篡改生活记录。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a15775-89f4-7ec8-89eb-b3554fb05d19`、`01a1577a-e852-7551-ace8-0b2028db9ebb`、`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-018 · 电脑优先、兼顾手机
主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。
来源评论:`01a1577b-b65c-787a-b436-b0a74c38fea4`、`01a1577c-d10f-7513-8f58-62b3b387240a`。
## CG-019 · 素材工具与新增费用
首版先使用已有工具和本地机器制作图片、视频素材,暂不新增付费云端生成接口;若实测不够,再列明方案效果和费用,由用户决定。
边界:此项是素材制作的费用边界,不能误解为禁止使用已提供的外接LLM或用户自行部署的小模型服务。
来源评论:`01a1577d-7203-7b2b-bf24-f880f56750cf`、`01a1577e-96f6-72c7-b66b-6c43779fc5f0`。
## CG-020 · 首版内网访问
首版在部署服务器所在内网使用,电脑和手机在该内网访问。
边界:外出访问后续扩展。摄像头和麦克风所需可信访问方式仍要在工程方案中落实。
来源评论:`01a1577f-0707-7ac6-9c0f-aef2e73ba108`、`01a15780-0fd0-7438-beee-4947f4c3c928`。
## CG-021 · 按任务选模型、视觉外接
该用小模型的地方用小模型;状态和时间推进用程序,记忆等后台任务可用小模型,主模型负责主对话和较复杂理解。摄像头用小视觉模型前处理,部署由用户在其他设备负责,项目通过接口接入;用户另有4080显卡。
边界:平时把带时间和不确定项的观察摘要交给主模型,必要时保留原图复核途径。具体小模型、部署参数、接口地址和性能尚未定版。
来源评论:`01a157a5-1668-723d-af23-732a612b30b9`、`01a157a6-b158-7ba5-8a3a-1d19920890ac`、`01a157a7-c9f2-7133-8340-9b497f12f7e7`。
## CG-022 · 首版摄像头范围
加入可开关的视觉观察,先聚焦人在不在、明显物品、房间环境变化;快速挥手、点头等连续动作放到后续。
边界:观察有时效,不能把旧画面继续当作眼前事实。刷新间隔和延迟以小视觉服务实测为准,5—10秒只是先前建议,不是固定性能保证。
来源评论:`01a157a6-b158-7ba5-8a3a-1d19920890ac`、`01a157a7-c9f2-7133-8340-9b497f12f7e7`。
## CG-023 · 天气、昼夜与作息
用户手动选择一个真实城市,房间天气、昼夜和生活作息跟随当地节奏;城市可自行更换,无需每次打开重新定位。
边界:具体城市、天气来源及跨城市切换细节仍待设置或工程设计。
来源评论:`01a157a8-510f-7d3d-9880-670dc9ae4d22`、`01a157a8-aeab-7611-9695-74afbc2dec02`。
## CG-024 · 第一道验收及后续顺序
先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。
边界:小样通过仅表示该阶段通过,不等于记忆、后台生活、摄像头或备份已全部实现和通过验收。 用户本次确认v2静态方向并授权继续最小动态验证;不等于动态或完整功能已通过,也不扩大为全量施工及任意模型安装授权。 本次同意CG-029/030后进入已确认方案的关键技术验证;旧小样通过不等于新实时交谈、衔接或完整功能通过。 CG-031明确完整前端设计不以嘴型技术验证通过为前置条件;设计样图、交互原型与真实功能接通须分别标明,不能把设计推进等同于全部后台施工获准。
来源评论:`01a157a9-bd18-7533-9b21-e5f4c3a90d7c`、`01a157ab-209c-7c8f-956c-40ebb3340f4e`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-025 · 当前工程人物外观
成年女性,年龄观感在20岁左右,冷白皮,圆鹅蛋脸,中短发,气质清纯干净。用户指出上一版人物太老、太路人,要求重新设计。
边界:用户已于2026年10月20日15:17(北京时间)认可v2人物静态方向。CG-002的写实风格、CG-006外观与伴侣身份分离保持;不代表分层和跨视频身份连续性已验证。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-026 · 当前房间设定
高层卧室,设置慵懒沙发和女性化装饰元素,整体温暖温馨。
边界:用户已认可v2高层卧室静态方向,四张v2图为当前视觉基准。CG-017的一间房、人物与环境独立、天气昼夜变化范围保持;分层几何、光照切换效果仍需实测。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-027 · 真人优先与二维备用
优先争取真人照片般的效果;如果真人路线无法达到所需效果、损害沉浸感,再考虑二维动画。
边界:真人优先、二维为条件备用保持。首版具体主验证路线现由CG-029确认;交谈身体范围按CG-028。真人转二维的具体判据和二维风格尚未定稿,不自动切换。
来源评论:`01a158c5-a25f-742b-975d-6af1ce8ff6b6`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-028 · 首版交谈的实时表现与预设身体动作
首版优先让口型、表情和目光自然;身体主要使用少数预设动作,不要求肩膀、手势根据每句话实时生成新的变化。
边界:这是首版范围选择,不是禁止预设肩部/手部动作或要求人物身体完全静止。具体模型能支持哪些表情、目光和动作仍待实测;没有测试证明现有硬件绝对无法实时驱动肩部或手势。此项同意不代表选定成品视频架构、具体模型、安装、付费API或新施工。 后续用户另于01a1599b-f137-71cc-8af2-a265499b4b6e同意CG-029/030方案,不把这两次确认混成同一授权。
来源评论:`01a158c7-f16f-7b98-844e-77271f16d6d8`、`01a158d7-1d0c-764c-8a4c-35359b4f5d8a`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-029 · 成品日常视频与本地实时交谈主路线
首版优先验证:人物与房间一起制作成品日常视频,按真实时间、天气及她的实际活动选片;交流时自然转入同一人物的实时半身交谈画面,结束后回到日常活动。人格、记忆、关系与后台生活由本地核心系统管理。
边界:替代首版浏览器每帧独立人物抠像合成的实施要求;天气、服装或活动变化需要对应视频与自然过渡,保持同脸、服装和光线连续。只确认架构方向,LiveTalking/MuseTalk/Ditto等具体驱动器仍需实际选型;不承诺任意新身体动作,不默认采用云端收费服务,也不代表全部功能已完成。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a158d9-2f3a-72f2-a911-5f58adf074c7`、`01a1597e-ef9e-7cfa-9456-1a0d4e248430`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-030 · 首批视频制作与后续用户自备素材
首轮工程素材由总管安排小队使用现有本地资源制作,以已确认的人物与高层卧室为基准;用户不需要先自行准备整套视频。后续提供模板、制作规范与导入检查,用户可自行生成替换外观。
边界:先验证少量日常、过渡和交谈素材的完整流程,再扩充服装/天气/动作;具体数量和格式待测试。生成有筛选、重试和处理成本,不承诺一张照片自动得到全套合格素材;换素材不改变八字、记忆和关系。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1597d-f505-7481-a9ee-62e025fbfa2f`、`01a1597e-ef9e-7cfa-9456-1a0d4e248430`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-031 · 完整前端以产品功能和使用流程为设计依据,人物与卧室样图只作视觉参考
完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。
边界:完整界面的布局、导航、内容层级、交互与视觉语言应围绕产品使用流程独立设计,不能被旧样图构图锁定,也不把成品视频策略扩张为所有界面必须全屏视频。嘴型同步作为独立技术研究,不作为完整前端设计开工的前置验收。此项确认设计依据与范围,不代表已认可任何完整页面稿,不授权直接实现所有后台功能或自动改换模型;此前素材仍可作参考,不能误记成需要删除旧素材或推翻全部既有需求。
来源评论:`01a15e8d-91ab-739e-84f9-fc4026bfcd70`、`01a15e92-e689-754a-aad3-4566100962b3`。
## VIS-001 · 两人的数字空间
整体像只属于两人的数字空间,人物、交流、回忆融在其中。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-002 · 整体气质
静谧梦幻、安静亲密,有空间深度和柔和微光。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-003 · 多主题的一致结构
切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-004 · 两套首轮主题
靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-005 · 空间优先的首页
首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-006 · 背景层次
光雾、景深与若隐若现的景物融合;具体画面通过样图验证。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-007 · 空间动效
平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-008 · 透光浮层
内容采用透光浮层,背后保留空间光影,文字清晰可读。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-009 · 轻盈文字
现代轻盈、字形简洁、标题克制,正文易读。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-010 · 微光强调
少量微光集中于关键操作和选中状态,两主题各有强调色。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-011 · 人物独立视窗
人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。交流状态静图已按评论 01a1668f-5810-7524-b72f-33574a32c7d6 认可为 HEL-798 共同空间构图;相处状态、过渡、雾光、手机与真实人物动态仍待验收。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-012 · 悬浮导航
底部小巧透光导航,图标配简短文字,选中亮起微光。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-013 · 交流呈现
交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## AM-001 · 未分享生活不可见
等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## AM-002 · 关系由用户选择
创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## AM-003 · 名字由用户起
首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## UX-001 · 手机对话可用
手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-002 · 对话文字动效
文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-003 · 滚动条主题一致
记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-004 · 出生资料与底色深度
创建与资料显示公历、农历、时辰及四柱说明;性格不能只用话多、直接、焦虑等浅标签概括。深度不能变成固定模板人设,仍须遵守 CG-013/014/015 的诠释自由、初始个性、成长分离。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-005 · 交流身份清楚
用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-006 · 完整界面功能保留
按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## GOV-001 · 完整需求跨任务继承
所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-002 · 只有用户能改需求
只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-003 · 对照和验收
每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-004 · 本轮返工范围
先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## DEL-001 · 演示与真实接入边界
前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。
边界:接入方案中的候选服务、声音、摄像头和硬件操作仍须满足各自前置与授权,不能从批次批准推断全部候选已经批准。
来源评论:`01a161a4-842f-7c26-9be0-1812b720976a`、`01a1629c-732f-7559-8417-6f2ea05c7151`。
关联全文:`sources/integration-plan-v1.md`。
## GOV-005 · 视觉总监接手整体视觉及必要代码
CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。
边界:总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。
## 尚未确定或尚未验证
### T-01 · 八字映射细则
具体规则表、权重、命理解释覆盖深度与验证样例尚未定稿。用户没有直接确认‘只保留明确量化项、排除所有抽象描述’;后续已明确保留性格诠释空间。
### T-03 · 素材包标准与具体数量
导入模板、尺寸、帧率、背景处理、动作节点、口型预处理、预览与回退方式等需结合方案、小样定稿。未承诺固定服装/动作数量或一张参考图自动生成整套合格视频。
### T-04 · 模型与速度
Qwen3-VL-4B、2B和此前提到的视频/口型方案都是候选;4080部署、真实摄像头、语音与人物同时运行尚未实测。既有接口单图测试的1.5秒/7秒不代表4080速度或端到端语音延迟。
### T-05 · 外接服务接入
4080视觉服务地址、具体模型、输入输出能力,以及现有ASR/TTS的流式和打断能力,需后续核实或由用户提供。部署视觉小模型由用户负责。
### T-06 · 运行与恢复细节
单伴侣重新创建/清空、服务器停机后的状态恢复、备份保留份数和空间策略尚未定稿;不能用多伴侣旧结论补全。
### T-07 · 天气和访问细节
具体城市、天气数据来源、缺失天气素材时的呈现方式、内网可信HTTPS方案等留待工程设计。
### T-08 · 实现完成情况
本记录是需求确认台账,不证明功能已实现。旧演示的部署和测试结果不能作为新写实动态路线的验收结果。
### T-09 · 真人呈现路线与交谈动态边界
CG-029已选定成品日常视频配本地实时半身交谈,CG-030确定首批由小队制作。待验证具体驱动器、同脸/服装/光线连续、音频/口型/头部表情实际能力、打断、日常与交谈往返、素材制作成本及本机并发/恢复;真人转二维的条件仍待明确。云端收费服务未选定。
### T-02 · 关系界面余项
初始关系由用户选择已由 AM-002 确定,旧待定结论被覆盖;自由提示词不能被用于修改出生底色。
### T-10 · 当前一两句的长回复呈现
分句、停留和前后查看细节在独立动态小样中验证,不冒充用户已选具体方案。
## 历史覆盖记录
原始 v1.6 的 `superseded` 字段保留全部早期路线变化。另有以下后续覆盖:
- 私人手账视觉初稿未获认可,按完整视觉访谈和后续认可稿重做。
- 初始关系“尚未定”由 AM-002 覆盖;生活“可先显示并标注未分享”由 AM-001 否决;名字由 AM-003 确定。
- 最新用户对对话与整体沉浸界面不满意,不得沿用此前阶段通过作为本轮通过。
- 以上不授权重置出生底色、伴侣身份、聊天、记忆或直接合并产品分支。
+35
View File
@@ -0,0 +1,35 @@
{
"baseline_version": "1.8",
"read_commit": "4242f8377e350e5f17e836d275c15b0f099bf109",
"requirement_ids": [
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005"
],
"deliverable": "按用户明确指令记录视觉总监接手设计与相应代码职责,并交接本轮返工",
"acceptance": [
"58项原意、边界、来源完整;初轮及视觉对话取齐",
"版本变更、来源、覆盖、交接缺项检查可运行",
"项目资源与说明及旧卡回读核对;独立审核后仅治理分支允许合入"
],
"deferred": [
"用户界面小样及其体验验收另卡",
"R1修正与R2–R7真实接入继续保留",
"CI自动合并门禁尚未配置,当前检查由交付与审核执行"
],
"requirement_changes": [
{
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"requirement_id": "GOV-005"
}
],
"verification": [
"59项需求及交接检查通过;原始来源和变更历史保留",
"项目规则、主卡与返工卡已明确视觉总监亲自接手设计与相应代码",
"用户手动恢复原运行时后,HEL-793运行01a163bc-9134-75e4-aa28-71c6371ff692真实开始;已读旧稿与样图并检查浏览器;没有重复派工",
"追溯确认此前2026-10-22T09:31:24Z的运行时变更来自总管执行01a162f2-61a5-7c6a-9704-44c8e15219ff,未经用户事先批准;已向用户如实说明",
"主卡恢复in_progress;用户设定的运行时、模型与认证未再更改,正式界面及用户数据保持原状"
]
}
+44
View File
@@ -0,0 +1,44 @@
{
"task": "HEL-811 总工独立复核 HEL-799 共同空间动态小样 v2,并机械保全原稿入库;本文件为总工交接(原作者 handoff 原样保全于 prototypes/hel-799/handoff-hel-799.json)",
"baseline_version": "1.9",
"read_commit": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0",
"read_notes": [
"v1.8(59项)全文与 sources/ 于提交 3a986726 完整读取;总工本轮以平台原话核验静态认可来源后,按治理规则升版 v1.9(仅 VIS-011 边界注明交流静图认可),来源原话进 sources/ 并写入 source-manifest。",
"与原作者 handoff 的差异仅在 baseline_version(原作者交付时为 1.8,当时静态认可评论尚未进入正式 sources,handoff 机械检查因此失败);内容性字段未改动原作者判断,验收结论以本文件与 HEL-811 审核记录为准。"
],
"requirement_ids": ["CG-002", "CG-003", "CG-004", "CG-008", "CG-017", "CG-018", "CG-023", "CG-024", "CG-025", "CG-026", "CG-028", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "(1)HEL-811 审核记录(附件):41 项独立复测、逐字节重建核验、像素级对照、素材与母版核验、用户原话平台核验;(2)分支 agent/agent/54df81e610fd:prototypes/hel-799/ 23 个交付文件逐字节保全+PROVENANCE.md(来源附件、zip 与逐文件 SHA256、独立重建结果);docs/requirements 升版 v1.9(sources 两件新增、source-manifest、baseline/current/changes/coverage 同步);未动 web/public/assets(哈希与仓库一致)、未动 PR#4/#6/#8、未合并、未部署。",
"acceptance": [
"独立构建:以仓库资产用独立实现的构建脚本重建 index.html,662,233 字节,SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e,与交付 HTML 逐字节一致;zip 内 HTML/上传附件/重建结果三者相同。",
"UI 为真实元素:2 张获批静帧图层层叠 4 坌 canvas 与 24 个按钮、表单、浮层,非整图贴底。",
"行为独立复测 40/41 通过(无头 Chromium 151,Playwright,真实键鼠事件):镜头渐进、无黑帧、照片仅等比缩放平移、中途反向不跳回、Esc/←/→ 14–36ms(<80ms)且有限动画为 0、自动续读到末句、全文与原文一致、暂停/回看/回到进度、发送恢复自动、身份标记、停止保留并标未完成、记录打开她留在桌对面且关闭回原句、入口均打开带演示/尚未接入标记、语音演示不申请麦克风、两主题结构一致、七种视口长回复无遮挡、长名字以省略号收纳、减少动效原位交叉淡化且雨停一帧、档 2 无逐帧遮罩、页面隐藏暂停(body.suspended、CSS 动画 paused、阅读计时冻结并于恢复后续走;合成 visibilitychange 事件,非 OS 级后台节流)。",
"像素级对照:我的运行 vs 作者证据(同状态均值差 9.5,差异集中于雨/热气动画区);我的交流态 vs 获认可静图(均值差 9.9,差异集中于文案与阅读控制区,场景区一致);母版几何核验(16:9 候选底板=16:10 底板等比高度铺满右移 112px,与声明的 ~113px 一致;含前景母版 vs 获认可静图均值差 5.0;无前景底板差异集中在底部前景区)。",
"手机 4:5 候选容器:390×844=390×487.5、320×568=320×328、390×430=390×374 与 HEL-802 v2 容器公式一致;脸中心实测 (170,157)/(143,105)/(176,120) 复现作者数字,与 16:9 公式差 7–11px(源为 16:10 获认可构图,可见宽 50% vs 45%,非等价,待新 CT 素材后重算);键盘态最近回复覆盖容器底部 80–128px(内容长度相关),脸与下巴不被遮;默认关闭、桌面不受影响、界面未把候选升格为用户选择。",
"用户原话核验:records 五条与 01a166aa/01a16699/01a1669d/01a166ad 均经 CLI 对照平台逐条一致(作者、时间、原话、父评论)。"
],
"deferred": [
"Firefox 未测:本运行时无法启动(缺 GTK3 链库且系统 NSS 最高 3.98 < 所需 3.101,无 root 无法补装);Safari/WebKit 无浏览器。真机软键盘、真实 GPU 帧率、低端手机未测;帧率参考值来自无头软件光栅,不代表真机。",
"候选容器底部露出旧书与居家裤(获认可构图 y≥0.80 原被页面桌沿遮挡)与推近途中远景静帧的书/腿:属明确素材依赖(按 HEL-802 v2 生成新 CT),非界面遮盖问题;320 偏挤为已预判的已知密度;真实软键盘未知。",
"相处、过渡、雾光、手机与整体动态均未经白栖知验收;真实视频入场/退出、循环三圈、同脸同衣同光、LIVE 接管与端到端延迟未验证(CG-030 首批小队承接)。本审核通过不等于用户验收或项目完成。",
"总工无法人眼审图(运行时模型不支持图片输入):视觉判断基于像素统计、几何复算与代码核对,截图证据供总管与用户人眼复核。"
],
"requirement_changes": [
{
"approval_comment_id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"quote": "静态效果我认可",
"kind": "交流静态视觉基准的更新",
"affected_ids": ["VIS-011"],
"old": "VIS-011:人物平时作为空间中独立视窗;交流时自然放大。",
"new": "交流状态(靛蓝月光、电脑 1600×1000)的获认可静态画面为 HEL-798 共同空间构图:无独立人物框,环境连续到四边,前景桌沿/茶杯/烛灯,中间是她,远处雨夜窗景,对话在近处视线下方。",
"scope": "仅交流状态静图。相处状态、过渡与动态、雾光、手机、真实人物动态和完整功能仍待验收;不永久取消 VIS-011 在其他状态中的原规则(CG-031 继续有效)。",
"evidence_file": "docs/requirements/sources/comment-evidence-shared-space-20261023.json",
"image_sha256": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a"
}
],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main:通过(v1.9 升版、来源追加、历史保留)。",
"python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-811.json:通过。原作者 handoff(baseline 1.8)按预期报『交接使用过期基准』,原件未改动,差异见 read_notes。",
"git diff --check:通过。逐字节重建与哈希清单见 prototypes/hel-799/PROVENANCE.md;行为复测明细见 HEL-811 审核记录附件。"
]
}
@@ -0,0 +1,12 @@
# 本次约束落地与验证
需求基准v1.7由白栖知主卡评论 `01a1635b-b47c-79ed-ac66-9408c7905af9` 批准。
- 已归档58项完整有效要求和125条来源评论;旧原始记录、获批设计及接入方案保留原样。
- 项目说明已加入必读路径、只有用户可改需求、每卡对照、阶段与全项目验收边界。项目仓库资源已绑定,回读URL正确;新卡将实际验证收到的项目上下文。
- 原39张尚未关闭子卡逐张补充入口及交接约束,回读确认状态和负责人不变;主卡同样补充且仍in_progress。迁移清单见 `task-migration.json`。旧试验未因迁移重新启动。
- 正常基准和交接通过;对覆盖漏项、来源改动、无升版/新批准的需求修改、过期交接共4种反向输入均失败,详见 `verification.json`。验证在隔离临时副本进行,未修改服务或用户库。
- `git diff --check` 通过。未改产品行为,未重跑无关后端与浏览器测试;本次没有新增界面画面可验收。
- 仓库治理请求为PR#5,仅含规则、需求、检查与说明;独立审核和合入main尚未完成。此前产品PR#4保持open未合并,8300/8310及用户数据保持原状。
此记录证明本轮写入与有限验证,不证明后续每位智能体都会理解正确。结构检查须配合原话审核与实际体验验收;当前没有配置CI强制合并门禁。
+13
View File
@@ -0,0 +1,13 @@
{
"governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
"design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
"approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
"frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797",
"comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9"
}
@@ -0,0 +1,267 @@
## CyberGril 完整产品设计 · 可施工规范(HEL-746 / R2)
延续 HEL-744 R1 已认可的方向「夜色里一扇亮着的窗」。本轮没有改首页的比例、字号和人物窗位置,也没有改交流时放大人物窗的方式。在这个基础上补齐了生活与共同回忆、首建、管理、交流的完整状态、离开后返回、主动消息,以及手机上的完整路径。
依据:需求记录 v1.6(CG-001~031、T-01~09),HEL-738 总工方案(F1~F9 流程、组件边界、需要视觉总监覆盖的状态清单)。本稿只是设计原型,不是正式产品。
---
### 1. 交付物与打开方式
| 文件 | 说明 |
|---|---|
| `index.html` | 完整原型,包含两套主题和电脑/手机两种布局。不需要联网,也不需要服务 |
| `phone.html` | 在 390×844 的手机框里打开同一份 index.html |
| `assets/` | 沿用 R1 的两张参考静帧(房间、近景) |
| `shots/` | 55 张实际截图(42 张电脑 1440×900,13 张手机 390×844@2x) |
| 本文件 | 可施工规范 |
- **演示控制**:电脑在左下角,手机在顶部中间。可以切换数据(正常/空/很长),以及「未讲述的事」的两种呈现方式,还能模拟两天后回来、她主动说话、断线(恢复/失败)、发送失败、麦克风被拒、语音识别出错、视觉服务失败、重新演示首建、创建失败和保存失败。**这个面板只属于原型,不是产品界面。**
- 截图参数:`?shot=<场景名>&theme=moon|fog`,场景名见第 14 节。自检参数:`?selftest=1`,结果写在 body 的 `data-selftest` 属性里。
---
### 2. 已认可、本轮不改动的部分
- 靛蓝月光和蓝紫雾光两套主题整体切换,DOM 和布局完全相同。用户没有指定默认主题,原型以月光打开只是临时设定,不代表用户已经决定。
- 首页:左侧一句话(30px/300)、心情与关系、她的留言、「你不在时」入口和两个按钮;右侧拱形人物窗(`clamp(300px,27vw,430px)`,比例 1:1.3,距右边缘 14vw)。
- 交流:人物窗展开为宽幅圆角窗,画面只留当前一两句;输入框常驻,麦克风是唯一实心的强调按钮,完整记录从右侧滑入。
- 底部导航保持小巧、透光:此刻 / 交谈 / 日子 / 设置。
- 人物和卧室仍用静帧占位,标注「示意画面 · 参考静帧」。没有新做嘴型实验。
---
### 3. 信息架构与导航、返回关系
```mermaid
title 区域与返回关系
flowchart LR
NOW[此刻 首页] -->|和她说话/麦克风/导航交谈| TALK[交谈]
NOW -->|你不在时/导航日子| DAYS[日子: 她的日子 / 我们的回忆]
NOW -->|导航设置| MANAGE[设置: 她/关系/城市/外观/备份/外接服务/显示]
NOW -->|她留给你的| TALK
DAYS -->|事件详情→看那段聊天| TALK
TALK -->|返回日子 原滚动位置与选中项| DAYS
TALK -->|回到此刻/Esc| NOW
DAYS -->|回忆↔事件 互链| DAYS
DAYS -->|× / Esc / 回到她身边| NOW
MANAGE -->|× / Esc| NOW
FIRST[首建 仅无伴侣时] -->|完成→去看看她| NOW
BACK[离开后返回] -->|你不在的这两天| NOW
REACH[主动消息] -->|回她: 记录定位到该条| TALK
```
- **离开日子以后还能回到原来的位置**:从事件详情打开聊天时,关闭按钮会改成「返回日子」。日子列表的 DOM 不重建,所以滚动位置和选中的条目都保留。自检里有专门的验证项。
- **Esc 逐层退出**:弹层 → 演示面板 → 返回欢迎层 → 聊天记录 → 交谈 → 手机详情 → 区域。首建过程中按 Esc 不会退出。
- **手机**:列表和详情、设置分区列表和分区内容都改成「推入」。左上角的「‹ 她的日子」「‹ 设置」负责返回。
- 设置里有没保存的修改时,关闭网页会触发浏览器离开提示(`beforeunload`);对应分区导航上会显示一个暖色小点。
---
### 4. 需求 → 页面 / 状态映射
| 需求 | 落点(原型中可以操作) |
|---|---|
| CG-007 单伴侣 | 首建只出现一次;设置→她,注明「首版只有一位,不提供新建、切换或清空」;恢复别人的备份会被阻止 |
| CG-008 后台生活 | 日子时间线;「你不在的这两天」;文案注明「网页关着她照常生活(服务器停机期间除外)」 |
| CG-009 备份恢复 | 设置→备份:每日自动备份列表、完整导出(进度→完成)、恢复(三项检查→要求输入「恢复」两个字的双重确认→恢复前先自动导出) |
| CG-010 连续语音 | 语音条:计时、状态(倾听/思考/她在说/停下来了/没听清),「打断她」和常驻的「结束语音」;输入框不会消失 |
| CG-011 主动联系 | 首页上从人物窗里浮出的轻提醒,注明「由她今天 15:30 的生活经历引起」;交谈导航上的未读点;聊天记录里标「她主动说的」;语音开着时她会直接开口 |
| CG-012/013/014 生辰与底色 | 首建 4 步:出生(随机生成/手动修改,未满 18 岁会被拦下)→ 底色(四柱、倾向词和白话说明,说明是「倾向不是台词」)→ 关系 → 确认(必须勾选「我明白」)。设置→她:底色只读,带锁标识 |
| CG-015 成长分层 | 回忆→她的成长;设置→她:「最初就有的」和「后来慢慢有的」分两栏 |
| CG-016 关系可调 | 设置→关系:关系、称呼、相处边界;改关系类型需要二次确认,确认框说明「记忆和信任都保留,底色不变」 |
| CG-005/006 换外观 | 设置→外观素材:选择素材包→五项检查(通过/不合格)→新旧对比→确认「她还是她」 |
| CG-022 视觉观察 | 交谈里的「窗」按钮:首次开启要说明并确认;状态分为正在看、刚刚看到 N 秒前、已过期(不再当作眼前)、失败;设置→外接服务里有总开关 |
| CG-023 城市 | 设置→城市:搜索、选择、保存(成功或失败),顶栏城市同步变化 |
| CG-018 电脑优先、兼顾手机 | 全部区域在手机上都能走完关键路径,见第 8 节 |
| CG-031 设计依据 | 布局以使用流程组织;人物窗只是一个区域,不铺满全屏 |
| HEL-738 F6 断线 | 顶部横幅「信号不太好,正在重新连上…」,人物窗变灰,发送暂停但草稿保留,语音安全结束;重连成功会提示补了几条;重连失败显示「再试一次」 |
| HEL-738 §5.5 φ 文案 | 所有错误都用她的话或生活化的话表达(「我没听清」「看不清」),不出现技术报错 |
| SPEC §8.5 不画仪表盘 | 关系、心情都用一句话描述,不出现数值、进度条或雷达图 |
---
### 5. 各区域规范
#### 5.1 日子(她的生活与共同回忆)
- **空间关系**:打开后,首页内容向左后退并模糊;人物窗缩小到右侧(`--mini-pw: clamp(170px,14vw,230px)`),留在原来的空间里;窗下写「她此刻 · …」,并注明「来自她的生活记录 · 画面为示意静帧」,把生活状态和素材播放区分开;背景推近到 1.03 倍并压暗。
- **面板**:`left 5vw / top 84px / bottom 104px / width min(1000px,66vw)`,圆角 28,使用阅读底 `--glass-read`。电脑上分两栏:列表 400px,详情占剩余宽度。
- **三种事件**:已发生(实心点,`--text-2`)、正在(强调色点,带呼吸光)、打算(虚线空心点),分组为「之后的打算」和按天分组。打算类注明「计划可能会变」。
- **标签**:「还没跟你说过」(强调色)、「和你有关」(有关联回忆)。已经说过的事标题会弱化成 `--text-2`。
- **详情**:类型、时间、地点、是否说过、她的话(引用块)、和这件事有关的回忆(可以点)、那时候的聊天(可以点,看完返回)。底部固定说明「这是模拟生活里的一条记录,不是真实发生的事;画面只按记录选片,不会反过来改写生活」。
- **回忆**分为四组:我们一起的 / 你们的小习惯 / 她知道的你 / 她的成长。每条显示时间与来源(如「你 10 月 8 日说的」)、她怎么记得,以及来自哪些日子(反向链接)。
- **空记录**:「她今晚刚住进来,还没有发生什么」;回忆为「聊着聊着就会有了」。
- **长记录**:先显示前 18 条,再按「看更早的日子」每次加载 12 条,加载时保持滚动位置。施工时按天分页。
- **不做**:情绪或信任的数值仪表盘;任何「你好久没来」之类带负罪感的叙述。
#### 5.2 首建
- 全屏,人物窗和导航隐藏,左侧是叙事一句话(跟随步骤变化),右侧是向导卡 `minmax(460px,560px)`。
- 步骤条只覆盖 4 个步骤;进行中和完成不计入步骤数。
- 校验:名字必填;出生日期必须满 18 年;关系必须选(原型不预选,T-02);确认页没勾选时「创建」按钮不可用。
- 创建过程分三段:排好底色 → 形成最初的小习惯 → 布置房间。失败时停在失败的那一段,并提示「资料都还在」,可以选「返回修改」(回到第 1 步,数据保留)或「再试一次」。
- 完成页写「她住进来了」,按「去看看她」进入首页的「刚搬进来」状态:生活为空,首页一句话换成首晚的文案。
- 创建之后,界面上任何地方都没有重建、清空或切换伴侣的入口。
#### 5.3 设置(创建与管理)
- 面板几何与日子相同;左侧是 220px 的分区导航,右侧是内容。
- **表单通用规则**:没有修改时「保存」不可用,状态写「尚未修改」;有修改后,文字变成暖色「有未保存的修改」,导航上出现暖色点;「取消」把内容恢复到上次保存的值。保存时显示进行中(转圈和说明,按钮锁定)→ 成功(`--ok`,一句话说明影响)或失败(`--danger`,「你的修改还在」,可以重试)。操作栏吸附在底部。
- **重要确认**:改关系类型、应用新外观用普通确认;从备份恢复用危险样式,并要求输入「恢复」两个字。
- **外接服务**:对话模型、ASR、TTS、视觉观察服务四项,各有地址输入框和「测试连接」。原型不会发出任何请求;测试结果明确写「界面演示 / 未真实检测」。地址为空或测试失败时显示「相关功能会显示为不可用,其余照常」。
#### 5.4 交流的完整状态
| 状态 | 人物窗左上标签 | 语音条 | 台词区 / 提示 |
|---|---|---|---|
| 文字待机 | 在你面前 | — | 上一句和她当前这一句 |
| 她在想 | 在想 | 她在想 | 三点跳动 |
| 她在打字或说话 | 在说话(声波) | 她在说 · 可以打断,并出现「打断她」 | 逐字显示加光标 |
| 发送失败 | — | — | 「没发出去 · 重试」,记录里虚线边框;重试不会重复发送 |
| 麦克风授权 | — | — | 说明弹层:允许 / 不允许(模拟) |
| 倾听 | 在听你说 | 红点计时、在听你说、结束语音 | 你说的话实时显示(灰色) |
| 被打断 | 停下来了 | 她停下来了,在等你 | 她的那句话半透明,后面加省略号 |
| 识别出错 | 没听清 | 刚才没听清 | 「刚才没听清,你再说一遍?」,然后自动继续听 |
| 麦克风被拒 | — | —(会话不开始) | 麦克风变灰并加斜线;φ 文案;再点一次会给出处理指引 |
| 断线 | 画面变灰 | 语音自动结束 | 顶部横幅;发送暂停,草稿保留 |
| 视觉观察 | 绿点:刚刚看到… N 秒前 | — | 过期后变黄并标「已过期,不再当作眼前」;失败为红点 |
- 语音会话期间,「结束语音」一直显示在语音条上,麦克风按钮也同时变成停止键,两处都能结束。
- 语音开着时,人物窗高度从 `100vh-360` 降到 `100vh-412`,给语音条留出位置,三者不会重叠。
#### 5.5 返回与主动消息
- **离开后返回**:首页左侧换成欢迎层「你不在的这两天」,列出时间范围、3 件小事和留言提示,用中性语气,按钮是「看她留的话 / 看她的日子 / 先待一会儿」。
- **主动消息**:在首页时,她的话从人物窗下部浮出(`width = 窗宽-32`);在日子或设置里时,提示出现在右下角,不打断当前操作。提示上注明这句话来自哪段生活经历。点「回她」会打开聊天记录并定位、高亮这一条,关闭后回到原处。点「稍后再看」会留在「交谈」的未读里。
---
### 6. 设计 Token(R1 的值照旧,以下只列 R2 新增)
| Token | 月光 | 雾光 | 用途 |
|---|---|---|---|
| `--glass-read` | rgba(10,18,46,.62) | rgba(34,20,66,.60) | 日子和设置这类长内容的阅读底 |
| `--surface` | #0b1330 | #24154a | 手机上推入的详情层和吸底操作栏(不透明,避免下层透字) |
| `--field` / `--field-line` | rgba(255,255,255,.05) / rgba(205,222,255,.16) | rgba(255,240,255,.06) / rgba(236,218,255,.16) | 输入框 |
| `--ok` | #9fe8cf | #a8f0d8 | 成功、视觉观察「新鲜」 |
| `--warn` | #ffd79c | #ffdcae | 未保存、待定、观察已过期 |
| `--danger` | #ff9fb3 | #ffa8c8 | 失败、危险操作、录音红点 |
| `--radius-field` | 14px | | 输入框、链接卡片 |
| `--pane-l / -w / -t / -b` | 5vw / min(1000px,66vw) / 84px / 104px | | 区域面板几何 |
| `--mini-pw` | clamp(170px,14vw,230px) | | 区域页里缩小后的人物窗 |
- 字体、字号层级(30/20/15/13/11.5)、间距(4/8/12/16/24/32/48)、圆角(999/20/28)、曲线、时长与 R1 一致。新增:详情标题 24px/300,分区标题 22px/300,表单标签 13px,分组标题 12px,字间距 .14em。
- 状态色只用在小点、细边框和短文字上,不做大面积色块,以免破坏夜色的整体气氛。
---
### 7. 组件与状态
| 组件 | 状态 |
|---|---|
| `.btn-primary` | 默认(微光)/ hover(光晕变大)/ 按下(缩到 .97)/ 不可用(.42 透明,去掉光) |
| `.btn-ghost` / `.btn-danger` / `.btn-text` | 同上;危险按钮只用描边加文字色 |
| `.input/.select/.textarea` | 默认 / 获得焦点(强调色描边并加 4px 柔光圈)/ 错误(`--danger` 描边加下方说明)/ 只读(虚线描边) |
| `.seg` | 选中项为强调色实底 |
| `.status` | busy / ok / fail |
| `.checklist` | 待检查 / 检查中(转圈)/ 通过 ✓ / 失败 ! |
| `.ev` 事件行 | 默认 / hover / 当前选中(强调色浅底)/ 已讲述(弱化)/ 打算 |
| `.tag` | 中性 / accent / ok / warn / danger;`.tag-demo` 用虚线表示「演示/模拟」 |
| `.modal` | 普通 / 危险;需要输入确认字时,主按钮会先锁住,输入正确后才能点 |
| `.netbar` | 重连中(转圈)/ 失败(红点,加「再试一次」) |
| `.observe` | asking / fresh / stale / fail |
| `.nav-item` | 默认 / 当前项 / 未读(小点) |
---
### 8. 响应式
| 断点 | 布局 |
|---|---|
| ≥1101px | 如上;区域页右侧显示缩小的人物窗和说明 |
| 721–1100px | 区域面板宽 90vw,隐藏缩小的人物窗,在面板标题上改用「她此刻在…」一行字;日子列表 340px,设置导航 200px |
| ≤720px | 首页和交流沿用 R1 已认可的手机布局。区域面板改为全屏层(`top 54 / bottom 84`),底部导航始终可见,不被遮挡。列表和详情、设置分区都改为推入方式,详情层和吸底操作栏使用不透明的 `--surface`。首建为纵向可滚动布局,「上一步 / 下一步」吸在底部。语音时人物窗高 `min(42vh, 1.1w)`,自上而下依次是台词、语音条(底部 162px 处)、输入框(98px 处)、导航(16px 处),互不重叠。弹层宽度为 `100vw-32px` |
---
### 9. 动效
| 动效 | 触发 | 时长 / 曲线 | 能否打断 |
|---|---|---|---|
| 进入日子或设置:首页后退并模糊,人物窗缩小移到右侧,面板上升 24px 并淡入,背景缩放到 1.03 | 导航、入口 | 人物窗 720ms、面板 420ms(延迟 80ms),ease-out `(.22,1,.36,1)` | 可以,全部用 transition,中途反向会从当前位置回去 |
| 列表 → 详情(手机推入) | 点条目 | 420ms ease-out | 可以 |
| 首建换步 | 下一步 / 上一步 | 420ms,前进时从右侧淡入 16px,后退时从左侧 | — |
| 弹层 | 确认、授权 | 280ms,缩放 .97→1 并淡入 | Esc 或点遮罩取消 |
| 主动消息浮出 | 推送 | 420ms,上移 10px 并淡入 | 可以 |
| 断线横幅 | 断开 | 280ms | — |
| 语音条 | 开始语音 | 280ms | — |
| 「正在」事件的点、录音红点、倾听外圈、声波 | 常驻或状态 | 1–2.4s 循环 | — |
| 主题切换 | 开关 | 1100ms `(.65,0,.35,1)` | 可以 |
- **减少动效**(系统设置或设置→显示):所有循环停止;空间不再推近;过渡统一为 200ms、无延迟;粒子只画一帧。
- **页面隐藏时**:粒子停止绘制,CSS 动画暂停(`html.paused`)。
- 施工建议(与 R1 相同):人物窗缩放改用 FLIP(transform),雾层模糊预先烘焙。**本轮没有做性能实测,所以不给帧率数字。**
---
### 10. 模拟与真实能力的边界
- 原型里所有数据、回复、语音、摄像头、生活、备份、导出、导入、服务测试都是模拟,没有连接任何服务,也不会写入或下载文件。界面上用「演示数据 / 模拟生活 / 模拟 / 原型 · 不连接服务、不写数据」等标签逐处标明。
- 施工时按 HEL-738 §7 的能力标签处理:`mock` 打「演示数据」角标;`unavailable` 用 φ 占位文案;不能为了显示「在线」而伪装。
- 人物画面始终标注「示意画面 · 参考静帧」。正式版通过 Presenter 替换成视频,无论有没有口型驱动,页面结构都不变。
- 文案不暗示「关网页会停机」,也不承诺「服务器停机时她还在生活」。
---
### 11. 施工验收清单(与 S1 切片对齐)
1. 两套主题下,每个区域的 DOM 和布局完全一致;切换主题不改变滚动位置,也不改变当前视图。
2. 首页比例、字号、人物窗位置和 R1 一致(拿 D01 与 R1 截图对照)。
3. 从日子打开聊天再返回,列表的滚动位置和选中项都不变。
4. 事件和回忆可以互相跳转;空记录和长记录(分页)都正常。
5. 首建:未成年、未选关系、未勾选确认三种情况都会被拦下;创建失败时资料保留并能返回修改;完成后进入首页的空生活状态;之后任何地方都没有重建入口。
6. 设置:有修改才能保存,取消能恢复,保存过程中按钮锁定,失败时修改保留,改关系要确认,恢复要输入两个字确认,并且会拦下别人的备份。
7. 交流:发送失败能重试且不重复;语音的倾听、思考、她在说、打断、出错、结束都能走通;语音中「结束语音」一直可见;麦克风被拒后文字照常可用;断线时草稿保留、语音安全结束,重连后补齐消息。
8. 视觉观察:标注时效;30 秒后显示为过期(演示用的阈值,真实值以小视觉服务的实测为准);失败时不影响交谈。
9. 主动消息注明依据;点开后定位到那条,关闭后回到原处;不展示负罪叙事。
10. 手机 390×844:输入框、语音条、导航、吸底操作栏和弹层互不遮挡;手机上的详情层不透出下面的文字。
11. 减少动效、隐藏页面暂停都生效;Esc 能逐层退出。
12. 所有模拟能力都带标签。
---
### 12. 待定项(不阻塞其他设计,都已做成可替换的呈现)
| 编号 | 事项 | 本稿处理 |
|---|---|---|
| A | 她没亲口分享过的事对你是否可见(用户未拍板) | 默认显示并标注「还没跟你说过」;演示控制里可以切到「讲之前不显示」。详情页用黄色虚线框标为待定 |
| B | 默认主题 | 两套都保留,原型以月光打开只是临时设定 |
| C | 关系的默认值和编辑形式(T-02) | 首建时不预选;设置里用表单呈现,可以换成引导式对话 |
| D | 单伴侣下的重新创建或清空(T-06) | 不设入口 |
| E | 城市清单和天气来源(T-07) | 搜索列表为演示 |
| F | 素材包规范(T-03) | 五项检查只是示意 |
| G | 自动备份的保留份数(T-06) | 列表只显示 3 份并注明待定 |
| H | 内网 HTTPS(T-07) | 麦克风授权弹层里注明;不可用时按「麦克风被拒或不可用」的状态显示 |
| I | 观察过期的阈值、刷新间隔(CG-022) | 原型用 30 秒演示,真实值以实测为准 |
| J | 她的名字是否在首建时由用户填写 | 原型放了名字输入框;如果要改成别的方式,只影响第 1 步 |
---
### 13. 原型检查记录
- **自动自检**(`?selftest=1`,用真实点击走关键流程,共 43 项):在 Edge 无头模式下,1440×900、1280×800、窄窗(手机布局生效)三种尺寸全部通过。覆盖:主题、日子的打开/详情/互链/看聊天后返回原位、空记录与长记录、未讲述切换、设置的修改/取消/确认/保存成功/保存失败、恢复拦截、素材检查、文字失败重试、麦克风拒绝与授权、语音的思考/说话/打断/结束、视觉观察开关、断线与重连、Esc、欢迎层、主动消息定位、首建的三项拦截/失败返回/成功进入首页。
- **截图检查**:电脑截图是 1440×900 下实际 DOM 的截图;手机截图在 390×844 的 iframe 里按 2 倍像素密度截取并裁切(无头窗口有最小宽度,所以不直接截窗口)。
- **本轮发现并已修正的问题**:手机详情层半透明,下面的列表文字透出来(改为不透明的 `--surface`);手机语音时人物窗没有铺满宽度;语音条文字换行(手机上隐藏声波图标,文字过长时截断);静帧截图里声波条高度为 0;吸底操作栏的偏移。
- **限制**:
- 没有录屏工具,动效请直接打开原型查看。
- 没有做性能实测。
- 没有在真实手机或 Safari 上测试,只用了 Edge(Chromium)。
- 截图时关闭了过渡,所以截图里看不到「你不在的这两天」卡片的闪亮反馈。
- 本运行环境里没有可用的设计或动效类技能(只有平台类技能),动效由本规范和自检把关,没有使用其他技能。
---
### 14. 截图索引
D01 首页 · D02/D03 事件详情(两套主题)· D04 你不在时摘要 · D05 打算中的事 · D06 还没跟你说过(待定呈现)· D07 长记录 · D08 空记录 · D09 回忆详情与关联日子 · D10 成长 · D11 返回欢迎层 · D12 主动消息(首页)· D13 主动消息定位 · D14~D21 首建(出生、校验错误、底色、关系、确认、失败、完成、首建后首页)· D22~D30 设置(她、关系确认、城市成功/失败、外观通过/失败、备份、恢复双重确认、外接服务)· D31~D41 交流(发送失败、授权、倾听、她在说、打断、麦克风被拒、断线、重连失败、观察新鲜/过期/失败)· D42 演示控制台 · P01~P13 手机(日子列表、详情、回忆、首建出生/确认/失败、设置列表、关系表单、恢复确认、语音、断线、返回、主动消息)。
@@ -0,0 +1,42 @@
[
{
"id": "01a16650-0355-72c4-b291-f1b4494cde91",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "主要是A和B,但是之前没注意到C的问题,你一说倒是有点",
"created_at": "2026-10-23T01:11:22Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16654-5fc5-74be-a704-99b76b48b0b1",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "对就是B",
"created_at": "2026-10-23T01:16:08Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16655-fe53-73a5-905f-4c9e71a1ed30",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个我不太能想象得到,我和她待在同一空间是什么概念",
"created_at": "2026-10-23T01:17:54Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16659-fdfa-72ce-a4a5-801e0762c0a9",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个能否现在就做个样图让我看一下",
"created_at": "2026-10-23T01:22:16Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "静态效果我认可",
"created_at": "2026-10-23T02:20:32Z",
"parent_id": null
}
]
@@ -0,0 +1,10 @@
[
{
"id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码",
"created_at": "2026-10-22T12:46:18Z",
"parent_id": "01a1639b-8416-7b0b-8dbf-0de16c257cb5"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
主卡 HEL-697 的整套界面设计已经白栖知验收。批准评论:01a15f42-2781-700e-b75a-3ae4b023f46c,原文:“整套认可,1选等她讲过才显示,2创建时由我选择,3首次创建时由我起名”。对应验收说明评论01a15f3b-ec7e-7bfb-bf86-9005b81b095c。
## 本次定稿
1. 她未亲口分享的生活经历,等她讲过才显示。覆盖生活列表、详情、离开摘要、关联回忆与消息中的生活引用;不能通过旧链接、搜索结果或摘要绕开。未确认已分享的记录不能当已分享展示。已有原型默认“显示并标注还没跟你说过”的分支被本条覆盖,正式前端不保留切回该分支的用户开关。
2. 首次创建时,由白栖知选择关系,不预选、不替用户决定;未选择不能完成。以后设置中修改关系按已认可设计处理:改变关系类型再次确认,保留已有记忆与信任,底色不变。
3. 首次创建时,由白栖知给她起名字,要求有效输入;主页、聊天、首建确认和设置统一使用该名字,不把“晚晚”等参考名硬编码为真实身份。不额外新增改名流程。
4. HEL-746 R2整套页面和流程认可;HEL-744定稿视觉延续。两套主题均保留;首页人物窗大小位置、首页文字分量和交流放大效果均沿定稿。不再请用户确认同一设计。
## 执行范围
启动HEL-738方案的S1:在项目仓库落地整套可构建前端、结构化状态/数据适配边界和完整演示数据,覆盖相处、交流、生活回忆、首建管理及电脑/手机。完成写码、构建、自测后交总工统一审核;审核合格部署独立测试版,再由白栖知验收。
本轮不是重做设计,也不是补齐全部后端:生活循环、真实记忆/备份、ASR/TTS、视觉观察与口型等依HEL-738后续切片接入,不以这些能力作为前端交付前置。真实能力与演示能力必须区分,尚未接通不能假称在运行。演示不能自动连接现有服务、申请麦克风或摄像头。
## 依据顺序
本次用户裁定优先于R2规范中的相关待定项和旧原型分支;其余业务以需求确认记录v1.6、最新主卡决定、HEL-738流程边界为依据。视觉以获批HEL-746 R2为依据,HEL-737私人手账稿已经否决。原型的临时初始主题不视为用户指定默认主题,保留两主题切换和用户选择。
真实后端未来提供数据时,未分享生活信息必须在服务器响应前裁剪;前端过滤不能冒充服务器权限控制。本轮只实现本地演示和适配边界,并在接线说明中明确该前置约束。
其余待定的天气来源、素材格式、HTTPS和观察时效等使用明确演示或可替换适配边界,不擅自接服务、买接口、装模型或新增产品规则。原型内演示控制只放演示模式,不混入正常产品流程。
@@ -0,0 +1,486 @@
# CyberGril 完整前端结构与可实施边界方案(HEL-738 · v1)
> 依据:HEL-697 首版需求确认记录 v1.6(31 项 CG + 9 项未定稿 T)、原仓库 CyberGril(commit `1e9b08a`,2026-10-19)实际代码、P21 源码阅读包。
> 性质:**只读分析 + 设计方案**。不写产品代码、不部署、不开施工卡。
> 状态标记(大白话区分):
> - 【已实现·有测试】仓库里真有,且测试守着
> - 【已实现·未接线】代码在,但没接真实后端/没人调用
> - 【仅规格】SPEC.md 里写了,代码没有
> - 【完全缺失】规格都没写或明确没有
> - 【拟议】本方案新提的接口,需要后续评审定稿
---
## 0. 一页结论(大白话)
**现有代码是一个"地基",不是一个产品。** 仓库里有:数据契约(7 个契约文件)、情绪动力学、八字编译、投影 φ、记忆存储、关系账本、模型路由——全部带测试。仓库里**没有**:服务器(api/ 目录不存在)、前端(一行都没有)、四个后台循环(tick/生活/整合/主动联系)、记忆检索与整合、表达管道、备份。所以"完整前端"不能假设任何后台已经在线,方案必须让前端在**纯 mock** 下就能完整设计、完整演示,接真后台是逐片替换的事。
**本方案的核心判断(对应 CG-031):**
1. 前端 = 一个"观看 + 交流 + 管理"的界面层,**它是服务器生活的视图,不是生活本身**。关掉网页她的生活继续(CG-008),回来做增量同步。
2. 画面(视频)是**生活状态的下游**:后端发"她在读书(晚上/晴/家居服)",前端据此选片播放;**前端永远不因为"手头有哪段视频"而反编生活事件**。
3. 视频/口型只是**一个可替换的呈现组件**(Presenter)。缺口型驱动器时,其余全部页面照常设计、照常演示;也不允许用全屏播放器冒充产品(CG-031)。
4. 所有界面数据带来源标签(mock / 契约已有 / 拟议),演示模式打"演示数据"角标,**不伪装已接通语音或记忆后台**。
---
## 1. 现有代码事实盘点(证据位置)
### 1.1 已有(【已实现·有测试】)
| 模块 | 文件 | 内容 | 对前端的意义 |
|---|---|---|---|
| 数据契约 | `src/cybergril/contracts/`:`state.py`(State 四维 v/a/d/e、Presence 枚举、StateBlock)、`percept.py`(Percept:speech_start/end、asr_partial/final、para、vision、env、system、silence)、`render.py`(RenderCmd:speak/expression/posture/idle/presence;visemes)、`context.py`(ContextBundle 六块)、`utterance.py`(text/speech_tags/withheld)、`audio.py`(AudioChunk、采样率)、`capabilities.py`(Capabilities/Degradation,"能力必须诚实、降级必须显式") | 全项目共同语言 | **WS 协议可以直接投影这些契约**,前端事件模型不用发明新概念 |
| 情绪动力学 | `core/mood.py`(209 行) | 衰减方程、负向偏置、麻木 | 状态是慢变量,前端展示"行为层"(她的样子),不展示数值 |
| 八字编译 | `core/bazi.py`(332 行) | 排盘→十神→derived 数值向量 | 创建向导的"生辰→底色预览"数据源 |
| 投影 φ | `core/projection.py`(131 行) | 系统事件→世界内陈述("刚才走神了") | **前端错误提示直接复用这套叙事**,不弹技术报错 |
| 记忆存储 | `memory/store.py`(200 行)+ `schema.sql` | SQLite 全套表(bazi_core、life_event、mem_episodic/semantic/relational、dyad_*、projection_gap)写入/衰减/FTS 查询 | 生活页、回忆页的表结构已定;`mem_vec` 向量表在 schema 里是注释(`schema.sql:183`),**向量检索未实现** |
| 关系账本 | `dyad/ledger.py`(108 行) | trust/intimacy/debt、未修复事件→行为偏移 | 关系设定页数据源 |
| 模型路由 | `think/router.py`、`scenes.py`、`budget.py`、`wiring.py` | 四档(none/low/standard/high)、场景→档位、预算降级 | 前端不用管;能力接口可透出"当前哪些档位可用" |
| Provider 抽象 | `providers/`:llm(echo/openai_compat)、embedding(openai_compat)、asr(scripted)、tts(silence)、turn(heuristic)、vad(energy)、avatar(**只有基类**) | 离线可跑通接口形状 | avatar provider 是空壳——**浏览器形象端从未实现** |
测试:`tests/` 共 7 个测试文件(contracts/providers/mood/bazi/projection/store/router)。
### 1.2 没有(对前端最关键)
| 缺失 | 证据 | 影响 |
|---|---|---|
| **服务器 api/** | `src/cybergril/` 下无 api 目录;SPEC 附录 A 的 `api/ws.py`、`session.py` 【仅规格】 | **没有任何 WS/HTTP 接口存在**,本方案第 5 节全部接口均为【拟议】 |
| **前端 web/** | SPEC 附录 A 设想 `web/src/Avatar.tsx` 等 【仅规格】,仓库无一行前端代码 | 前端从零开始,技术选型自由(见 5.1) |
| **四个常驻循环** | `core/` 下无 tick.py/life.py/reachout.py(README"下一步"明列未做) | "她在生活"目前没有运行时——前端演示必须 mock |
| **记忆检索/整合** | 无 retrieval.py/consolidation.py;store.py 只有 FTS 搜索 | 回忆页接真是后期切片 |
| **表达管道 express/** | 不存在;TTS 只有 silence 占位 | 语音会话依赖已有 ASR/TTS 服务另行接线(T-05) |
| **城市/天气** | 无任何代码(T-07 未定稿) | 环境条数据源待定 |
| **备份** | SPEC §11.3 【仅规格】 | 管理页的导出/恢复是拟议接口 |
### 1.3 与旧仓库冲突处,按 v1.6 用户最新决定执行
- 旧 SPEC §8.2 / `config/models.yaml:130-134` 的 VRM+three.js 形象路线 → 由 CG-002/029 覆盖为"成品视频为主、呈现器可替换";`RenderCmd` 契约保留作为指令语言。
- 旧仓库"重大经历微调出生核心"(README 三层表里 derived 可被整合器 ±0.02 微调)→ 按 CG-015 收缩为"成长层另存,八字底色只读";前端身份卡不提供任何 derived 编辑入口。
- 旧样图/卧室构图 → 只作视觉参考(CG-031),布局、导航、信息层级围绕使用流程重新设计。
---
## 2. 产品形态与用户流程(CG → 流程映射)
### 2.1 产品一句话
单一伴侣(CG-007)、电脑优先兼顾手机(CG-018)、首版内网访问(CG-020);她有后台生活(CG-008),写实成品视频呈现日常(CG-029),交谈时切到同一人物的实时半身画面(CG-004/028);文字常驻、语音可开可打断(CG-010);网页内主动联系(CG-011);生活与共同回忆可见(CG-008/015);创建一次、八字锁定(CG-012/013);外观可换人不换(CG-005/006);每日自动备份 + 手动全量导出恢复(CG-009)。
### 2.2 九条关键流程(每条:触发 → 界面行为 → 数据来源)
**F1 首次创建(CG-012/013/007)**
空库 → 创建向导:① 输入她的出生年月日+时辰(可"自动生成一组"按钮,可手动改)→ ② 实时预览排盘结果与底色倾向(结构化指标+一句白话解释,CG-013/014:展示"倾向",不展示死板台词)→ ③ 设定初始关系(朋友/恋人、称呼;默认值待用户定,T-02)→ ④ 确认。确认即锁定:八字部分永久只读,界面上不出现任何"编辑底色"入口。已有伴侣时创建入口直接禁用(单伴侣守卫),不提供切换 UI。
数据:排盘预览来自 `core/bazi.py`【已实现】;"创建后进入她已在过自己的生活"首屏叙事由生活事件回填(mock 阶段用演示数据)。
**F2 正常打开(CG-008/029)**
进入 → 首次交互门(浏览器自动播放策略需要一次点击,正好用作"推门进屋"的仪式感)→ WS hello/sync → 显示:她当前的活动(视频舞台)、环境条(城市时段/天气)、在场状态、未读的她的话。视频按 scene_key 从素材清单选片,选不到就走降级链(见 6.2),**不黑屏**。
**F3 离开后返回(CG-008/011)**
网页关闭期间服务器继续生活(前提:服务器没停机,CG-008 边界)。返回时 sync 带 `since` 游标 → 增量拉取:期间的生活事件时间线、她留的离线消息("你不在的时候"列表)、关系/状态变化。**缺席可被提及、不可被武器化**(SPEC §10.2 红线)——离线消息展示语气遵循投影 φ,不显示"我等了你好久"式内容(后端约束,前端只展示收到的内容)。
**F4 主动消息(CG-011)**
在线时:页面内非侵入提醒(角落轻提示 + 舞台她转向镜头的呈现,不打断用户当前操作)。离线后回来:并入 F3 的"你不在的时候"。首版无推送(CG-011 边界)。
**F5 语音会话:开始/结束/打断(CG-010/028/022)**
- 开始:点麦克风 → 授权提示(需 HTTPS 安全上下文,T-07)→ 会话建立 → 舞台切到交谈近景(过渡素材)→ 连续收音。
- 进行中:字幕流(asr_partial 实时显示,contracts `percept.py` 的事件形状)→ 她回复(chat.delta + 音频流 + 可选口型驱动)。
- **打断**:用户开口 → 服务端/客户端 VAD 判定 → 她 150ms 内停(SPEC 目标值,实测待定)→ 界面给"她停下来了"的轻反馈。
- 结束:点停止或闲置超时 → 停止收音 → 舞台回到日常活动 → 回到文字模式。
- 全程文字输入框不消失(CG-010:语音与文字并存)。
**F6 断线恢复**
WS 断开 → 界面进入"连接中"(她的画面停在最后一帧或低动效待机,不显示技术错误,用 φ 文案如"信号不太好")→ 指数退避重连 → 重连后带 epoch+游标重同步,期间的消息与事件按序补齐;语音会话中断则安全收尾(停收音、释放麦克风)。**旧轮结果隔离见 5.4。**
**F7 城市改变(CG-023)**
管理页 → 选择城市 → 保存 → 环境条与作息节奏切换到新城市;下次选片按新天气/时段走。**无需重建伴侣、不动记忆**。城市清单与天气源【拟议,待 T-07】。
**F8 素材替换(CG-005/006/030)**
管理页 → 素材包导入:上传素材包(含 manifest)→ 校验(格式、时长、命名、人脸一致性标记)→ 预览对比 → 应用。**只换外观:manifest 的 appearance_id 变,身份/八字/记忆/关系全部不动**(CG-006)。素材包规范 v1【拟议】(T-03),首批素材由小队制作(CG-030),导入器首版只收本地文件,无云端付费接口(CG-019)。
**F9 备份恢复(CG-009)**
管理页 → 备份区:显示每日自动备份状态(份数/空间策略【T-06 待定】);"完整导出"=下载数据库+素材包引用+设定到用户电脑;"从备份恢复"=上传包 → 校验(完整性、版本兼容、八字指纹比对:恢复的必须是同一位她)→ 确认恢复(恢复前自动再做一次当前状态导出,可反悔)。
### 2.3 明确不做(首版界面不出现)
多伴侣切换/并列(CG-007);创建后编辑八字底色(CG-013);通用人物提示词编辑器(CG-016 边界:可改的只有关系设定);云端付费生成接口入口(CG-019);手机推送(CG-011);外出场景/多房间(CG-017);把视频全屏铺满当产品(CG-031)。
---
## 3. 页面/区域结构(四类要素 → 页面模型)
### 3.1 布局模型(电脑优先,CG-018)
```
┌────────────────────────────────────────────────┬──────────────┐
│ │ 侧栏(可收起) │
│ 舞台 StageArea │ · 对话流 │
│ (可替换呈现器:视频/交谈/待机/降级图) │ · 文字输入 │
│ │ · 语音控制 │
│ 底部:环境条(时段·天气·城市)+ 她的状态(行为层) │ · 快捷入口 │
└────────────────────────────────────────────────┴──────────────┘
导航(左上/顶栏):相处(主界面)|她的生活|回忆|管理
```
- 舞台**不是**全屏播放器:它是产品的一个区域组件,四周永远有产品界面(导航、状态、输入),CG-031 明确禁止"先铺满场景再叠界面"。
- 手机端:舞台置顶 + 对话全屏切换(标签式),保画面、文字聊天和连续语音(CG-018),管理/回忆功能折叠进"更多"。
### 3.2 四类要素的落位(CG-031)
**P1 相处主界面**(默认页):舞台 + 环境条 + 在场状态 + 对话侧栏。承担 F2/F3/F4/F5 的日常界面。
**P2 交流**(与 P1 融合,不单独成页):对话流(文字气泡,她的消息支持副语言标签的可视化由视觉总监定)、语音会话控制器、字幕条、打断反馈。交谈时舞台自动切近景(CG-004)。
**P3 她的生活与共同回忆**(一页两栏或两标签):
- 她的日子:生活事件时间线(按天分组,`life_event` 表已有 told 标记→"已经跟你说过的"弱化显示);
- 共同回忆:mem_episodic(记得的片段)、mem_relational(你们的习惯/暗语/仪式)、mem_semantic(她知道的事)分层展示;成长记录(CG-015:初始喜好 vs 后天成长分开两段)。
**P4 创建与管理**:创建向导(仅首次);日常为管理面板:关系设定(CG-016)、城市(CG-023)、素材包(F8)、备份恢复(F9)、视觉观察开关(CG-022:"一扇窗",开/关状态与最近观察摘要,时效性标注)。
### 3.3 状态流转(mermaid)
```mermaid
title 用户会话与舞台状态流转
stateDiagram-v2
[*] --> ColdStart: 打开页面
ColdStart --> Connecting: WS hello/sync
Connecting --> Syncing: 已连接
ColdStart --> FirstCreate: 无伴侣(仅一次)
FirstCreate --> Syncing: 创建完成
Syncing --> DailyLive: 同步完成
DailyLive --> Talking: 语音开始/用户发文字
Talking --> DailyLive: 会话结束/她答完
DailyLive --> Offline: 断线
Talking --> Offline: 断线
Offline --> Syncing: 重连(带游标)
DailyLive --> AwayView: 用户闲置(她继续活动)
AwayView --> DailyLive: 用户回来
```
舞台呈现器内部状态(与上正交):`daily_clip → transition → talk(可带口型驱动) → transition → daily_clip`;任一态可降级为 `static_image`。
---
## 4. 统一生活状态与数据来源表
### 4.1 生活状态 LifeScene【拟议——契约已有部分投影】
后端是唯一事实源。前端收到的是一个"场景描述",而不是"播放某视频"的指令:
```json
{
"epoch": 41,
"presence": "idle_daily", // 投影 contracts/state.py Presence + 日常态
"scene": {
"mode": "daily", // daily | talk | away | asleep
"activity": "reading", // 来自 life_event/schedule
"time_band": "evening", // 由城市时区计算
"weather": "clear", // 城市天气源(T-07)
"outfit": "home"
},
"visible": "平静", // StateBlock.visible:一个词,不展示 v/a/d/e 数值
"since": "2026-10-21T19:40:00+08:00"
}
```
**铁律三条:**
1. **媒体是下游**:`scene.change` 事件驱动前端选片;前端/选片器**不产生** life_event,也不因素材缺失请求后端改生活事件(素材缺失走降级链,见 6.2)。
2. **前端是视图**:关网页不等于停机(CG-008);前端本地不保存权威状态,重连必重新 sync。
3. **状态展示走行为层**:SPEC §8.5"不要把状态画成仪表盘"——展示 visible 词、姿态、回复快慢、活动内容;数值只进调试模式(工程开关,默认隐藏)。
### 4.2 各区域数据来源一览
| 界面区域 | 数据 | 来源现状 |
|---|---|---|
| 舞台-日常 | scene + 素材 manifest | manifest【拟议】;选片【拟议】;scene 数据 life_event 表【已实现·表结构】但生成循环【完全缺失】 |
| 舞台-交谈 | 音频流 + visemes(可选) | RenderCmd.speak(ts, audio_ref, visemes) 契约【已实现】;驱动器【HEL-739 独立研究,非门禁】 |
| 对话流 | 文字 + speech_tags | Utterance 契约【已实现】;生成管道(think/context+generate)【仅规格】 |
| 字幕条 | asr_partial/final | Percept 契约【已实现】;真实 ASR 接线【T-05】 |
| 她的日子 | life_event 列表 | 表【已实现】;每日生成循环【完全缺失】 |
| 共同回忆 | mem_episodic/relational/semantic | 表与写入【已实现】;检索/整合【完全缺失】(FTS 查询有,向量检索注释着) |
| 关系卡 | dyad_state | 表与账本【已实现】;循环推进【缺失】 |
| 身份卡(只读) | bazi_core + derived 白话化 | bazi.py【已实现·有测试】 |
| 环境条 | 城市/时段/天气 | 【完全缺失】拟议 GET /api/env |
| 能力徽章 | mock/real 标签 | 投影 contracts/capabilities.py 哲学【拟议端点】 |
---
## 5. 前端技术选型、组件边界与接口示例
### 5.1 技术选型(够用且不过度设计)
- **React 18 + TypeScript + Vite**。理由:SPEC 附录 A 原设想即为 React 组件;单一桌面+移动 Web 应用,无需 SSR/Next;Vite 开发热更新快,内网部署就是静态文件 + 反代。扩展:将来要 3D/二维呈现器时按 Presenter 插件加库,不预先引入。
- **状态管理:zustand(或 React 内置)**。单伴侣单用户应用,全局状态很小(连接、场景、会话、UI 偏好),不需要 redux 级别机械。
- **样式:视觉总监定的 Token 体系落地为 CSS Variables + 常规 CSS(或 CSS Modules)**。风格不由本方案锁定。
- **不引入**:Electron(Web 已够)、大型 UI 库全家桶(组件不多,可控成本内手写 + 少量无头组件)、测试框架之外的重型设施(Vitest + Testing Library 足够)。
- 部署形态:静态构建产物由部署服务器(192.168.200.12)反代承载,与后端 WS 同源,避免跨域与混合内容问题【拟议,部署细节后续定】。
### 5.2 组件清单与职责边界
```
web/src/
app/ 布局、路由、导航、首交互门(自动播放策略)
connection/ WSClient(重连/退避/epoch)、SyncStore、useLiveState
stage/ StageArea(容器,唯一决定"用哪个呈现器")
stage/presenters/
DailyVideoPresenter 成品视频选片+双缓冲切换+过渡素材(CG-029)
TalkPresenter 交谈呈现:音频播放+可插接口型驱动器(CG-028/031)
FallbackPresenter 静态图/低动效待机(降级链末端,永不黑屏)
chat/ ChatPanel、MessageList、Composer(文字常驻)、SubtitleBar
voice/ VoiceSessionController(开始/结束/打断/权限)、
BargeInIndicator、MicPermissionGate
life/ LifeTimeline(她的日子)、GrowthPanel(初始喜好vs成长)
memory/ MemoryView(三层回忆)
manage/
CreationWizard 生辰→排盘预览→关系初值→锁定(F1)
RelationSettings 关系设定(CG-016 范围内)
CityPicker 城市(F7)
MaterialImporter 素材包导入/校验/预览(F8)
BackupPanel 备份/导出/恢复(F9)
VisionWindowToggle 视觉观察开关+摘要(CG-022)
shared/ CapabilityBadge(mock/real 角标)、PhiToast(φ 叙事化提示)、
StatusStrip、EnvBar、Skeletons、EmptyStates
```
**Presenter 接口(TS,本方案关键边界):**
```ts
interface StagePresenter {
mount(el: HTMLElement): void;
applyScene(scene: SceneDesc): Promise<void>; // 幂等;内部处理过渡
degrade(reason: string): void; // 显式降级,触发 FallbackPresenter
destroy(): void;
}
interface TalkPresenter extends StagePresenter {
attachSession(s: VoiceSession): void;
feedAudio(chunk: AudioPcmChunk): void; // 播放她的话
feedVisemes(v: number[]): void; // 有驱动器才被调用;无则忽略
interrupt(): void; // 目标 <150ms 停声停像
}
// 口型驱动器是 TalkPresenter 的注入依赖,可替换:
createTalkPresenter({ lipsync: null | 'local-w4w' | 'future-x' })
// lipsync=null 时:交谈画面退为"预设交谈近景素材/静态图 + 音频",
// 全部其他页面与流程不受影响(CG-031:不等待、不伪装)。
```
### 5.3 接口事件示例【全部拟议——api/ 完全缺失,形状投影现有契约】
一条 WebSocket(JSON 控制消息 + 二进制音频帧交错),加少量 REST。
```jsonc
// C→S
{"t":"hello","proto":1,"epoch":40,"caps":{"audio":"opus","video":false}}
{"t":"chat.send","turn_id":"c7-12","text":"今天过得怎么样"}
{"t":"voice.begin","session_id":"v3"} // 请求开麦(授权在浏览器层)
{"t":"voice.end","session_id":"v3"}
{"t":"sync","since":"2026-10-21T08:00:00Z"} // 断线/返回后增量
// S→C
{"t":"sync.done","epoch":41,"companion":{...},"scene":{...},"unread":[{"kind":"reach_out",...}]}
{"t":"scene.change","scene":{"mode":"talk","activity":"talking","time_band":"evening","weather":"clear","outfit":"home"},"reason":"voice_session"}
{"t":"presence.update","presence":"speaking"}
{"t":"chat.delta","turn_id":"c7-12","delta":"今天"}
{"t":"chat.final","turn_id":"c7-12","utterance":{"text":"今天挺好的,下午看了一会儿书[laughter]","speech_tags":["laughter"],"emotion_intent":"warm"}}
{"t":"asr.partial","session_id":"v3","text":"那你还想"}
{"t":"asr.final","session_id":"v3","text":"那你还想不想看电影"}
{"t":"interrupt.ok","session_id":"v3"} // 她已停
{"t":"reach_out","message":{"id":"r9","text":"刚看到窗外的晚霞,想跟你说一声","based_on_life_event":1234}}
{"t":"degraded","cap":"tts.speech_tags","reason":"provider_silent","phi":"(这句她的声音少了点语气)"}
{"t":"error","scope":"chat","retryable":true,"phi":"我没听清,你再说一遍?"}
```
音频帧:二进制帧头 8 字节(session_id 哈希 + seq + 方向),opus/pcm16k;细节施工时定稿。
REST【拟议】:`GET /api/companion`(只读档案)、`POST /api/companion`(创建,已存在则 409)、`PATCH /api/relation`、`GET|PUT /api/env/city`、`GET /api/media/manifest`、`POST /api/media/import`(multipart 素材包)、`GET /api/capabilities`(各能力 mock/real 标签,前端据此打角标)、`POST /api/backup/export`(下载 zip)、`POST /api/backup/restore`(上传 zip + 校验报告)。
### 5.4 取消与旧轮结果隔离(防"过期回答覆盖新问题")
1. **turn_id 配对**:客户端每个 chat.send 生成 `客户端id-自增`;所有 chat.delta/final 必须回带同 turn_id。用户放弃输入/新问题时丢弃不匹配的流;渲染层只认"当前活跃 turn_id"。
2. **epoch 门槛**:sync.done 携带 epoch(后端重启/状态纪元变化时 +1);收到旧 epoch 消息一律丢弃并触发一次 resync。
3. **语音会话隔离**:一切 asr/tts/interrupt 事件带 session_id;会话结束后的迟到帧全部丢弃;interrupt 后同 session 内更早 seq 的音频块不再播放。
4. **HTTP**:管理页所有请求走 AbortController,页面切换/重复点击即取消;导出/导入等长任务用任务句柄轮询,不悬挂按钮。
5. **重连风暴保护**:指数退避 + 抖动;重连成功先 sync 再恢复输入框可用。
### 5.5 错误与降级展示(φ 叙事化,不用技术黑话)
- 原则来自 `contracts/capabilities.py`:**降级必须显式**;但显式给的是"世界内"表达,不是堆栈。展示分层:φ 文案("她好像走神了,稍等")为主,次要系统徽章("语音暂不可用")为辅,详细原因收进"工程信息"抽屉。
- 视频素材缺失:FallbackPresenter 静态图 + 轻动效,环境条照常,绝不黑屏、绝不停摆其他区域。
- 语音不可用(无 HTTPS/拒授权/服务离线):麦克风按钮置灰 + φ 文案,文字聊天完全可用。
- 后台不可达:连接态横幅(弱提示),本地保留未发出的文字草稿,重连后由用户重发(不自动重发,避免陈词滥调式重复)。
---
## 6. 媒体适配边界
### 6.1 素材清单 manifest【拟议,规范 T-03 待定稿】
```jsonc
{
"manifest_version": 1,
"appearance_id": "her-v1", // 换外观=换这个 id(CG-006)
"clips": [
{"id":"daily__reading__evening__clear__home",
"src":"clips/daily_reading_evening_clear.mp4",
"scene":{"mode":"daily","activity":"reading","time_band":"evening","weather":"clear","outfit":"home"},
"loop":true,"duration_s":12.5,"in_point":0.0,"out_point":12.5}
],
"transitions":[{"id":"daily_to_talk","from":"daily","to":"talk","src":"clips/tr_daily_talk.mp4"}],
"talk_base":{"src":"clips/talk_base.mp4","loop":true} // 无口型驱动器时的交谈底
}
```
- 键 =(mode, activity, time_band, weather, outfit)五元组;**逻辑状态与素材解耦**(CG-017:活动/天气是逻辑状态,不用选片篡改生活记录)。
- 首版范围:一间高层卧室(CG-026 只作视觉参考)、少量服装/动作(CG-017/030),素材数量随试片扩充。
### 6.2 选片与降级链(顺序固定)
`精确匹配 → 去掉 weather → 去掉 outfit → 换近似 activity → FallbackPresenter 静态图`。每级降级在工程抽屉记录(对齐 projection_gap 的"泄漏可统计"思想),**不向生活状态倒灌任何东西**。
### 6.3 口型/交谈驱动器(可替换,非门禁)
- 驱动器是 TalkPresenter 的注入实现,候选(LiveTalking/MuseTalk/Ditto 等)由 HEL-739 独立研究选型【候选·未测】,本方案不预设结论。
- 无驱动器时:交谈呈现 = talk_base 素材(预设少量身体动作,CG-028:口型/表情/目光优先,身体预设)+ 音频 + 字幕;**产品完整可用**。
- 驱动器上线时只改注入参数,不动任何页面结构(CG-031:视频/口型仅一种可替换呈现组件)。
- 真人→二维的条件判据未定(CG-027),Presenter 抽象同时兼容二维素材包,不需要结构变更。
### 6.4 播放工程
双 `<video>` 缓冲交替切素材实现无黑帧;transition 素材垫在模式切换处(日常↔交谈,CG-004 要求自然切镜、不突兀跳切);移动端降码率副本可选(不做首版承诺);自动播放策略由首交互门解决。
---
## 7. Mock 与真实:能力标签、最小演示数据
### 7.1 能力标签(诚实三档)
每个数据区域绑定标签,来自 `GET /api/capabilities`(mock 模式下由演示脚本静态给出):
- `mock`:演示数据,UI 打"演示数据"角标(如生活时间线右上角小字);
- `real:<tier>`:真实后端(标注来源档位,如 real:echo / real:high);
- `unavailable`:能力缺失,显示 φ 化占位("这部分还在准备"),**绝不假装在线**。
演示模式全局开关:`?demo=1`。演示脚本(一个定时器驱动的假 WS 层)按剧本推 scene.change / chat.final / reach_out,让**全部九条流程(F1–F9)在没有后端的情况下走通**——这是"界面和原型阶段与真实功能施工分开"的机制保障。
### 7.2 最小演示数据约定(demo pack)
```
demo/
capabilities.json # 全部能力标 mock
companion.json # 一位已创建的她(含八字只读档案、白话底色)
life_events.json # 3 天 × 每天 1–4 条(含 1 条高显著未讲述→主动消息素材)
chat_backlog.json # 6 轮对话(含 1 条带 [laughter])
reach_out.json # 1 条主动消息
manifest.json # 4 段占位素材(早/昼/晚/夜 reading)+ 1 段过渡 + talk_base
clips/*.mp4|*.png # 占位片可用静态图代替(降级链自动接管)
timeline_script.json # 演示剧本:事件发生顺序与时刻
```
### 7.3 阶段划分(设计/原型 ≠ 真实施工)
| 阶段 | 内容 | 用什么数据 |
|---|---|---|
| A 设计 | 视觉总监产出完整界面稿与交互原型(HEL-737) | demo pack(本方案 7.2) |
| B 原型验证 | 前端壳 + mock WS 跑通 F1–F9 | demo pack |
| C 真实施工 | 按第 8 节切片逐片接真 | 逐片换 real 标签 |
---
## 8. 施工切片与验收清单(供总管排期,不在此开卡)
> 前端能立的切片尽量先立;每片验收要点独立,对齐 CG-024 的"分阶段验收"。
| # | 切片 | 依赖的后端(现状) | 验收要点 |
|---|---|---|---|
| S1 | 前端壳+路由+demo pack | 无 | F1–F9 全流程在 ?demo=1 走通;降级链全程无黑屏;手机布局不崩 |
| B1 | API 骨架:WS hello/sync/chat + REST | api/【完全缺失】→ 新建 | echo 级应答;epoch/游标正确;断线重连补齐 |
| B2 | tick+life 循环 | core/tick.py【缺失】 | 离线数小时后回来,时间线有新事件且与状态一致 |
| S2 | 文字聊天接真 | think 上下文组装+生成【仅规格】 | chat 流式;turn_id 隔离(旧回答不串台);φ 化错误 |
| S3 | 语音会话接真 | ASR/TTS 接线【T-05 待核实】 | 连续对话;打断停得下来(目标值待实测);结束即停收音 |
| B3 | 记忆检索+整合 | retrieval/consolidation【缺失】 | 回忆页三层数据真实;"她记得"与对话一致 |
| S4 | 生活/回忆页接真 | B2/B3 | 时间线、told 标记、成长分层展示正确 |
| S5 | 管理:城市/素材/备份 | env、media、backup 端点【拟议】 | F7/F8/F9 全走通;换素材后身份卡不变 |
| S6 | 视觉窗 | 4080 小视觉服务【用户部署,T-04/05】 | 开关生效;摘要带时效;关闭显示"窗户关上了" |
---
## 9. 与视觉总监(HEL-737)的对齐输入
**我方提供(本方案已定,视觉总监可直接取用)**:第 3 节页面模型、3.3 状态图、5.2 组件清单、7.2 demo pack、第 4 节各区域数据字段。视觉风格、Token、构图**由视觉总监决定**,不受旧样图锁定(CG-031)。
**需要视觉总监设计时覆盖的状态清单**(每态都需要可识别的视觉表达):
- 在场/呈现:日常活动、交谈中、待机(away)、睡眠(asleep)、降级占位
- 连接:正常、连接中、离线(φ 语气)
- 语音会话:未开启、授权中、聆听中、她说话中、被打断、已结束、不可用
- 对话:她正在输入(流式)、已说完、发送失败(可重试)
- 场景维度:早/昼/晚/夜 × 晴/阴/雨 × 居家/外出服(素材命名的视觉对照)
- 流程态:创建向导各步、导入校验中/成功/失败、备份进行中、恢复确认(高危操作的双确认样式)
- 空态:无回忆、无生活记录(新生的她)、无未读
- 角标体系:mock"演示数据"、降级徽章、视觉观察"窗"状态
**需要视觉总监回传给我方**:Token(色/字/圆角/间距/动效曲线)、桌面与手机两版布局定稿、过渡动效规范(含打断的即时反馈)、素材安全框(视频区域内 UI 避让区)——这些直接决定 5.1 样式层的实现参数。
---
## 10. 风险与尚待决定项
**风险(前端相关):**
| 风险 | 影响 | 缓解 |
|---|---|---|
| 内网 HTTPS 方案未定(T-07) | 麦克风/摄像头不可用,语音/视觉窗全灭 | S1 阶段就出"HTTP 降级态"设计;证书方案尽早定(自签+设备安装或内网 CA) |
| 素材制作跟不上切片 | 演示/施工期素材缺口 | 降级链 + 占位图规范进 demo pack;素材数量按 CG-030 试片后再定 |
| 视频自动播放/移动端功耗 | 首屏黑屏、手机发烫 | 首交互门;移动端默认低动效可选 |
| 口型研究(HEL-739)失败或延期 | 交谈近观感打折 | Presenter 已解耦:talk_base+音频先行,不阻塞任何页面(CG-031) |
| WS 重连边界(epoch/游标设计错) | 状态错乱、串台 | 5.4 五条隔离规则 + B1 验收专测 |
| 素材 manifest 版本漂移 | 升级后选片失配 | manifest 带版本,导入校验含兼容性检查 |
| 长时间线数据量 | 生活页变慢 | 分页 + 按天聚合(对齐 state_snapshot 90 天聚合思想) |
**尚待用户/总管决定(不在本卡解决,汇总供决策):**
1. 关系初始值与编辑形态(T-02):默认初识还是情侣、表单还是引导式对话设定;
2. 城市清单与天气数据源(T-07);缺失天气素材时的默认呈现;
3. 内网可信访问(HTTPS)具体方案;
4. 素材包规范 v1 定稿(T-03):格式/时长/命名/人脸一致性检查项;
5. 单伴侣"重新创建/清空"规则(T-06)——影响创建守卫与高危操作设计;
6. 语音服务(ASR/TTS)流式与打断能力的实测参数(T-05),决定 F5 的实际体验目标;
7. 主动消息频率参数化取值(CG-011:原仓库 reach_out 参数可作起点:间隔≥6h、8–23 点)。
---
## 11. 附录:CG-001~031 → 界面/流程映射总表
| CG | 落点 |
|---|---|
| 001 需求边界 | 治理原则,无界面 |
| 002 写实风格 | 舞台呈现器路线(视觉风格归 HEL-737) |
| 003 混合路线 | manifest 选片器 + TalkPresenter |
| 004 交流镜头 | 日常↔交谈转场与过渡素材 |
| 005 工程外观/自备素材 | MaterialImporter + 校验预览 |
| 006 换外观不换人 | appearance_id 机制;身份卡不可变 |
| 007 单伴侣 | 创建守卫;无切换 UI |
| 008 后台生活 | LifeScene 唯一事实源 + 返回增量同步 |
| 009 备份恢复 | BackupPanel(F9) |
| 010 连续语音+文字 | VoiceSessionController + Composer 常驻 |
| 011 主动联系 | 页内轻提醒 + 离线消息列表(F3/F4) |
| 012 生辰创建 | CreationWizard 步骤①② |
| 013 底色只读 | 身份卡只读 + 无编辑入口 |
| 014 诠释空间 | 底色展示为倾向词+白话解释 |
| 015 初始习惯与成长 | GrowthPanel 两段分层 |
| 016 关系可调 | RelationSettings(仅关系范围) |
| 017 场景范围 | scene_key 维度约束(一间卧室) |
| 018 电脑优先 | 布局模型 + 手机适配 |
| 019 素材费用 | 仅本地素材包导入,无付费入口 |
| 020 内网访问 | 部署约束 + HTTPS 待定项 |
| 021 按任务选模型 | 能力接口透出,前端不感知 |
| 022 摄像头范围 | VisionWindowToggle + 时效摘要 |
| 023 城市作息 | CityPicker + EnvBar |
| 024 验收顺序 | 第 8 节切片验收对齐 |
| 025 人物样图 | 视觉参考(CG-031),不锁布局 |
| 026 卧室样图 | 同上 |
| 027 真人优先二维备用 | Presenter 抽象兼容两种素材包 |
| 028 交谈动作范围 | TalkPresenter 动作范围=预设;口型表情优先 |
| 029 成品视频主路线 | DailyVideoPresenter 默认实现 |
| 030 首批素材分工 | manifest v1 由小队产出 + 导入检查 |
| 031 设计依据 | 本方案组织原则:流程先行、样图仅参考、嘴型独立 |
---
*HEL-738 · 总工 · 2026-10-21 · 本卡只交方案,未改任何业务代码/配置/PR,未启动 GPU/模型/服务,未访问 4080,未建施工卡。*
@@ -0,0 +1,95 @@
本稿供白栖知确认执行办法;不重新表决已经确认的需求,不代表界面已经验收,也不把任何未完成能力写成已实现。
## 要解决的问题
完整需求已经经过多轮逐项确认,但项目说明目前只有简介,项目没有绑定资源。需求记录、后续裁定和设计规范散在旧卡、附件中。新任务卡容易只继承最近一条反馈,施工和审核也容易把局部完成误当成整体满足需求。
本次建立统一、可追溯的需求入口。只有白栖知明确修改的要求才能覆盖原要求;缩小某阶段施工范围、技术做不到、模型登录失效、演示通过,都不能成为改需求的理由。
## 一、把完整需求留在项目中
建议在正式仓库建立以下材料,并以同一个入口链接它们:
| 材料 | 保存什么 | 如何使用 |
| --- | --- | --- |
| `docs/requirements/README.md` | 当前有效版本、完整需求入口、来源、未定项、验收状态 | 所有任务开工前先读 |
| `docs/requirements/current.md` | 所有有效产品要求、视觉要求、后续明确裁定,每条有编号和来源 | 按主题完整阅读,不能只读本卡摘录 |
| `docs/requirements/sources/` | 原始确认记录、已认可设计和主卡裁定 | 核对含义和解决冲突,保留历史 |
| `docs/requirements/changes.md` | 用户原话、来源评论、旧要求、新要求、影响范围 | 只有用户明确改变要求时才更新结论 |
| `docs/requirements/coverage.md` | 每项要求对应的实现、验证证据和未完成部分 | 每次派工、审核、交付都更新 |
整理依据包括需求确认记录 v1.6 中的 31 项产品决定、后续完整视觉访谈与获批设计、整套设计验收的三项补充,以及真实功能接入批次。不能把 v1.6 的旧待定项原封不动当成当前状态,例如首次关系已明确由用户选择。
完整当前文档必须保留以下层次:已确认要求、用户明确覆盖的旧要求、未确认提议、技术待验证项、实现与验收状态。后两项不能冒充产品决定。
原始对话来源用主卡评论链接或评论编号逐条保留。整理发现意思不一致时先核对原话;只有原话仍无法解决的真实歧义才问白栖知,不重问已经回答的选择题。
## 二、让新卡也必须读到它
### 项目级说明草案
保留原项目简介、仓库和部署信息,补充以下约束,并把仓库绑定到项目资源:
> 本项目的产品约定以正式仓库 `docs/requirements/README.md` 指向的完整当前需求为准。每张新卡、续办卡、设计卡、施工卡和审核卡,开始前必须获取并阅读该入口及相关原始来源。只有白栖知明确的新决定可以修改需求,必须记录来源与变更;阶段范围不能覆盖完整需求。任务必须列出基准版本、对应需求编号、交付内容和暂未实现项。阶段或演示通过不代表项目完成。发现本卡与完整需求冲突,先指出冲突,不擅自降低要求。
项目说明随项目任务提供,仓库中的规则负责后续设计、施工与审核。两处同时保留入口,避免只依靠某个会话的记忆或旧卡附件。CyberGril 的后续任务均应归属这个项目;归属错误也应在派工前纠正。
### 仓库必读规则草案
在仓库自有的 `AGENTS.md` 中加入以下要求,不改运行环境自动管理的指令区:
1. 开始前阅读需求入口、完整当前需求与本次关联的原始来源,记录所用版本及提交位置。
2. 本卡的摘录是施工指引,完整需求仍有效。不得因本卡没写某条就把它删掉。
3. 需求、实现方案、阶段范围分别记录;施工者不能把自己的方案改写成用户决定。
4. 实现和审核都提交需求对照表。发现旧代码偏离要求,记为欠缺,不把现状倒推成需求。
5. 用户要求的体验不能用另一种较容易实现的体验替换,例如不能把“当前一两句、历史按需展开”换成长消息滚动窗。
6. 修改需求文档须附白栖知明确批准的来源和影响说明;只有智能体提议或认可不能通过审核。
### 每张卡的固定交接项
- 基准:当前需求版本与仓库提交位置。
- 本次关联要求:编号、原意、相关来源。
- 本次交付:做到什么程度、怎样验收。
- 暂未实现:对应要求仍有效,后续由哪张卡承接。
- 需求变更:没有则写“无”;有则附白栖知明确裁定,不能只写“方案调整”。
- 审核证据:功能验证、真实画面、必要的动态过程,以及实际未验证的设备与场景。
现有未完工卡也补上这些交接项,不只约束新卡。
## 三、把约束带进验收
在仓库检查中核对需求编号、版本、必要交接项和需求变更来源是否缺失;审核人再对照原文判断实现是否符合。自动检查只能发现缺项和失配,不能判断设计是否有沉浸感,也不能代替白栖知验收。
每次交付分别写明“做了什么”“验证了什么”“还差什么”。演示验收、某批接入验收、整项目验收分开记录。全部功能未完成前,不再建议把主卡当成已完成归档。
## 四、当前前端返工聚焦两件事
### 对话显示效果
| 已确认要求 | 小样与验收必须看见的效果 |
| --- | --- |
| 展开交流后以人物为主 | 人物自然放大,与相处状态之间有连续的空间关系,退出后回到原处 |
| 默认只呈现当前一两句 | 长回复也有可读完的呈现方案;不裁掉内容,不强迫主界面堆成长聊天记录;完整记录按需展开 |
| 双方身份清楚 | 实际创建名字和用户身份可辨,不能只靠左右位置猜;完整记录同样清楚 |
| 文字柔和出现和退场 | 展示短句、长句、连续来句与发送过程的真实动态,能读清,不因每次更新反复闪烁 |
| 手机能完整交流 | 小屏、长句、弹出键盘时文字和输入操作不被人物、导航或底部区域挡住 |
长回复如何分句、停留多久、能否提前切换等属于待验证的交互方案,先用可操作的小样展示,不在此稿冒充用户已选规则。
### 整体沉浸界面
继续沿用已确认的两套主题、轻盈文字、透光浮层、底部小巧导航和空间优先的层次。空间应有光雾、景深、若隐若现的景物与舒缓变化;操作时有连续的空间过渡。人物是其中的独立视窗,完整界面覆盖相处、交流、生活回忆、创建管理。
验收看整个使用过程:进入空间、安静停留、展开交流、查看记录、进入生活回忆或管理、返回空间,以及两主题切换。滚动条、弹层、加载、失败和未接通状态也要融入同一视觉。不能只交一张好看的首页或只改聊天框。
涉及未接通能力,小样明确使用演示状态;其真实功能继续按既定批次接入。不能为了沉浸效果伪造她已经发生的生活、记忆或服务运行。
先交电脑与手机的相处、交流样图,并附能操作的动态小样,展示两主题、长句、身份区分、历史展开、交流开合及空间过渡。由白栖知判断是否达到原先想要的体验,认可后才替换正式界面;不重新表决已经认可的视觉选择。
## 五、本次拟修改范围及影响
确认后先落地项目说明、项目仓库资源、仓库需求入口和必读规则、已有任务的需求对照项,再安排上述界面小样。当前 PR 保持未合并,未获认可的界面不作为最终定稿。
这次机制建设不修改已有伴侣、名字、生日、记忆和聊天记录,不重置服务器数据,不改变底色与成长规则,不切换队员的全局模型,不合并或覆盖正式服务。界面小样使用独立预览,真实功能接入工作仍受完整需求约束。
本稿是待确认的执行办法;上述项目与仓库约束尚未写入生效,动态小样尚未制作。
@@ -0,0 +1,208 @@
# CyberGril 真实服务核查与功能接入批次方案(HEL-778 · v1)
> 依据:main 合并提交 `498429399812a0776dadd4b56662d31b455a9ea5`(第二父提交为已审核前端 `6c3b09e6936881fb3e0681ce40bf6069d2c250ac`,已核对无误);HEL-738 结构方案(本卡附件,不重复其内容,冲突处以本文为准);《整套设计验收与施工裁定-v1》;需求确认记录 v1.6。
> 性质:**只读核查 + 实施方案**。本轮未写产品代码、未改任何服务、未访问 4080、未输出任何凭据。所有尚未实施的接口均标【拟议】。
> 核查手段:main 源码全量阅读;Python 测试复跑(**78 passed**);对 .11 LLM 网关只读 GET;经既有授权 SSH 对 .12 做 systemctl / ss / docker / 进程 / 本机 HTTP 只读探测;对 8300/8090 只读 HTTP 探测。
---
## 0. 一页结论
1. **第一批该干什么已经清楚了**:把 B1 与 S2 合并为一批(本方案称 **R1**),让白栖知用普通入口在服务器上创建唯一真实档案,并与 .11 的真实模型流式文字对话。服务器是唯一事实源,重开/换设备都是同一位她。不动语音、不做生活循环、不做口型。
2. **比 HEL-738 写方案时多出一个重大事实**:.12 上已有一套**端到端验证过的全双工语音底座** `/opt/voice-agent`(FunASR 流式 ASR + CosyVoice3 流式 TTS + LiveKit + silero VAD 打断,2026-10-16 实测可用,含可用 HTTPS 自签通话页 8090)。语音批次(R5)从"从零接线"变成"复用已验证底座换上她的人格与记忆",风险大幅下降。
3. **LLM 网关 .11:8731 实测可用**:OpenAI 兼容,无需鉴权,单模型 `halogen-qwen3.8-flash-next`(262K 上下文)。现有 `providers/llm/openai_compat.py` + `think/wiring.py` 只改 `config/models.yaml` 即可接上,代码零改动。
4. **S1 前端把接缝留对了**:`ChatAdapter` 接口 + `TurnGate`(turn_id/epoch/abort 三重门槛)就是为换真实传输准备的;真实模式只需要新增一个适配器和普通入口路由,演示模式代码路径完全不动。
---
## 1. 核查证据表
### 1.1 前端(S1,`6c3b09e`,已合并 main)
| 事实 | 证据 | 结论 |
|---|---|---|
| 聊天传输是可替换接口:`send(text, turnId, epoch, signal, emit, fail?)` | `web/src/adapters/demo.ts:2-3` | 【已有可复用】R1 新增 `RealChatAdapter` 实现同接口即可,`ChatPanel.tsx:26` 只改 `useRef(new DemoAdapter())` 一处注入 |
| 旧轮隔离门槛:事件必须同时匹配 turn_id + epoch 且未 abort,非协作适配器的迟到事件同样被拒 | `web/src/adapters/demo.ts:25-33`(TurnGate),`ChatPanel.tsx:57` 每事件过 `gate.accepts` | 【已有可复用】真实 SSE 流直接受益,无需重写 |
| 服务端事件形状 `chat.delta` / `chat.final`(带 epoch + turn_id) | `web/src/domain/model.ts:61-74` | 【已有可复用】R1 服务端 SSE 事件与它一字不差,前端模型层零改动 |
| 隐私 fail-closed 裁剪:未分享事件及其关联回忆、消息全部过滤,未知引用默认拒绝 | `web/src/domain/privacy.ts:4-11` | 【已有可复用·仅前端】按裁定,真实后端必须在响应前裁剪(R2 起服务端强制),前端过滤不构成权限控制 |
| 创建守卫:名字 1–20 字无尖括号/控制符、日期合法且满 18 岁、关系必选不预选 | `web/src/domain/model.ts:76-86`(validateCreation),`CreationWizard.tsx:43` 关系无预选 | 【已有可复用】R1 服务端必须同规则复验(不信任客户端) |
| 演示档案存 `localStorage` key `cybergril.demo.v1`,导入走严格 schema 重建 | `web/src/adapters/storage.ts:2,6-32` | 【已有可复用】真实模式使用独立数据源(服务器),key 不冲突 |
| 普通入口(非 `?demo=1`)显示"服务尚未接通"卡 + 演示入口链接,不读档案 | `web/src/app/App.tsx:13,99` | 【需改】R1 在此分岔:可达且有档案→主界面;可达无档案→创建向导;不可达→维持现状卡 |
| S1 全程零网络请求、不碰 getUserMedia | `docs/frontend/IMPLEMENTATION.md` + 源码通读(无 fetch/XHR/WS) | 【已验证】演示隔离的基线成立 |
| 演示模式全局开关 `?demo=1`,演示剧本与页面分离 | `App.tsx:13`、`web/src/demo/fixtures.ts` | 【已有可复用】真实模式上线后演示路径原样保留 |
### 1.2 Python 核心(自 `1e9b08a` 起未变:`git diff 1e9b08a..main -- src/ config/ tests/` 为空;78 测试通过)
| 模块 | 关键接口(证据) | 结论 |
|---|---|---|
| 八字编译 | `pillars_from_lunar()`、`compile_pillars()→Derived`、`Derived.traits_text()` 白话倾向(`core/bazi.py:271,310,159`) | 【已有可复用】R1 创建时服务端排盘 + 底色白话直接产出 |
| LLM 适配 | `OpenAICompatLLM.stream()`(SSE 流式)、`health()`(GET /models 探活)(`providers/llm/openai_compat.py:71-76,78-134`) | 【已有可复用】对 .11:8731 协议完全匹配 |
| 模型路由 | `build_llm()` / `build_router_from_config()`:配置→provider→router 唯一接缝(`think/wiring.py:30,100`;`config/models.yaml`) | 【已有可复用】换模型只改 yaml,业务代码不碰 |
| 记忆存储 | `Store`:`add_episodic`/`decay_accessibility`/`reinforce`/`search_fts`(FTS5 已建,向量表在 `schema.sql:183` 注释)(`memory/store.py`) | 表结构可用;R1 需**增量补一张 `chat_message` 表**(schema 无聊天记录表);检索/整合【需补实现】(R3) |
| 关系账本 | `dyad/ledger.py`:`Dyad.apply`/`tick_daily_regression`/`behavior_hints` | 【已有可复用】R4 起用 |
| 情绪/投影 | `core/mood.py` 全套衰减方程;`core/projection.py` Projector | 【已有可复用】R2/R4 起用 |
| 服务器 api/ | 不存在(`src/cybergril/` 下无 api 目录) | 【需补实现】R1 新建 |
| tick/生活/reach_out 循环、检索/整合、表达管道、天气、备份 | 无任何代码(HEL-738 §1.2 结论仍成立,本轮复核确认) | 【需补实现】R2 起分批 |
### 1.3 授权服务实测结果(本轮只读探测)
| 服务 | 地址/端口 | 实测结果 | 证据 |
|---|---|---|---|
| LLM 网关(halogen) | `http://192.168.200.11:8731` | `GET /v1/models` → 200,**无鉴权头也 200**;单模型 `halogen-qwen3.8-flash-next`,上下文 262144,max_tokens 上限 65536/默认 32768 | 本轮 curl 实测;且 `/opt/voice-agent/README.md` 记载语音栈已于 2026-10-16 用它端到端验证(OpenAI 兼容) |
| ASR(FunASR 2pass 流式) | .12 `:10095`(docker `voice-funasr`,funasr-runtime-sdk-online-cpu-0.1.13,0.0.0.0) | WS 协议,流式 ~600ms + 离线纠错;已知问题:长句偶发被切成两次 final(两次触发回复),可切 mode=online 或调 VAD 尾点 | `docker ps`、`ss -tlnp`、`/opt/voice-agent/agent/stt_funasr.py:29`、README"已知限制" |
| TTS(CosyVoice3 流式) | .12 `:9880`(`/opt/voice-agent/cosyvoice/server.py`,GPU ~5.6GB 显存) | FastAPI:`/health` + `POST /tts` → 24k int16 PCM 字节流;首包 1.7–2.5s(官方 150ms 需 vLLM 后端,未开);`voices.json` 零样本音色注册 + 热加载 + 网页音色管理 | `curl /openapi.json` → `['/health','/tts']`、`tts_cosyvoice.py:22`、README |
| LiveKit SFU | .12 `:7880/:7881`(docker `voice-livekit`,host 网络) | 运行中;配套 livekit-agents 1.8.8 worker(.12:8081)内置 silero VAD,打断判定 min_silence 0.55s 已含在链路 | `docker ps`、`ps`(agent.py start)、README |
| HTTPS 通话页(先例) | `https://192.168.200.12:8090`(/opt/voice-agent/web) | 自签证书,TLS 200;浏览器"高级→继续"后麦克风可用;token 由服务端签发,密钥不进浏览器 | `ss`、`curl -k` 实测、README"浏览器通话" |
| S1 演示站 | .12 `:8300`(systemd `cybergril-s1-web.service`,`/opt/cybergril-s1/current` 指向 6c3b09e 构建,releases/current 结构) | 200 正常;静态 http.server;回滚=切符号链接 | `systemctl cat`、curl 实测。**本轮未做任何变更** |
| 口型试验(遗产) | .12 `:8010`(systemd `cybergril-musetalk-test.service`:MuseTalk + WebRTC + cosyvoice TTS) | 运行中,为口型独立线的既有验证件 | `systemctl list-units`。按边界不动 |
| 旧原型页 | .12 `:18080/:18081`(含 HEL-711 卧室分层校准等) | 存在且运行 | 按边界保留不动 |
| 摄像头/视觉 4080 | 未访问 | 按本轮边界继续禁止访问,仅列 R7 独立决策 | — |
### 1.4 普通入口如何进入真实模式;演示如何隔离;演示档案不会被当成真实伴侣
- **普通入口(R1 后)**:非 `?demo=1` 打开 → 前端 `GET /api/health`:可达 → 按 `GET /api/companion` 结果分岔(404→创建向导;200→主界面+服务器历史消息);不可达 → 维持现在的"服务尚未接通"卡。真实档案创建只写服务器 SQLite,浏览器不再持有权威状态。
- **演示隔离**:`?demo=1` 路径、localStorage key `cybergril.demo.v1`、演示剧本、演示角标全部原样保留;演示模式继续零网络请求。
- **不自动上传**:真实模式代码从不读取 `cybergril.demo.v1`,也不提供"演示档案转真实"入口;R1 验收含 Network 面板核验。浏览器里的演示数据与服务器上的她是两个世界,互不可见。
---
## 2. 第一批施工卡正文(R1:真实档案 + 真实文字对话,可直接交施工员)
> 即 HEL-738 切片 B1+S2 的合并最小版。目标一句话:**白栖知用普通入口创建唯一真实档案,和真实模型流式文字聊天,关掉浏览器再开(换设备也一样)还是同一位她。**
### 2.1 做(范围)
**A. 后端 `src/cybergril/api/`(拟议,FastAPI + uvicorn,单进程 systemd 服务)**
| 端点【拟议】 | 行为 | 错误语义 |
|---|---|---|
| `GET /api/health` | `{ok, version, epoch, companion_created, llm:{ok, model}}`;llm 探活用 `OpenAICompatLLM.health()`(GET /models,5s 超时,结果缓存 30s) | 永远 200(状态在体里) |
| `GET /api/companion` | 档案:名字、生辰+时辰、关系、称呼、八字只读白话(`compile_pillars().traits_text()` 输出);不含内部数值向量 | 404 未创建 |
| `POST /api/companion` | body `{name, birth, hour, relation, nickname?}`。服务端复验(规则与前端 `validateCreation` 一致:名字 1–20 字不含尖括号/控制符、日期合法且已满 18 岁、≥1900、relation ∈ {朋友,恋人} 且必选——不设默认值);排盘入 `bazi_core`(单行表);写档案 | 409 已有一位(单伴侣守卫);422 字段级中文错误;birth/derived **不提供任何修改端点**(八字只读守卫) |
| `PATCH /api/relation` | body `{relation}`;只改关系字段,其余不动;"再次确认"交互由前端承担 | 404 / 422 |
| `POST /api/chat` | body `{text, turn_id}` → **SSE**(`text/event-stream`)。事件复用前端 `ServerEvent` 形状:`chat.delta {epoch, turn_id, delta}`、`chat.final {epoch, turn_id, utterance{text, speech_tags}}`,另加 `chat.error {epoch, turn_id, phi, retryable}`。客户端断开(AbortController)即关闭上游 LLM 流 | 503 映射为 chat.error(phi:"她这会儿没接上话,稍后再试试",retryable:true) |
| `GET /api/messages?limit=200` | 重开/换设备恢复历史(升序返回) | — |
**B. 服务端状态与持久化**
- SQLite WAL:`/opt/cybergril-data/cybergril.db`【拟议路径】。复用 `Store`;**增量新增 `chat_message` 表**(id, ts, role, text, turn_id, status;`CREATE TABLE IF NOT EXISTS` 迁移,不动既有表)。`bazi_core` 存排盘;`meta` 表存 `epoch`(服务每次启动 +1 并持久)。
- chat 上下文组装(最小版):系统提示 = 她的名字 + 关系 + 称呼 + 八字白话 traits_text + 世界红线(不出戏、不假装有物理身体、不把"等了你很久"当武器)+ 最近 20 轮消息 + 用户文本。经 `ModelRouter` 的 `dialogue_main` 场景(high 档)。
- `config/models.yaml`【拟议改法】:三档先全指 `http://192.168.200.11:8731/v1` + `halogen-qwen3.8-flash-next`(api_key_env: null;high temperature 沿 0.85,standard 0.7,low 0.4,timeout 沿用);budget 沿用(200 万 token/天,0.7 警告,0.95 硬停)。
**C. 前端(真实模式)**
- 新增 `web/src/adapters/real.ts`【拟议】:`RealChatAdapter implements ChatAdapter`——`fetch POST /api/chat` + ReadableStream 解析 SSE,`signal` 直通 TurnGate 的 AbortController;`fail` 参数忽略。
- 普通入口路由按 1.4 节分岔;创建向导复用四步流程,去掉"本机演示"文案,第 2 步"底色"展示服务端真实排盘白话(替换现在的"示例底色"),确认步的三段动画对应真实排盘/建档。
- 关系修改接 `PATCH /api/relation`;管理页其余(城市/素材/备份)保持演示/占位态不变。
### 2.2 不做(R1 边界)
语音、生活循环、记忆检索、主动消息、天气、素材、备份、口型、视觉、多用户、鉴权体系(内网 + 单伴侣 409 守卫先行)、WebSocket(文字轮次 SSE 足够,语音批次再上 WS/RTC)。不以口型或生活引擎作为前置。
### 2.3 验收清单(总工复核用)
1. 全新浏览器普通入口 → 四步创建(关系不选无法下一步)→ 服务器 DB 出现 `bazi_core` + 档案;重复创建 → 409。
2. 发送文字:**真实流式回复**(非演示固定句"我听见了…");中途"取消本轮回复"立即停止;马上提新问题,旧流残句不串台。
3. 关浏览器重开 / 换另一台设备打开:同一位她、历史消息完整恢复(服务器为唯一事实源)。
4. `systemctl restart cybergril-api` → epoch +1,前端后续请求自动重同步,无旧事件混入。
5. LLM 不可达(验收时用 health 面板状态 + 一次真实 503 路径验证)→ φ 文案、草稿保留、重试可成功。
6. `?demo=1` 演示功能与现在完全一致;Network 面板确认演示模式零外发请求、真实模式从不读取 `cybergril.demo.v1`。
7. `curl :8310/api/health` 返回可读服务状态(含 llm.ok 与模型名)。
8. web build 通过;Python 78 测试不回归;新增端点有对应测试。
### 2.4 停止条件
若 halogen 模型对话质量明显不可用(连续乱码/系统性拒答/超时 >30s 成为常态):停止验收,记录请求与响应证据,回报总管,**不自行换模型、不开调参循环**。
### 2.5 部署与回滚(总工执行)
- 部署:`/opt/cybergril-api/releases/<ts>/` + `current` 符号链接;systemd `cybergril-api.service`;端口 **8310**(当前空闲,部署时复核);前端构建由同一服务以 StaticFiles 同源挂载(API 走 `/api` 前缀)——同源避免 CORS 与混合内容;SSE 需关闭代理缓冲(uvicorn 直连即可)。
- 回滚:`systemctl stop cybergril-api` + 符号链接切回上一 release + start;数据目录 `/opt/cybergril-data/` 永不随回滚删除。**8300 演示服务与浏览器演示档案全程不动。**
- 用户验收通过后,是否把 8300 切到新构建或以 8310 为正式入口,由总管与白栖知确认后执行。
---
## 3. 后续有限批次
依赖关系:`R1 → {R2, R3}`;`R2+R3 → R4`;`R5 依赖 R1(可并行于 R2–R4)`;`R6 依赖 R1`;`R7 依赖 R5 的 HTTPS 路径 + 用户明确授权`。
### R2 生活循环(最小版)+ 未分享裁剪 + 日子页接真
- **输入**:R1 档案与 DB;`character.yaml`(day_start_hour=7、每天 1–4 条、reach_out 参数)。
- **做**:API 进程内 asyncio 定时任务(或独立 worker 服务)每日生成 `life_event`(low 档生成、过投影 φ 成世界内陈述、记 salience、told=0);"她讲过"最小机制:对话中引用该事件即置 told=1;`GET /api/life?since=` 分页,**只返回 told=1 的事件,且响应前裁剪其一切关联引用**(回忆 lifeIds、消息 lifeIds 一并剥离,覆盖列表/详情/摘要/搜索路径——服务器侧强制,对应前端 `publicView` 的服务端版);日子页数据源换 real 标签。
- **不做**:记忆整合、主动消息、素材视频(仍静帧+文字)、生活事件编辑/删除。
- **验收**:连续 3 天每天 1–4 条;curl 逐端点核验未 told 事件及引用零泄漏;**网页关闭期间照常生成**(服务端循环,非浏览器假数据);LLM 故障时循环降级跳过或模板句,不伪造生活。
- **停止条件**:事件质量持续荒谬或预算异常 → 停循环回报。
### R3 记忆最小版 + 对话记忆
- **做**:每轮对话后写 `mem_episodic`(raw + low 档 gist 压缩 + affect 粗估);chat 上下文加 FTS top-k 检索("她记得");`reinforce`/`decay_accessibility` 定期跑批;回忆页 episodic 层接真。
- **不做**:向量检索(mem_vec 仍注释)、语义/关系层整合、identity promotion。
- **验收**:隔天提及前日话题,回复体现记忆;回忆页展示与对话一致;FTS 命中有日志可查。
- **停止条件**:检索噪声导致人设漂移 → 关检索开关回报(可配置降级为纯近轮上下文)。
### R4 主动消息 + 返回同步
- **做**:reach_out 规则引擎(间隔≥6h、8–23 点、salience≥0.6,参数照 `character.yaml`);`GET /api/sync?since=` 统一游标(life + messages + reach_out 增量);页内轻提醒 + "你不在的时候"列表;措辞过 φ(缺席可被提及、不可被武器化)。
- **不做**:手机推送、危机通道。
- **验收**:构造高显著已分享事件后,规则窗口内收到主动消息且来源可点回;离线消息语气符合红线。
### R5 语音连续对话(复用 /opt/voice-agent 底座)
- **前置**:HTTPS 方案落地(见第 5 节)+ 用户选音色(见第 4 节 D1)。
- **做**:新增一个 CyberGril 专属 agent worker(参照现有 agent.py 组装:FunASR STT + halogen LLM + CosyVoice TTS + silero VAD),system prompt 换成她的人格 + R1/R3 的档案与记忆上下文;前端聊天面板语音模式接 LiveKit client(HTTPS 页面内);字幕走 asr partial;打断沿用 VAD(0.55s 判定 + 清流);结束即停麦释放;文字模式全程并存。
- **不做**:口型(MuseTalk 8010 试验件为独立可替换线,不阻塞)、手机推送、副语言标签。
- **已知限制内置缓解**:TTS 首包 1.7–2.5s 期间显示"在想"+字幕先行;FunASR 双 final 出现频率记录,影响体验时切 mode=online。
- **验收**:连续对话 ≥3 轮;用户开口她 ~1s 内停声;结束即停收音;打断后无旧音频残留。
- **停止条件**:打断常态 >2s 或首包常态 >5s → 记录参数与日志回报总管,**不重开旧无限调参流程**。
### R6 城市/天气 + 素材包 + 真备份
- **做**:城市存档 + `GET /api/env`(时段/天气);天气源按 D2 决定(推荐 open-meteo,先实测 .12 出网连通);素材包 manifest v1 导入校验(结构/时长/命名),只换 `appearance_id`,身份卡不变;备份:`POST /api/backup/export`(zip:DB+设定+素材引用)与 restore(上传→版本兼容+**八字指纹比对=同一位她**→恢复前自动先导出当前→确认执行);systemd timer 每日自动备份(7 份滚动,拟议)。
- **验收**:F7/F8/F9 全走通;恢复后记忆与关系保留、身份唯一;换素材档案不变;备份空间受控。
### R7 视觉观察(独立决策,默认关)
- **前置**:R5 的 HTTPS 路径已跑通 + 白栖知明确授权摄像头 + 4080 使用决策(D4)。
- **做**:观察摘要带时效(前端 30s 过期演示逻辑的真实版);关闭显示"窗户关上了";隐私边界写入服务端。
- **验收**:开关生效、摘要时效、关闭后无任何采集。
---
## 4. 必须白栖知拍板的问题(均已给推荐答案,其余技术取舍由小队自行决定不升级)
| # | 问题 | 影响批次 | 推荐 |
|---|---|---|---|
| D1 | 她的声音用哪支音色 | R5 前 | 在 8090 音色管理页试听现有中文女声;都不满意就录一段 5–20s 中文参考音频做零样本克隆(页面已支持,这是"像不像"的关键) |
| D2 | 天气数据源 | R6 前 | open-meteo(免费、无 key);若 .12 出网不可达则暂用固定显示,不买付费接口 |
| D3 | 手机/电脑麦克风的可信访问方式 | R5 前 | 自签证书 + 每台设备首次"高级→继续"信任一次(8090 页已验证此路可行);不建内网 CA、不买域名公网证书、不改防火墙 |
| D4 | 是否开启视觉观察(摄像头"一扇窗"),及 4080 小视觉服务的使用方式 | R7 前 | 默认关闭;确认开启后再单独决策时段/帧率 |
---
## 5. 可信访问(HTTPS)核查结论与最少影响方案
- 事实:浏览器麦克风/摄像头要求安全上下文;**8090 通话页已用自签证书在局域网内验证过麦克风可用**(浏览器手动信任一次)。
- 推荐:R1 纯文字阶段无需 HTTPS;R5 前为正式入口(8310 或正式端口)按同一流程签一张覆盖 `192.168.200.12` 的自签证书即可。**不改证书体系、不动防火墙、不影响任何现有服务**;若未来要消除手动信任,再评估内网 CA(首版不做)。
---
## 6. 风险表
| 风险 | 影响 | 缓解 |
|---|---|---|
| halogen 单模型、无 SLA、质量未实测 | R1 回复质量不达标 | R1 验收实测 + 明确停止条件;路由/预算框架已支持随时换模型(只改 yaml) |
| .11:8731 无鉴权、内网明文 | 同网段任何人可调用 | 保持内网边界;产品侧不内置 key;后续可加访问口令(建议项,不阻塞) |
| 单用户 API 无鉴权,LAN 内他人可创建/聊天 | 档案被抢先创建 | 单伴侣 409 守卫 + 内网边界;访问口令列为 R2 建议项 |
| .12 GPU 显存共享(CosyVoice ~5.6G + musetalk 试验 + 未来视觉) | R5/R7 显存不足 | R5/R7 开工前 `nvidia-smi` 核算;口型试验件可停(有启停记录) |
| FunASR 双 final、TTS 首包 1.7–2.5s | 语音体验打折 | 已知限制,R5 方案内置缓解;vLLM TTS 后端列为后续优化 |
| SSE 被代理缓冲 | 流式变一次性 | uvicorn 直连;如加反代显式关缓冲 |
| epoch/游标边界设计错误 | 状态串台 | TurnGate 已守客户端;R1 验收专测重启/换设备场景 |
| 演示与真实模式混淆 | 用户误以为已接通 | 存储 key 分离、无自动上传、普通入口显式状态、演示角标保留 |
| 生活循环用假数据冒充 | 违反"不伪装运行"红线 | R2 验收明确服务端生成证据(DB 记录 + LLM 调用日志) |
---
## 7. 测试部署与回滚总安排
- 每批均走 HEL-749 已验证的模式:`/opt/cybergril-*/releases/<ts> + current` 符号链接 + 独立 systemd 服务 + 独立端口 + 数据目录与代码分离;8300 演示站在白栖知明确验收前不动。
- 回滚通式:`systemctl stop <svc> && ln -sfn releases/<prev> current && systemctl start <svc>`;数据目录永不删除;Git 侧对应回退 + 重新部署。
- 各批验收含"服务状态可验证"(/api/health + 日志位置 + 启停命令写入部署记录)。
---
*HEL-778 · 总工 · 2026-10-22 · 本轮只读核查 + 方案;未写产品代码、未改 .11/.12 任何服务与配置、未访问 4080、未输出凭据、未创建施工卡。*
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
## 交流静态视觉基准的更新(2026-10-23)
视觉总监在 HEL-799 整理,供总管入库核验。本记录只写明用户已经说过的话及其适用范围,不改动任何已有来源文件,也不改写 `current.md`、`baseline.json` 的现行条文。
### 用户原话(主卡 HEL-797,作者白栖知 `e5c6c938-db48-4ff9-954f-4fb8b063a132`)
原文逐字保存在同目录 `comment-evidence-shared-space-20261023.json`。
| 评论 | 时间(UTC) | 原话 | 回应的问题 |
| --- | --- | --- | --- |
| `01a16650-0355-72c4-b291-f1b4494cde91` | 2026-10-23 01:11 | 主要是A和B,但是之前没注意到C的问题,你一说倒是有点 | 总管问最妨碍沉浸的是哪种感觉:A 空间太空泛;B 和她有距离,像窗口里的照片;C 操作出戏 |
| `01a16654-5fc5-74be-a704-99b76b48b0b1` | 01:16 | 对就是B | 总管问:她即使能眨眼、抬头、回应,但仍在人物窗口里,是否主要的不满意还在 |
| `01a16655-fe53-73a5-905f-4c9e71a1ed30` | 01:17 | 这个我不太能想象得到,我和她待在同一空间是什么概念 | 总管问更接近「同一个空间」还是「通向她的世界」 |
| `01a16659-fdfa-72ce-a4a5-801e0762c0a9` | 01:22 | 这个能否现在就做个样图让我看一下 | 总管用第一人称镜头解释「同一空间」后 |
| `01a1668f-5810-7524-b72f-33574a32c7d6` | 02:20 | 静态效果我认可 | 总管交付 HEL-798 样图(评论 `01a16663-3ad2-727c-8888-e116eca13116`),问是否更像「自己坐在她面前」 |
问题原文由总管撰写,在主卡根线程 `01a1664d-f242-71fa-a4ba-295b6c347478` 内,不属于用户原话。
### 被认可的图
- 文件:`hel-798-face-to-face-moon.png`,1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`。
- 主卡附件 `01a16663-3a5a-76eb-ac3c-1ef93165617c`(随交付评论 `01a16663-3ad2-727c-8888-e116eca13116`);HEL-799 卡附件 `01a16691-b0f0-73a9-aae4-9aba2146899c`,两者字节相同。
- 同一评论另附的标注图 `hel-798-face-to-face-annotated.png` 和新旧对照图 `hel-798-compare-pr8.png` 是说明材料。
- 作者:视觉总监(HEL-798)。源码在 `prototypes/hel-799/reference/hel-798-static/`。
### 旧要求、新认可与适用范围
| | 内容 |
| --- | --- |
| 旧(VIS-011,v1.8 仍有效) | 「人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。」来源 `01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。 |
| 新认可 | **交流状态、靛蓝月光、电脑 1600×1000 的静态画面**:不再用独立人物框;她身边的环境连续铺到屏幕四边;前景有你这一侧的圆桌沿、茶杯和烛灯,中间是她,远处是雨夜窗景;当前一两句对话落在近处、视线下方,底部保留输入和此刻/交谈/日子/设置导航。 |
| 适用范围 | 仅以上交流状态静图。用户认可的是「静态效果」。 |
| 不在范围内(仍待验收) | 相处(平时)状态的构图;进入与退出交流的过渡和整体动态;蓝紫雾光主题;手机版;真实人物动态、眨眼、口型与语音;生活、回忆、创建、管理等完整功能的最终页面。 |
| 不能推出的结论 | 不能据此永久取消 VIS-011 在全部状态中的原规则;不能把完整前端锁定为全屏卧室照片或视频(CG-031 继续有效);不能视为整套界面或任何功能已经验收。 |
### 对其他要求的影响
- VIS-001、VIS-002、VIS-006、VIS-013:这张图是「两人的数字空间」「空间深度」「交流以人物为主、当前一两句」的一种获认可呈现。
- CG-004「交谈时允许自然切近」:图只证明切近后的静态画面,切近过程要在 HEL-799 动态小样中另行验收。
- 其余已确认要求不变。
### 入库建议(由总管决定,视觉总监未改动正式基准)
1. 将 `comment-evidence-shared-space-20261023.json` 加入 `docs/requirements/sources/`,并在 `source-manifest.json` 追加它的 SHA256;历史来源不动。
2. 如决定升版为 v1.9:在 `changes.json` / `changes.md` 追加一条,`approval_comment_id` 为 `01a1668f-5810-7524-b72f-33574a32c7d6`,`affected_ids` 至少含 `VIS-011`,摘要写「交流状态静态视觉基准更新为 HEL-798 共同空间样图」,影响写明上表的适用范围与不在范围内;VIS-011 的结论原文保留,只在边界中补一句「交流状态静图已按上述评论更新,平时状态、过渡、雾光、手机待验收」,`current.md` 与 `baseline.json` 同步;`coverage.json` 的版本跟着改。
3. 入库后运行 `python3 scripts/check_requirements.py --base-ref origin/main` 及 `--handoff prototypes/hel-799/handoff-hel-799.json`。本运行环境没有 git 和 Python,这两项都未执行。
@@ -0,0 +1,48 @@
本稿汇总 HEL-697 主卡本轮逐项确认的视觉方向,供确认重做范围。确认后交视觉总监用于重新出样图;本稿不代表页面样图已验收。
## 产品与范围
CyberGril 是有自己生活的单伴侣。完整前端覆盖相处、文字和语音交流、她的生活与共同回忆、创建与管理。人物视频是独立展示模块,整体界面需要自己的空间、氛围与交互体系。
既有产品需求继续有效;本轮只调整视觉方向和呈现方式,不重定业务规则。HEL-738 的页面流程和实现边界可继续作为输入;HEL-737 的私人手账视觉初稿未获认可,需要按本稿重做。
## 已确认的视觉决定
1. 整体像只属于两人的数字空间,人物、交流、回忆融在其中。
2. 气质静谧梦幻、安静亲密,有空间深度和柔和微光。
3. 支持多主题。主题切换同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。
4. 首轮主题为「靛蓝月光」与「蓝紫雾光」。墨绿萤光与浅雾晨光未选入首轮;最终主题数量和默认主题尚未确定。
5. 首页空间优先,只留少量当前状态与入口,其余内容按需展开。
6. 背景由光雾、景深和若隐若现的景物融合构成。具体景物由设计师提出,并在样图中验证。
7. 平时能看到舒缓流动与景物微动;操作时有明显的景深变化和空间过渡。
8. 主要内容采用透光浮层,背后保留空间光影;文字区域保证清晰可读。
9. 文字现代轻盈、字形简洁、标题克制,正文易读。字体和字号由样图验证。
10. 强调色使用少量微光,集中于关键操作和选中状态;每套主题有自己的强调色。
11. 人物平时作为空间中的独立视窗,能看清她正在做什么;交流时自然放大。具体位置、比例与形状由样图验证。
12. 使用底部小巧透光的悬浮导航,图标配简短文字,选中时亮起微光。
13. 交流展开后以人物为主,只呈现当前一两句对话,完整聊天记录按需展开;文字输入和语音操作均保留。
## 建议的重做交付顺序(待本次确认)
先验证视觉最关键的部分,再展开整套设计:
第一步:提交同一布局下两套主题的电脑首页与交流展开样图,共四张;补手机首页与交流展开样图,共两张。样图必须展示完整界面、真实可读文字与操作入口,能比较人物分量、空间留白、浮层材质和两套主题的区别。
同时提交一份轻量动效原型,展示背景的舒缓变化、交流开合和主题切换。模拟数据与未接通能力须说明,不用真实口型或后台接入作为交付前置。
第二步:首轮样图经白栖知认可后,沿定稿方向补齐生活与回忆、创建与管理、完整聊天记录等页面及其状态、响应式与可施工规范。继续覆盖完整产品,不停留在人物展示页。
正式产品施工另在完整设计确认后启动。
## 样图验收要点
- 第一眼能感到静谧、梦幻、有空间深度;布局、文字与入口符合本稿已确认方向。
- 两主题沿用相同布局,背景、光影和材质有可见差异;两套都保持清晰可读。
- 首页能看到她的生活,仍有充足空间留白;展开交流后人物成为重心。
- 默认与展开状态之间有连续的空间关系,关闭后回到原来的位置。
- 电脑和手机的关键入口容易找到,人物和文字不会互相遮挡。
- 动效有实际可看的原型;具体速度、曲线、效果强度由设计师提出,允许在样图与原型验收时调整。
## 本轮边界
只重做设计样图与设计原型;不修改正式产品代码、不部署、不合并分支、不更改现有服务,也不重新启动嘴型研究。现有功能流程、已确认的产品规则和服务保持原有边界。
@@ -0,0 +1,26 @@
# 任务需求交接模板
- 基准版本:从当前入口读取,当前为1.7;记录读取时的仓库提交。
- 完整需求阅读:`docs/requirements/README.md`、`current.md`、`coverage.md` 与相关原始来源。
- 本次关联要求:编号、原意、出处;不能只写“参见需求”。
- 交付与验收:具体行为、可观察结果、实际验证方法。
- 未完成项及承接:本次暂不做的要求仍有效,说明下一批或卡片;未知写明,不能假装已完成。
- 需求变更:无;若有,附白栖知明确原话、评论编号及影响,不以方案调整代替。
- 审核证据:功能验证、电脑/手机画面、必要动态过程、未验证设备和场景。
结构化交接JSON必填键:
```json
{
"baseline_version": "1.7",
"read_commit": "实际读到的40位仓库提交",
"requirement_ids": ["VIS-013", "CG-018"],
"deliverable": "本卡实际交付",
"acceptance": ["可观察的验收项"],
"deferred": ["本卡不做但仍须后续承接的内容"],
"requirement_changes": [],
"verification": ["实际证据或清楚写明待验证"]
}
```
交付前运行 `python3 scripts/check_requirements.py --handoff <文件>`。填写占位文字不算真正交接;审核人须核对原话和实际证据。
+275
View File
@@ -0,0 +1,275 @@
[
{
"id": "01a1548a-1744-780f-9972-48b0fa078eb9",
"identifier": "HEL-698",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a1548a-55be-77eb-8b01-71ed0a37424d",
"identifier": "HEL-699",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a1548a-55f4-726d-8624-154ce928f319",
"identifier": "HEL-700",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a1548a-5631-7321-94d1-a88053ffcda9",
"identifier": "HEL-701",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a156e0-2ac3-7274-a9d2-d39f10d19581",
"identifier": "HEL-702",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a156e0-2f1d-7c55-b2e5-b169b8f6f00d",
"identifier": "HEL-703",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a15700-8422-785a-93c3-fff3351a2aa1",
"identifier": "HEL-706",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15700-add2-7bf6-a488-0e64062139f8",
"identifier": "HEL-707",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a156e0-2f57-7286-8721-7a9371ff3474",
"identifier": "HEL-704",
"status": "backlog",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a156e0-2fa0-7589-a62e-b9aa985fba95",
"identifier": "HEL-705",
"status": "backlog",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a157b9-33d0-7324-98f3-44c23ecaa171",
"identifier": "HEL-708",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a157b9-3839-733a-b363-82d7716bc7dc",
"identifier": "HEL-709",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a15830-293e-79c7-a284-20f0c526f0dd",
"identifier": "HEL-711",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15851-3750-762e-a0c3-3de0d4d50f34",
"identifier": "HEL-712",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15830-1c80-7326-8951-2176f08ba180",
"identifier": "HEL-710",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15863-1869-725f-8dae-4c8170e4efe5",
"identifier": "HEL-713",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a159a1-030a-793b-8c30-4ef799ee9941",
"identifier": "HEL-715",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a159a0-f64c-780b-8bf4-85fec219dcf0",
"identifier": "HEL-714",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a22-6fc6-7c94-b662-40599cbf73a9",
"identifier": "HEL-717",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a43-e2c2-76c7-8e4b-18330ad0fa3e",
"identifier": "HEL-718",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a57-0de5-7eaa-aecc-bdc21efb5f56",
"identifier": "HEL-719",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a69-07f6-786d-a51e-d2057b7a9a3a",
"identifier": "HEL-720",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a7f-3292-7d81-8de1-0d9628ca6c1b",
"identifier": "HEL-721",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a97-342a-7e91-b65e-8e4b7b415765",
"identifier": "HEL-722",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15aa7-f766-71a8-ad17-7da10dcd4bf7",
"identifier": "HEL-723",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15abf-566c-770a-a0a4-c1db4941c12b",
"identifier": "HEL-724",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15ad2-7eae-7b48-b4e6-e11597d4fe3e",
"identifier": "HEL-725",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15e9b-e35f-72f3-926f-23997c8e4e42",
"identifier": "HEL-737",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15e9b-e3c5-7dfe-98b8-5acf067075cb",
"identifier": "HEL-738",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15f0b-6915-7d4b-bd2f-788ff1ebd063",
"identifier": "HEL-744",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15f21-25c6-7e95-a55f-bd24a3128838",
"identifier": "HEL-746",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15f44-ac39-7b90-a4af-328fbdbfd973",
"identifier": "HEL-748",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15f45-40cf-7479-a9af-fb4199b82928",
"identifier": "HEL-749",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a161a9-13b3-7af2-8694-a28ced583f85",
"identifier": "HEL-778",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a1629e-4eff-780f-94b1-d126027b3136",
"identifier": "HEL-780",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a1629e-f348-7aff-80e5-ef64b5067501",
"identifier": "HEL-781",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a162f1-0942-7b0d-b1ba-af821ddc1594",
"identifier": "HEL-783",
"status": "in_review",
"assignee_id": "bbcc1179-3a55-48e7-a72e-c84cf79f41cb",
"verified": true
},
{
"id": "01a162f2-3fc7-7e01-9744-ef54cae2591e",
"identifier": "HEL-784",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15e9b-e42b-7c92-a925-b84802c25ab3",
"identifier": "HEL-739",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
}
]
+37
View File
@@ -0,0 +1,37 @@
[
{
"case": "valid",
"passed": true,
"observed": []
},
{
"case": "missing-coverage",
"passed": true,
"observed": [
"对照表遗漏、重复或包含不存在的要求"
]
},
{
"case": "changed-source",
"passed": true,
"observed": [
"历史来源已改变:visual-confirmation-v2.md"
]
},
{
"case": "unversioned-change",
"passed": true,
"observed": [
"需求有变但版本未更新",
"需求有变但没有追加用户授权记录",
"新需求变更不能重复使用旧批准评论"
]
},
{
"case": "stale-handoff",
"passed": true,
"observed": [
"交接使用过期基准"
]
}
]
+42
View File
@@ -0,0 +1,42 @@
# HEL-799 原稿保全记录(机械入库,总工 HEL-811)
本目录是视觉总监 HEL-799 v2 交付原件的逐字节保全,由总工于 2026-10-23 机械入库;未改动任何作者文件内容。
## 来源
- 交付评论:HEL-799(01a16691-b114-7880-bf13-51b6201f6871)根评论 01a1679c-4178-7d65-8867-cd2716d91a13,作者为视觉总监(agent `d6da7655-1462-43b7-9b9c-227dcdf21bba`)。
- 源码包附件 `01a1679a-4bfd-7651-bf62-176c025894d5`(HEL-799-v2-源码与离线HTML.zip,5,856,837 字节,SHA256 `ce302111150597793ef942d343583a3152e7b99489587a230d7fafc6c836bae7`)。zip 条目为 Windows 反斜杠分隔,入库前已规范为正斜杠;除路径分隔外未做任何内容改动。
- `web/public/assets/her-room.jpg`、`her-talk.jpg` 与仓库现有文件 SHA256 逐一相同(51ca450a…、0b9e91b5…),正式 web 资产未改动、未覆盖。
- 离线 HTML 附件 `01a1679a-25d1-74d9-aac8-80102716e895` 与包内 `index.html` 字节相同(SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e)。
## 独立重建核验(HEL-811)
总工用独立实现的构建脚本(Python 复刻 build.ps1 的模板替换与 base64 内嵌规则,输入为本仓库 `web/public/assets/` 两张获批静帧)重建 `index.html`,结果 662,233 字节,SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e,与交付 HTML 逐字节一致。
原中断运行(01a16691-b121-725d-a2e3-8d1f0ce57c02)的完整保全包为附件 `01a1679c-3ba9-7b62-a0a5-e828cb6e5d41`(26 个文件:代码、handoff、records、verification.json、两张总览图与 hel-798 静图参考),未随本目录入库;其与本目录续作差异见 HEL-811 审核记录。
## 文件清单与 SHA256
- `prototypes/hel-799/README.md` `8269d688ffbda2dd34608778204f5074079c254dbf69072e7a2fe78e793f3093`
- `prototypes/hel-799/build.ps1` `8a5d8cba2c60eb0b4999a482635c317cf8f1df54bb295eb5fca4b5f9464b43a1`
- `prototypes/hel-799/handoff-hel-799.json` `812d4eab8f1b4f57b8378ff0a1f810e8b2dce5342cbfc686cf430c03bd2e1452`
- `prototypes/hel-799/index.html` `826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e`
- `prototypes/hel-799/records/comment-evidence-shared-space-20261023.json` `e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d`
- `prototypes/hel-799/records/shared-space-static-acceptance-20261023.md` `9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9`
- `prototypes/hel-799/reference/hel-798-static/assets/her-room.jpg` `51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a`
- `prototypes/hel-799/reference/hel-798-static/assets/her-talk.jpg` `0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33`
- `prototypes/hel-799/reference/hel-798-static/hel-798-compare-pr8.png` `6ec7b3d454af4404eafb0b0fa86c17baa10e6aa622456c2ef8689d9011bec764`
- `prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-annotated.png` `8d5092b722d653d0217ed758635d0be61b70a5ebdc79da449a280a8bed43d52d`
- `prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-moon.png` `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`
- `prototypes/hel-799/reference/hel-798-static/index.html` `8ee3fba1e62409f06d0ee8b42cee735a1d5eb64965ea3a027b997352bcce3d7c`
- `prototypes/hel-799/src/ambient.js` `6a9b5edf204364a31e855fa3554643715b91d1b2ef88175a0bc2be2cb0792e27`
- `prototypes/hel-799/src/room.css` `a3daf8ac189969e8dab1de8f8139bf6fc2426b5c15d14d332f40532b3fe50515`
- `prototypes/hel-799/src/scene.js` `d51302195996d6ec75c2044dfa01ec81ad2f17b11676f143ede965f0d282e2f6`
- `prototypes/hel-799/src/shell.html` `53b468896e6b8d722fc8ded1d7a3f83b8f8e6ab0b027d7d0b0a2ba4bec70b292`
- `prototypes/hel-799/src/talk.js` `55af813358f83c5e0f99948056489b4387138c1464c51bafaa1359ec6072f73a`
- `prototypes/hel-799/tools/cdp.ps1` `e7b872ee6915bbfd4c6bb528c7b5593f25e60bde0bc66c79f3c63f0efddd22fb`
- `prototypes/hel-799/tools/contact.ps1` `f91761ec80dc44d1746238667da47df291f43f7bb14d6137002d73777612fd0c`
- `prototypes/hel-799/tools/record.ps1` `b6ed8344b125e739664c2a31b735d18238577f5d591ec9ad3e011bb20be99d37`
- `prototypes/hel-799/tools/snap.ps1` `ecb8dc017bfcb3883942ac7b01e160037c8261749ddc5759b4048d85ad6020e6`
- `prototypes/hel-799/tools/strip.ps1` `fe37c21270feeb64f810aef8f6aead21bdc3b79fc57ed99cff8435e35896817d`
- `prototypes/hel-799/tools/verify.ps1` `796739f6d079cc500d6221a6155cc1fb4545f246c3cb753007b8d4b0b969d557`
+161
View File
@@ -0,0 +1,161 @@
# 对坐 · HEL-799 共同空间动态小样
视觉总监亲自设计并编写。直接用浏览器打开 `index.html` 即可:两张参考静帧、样式和脚本都已内嵌,不用安装,也不联网。这是独立小样,没有接入产品入口,也不替换正式界面。
基准:白栖知在主卡 HEL-797 评论 `01a1668f-5810-7524-b72f-33574a32c7d6` 中认可的 HEL-798 交流静图 `hel-798-face-to-face-moon.png`(1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`)。原图及其 HTML/Canvas 源码原样保存在 `reference/hel-798-static/`。
人物仍是两张获批参考静帧,界面上标为「人物为参考静帧」。走近她的过程是镜头在两张照片之间对齐她的脸后推近,不是人物视频:她不会眨眼、呼吸、抬头或说话,也没有口型。文字、生活、回忆和天气都是虚构示例。小样没有连接模型、麦克风、摄像头、生活、天气或备份服务。
## 一句话方向
雨夜里,你坐在自己的小桌旁,她在窗边那张沙发上做她自己的事。想说话时,房间向你靠近,她就坐在桌子对面;说完,房间舒展开,她回到自己的事里。
> 相处画面用的仍是旧参考静帧,静帧里她在看书——这只示意你们在同一间房里的位置。用户已否定以阅读作为默认日常(主卡 01a166aa),按时段、天气、穿搭准备的新日常素材尚未生成;小样里的界面文字已不再写“看书”。
## 怎样试
1. **相处(此刻)**:先停留十几秒,不要操作。窗外的雨在落,城市的灯一盏盏亮灭,水珠沿玻璃滑下,月光下有薄云掠过。她身边和你桌上的烛光在跳,你的茶在冒热气,灯下有细小的光尘。电脑上移动鼠标,远处的房间和你面前的桌子会朝相反方向轻微移动。
2. **走近她**:点她本人,或点「和她说话」。镜头从房间推到桌前:她从远处的沙发一路变近,近景从她脸部周围柔和地展开,直到成为获认可的交流画面;你面前的桌沿、茶杯和烛灯一直都在。全程是同一个人、同一身衣服、同一间房。
3. **退回原处**:点右上「结束交谈」或底部「此刻」,镜头沿原路拉回。中途随时可以反向点击,镜头会从当前位置转向,不会跳回起点或闪黑。键盘 Esc 立刻回到相处画面(不播放过渡)。
4. **交流**:保留 PR#8 已验证的阅读方式。回复默认自动一句接一句呈现,一直读到最后一句,中间不用点击。屏幕上只保留上一句(变淡、与身份同行)和当前一句。计数下的细线表示这句还会停留多久,鼠标停在文字上会先停住计时。「暂停」后可用 ‹ › 或键盘 ← → 回看,会出现「回到读到的地方 · 后面还有 N 句」;「全文」看整段;输入框左侧的按钮打开完整记录。发送新消息会自动恢复呈现,「停止回复」保留已出现的内容并标为未完成。
5. **完整记录、全文**:从左侧展开,她留在桌对面不动;关闭后回到刚才那一句。**日子、设置**:房间退回远景并暗下来,浮层从下方升起;生活条目可进入详情,再「看那时说的话」。设置里的关系、城市、外观、备份、语音与视觉观察、空间与显示都能打开对应的演示或说明。
6. **月光 ⇄ 雾光**:右上切换。布局和操作位置不变,窗外天色、房间色调、云或雾、雨和灯光颜色、桌面反光、茶杯与浮层材质约 1 秒内过渡。月光有月亮和冷蓝夜色;雾光没有月亮,窗外有缓慢折叠的紫粉雾带,整体偏紫。
7. **手机**:同一文件。相处时她在上方的窗边,文字和入口在下方;交流时她的脸在上部,对话、输入框和导航在下方,彼此不遮挡。键盘弹出(可视高度变小)时先收上一句,正在输入时导航让开。
8. 右上「独立小样 · 演示数据」里有长回复、连续两段话、生成中与停止、初次创建与长名字等试用片段。
可用的地址参数(评审和录屏用):`?theme=fog`、`?mode=talk`、`?scene=long`、`?tier=0|1|2`;候选与导出:`?frame=media`(手机 4:5 容器候选)、`?clean=1`(无界面)、`&fore=0`(去前景与暗角)、`&fit=height`(16:9 母版取景)。
## 2026-10-23 续作:恢复、候选取景与母版
原运行 `01a16691-b121-725d-a2e3-8d1f0ce57c02` 因会话额度中断,未交付。本轮由同一作者在原配置下恢复:先把原目录 `hel-799-8d1f0ce57c02/workdir/proto/prototypes/hel-799/` 整体打包保全(`hel-799-original-run-8d1f0ce57c02.zip`,随本卡附件交付,未删改原目录),再在副本上续作。原稿的 CSS 夜色层顺序修正保留。`evidence/` 根目录与 `evidence/media-candidate/` 下的截图、录屏和 `verification.json` 由本轮构建重新生成;上一构建(仍写“看书”文案)的总览图、过渡帧和录屏移到 `evidence/previous-build-20261022/`,只作历史对照。
### 本轮改动
1. **去掉“看书”作为日常的文字**:顶部状态、此刻标题、演示对话里的“这一页我念给你听”“读到……”改成不指向阅读的说法;参考说明改为「人物为旧参考静帧 · 不代表新日常」。交流态示例句因此与获认可静图的示例文字不同(静图只认可画面,不认可演示文案)。
2. **手机 4:5 媒体容器候选**(`?frame=media`,默认关闭):按 HEL-802 v2 §2.3 实现,见下表。桌面不受影响。
3. **无界面母版导出**(`?clean=1`,可加 `fore=0`、`fit=height`),见“母版与前景分工”。
### 手机 4:5 媒体容器:实际 DOM 与 HEL-802 v2 公式对照
规则:容器高 `H = min(1.25·宽, max(宽, 可视高 − 240), 可视高 − 56)`(后一项即键盘态的“可视高减输入条”),cover,底部 12% 渐隐到页面底色,容器本身不随镜头缩放,手机不画前景桌杯。数据来自无头 Edge 实测(`evidence/media-candidate/`)。
| 视口 | 容器 公式 → 实测 | 她的脸中心 公式 → 实测 | 对话/输入实测位置 | 差异与原因 |
| --- | --- | --- | --- | --- |
| 390×844 | 390×487.5 → 390×487.5 | (178,146) → (170,157) | 当前句 619–698,输入 736–784,导航 790–838;对话区从 566 起,在容器下方 | 公式按 16:9 源(s=0.4514,可见 45%);小样用的是 16:10 获认可构图(s=0.4875,可见 50%),且该图脸在 y=0.315 而非 0.30,故偏 8/11px。相处态“此刻”文字从 462 起,压进容器底部渐隐带 26px,未压到人物 |
| 320×568 | 320×328 → 320×328 | (148,98) → (143,105) | 当前句 359–432,输入 466–514,导航 518–562 | 同上;此高度触发紧凑排版(隐藏上一句),对话与容器不重叠,但整体偏挤,与 HEL-802 预判一致 |
| 390×430 键盘 | 390×374 → 390×374 | (182,112) → (176,120) | 最近回复 294–342 盖在容器底部 80px(公式 88px),输入 376–424,导航让开 | 回复只压在开衫上,脸与下巴不被遮 |
| 1600×1000 | 不启用 | (856,321) 与默认一致 | — | 候选只作用于 ≤720px |
**用现有静帧放进候选容器时暴露的问题(须用新素材解决,不能靠界面遮):** 获认可构图底部约 20%(y≥0.80)原本被页面绘制的桌沿挡住,里面是旧素材的书和深色居家裤。手机候选容器不画前景、整高可见,这一块落在容器底部(约 390–463px),一部分在渐隐带外仍看得见;推近途中两张静帧交叉时也能看到远景静帧的书和腿。HEL-802 v2 已要求近景 CT 的 y≥0.78 只出现衣服和沙发、不出现手部动作,新母版按此生成即可消除;现阶段如实保留,不另加遮挡。
**仍待验证**:真实视频放入容器后的推近是否保持人物中线(HEL-802 §2.4 的 ±0.03 门槛)、真机键盘弹出时容器 240ms 收缩的观感、渐隐带与真实视频暗部的衔接。用户未认可手机候选外观,默认仍是原整屏版本。
### 母版与前景分工
`masters/` 下为无界面母版(月光与雾光两套):
| 文件 | 内容 | 用途 |
| --- | --- | --- |
| `talk-*-1600x1000-with-foreground.png` | 获认可构图:照片+夜色叠层+月亮+窗内雨+灯光+页面桌沿/茶杯/近处烛灯+暗角,去掉全部界面 | 与获认可静图对照、给生成工具看“目标氛围”,**不作为视频首尾帧** |
| `talk-*-1600x1000-plate.png` | 同上但去掉桌沿、茶杯、近处烛灯和暗角 | 16:10 路线的视频底板参考 |
| `talk-*-1920x1080-plate-16x9-candidate.png` | 按高度铺满、左右各外延,原构图约占中间 90%(因原图右侧只多 74px,构图整体右移约 16px:x16:9 ≈ 0.058 + 0.9·x16:10);脸中心实测 (0.540, 0.317),HEL-802 v2 写 (0.54, 0.30) | 16:9 路线候选,**新取景待核对**,未裁脸、未拉伸 |
| `room-moon-1600x1000-plate-reference-only.png` | 相处远景静帧 | 只作空间参考;相处远景未获认可,不是必备母版 |
分工(与 HEL-802 v2 §2.6 一致,均为候选):
- **烘焙进视频**:她、房间、窗外城市、窗内的雨、月亮(月光组)、她那一侧的落地灯和花旁小烛光、时段光线。
- **页面另绘**:你这一侧的桌沿、茶杯与热气、近处烛灯、暗角、主题浮层。页面现有的窗内雨和月亮在接入真实视频后关闭,以视频里的天气为准,避免重复。
- 前景只在镜头静止的近景交谈态淡入,运镜时不出现;手机候选不画前景。
**这些母版都不能直接拿去当最终首尾帧**:人物仍是旧参考素材(黄昏调原图经页面调成夜色、手里有书、穿居家裤),而 HEL-802 v2 的夜间造型是奶白开衫配百褶短裙和过膝袜、y≥0.78 不出现手。它们的作用是钉住构图、光色和脸的位置;正式 S/T 母版需按 HEL-802 v2 重新生成。原始 `her-talk.jpg`(1600×900 黄昏无字)仍只适合做人物稳定性试片。
## 与 HEL-798 静图、PR#8 的关系
| 项目 | 做法 |
| --- | --- |
| 交流状态 | 1600×1000 下与获认可静图使用同一张人物素材、同一组夜色叠层(月亮、雨、光束、灯光)和同一套桌沿、茶杯、烛灯绘制代码,对照见 `evidence/compare-approved-vs-talk.png`。差别:雨、灯、热气、光尘现在会动;文字区多了一行很轻的阅读控制(暂停、回看、计数、全文),所以当前句比静图高约 30px;短句会并成一行,与静图一致。 |
| 相处状态(新增,待验收) | 同一间房的远景(第二张获批静帧),按静图的夜色重新调色,窗外同样下雨、有月亮。前景仍是你这一侧的桌沿、茶杯和烛灯,只是略低一些。左侧放「此刻」的状态和入口。她不在任何框里,房间铺满屏幕四边。 |
| 过渡(新增,待验收) | 不是两张图硬切,也不是把人物框放大:两张静帧按她的脸对齐(脸的大小差约 3.04 倍),镜头在两者之间连续推近,近景从她脸部周围以柔和边缘展开,画面边缘一起变柔。月亮作为远处的物体,比房间移动得少,在两扇窗之间交接。 |
| PR#8 保留的部分 | 两套主题 token、阅读节奏和所有阅读控制、身份标记、记录与全文、未分享不显示、初次创建与起名、语音界面演示、键盘即时规则(HEL-795)、减少动效和低档设备降级。 |
| PR#8 去掉的部分 | 拱形人物窗与它的几何动画、Canvas 抽象光雾背景。空间改由房间本身承担。 |
## 视觉规范(施工依据)
**配色 Token**(两套主题结构相同;与 PR#8 相同的不再重复列出)
| Token | 靛蓝月光 | 蓝紫雾光 | 用途 |
| --- | --- | --- | --- |
| `--accent` | `#a9dcff` | `#ebbcff` | 关键操作、选中、她的身份点 |
| `--text-1/2/3` | `#eef2ff / #b9c6e6 / #94a5c8` | `#f6efff / #dccbee / #bba7d6` | 正文/次要/说明 |
| `--glass` / `--glass-read` | `#16244e66` / `#0b1534c4` | `#36226666` / `#211444c8` | 轻浮层/阅读浮层 |
| `--input-a/b/c` | `#16244e70 → #1c264c70 → #3a2c3a60` | `#3a245e70 → #3a265470 → #5a2c5260` | 输入框横向渐变(静图原样) |
| `--pool` | `#050918` | `#0e0820` | 文字背后的暗池与字影 |
| 房间夜色(multiply) | `#16225c → #2a3a8a` | `#2c1f5e → #4a3a8a` | 远景调色;她和灯的位置保留原暖色 |
| 窗外天色(color) | `#24357c` | `#5a3a92` | 只染窗和天空 |
| 雨色 | `rgb(190,210,255)` | `rgb(228,204,255)` | 1 秒混色 |
| 桌面 | `#3b2c30 → #221a24 → #0b0a14` | `#3a2a3e → #231a30 → #0e0a18` | 你这一侧的桌子 |
**字体**:PingFang SC / HarmonyOS Sans SC / MiSans / 微软雅黑。
| 场景 | 电脑 | 手机 | 键盘弹出 |
| --- | --- | --- | --- |
| 她的当前句 | 23px / 350 / 行高 1.5 | 18px | 16px |
| 你的当前句 | 20px / 300 | 16px | 16px |
| 上一句(与身份同行) | 15px / 300,透明度 .6,单行省略 | 13px | 隐藏 |
| 身份标签 | 12px,字距 .08em | 12px | 12px |
| 名字(顶部) | 22px / 400 | 16px | 16px |
| 此刻标题 | 30px / 300 | 22px(矮屏 19px) | 18px |
**身份**:她的话前面是实心发光点和她的名字;你的话前面是空心圆圈和「你」。完整记录使用同样的标记。
**动效**:曲线沿用 PR#8:进入和停稳 `cubic-bezier(.22,1,.36,1)`;空间移动 `cubic-bezier(.65,0,.25,1)`;镜头本身用临界阻尼弹簧。
| 动效 | 触发 | 时长与方式 | 能否打断 |
| --- | --- | --- | --- |
| 镜头走近/退回 | 点她、和她说话、结束交谈、此刻、交谈 | 临界阻尼弹簧 ω=7.2,约 0.85 秒停稳;缩放在对数空间插值,她的脸在屏幕上走直线 | 是,保留当前速度转向 |
| 近景展开 | 随镜头 | 走到 8%–45% 时近景淡入;以她的脸为圆心、半径 150→1600px、羽化 520px 展开;画面边缘羽化 260px,在 70%–98.5% 收为 0,到终点去掉遮罩 | 是 |
| 月亮交接 | 随镜头 | 20%–80% 之间从远景窗移到近景窗,位移小于房间 | 是 |
| 你的桌子 | 模式切换 | 850ms:相处时下移 3.5vh,交流时归位,浮层打开时下移 7vh 并变暗 | 是 |
| 此刻文字 | 离开/回来 | 离开 220ms 淡出加模糊;回来延迟 240ms,360–520ms 淡入 | 是 |
| 交流区 | 进入/离开 | 进入延迟 300ms,520–620ms;离开 220–420ms | 是 |
| 结束交谈按钮 | 进入交流 | 420ms,延迟 200ms | — |
| 新句、换句、逐字 | 自动或点击 | 与 PR#8 相同:680ms 进入;固定字号的两份副本交叉 520/620ms;逐字错开 14ms,最多 48 字 | 是 |
| 浮层 | 打开/关闭 | 460ms/240ms,从下方升起并由模糊变清晰 | 是 |
| 导航亮块 | 切换 | 460ms,只移动 transform | 是 |
| 按钮反馈 | 按下 | 160ms,缩放 .97 | 是 |
| **键盘** | Esc 退出交流或关闭浮层;← / → 回看、前进 | **0ms**:镜头、文字、界面直接到目标状态,不重播逐字;进行中的有限动画直接跳到终点 | — |
| 主题 | 切换 | 房间调色 0.9s,雨色、月亮 1s,界面 0.6s | 是 |
| 环境循环 | 一直运行 | 雨每秒 520–940px;城市灯慢闪;水珠下滑;云/雾 34s、47s;灯光呼吸 9s;烛光 2.8–3.2s 跳动;热气 7s、9s;光尘 17–24s | 页面隐藏时暂停 |
| 指针视差 | 鼠标移动(仅精确指针) | 远景 ±16/10px,桌面反向 ±22/10px,1.4s/0.85s 缓出 | 是 |
**断点**
- **电脑(>1100px)**:相处时「此刻」在左侧约 6.5vw,她在右侧窗边;交流时她居中,对话落在桌沿上方,输入框宽 640px,导航贴底。
- **720–1100px**:隐藏天气和右下说明。
- **手机(≤720px)**:相处时她在上方,「此刻」文字和入口在下方;交流时她的脸在上部 27% 处,对话、输入框、导航在下方;结束交谈只留图标。宽度 ≤360px 时,演示标签缩成「演示」。
- **可视高度 <610px**(键盘或横屏):隐藏上一句和顶部说明,正在输入时导航让开。**<420px**:再隐藏计数和「全文」,入口仍在记录中。
**性能与降级**
- 每帧只改两张静帧的 transform、近景的透明度和走近途中的遮罩。照片只随镜头整体等比缩放、平移,不做局部变形,也不单独做呼吸、眨眼、抬头之类的动画。远景、近景、月亮各自合成。
- 雨只在两扇窗的玻璃范围内绘制;看不见的那张静帧不绘制雨。桌面、茶杯、烛灯按视口尺寸只在尺寸变化或换档时重画。
- 自动降档:镜头静止时,最近 120 帧平均帧间隔超过 26ms 就降一档。档 1:雨降为半分辨率,桌面画布按 1 倍像素。档 2:走近时不用逐帧遮罩,改为两张静帧直接交叉淡化;去掉毛玻璃、光尘和雾带;雨约 30fps。
- 系统「减少动态效果」或设置里的「减少动效」:镜头不推近,两张静帧原位 200ms 交叉淡化;雨停在一帧;环境循环、视差和逐字都关闭。
- 页面隐藏时,绘制和 CSS 动画暂停。
- 本机只有无头 Edge 软件光栅:走近过程中实测约 16–22fps(evidence 里的录屏同样来自软件光栅)。**真实 GPU 帧率未测**,不据此推断真机表现。
## 复现
```powershell
powershell -File prototypes/hel-799/build.ps1 # 生成 index.html(只需 PowerShell 5.1)
powershell -File prototypes/hel-799/tools/verify.ps1 # 行为检查,写入 evidence/verification.json
```
`build.ps1` 读取 `src/` 下的 `shell.html`、`room.css`、`ambient.js`(雨、窗、桌)、`scene.js`(镜头)、`talk.js`(对话、浮层、模式),以及仓库 `web/public/assets/` 里的两张获批静帧。`tools/` 是本次在 Windows 上实际使用的无头 Edge 驱动:`cdp.ps1`(沿用 PR#8)、`snap.ps1` 截图、`verify.ps1` 行为检查、`record.ps1` 录屏、`contact.ps1` 录屏抽帧、`strip.ps1` 拼图。没有新增产品依赖。
## 验证范围与未验证项
- 已实测(无头 Edge,Chromium 内核):电脑 1600×1000、1440×900、1280×720;手机等效视口 390×844、360×740、320×568;键盘等效 390×430;两套主题;系统减少动效;强制档 2。结果见 `evidence/verification.json` 和截图、录屏。
- **未验证**:真机软键盘、iOS Safari 和 Safari 桌面版、Firefox、真实 GPU 帧率、低端手机。等效视口不能代替真机验收。`mask-composite` 在旧版 Safari 上可能退化为只有圆形展开、没有边缘羽化。
- 回复是逐句模拟生成的,不是真实 SSE。
- 相处构图、走近与退回的过渡、雾光主题和手机版都还没有经过白栖知验收;本次认可只针对 HEL-798 那张月光交流静图。
+17
View File
@@ -0,0 +1,17 @@
# Builds the single offline index.html (Windows PowerShell 5.1; no Python or Node needed).
# Inputs: src/shell.html, src/room.css, src/ambient.js, src/scene.js, src/talk.js and the two approved stills in web/public/assets.
$root = $PSScriptRoot
$src = Join-Path $root 'src'
$utf8 = New-Object System.Text.UTF8Encoding($false)
$read = { param($p) [System.IO.File]::ReadAllText((Join-Path $src $p), $utf8) }
$html = & $read 'shell.html'
$html = $html.Replace('{{STYLE}}', (& $read 'room.css'))
$html = $html.Replace('{{SCRIPT}}', (& $read 'ambient.js') + "`n" + (& $read 'scene.js') + "`n" + (& $read 'talk.js'))
$assets = Join-Path (Split-Path (Split-Path $root)) 'web\public\assets'
foreach ($pair in @(@('ROOM', 'her-room.jpg'), @('TALK', 'her-talk.jpg'))) {
$b64 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes((Join-Path $assets $pair[1])))
$html = $html.Replace('{{' + $pair[0] + '}}', 'data:image/jpeg;base64,' + $b64)
}
if ($html.Contains('{{')) { throw 'Unreplaced placeholder' }
[System.IO.File]::WriteAllText((Join-Path $root 'index.html'), $html, $utf8)
"Built offline index.html ($($utf8.GetByteCount($html)) bytes)"
+61
View File
@@ -0,0 +1,61 @@
{
"task": "HEL-799 视觉总监沿获认可的 HEL-798 交流静图,做共同空间(相处 ⇄ 交流)可操作动态小样;2026-10-23 中断恢复续作:保全原稿、去掉阅读日常文案、实现并实测 HEL-802 v2 手机 4:5 媒体容器候选、导出无界面母版与前景分工",
"baseline_version": "1.8",
"read_commit": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0",
"read_notes": [
"原运行(01a16691-b121-725d-a2e3-8d1f0ce57c02)经 Gitea API 核对 main = 3a986726d8593e0ed2274f79db6cc5d807d2ebf0(v1.8,59 项),下载该提交归档并完整读取 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、changes.md、source-manifest.json、task-handoff-template.md、scripts/check_requirements.py 及 sources/visual-confirmation-v2.md、design-acceptance-v1.md、comment-evidence-visual-role-20261022.json。",
"本轮(恢复运行)未能独立复核 main:本机无 git,Gitea API 匿名访问返回 404。以总管在本卡写明的 origin/main 3a986726 为准,本轮未见到更新提交的证据;需总管入库时机械复核。",
"本轮读取:本卡最新描述(恢复交接、HEL-800 素材核对、母版与镜头约束、原完整验收);附件 HEL-802 v2 HTML(§1–§3 全文、§2.3 容器公式与 §2.6 前景分工)、handoff-HEL-802-v2-manager-check.json、HEL-803 v2 衔接规格全文。用户最新原话来源 01a166aa(否定阅读试片、按时段/天气/穿搭准备完整素材)、01a166ad(基于前端给视频参数)、01a16699(效果取决于实际生成结果、未选远景路线)按本卡与 HEL-803 v2 记录引用。",
"PR#8 仍未合并,head 21170f9fc9ab4666c4e9016ea80a1fabdd1f76aa;阅读逻辑、主题 token 与 HEL-795 键盘即时规则沿用其 prototypes/hel-793/src。"
],
"requirement_ids": ["CG-002", "CG-003", "CG-004", "CG-008", "CG-017", "CG-018", "CG-023", "CG-024", "CG-025", "CG-026", "CG-028", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "独立目录 prototypes/hel-799/(视觉总监亲自设计并编写,未改正式产品、main、PR#4/#6/#8 或需求基准文件):离线单文件 index.html;源码 src/shell.html、room.css、ambient.js、scene.js、talk.js;build.ps1;tools/ 无头 Edge 截图、行为检查、录屏、抽帧与拼图脚本;README.md(方向、token、字体、动效表、断点、性能降级、验证范围,新增“2026-10-23 续作”:手机 4:5 容器候选的 DOM 与公式对照、母版与前景分工);evidence/(重新生成的截图、verification.json 29 项、walkthrough-desktop-v2.webm 及抽帧)与 evidence/media-candidate/(候选容器 390×844、320×568、390×430 键盘态截图、进出与 Esc 检查、录屏);masters/ 无界面母版 7 张(月光/雾光 × 含前景对照/无前景底板 1600×1000、16:9 1920×1080 候选底板、相处远景仅参考);reference/hel-798-static/ 获认可静图与其 HTML/Canvas 源码原样;records/ 用户原话来源与交流静态视觉基准更新记录。另附原运行目录完整打包 hel-799-original-run-8d1f0ce57c02.zip(保全原稿与原证据,未删改原目录)。",
"acceptance": [
"交流状态:1600×1000 下与获认可静图同素材、同夜色叠层、同一套桌沿/茶杯/烛灯绘制,环境铺满四边、无独立人物框,对话落在视线下方,底部保留输入与四个导航(evidence/compare-approved-vs-talk.png 为原运行生成;本轮 desktop-talk-moon.png 重新生成)。演示句已改为不指向阅读,静图认可只针对画面。",
"相处状态(待用户验收):同一间房远景静帧调成同一雨夜,前景仍是你这一侧的桌沿、茶杯和烛灯;界面文案改为“她在窗边,雨声不大”,参考说明标“人物为旧参考静帧 · 不代表新日常”。静帧里的看书姿态只示意空间,不代表新日常。",
"往返与快速开关:点她/和她说话/交谈进入,结束交谈/此刻退回;两张静帧按她的脸对齐由一台临界阻尼镜头连续推拉,中途反向点击从当前位置转向,不跳回、不闪黑(verification: click-enter-*、enter-no-jump-no-black-frame、rapid-toggle-redirects-smoothly 均 PASS)。",
"键盘即时:Esc、←/→ 在 80ms 内到达目标、有限动画 0 个、不重播逐字(HEL-795 规则);候选容器模式下 Esc 同样 80ms 内回到相处、有限动画 0。",
"照片不变形:两张静帧只随镜头整体等比缩放和平移,不用缩放冒充眨眼、呼吸、抬头或口型(photos-never-warped-or-animated PASS)。",
"交流能力沿用 PR#8:长回复自动续读到最后、全文与原文一致、暂停/回看/回到进度、发送恢复自动、身份标记、停止保留已生成内容并标未完成、记录打开时她仍在桌对面且关闭回原句。",
"功能入口:日子→生活详情→看那时说的话、设置→关系/备份/语音与视觉观察等均打开带“演示/尚未接入”标记的内容;语音只演示界面,未申请麦克风。",
"两主题:月光与雾光切换后输入框、导航、对话区、结束按钮位置完全相同,房间、窗外、雨、桌面与材质不同(themes-keep-structure PASS)。",
"布局:1600×1000、1440×900、1280×720、390×844、360×740、320×568、390×430 长回复流动全程无遮挡、长名字不溢出(no-overlap-* 7 项 PASS)。",
"减少动效与降级:系统减少动效时原位约 200ms 交叉淡化、环境循环停止;强制档 2 无逐帧遮罩、无毛玻璃、雨半分辨率。",
"手机 4:5 媒体容器候选(?frame=media,默认关闭):390×844 容器 390×487.5、320×568 为 320×328、390×430 键盘态 390×374,三者与 HEL-802 v2 公式一致;脸中心实测 (170,157)/(143,105)/(176,120),公式 (178,146)/(148,98)/(182,112),差异来自小样用 16:10 获认可构图而非 16:9 源;键盘态最近回复覆盖容器底部 80px(公式 88px),只压在开衫上;桌面不受影响。",
"无界面母版:masters/ 下母版不含任何界面;含前景版与获认可构图对照,无前景底板与 16:9 候选底板用于视频取景参考;README 写明烘焙进视频与页面另绘的分工。",
"行为检查 29/29 通过(evidence/verification.json,本轮构建重新运行)。"
],
"deferred": [
"用户体验验收:相处构图、走近与退回的过渡、雾光主题、手机版(含 4:5 容器候选)、整体动态均未经白栖知验收;本次认可只针对 HEL-798 月光交流静图。认可前不替换正式界面(8300/8310),不合并 PR#4/#6/#8。",
"现有静帧放进候选容器时暴露获认可构图底部(y≥0.80)原本被页面桌沿挡住的书和居家裤,推近途中也能看到远景静帧的书和腿;需按 HEL-802 v2 重新生成近景 CT(y≥0.78 只有衣服和沙发)与新造型母版,不靠界面遮挡。",
"母版仍是旧参考人物(黄昏原图经页面调成夜色、手里有书、居家裤),只钉构图、光色与脸位置,不能当最终首尾帧;正式 S/T 母版、真实视频、同脸/循环/推拉中线(±0.03)与 LIVE 接管几何由主卡与首批小队制作承接(CG-030)。",
"需求入库:records/ 中的来源与静态基准更新记录需总管放入 docs/requirements/sources/ 并更新 source-manifest;是否升版及 VIS-011 边界注明由总管按 records/shared-space-static-acceptance-20261023.md 核定。视觉总监未改 baseline/current/changes/coverage。",
"工具:本运行时无 git 与 Python,未能运行 python3 scripts/check_requirements.py --base-ref origin/main、--handoff,也未能运行 git diff --check;已用 PowerShell 检查本 JSON 必填键、改动文本无行尾空白。须在入库环境补跑。",
"未验证:真机软键盘与容器 240ms 收缩观感、iOS/macOS Safari(mask-composite 可能退化)、Firefox、真实 GPU 帧率与低端手机;本机为无头 Edge 软件光栅。页面隐藏暂停已实现但无头环境未实测。",
"emil-skills 在本运行时不可用,未能查阅 animate/MAIN.md、review-animations/MAIN.md 与 STANDARDS.md;按本卡第 6 条明确约束执行。",
"完整生活/回忆/创建/管理状态、R1 个性成长、R2–R7 真实接入由主卡 HEL-797 按获批依赖推进;小样通过不等于项目完成。"
],
"requirement_changes": [
{
"approval_comment_id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"quote": "静态效果我认可",
"kind": "交流静态视觉基准的更新",
"affected_ids": ["VIS-011"],
"old": "VIS-011:人物平时作为空间中独立视窗;交流时自然放大。",
"new": "交流状态(靛蓝月光、电脑 1600×1000)的获认可静态画面改为 HEL-798 共同空间样图:无独立人物框,环境连续到四边,前景桌沿/茶杯/烛灯,中间是她,远处雨夜窗景,对话在近处视线下方。",
"scope": "仅交流状态静图。相处状态、过渡与动态、雾光、手机、真实人物动态和完整功能仍待验收;不永久取消 VIS-011 在其他状态中的原规则,不把完整前端锁定为全屏卧室照片或视频(CG-031)。",
"context_comment_ids": ["01a16650-0355-72c4-b291-f1b4494cde91", "01a16654-5fc5-74be-a704-99b76b48b0b1", "01a16655-fe53-73a5-905f-4c9e71a1ed30", "01a16659-fdfa-72ce-a4a5-801e0762c0a9"],
"evidence_file": "prototypes/hel-799/records/comment-evidence-shared-space-20261023.json(入库时放入 docs/requirements/sources/)",
"image_sha256": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a"
}
],
"verification": [
"build.ps1 本轮实际运行,生成离线 index.html(662,233 字节,无残留占位符)。",
"evidence/verification.json:tools/verify.ps1 本轮在无头 Edge 用 CDP 真实鼠标、键盘事件执行 29 项检查,29/29 通过。",
"evidence/media-candidate/:候选容器 390×844(月光/雾光、相处/交流)、320×568、390×430 键盘态截图,现行整屏 390 与键盘态对照截图,进出中途帧 m390-enter-mid.png;DOM 实测数字见 README 续作表;进入 200ms 时镜头 0.47、无黑帧,退出与 Esc 后有限动画 0。",
"evidence/walkthrough-desktop-v2.webm(37 秒,1440×900)与 evidence/media-candidate/walkthrough-mobile-media-candidate.webm(37 秒,390×844)本轮录制,并各有抽帧拼图;录屏圆圈为点击位置标记,非小样内容。原运行录屏仍在原稿打包中。",
"masters/ 7 张母版本轮导出(tier 0、无界面),逐张目视:含前景版与获认可构图一致;无前景底板与 16:9 底板露出书与居家裤(已列 deferred)。16:9 底板脸中心实测 (0.540, 0.317)。",
"未运行 Python 需求检查与 git diff --check(见 deferred)。"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
[
{
"id": "01a16650-0355-72c4-b291-f1b4494cde91",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "主要是A和B,但是之前没注意到C的问题,你一说倒是有点",
"created_at": "2026-10-23T01:11:22Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16654-5fc5-74be-a704-99b76b48b0b1",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "对就是B",
"created_at": "2026-10-23T01:16:08Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16655-fe53-73a5-905f-4c9e71a1ed30",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个我不太能想象得到,我和她待在同一空间是什么概念",
"created_at": "2026-10-23T01:17:54Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16659-fdfa-72ce-a4a5-801e0762c0a9",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个能否现在就做个样图让我看一下",
"created_at": "2026-10-23T01:22:16Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "静态效果我认可",
"created_at": "2026-10-23T02:20:32Z",
"parent_id": null
}
]
@@ -0,0 +1,46 @@
## 交流静态视觉基准的更新(2026-10-23)
视觉总监在 HEL-799 整理,供总管入库核验。本记录只写明用户已经说过的话及其适用范围,不改动任何已有来源文件,也不改写 `current.md`、`baseline.json` 的现行条文。
### 用户原话(主卡 HEL-797,作者白栖知 `e5c6c938-db48-4ff9-954f-4fb8b063a132`)
原文逐字保存在同目录 `comment-evidence-shared-space-20261023.json`。
| 评论 | 时间(UTC) | 原话 | 回应的问题 |
| --- | --- | --- | --- |
| `01a16650-0355-72c4-b291-f1b4494cde91` | 2026-10-23 01:11 | 主要是A和B,但是之前没注意到C的问题,你一说倒是有点 | 总管问最妨碍沉浸的是哪种感觉:A 空间太空泛;B 和她有距离,像窗口里的照片;C 操作出戏 |
| `01a16654-5fc5-74be-a704-99b76b48b0b1` | 01:16 | 对就是B | 总管问:她即使能眨眼、抬头、回应,但仍在人物窗口里,是否主要的不满意还在 |
| `01a16655-fe53-73a5-905f-4c9e71a1ed30` | 01:17 | 这个我不太能想象得到,我和她待在同一空间是什么概念 | 总管问更接近「同一个空间」还是「通向她的世界」 |
| `01a16659-fdfa-72ce-a4a5-801e0762c0a9` | 01:22 | 这个能否现在就做个样图让我看一下 | 总管用第一人称镜头解释「同一空间」后 |
| `01a1668f-5810-7524-b72f-33574a32c7d6` | 02:20 | 静态效果我认可 | 总管交付 HEL-798 样图(评论 `01a16663-3ad2-727c-8888-e116eca13116`),问是否更像「自己坐在她面前」 |
问题原文由总管撰写,在主卡根线程 `01a1664d-f242-71fa-a4ba-295b6c347478` 内,不属于用户原话。
### 被认可的图
- 文件:`hel-798-face-to-face-moon.png`,1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`。
- 主卡附件 `01a16663-3a5a-76eb-ac3c-1ef93165617c`(随交付评论 `01a16663-3ad2-727c-8888-e116eca13116`);HEL-799 卡附件 `01a16691-b0f0-73a9-aae4-9aba2146899c`,两者字节相同。
- 同一评论另附的标注图 `hel-798-face-to-face-annotated.png` 和新旧对照图 `hel-798-compare-pr8.png` 是说明材料。
- 作者:视觉总监(HEL-798)。源码在 `prototypes/hel-799/reference/hel-798-static/`。
### 旧要求、新认可与适用范围
| | 内容 |
| --- | --- |
| 旧(VIS-011,v1.8 仍有效) | 「人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。」来源 `01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。 |
| 新认可 | **交流状态、靛蓝月光、电脑 1600×1000 的静态画面**:不再用独立人物框;她身边的环境连续铺到屏幕四边;前景有你这一侧的圆桌沿、茶杯和烛灯,中间是她,远处是雨夜窗景;当前一两句对话落在近处、视线下方,底部保留输入和此刻/交谈/日子/设置导航。 |
| 适用范围 | 仅以上交流状态静图。用户认可的是「静态效果」。 |
| 不在范围内(仍待验收) | 相处(平时)状态的构图;进入与退出交流的过渡和整体动态;蓝紫雾光主题;手机版;真实人物动态、眨眼、口型与语音;生活、回忆、创建、管理等完整功能的最终页面。 |
| 不能推出的结论 | 不能据此永久取消 VIS-011 在全部状态中的原规则;不能把完整前端锁定为全屏卧室照片或视频(CG-031 继续有效);不能视为整套界面或任何功能已经验收。 |
### 对其他要求的影响
- VIS-001、VIS-002、VIS-006、VIS-013:这张图是「两人的数字空间」「空间深度」「交流以人物为主、当前一两句」的一种获认可呈现。
- CG-004「交谈时允许自然切近」:图只证明切近后的静态画面,切近过程要在 HEL-799 动态小样中另行验收。
- 其余已确认要求不变。
### 入库建议(由总管决定,视觉总监未改动正式基准)
1. 将 `comment-evidence-shared-space-20261023.json` 加入 `docs/requirements/sources/`,并在 `source-manifest.json` 追加它的 SHA256;历史来源不动。
2. 如决定升版为 v1.9:在 `changes.json` / `changes.md` 追加一条,`approval_comment_id` 为 `01a1668f-5810-7524-b72f-33574a32c7d6`,`affected_ids` 至少含 `VIS-011`,摘要写「交流状态静态视觉基准更新为 HEL-798 共同空间样图」,影响写明上表的适用范围与不在范围内;VIS-011 的结论原文保留,只在边界中补一句「交流状态静图已按上述评论更新,平时状态、过渡、雾光、手机待验收」,`current.md` 与 `baseline.json` 同步;`coverage.json` 的版本跟着改。
3. 入库后运行 `python3 scripts/check_requirements.py --base-ref origin/main` 及 `--handoff prototypes/hel-799/handoff-hel-799.json`。本运行环境没有 git 和 Python,这两项都未执行。
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,314 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>对坐 · 共同空间样图</title>
<style>
:root {
--accent: #a9dcff;
--accent-glow: #96d2ff55;
--text-1: #eef2ff;
--text-2: #b9c6e6;
--text-3: #94a5c8;
--glass: #16244e66;
--glass-line: #b8d4ff26;
--user: #d7e2ff;
--warm: #ffcf9a;
--font: "PingFang SC", "HarmonyOS Sans SC", "MiSans", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1600px; height: 1000px; overflow: hidden; background: #050918; font-family: var(--font); color: var(--text-1); }
.stage { position: relative; width: 1600px; height: 1000px; overflow: hidden; }
/* 她那一侧:参考静帧,靛蓝月光调色 */
.her {
position: absolute; left: -118px; top: -10px; width: 1792px; height: 1008px;
background: url(assets/her-talk.jpg) center / cover no-repeat;
filter: saturate(.92) brightness(.9) contrast(1.04);
}
/* 窗外换成月夜:只染窗与远景,她和灯保持暖 */
.night {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 420px 520px at 905px 360px, transparent 0 55%, #0000 100%),
linear-gradient(100deg, #1c2f7fcc 0%, #1b2c78aa 28%, #1a2a7044 46%, transparent 60%);
mix-blend-mode: multiply;
}
.night2 {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg, #24357ccc 0%, #24357c66 18%, transparent 34%);
mix-blend-mode: color;
-webkit-mask: linear-gradient(90deg, #000 0 40%, transparent 56%);
}
.moon {
position: absolute; left: 382px; top: 70px; width: 44px; height: 44px; border-radius: 50%;
background: radial-gradient(circle at 42% 40%, #fbfdff 0%, #e4ecff 55%, #c9d6f5 100%);
filter: blur(.6px); opacity: .92; box-shadow: 0 0 22px 6px #cfe0ff88, 0 0 90px 40px #8fb0ff33, 0 0 220px 120px #5f7fe022;
}
.beam {
position: absolute; left: 120px; top: 40px; width: 560px; height: 1040px; pointer-events: none;
background: linear-gradient(180deg, #a9c8ff22, #a9c8ff10 55%, transparent);
transform: skewX(18deg); filter: blur(30px); mix-blend-mode: screen;
}
/* 灯光从她那边一路落到你这边 */
.lampspill {
position: absolute; inset: 0; pointer-events: none; mix-blend-mode: screen;
background:
radial-gradient(ellipse 520px 380px at 1290px 40px, #ffcf8a30, transparent 70%),
radial-gradient(ellipse 760px 260px at 1180px 900px, #ffb97a2a, transparent 70%);
}
.vignette {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 980px 720px at 860px 420px, transparent 52%, #050918aa 100%),
linear-gradient(180deg, #050918aa 0%, transparent 13%);
}
canvas { position: absolute; inset: 0; }
/* 界面 */
.top { position: absolute; left: 40px; right: 40px; top: 28px; display: flex; align-items: center; justify-content: space-between; }
.who { display: flex; align-items: baseline; gap: 12px; text-shadow: 0 1px 12px #050918; }
.who .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); align-self: center; }
.who b { font-size: 22px; font-weight: 400; letter-spacing: .04em; }
.who span { font-size: 13px; color: var(--text-2); letter-spacing: .04em; }
.meta { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--text-2); }
.chip {
height: 30px; padding: 0 13px; display: inline-flex; align-items: center; gap: 7px; border-radius: 15px;
background: var(--glass); border: 1px solid var(--glass-line); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
}
.chip.demo { border-style: dashed; color: var(--text-3); }
.theme { padding: 3px; gap: 2px; }
.theme i { font-style: normal; height: 24px; padding: 0 11px; border-radius: 12px; display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); }
.theme i.on { background: #a9dcff22; color: var(--text-1); box-shadow: inset 0 0 0 1px #a9dcff44; }
.theme i::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.theme i.on::before { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.theme i.fog::before { background: #ebbcff; }
.end { color: var(--text-1); }
.talk { position: absolute; left: 0; right: 0; top: 816px; text-align: center; }
.line { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.tag { font-size: 12px; letter-spacing: .08em; color: var(--text-2); display: inline-flex; align-items: center; gap: 7px; }
.tag .me { width: 7px; height: 7px; border-radius: 50%; border: 1px solid var(--user); }
.tag .she { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.prev { opacity: .6; margin-bottom: 6px; flex-direction: row; justify-content: center; gap: 10px; }
.prev p { font-size: 15px; color: var(--user); font-weight: 300; text-shadow: 0 1px 10px #050918; }
.cur { gap: 4px; }
.cur p {
font-size: 23px; font-weight: 350; line-height: 1.5; letter-spacing: .02em; max-width: 900px;
text-shadow: 0 2px 18px #050918, 0 0 2px #05091899;
}
.meter { width: 72px; height: 2px; border-radius: 1px; background: #ffffff1c; margin-top: 8px; overflow: hidden; }
.meter::before { content: ""; display: block; width: 38%; height: 100%; background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.input {
position: absolute; left: 50%; top: 914px; transform: translateX(-50%); width: 640px; height: 50px; border-radius: 25px;
display: flex; align-items: center; gap: 12px; padding: 0 6px 0 18px;
background: linear-gradient(90deg, #16244e70, #1c264c70 70%, #3a2c3a60);
border: 1px solid var(--glass-line); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
box-shadow: 0 10px 40px #02051466, inset 0 1px 0 #ffffff12;
}
.input .ph { flex: 1; font-size: 15px; color: var(--text-3); }
.ico { width: 18px; height: 18px; stroke: var(--text-2); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.btn { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; }
.btn.mic { background: var(--accent); box-shadow: 0 0 18px var(--accent-glow); }
.btn.mic .ico { stroke: #0b1534; }
.nav {
position: absolute; left: 50%; top: 966px; transform: translateX(-50%); display: flex; gap: 4px; padding: 3px;
border-radius: 16px; background: #0b153455; border: 1px solid #b8d4ff1a; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
}
.nav a { height: 26px; padding: 0 12px; border-radius: 13px; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-3); letter-spacing: .04em; }
.nav a .ico { width: 14px; height: 14px; stroke: var(--text-3); }
.nav a.on { color: var(--text-1); background: #a9dcff1f; box-shadow: 0 0 14px #96d2ff22; }
.nav a.on .ico { stroke: var(--accent); }
.ref { position: absolute; right: 40px; bottom: 26px; font-size: 11px; color: var(--text-3); opacity: .8; letter-spacing: .04em; }
/* 标注版 */
.notes { display: none; }
body.annot .notes { display: block; }
.note { position: absolute; display: flex; align-items: center; gap: 10px; font-size: 14px; color: #fff; }
.note b { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: #ffd27a; color: #1a1430; font-size: 14px; font-weight: 700; box-shadow: 0 0 0 4px #ffd27a33; }
.note span { padding: 6px 12px; border-radius: 8px; background: #0b1534e6; border: 1px solid #ffd27a88; line-height: 1.5; }
.band { position: absolute; left: 0; right: 0; border-top: 1px dashed #ffd27a99; }
</style>
</head>
<body>
<div class="stage">
<div class="her"></div>
<div class="night"></div>
<div class="night2"></div>
<div class="beam"></div>
<div class="moon"></div>
<canvas id="rain" width="1600" height="1000"></canvas>
<div class="lampspill"></div>
<canvas id="near" width="1600" height="1000"></canvas>
<div class="vignette"></div>
<div class="top">
<div class="who"><i class="dot"></i><b>晚晚</b><span>在窗边看书 · 抬头看着你</span></div>
<div class="meta">
<span class="chip demo">独立样图 · 演示内容</span>
<span>上海 · 小雨 14° 示例</span>
<span class="chip theme"><i class="on">月光</i><i class="fog">雾光</i></span>
<span class="chip end"><svg class="ico" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M6 6l12 12M18 6L6 18"/></svg>结束交谈</span>
</div>
</div>
<div class="talk">
<div class="line prev">
<span class="tag"><i class="me"></i>你</span>
<p>外面下雨了,你那边听得见吗?</p>
</div>
<div class="line cur">
<span class="tag"><i class="she"></i>晚晚</span>
<p>听得见,打在玻璃上很轻。你也坐一会儿吧,这一页我念给你听。</p>
<i class="meter"></i>
</div>
</div>
<div class="input">
<svg class="ico" viewBox="0 0 24 24"><path d="M4 7h16M4 12h10M4 17h13"/></svg>
<span class="ph">想和她说点什么…</span>
<span class="btn"><svg class="ico" viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></svg></span>
<span class="btn mic"><svg class="ico" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg></span>
</div>
<nav class="nav">
<a><svg class="ico" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg>此刻</a>
<a class="on"><svg class="ico" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 1 3.5 6.6L4 20l1.2-3.6A8 8 0 0 1 4 12z"/></svg>交谈</a>
<a><svg class="ico" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></svg>日子</a>
<a><svg class="ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>设置</a>
</nav>
<div class="ref">人物为参考静帧 · 未接入真实模型与动态</div>
<div class="notes">
<div class="band" style="top:250px"></div>
<div class="band" style="top:780px"></div>
<div class="note" style="left:470px;top:150px"><b>3</b><span>远处:窗外雨夜、月光和城市<br>月光与雨从这里进到房间</span></div>
<div class="note" style="left:1150px;top:470px"><b>2</b><span>中间:她就坐在对面<br>与你视线同高,抬头看你</span></div>
<div class="note" style="left:60px;top:700px"><b>1</b><span>近处:你这一侧的小桌和茶杯<br>她的灯光一直落到你面前</span></div>
<div class="note" style="left:1200px;top:800px"><b>✦</b><span>对话在视线下方、落在你这边<br>只留当前一两句</span></div>
</div>
</div>
<script>
// 窗外细雨:只落在左侧窗玻璃范围内
(function () {
const c = document.getElementById('rain').getContext('2d');
c.save();
c.beginPath(); c.rect(0, 0, 640, 600); c.clip();
let s = 7;
const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < 260; i++) {
const x = rnd() * 700, y = rnd() * 620, l = 10 + rnd() * 22, a = .08 + rnd() * .16;
c.strokeStyle = `rgba(190,210,255,${a})`; c.lineWidth = .8;
c.beginPath(); c.moveTo(x, y); c.lineTo(x - l * .18, y + l); c.stroke();
}
// 玻璃上的水珠
for (let i = 0; i < 70; i++) {
const x = rnd() * 640, y = rnd() * 560, r = 1 + rnd() * 2.4;
const g = c.createRadialGradient(x - r * .3, y - r * .3, 0, x, y, r);
g.addColorStop(0, 'rgba(230,240,255,.45)'); g.addColorStop(1, 'rgba(160,190,255,0)');
c.fillStyle = g; c.beginPath(); c.arc(x, y, r, 0, 7); c.fill();
}
c.restore();
})();
// 你这一侧:小圆桌边缘、茶杯、一盏小烛灯、近景光斑(前景虚化)
(function () {
const c = document.getElementById('near').getContext('2d');
const W = 1600, H = 1000;
// 桌面:大圆桌的一段弧,离你最近
c.save();
c.filter = 'blur(2.5px)';
c.beginPath();
c.ellipse(800, 1585, 1300, 775, 0, Math.PI, 2 * Math.PI);
c.lineTo(W + 100, H + 100); c.lineTo(-100, H + 100); c.closePath();
const top = c.createLinearGradient(0, 780, 0, H);
top.addColorStop(0, '#3b2c30'); top.addColorStop(.3, '#221a24'); top.addColorStop(1, '#0b0a14');
c.fillStyle = top; c.fill();
c.clip();
// 桌面上的月光(左,冷)与灯光(右,暖)反射
let g = c.createRadialGradient(300, 860, 0, 300, 860, 520);
g.addColorStop(0, 'rgba(150,180,255,.22)'); g.addColorStop(1, 'rgba(150,180,255,0)');
c.fillStyle = g; c.fillRect(0, 760, W, 260);
g = c.createRadialGradient(1260, 840, 0, 1260, 840, 560);
g.addColorStop(0, 'rgba(255,190,130,.26)'); g.addColorStop(1, 'rgba(255,190,130,0)');
c.fillStyle = g; c.fillRect(0, 760, W, 260);
// 她那边烛光和花在桌面的倒影
g = c.createRadialGradient(300, 830, 0, 300, 830, 60);
g.addColorStop(0, 'rgba(255,214,150,.25)'); g.addColorStop(1, 'rgba(255,214,150,0)');
c.fillStyle = g; c.fillRect(200, 790, 200, 90);
// 木纹
c.globalAlpha = .06;
for (let i = 0; i < 26; i++) {
c.strokeStyle = i % 2 ? '#c9b8ff' : '#ffd9b0'; c.lineWidth = 1;
c.beginPath(); c.ellipse(800, 1585, 1300 - i * 9, 775 - i * 6 - 14, 0, Math.PI * 1.08, Math.PI * 1.92); c.stroke();
}
c.restore();
// 桌沿受光的细边:左冷右暖
c.save();
c.filter = 'blur(1px)';
const rim = c.createLinearGradient(0, 0, W, 0);
rim.addColorStop(0, 'rgba(170,200,255,.05)'); rim.addColorStop(.25, 'rgba(170,200,255,.45)');
rim.addColorStop(.55, 'rgba(240,230,255,.3)'); rim.addColorStop(.8, 'rgba(255,205,150,.55)'); rim.addColorStop(1, 'rgba(255,205,150,.08)');
c.strokeStyle = rim; c.lineWidth = 2;
c.beginPath(); c.ellipse(800, 1585, 1300, 775, 0, Math.PI * 1.03, Math.PI * 1.97); c.stroke();
c.restore();
// 你的茶杯(最近,最虚)
c.save();
c.filter = 'blur(5px)';
c.fillStyle = '#0d0c18';
c.beginPath(); c.ellipse(205, 985, 150, 34, 0, 0, 7); c.fill(); // 杯碟影
const cup = c.createLinearGradient(100, 0, 320, 0);
cup.addColorStop(0, '#5a6aa0'); cup.addColorStop(.35, '#d9def2'); cup.addColorStop(.7, '#9ea6c8'); cup.addColorStop(1, '#3a3f66');
c.fillStyle = cup;
c.beginPath(); c.moveTo(108, 830); c.bezierCurveTo(108, 950, 140, 985, 210, 985); c.bezierCurveTo(280, 985, 312, 950, 312, 830); c.closePath(); c.fill();
c.strokeStyle = '#aeb6d8'; c.lineWidth = 16;
c.beginPath(); c.arc(320, 885, 34, -Math.PI / 2.2, Math.PI / 2.2); c.stroke();
c.fillStyle = '#3a2416'; c.beginPath(); c.ellipse(210, 830, 102, 18, 0, 0, 7); c.fill();
c.fillStyle = 'rgba(255,200,140,.35)'; c.beginPath(); c.ellipse(240, 828, 46, 7, 0, 0, 7); c.fill();
c.restore();
// 热气
c.save();
c.filter = 'blur(10px)';
c.strokeStyle = 'rgba(210,225,255,.16)'; c.lineWidth = 16; c.lineCap = 'round';
c.beginPath(); c.moveTo(195, 815); c.bezierCurveTo(160, 760, 250, 720, 210, 650); c.stroke();
c.beginPath(); c.moveTo(235, 812); c.bezierCurveTo(270, 760, 200, 710, 250, 640); c.stroke();
c.restore();
// 你这边的小烛灯,和她身边那支相呼应
c.save();
c.filter = 'blur(3.5px)';
const glass = c.createLinearGradient(1350, 0, 1450, 0);
glass.addColorStop(0, 'rgba(255,190,130,.25)'); glass.addColorStop(.5, 'rgba(255,225,180,.55)'); glass.addColorStop(1, 'rgba(255,170,110,.22)');
c.fillStyle = glass; c.beginPath(); c.moveTo(1356, 850); c.lineTo(1444, 850); c.lineTo(1440, 952); c.quadraticCurveTo(1400, 966, 1360, 952); c.closePath(); c.fill(); c.fillStyle = 'rgba(255,240,215,.35)'; c.beginPath(); c.ellipse(1400, 850, 44, 9, 0, 0, 7); c.fill();
c.restore();
c.save();
c.globalCompositeOperation = 'screen';
g = c.createRadialGradient(1400, 850, 0, 1400, 850, 200);
g.addColorStop(0, 'rgba(255,220,160,.85)'); g.addColorStop(.12, 'rgba(255,190,120,.45)'); g.addColorStop(1, 'rgba(255,170,100,0)');
c.fillStyle = g; c.fillRect(1180, 640, 440, 360);
c.restore();
// 近景光斑(景深)
c.save();
c.globalCompositeOperation = 'screen';
const bok = [[60, 120, 70, '160,190,255', .1], [1530, 300, 90, '255,200,140', .09], [1480, 980, 120, '255,190,130', .1],
[560, 990, 80, '150,180,255', .08], [30, 560, 60, '150,180,255', .08]];
for (const [x, y, r, col, a] of bok) {
g = c.createRadialGradient(x, y, r * .6, x, y, r);
g.addColorStop(0, `rgba(${col},${a})`); g.addColorStop(.9, `rgba(${col},${a * .8})`); g.addColorStop(1, `rgba(${col},0)`);
c.fillStyle = g; c.beginPath(); c.arc(x, y, r, 0, 7); c.fill();
}
c.restore();
})();
if (location.hash === '#annot') document.body.classList.add('annot');
</script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
'use strict';
// ── Ambient life of the room (drawn, never taken from her photo). ──
// Rain: falls only inside each still's window panes; city windows blink; a few drops slide down the glass.
const Rain = (() => {
const html = document.documentElement;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const still = () => reduced.matches || html.classList.contains('no-motion');
const COLORS = {moon: [190, 210, 255], fog: [228, 204, 255]};
// Plate px. clip: the glass; city: where lit windows may blink.
const CFG = {
room: {clip: [655, 0, 545, 545], city: [660, 215, 530, 320], n: 110, seed: 11},
talk: {clip: [0, 0, 640, 600], city: [0, 200, 640, 360], n: 150, seed: 7}
};
const S = {};
let mix = 0, mixFrom = 0, mixTo = 0, mixStart = 0, visible = {room: true, talk: false}, dirty = true, lastDraw = 0, res = 0;
const rng = seed => () => (seed = (seed * 16807) % 2147483647) / 2147483647;
function build(name, scale) {
const c = CFG[name], cv = document.querySelector(`#plate-${name} canvas.rain`), r = rng(c.seed);
cv.width = 1600 * scale; cv.height = 1000 * scale;
const ctx = cv.getContext('2d');
const [x, y, w, h] = c.clip;
const streaks = Array.from({length: c.n}, () => ({x: x + r() * (w + 60), y: y + r() * h, len: 10 + r() * 24, spd: 520 + r() * 420, a: .07 + r() * .17}));
const lights = Array.from({length: 30}, () => ({x: c.city[0] + r() * c.city[2], y: c.city[1] + r() * c.city[3], p: r() * 6.28, f: .25 + r() * .7, warm: r() > .35}));
const slides = Array.from({length: 4}, () => ({x: x + r() * w, y: y + r() * h, spd: 6 + r() * 14, r: 1.6 + r() * 1.4}));
// Static droplets on the glass, rendered once.
const drops = document.createElement('canvas'); drops.width = cv.width; drops.height = cv.height;
const d = drops.getContext('2d'); d.scale(scale, scale);
for (let i = 0; i < 70; i++) {
const px = x + r() * w, py = y + r() * h, rr = 1 + r() * 2.4, g = d.createRadialGradient(px - rr * .3, py - rr * .3, 0, px, py, rr);
g.addColorStop(0, 'rgba(230,240,255,.45)'); g.addColorStop(1, 'rgba(160,190,255,0)');
d.fillStyle = g; d.beginPath(); d.arc(px, py, rr, 0, 7); d.fill();
}
S[name] = {cv, ctx, c, streaks, lights, slides, drops, scale};
}
function paint(name, dt, now) {
const s = S[name]; if (!s) return;
const {ctx, c, scale} = s, [x, y, w, h] = c.clip;
const col = COLORS.moon.map((m, i) => Math.round(m + (COLORS.fog[i] - m) * mix));
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(x - 2, y - 2, w + 4, h + 4);
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
// City windows: slow blink, warm or cool.
for (const l of s.lights) {
const a = Math.max(0, Math.sin(l.p + now / 1000 * l.f)) * .55;
if (a < .02) continue;
ctx.fillStyle = l.warm ? `rgba(255,214,150,${a.toFixed(3)})` : `rgba(${col[0]},${col[1]},${col[2]},${(a * .8).toFixed(3)})`;
ctx.fillRect(l.x, l.y, 2.2, 2.6);
}
ctx.lineWidth = .8; ctx.lineCap = 'round';
for (const k of s.streaks) {
k.y += k.spd * dt; k.x -= k.spd * dt * .18;
if (k.y > y + h) { k.y = y - k.len; k.x = x + Math.random() * (w + 60); }
ctx.strokeStyle = `rgba(${col[0]},${col[1]},${col[2]},${k.a.toFixed(3)})`;
ctx.beginPath(); ctx.moveTo(k.x, k.y); ctx.lineTo(k.x - k.len * .18, k.y + k.len); ctx.stroke();
}
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(s.drops, 0, 0); ctx.setTransform(scale, 0, 0, scale, 0, 0);
for (const q of s.slides) {
q.y += q.spd * dt; if (q.y > y + h) { q.y = y; q.x = x + Math.random() * w; }
ctx.fillStyle = 'rgba(225,236,255,.35)'; ctx.beginPath(); ctx.arc(q.x, q.y, q.r, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(225,236,255,.08)'; ctx.fillRect(q.x - .5, q.y - 26, 1, 26);
}
ctx.restore();
}
function draw(now, tier) {
const want = tier > 0 ? .5 : 1;
if (want !== res) { res = want; build('room', res); build('talk', res); dirty = true; }
if (mixStart) { const k = Math.min(1, (now - mixStart) / 1000); mix = mixFrom + (mixTo - mixFrom) * (k * k * (3 - 2 * k)); if (k >= 1) mixStart = 0; dirty = true; }
if (still()) { if (!dirty) return; dirty = false; paint('room', 0, 0); paint('talk', 0, 0); return; }
if (document.hidden) return;
if (tier > 1 && now - lastDraw < 32) return; // ~30fps on slow devices
const dt = lastDraw ? Math.min(.05, (now - lastDraw) / 1000) : 0; lastDraw = now;
if (visible.room) paint('room', dt, now);
if (visible.talk) paint('talk', dt, now);
}
function view(cam, talkOpacity) { visible.room = talkOpacity < .998; visible.talk = talkOpacity > .002; dirty = true; }
function setTheme(name, instant) {
mixTo = name === 'fog' ? 1 : 0;
if (instant) { mix = mixTo; mixStart = 0; dirty = true; return; }
mixFrom = mix; mixStart = performance.now();
}
document.addEventListener('visibilitychange', () => { lastDraw = 0; });
return {draw, view, setTheme};
})();
// Your side of the table: drawn per theme at the viewport's own size, so the cup and candle never stretch.
const Near = (() => {
const host = document.getElementById('near');
const PAL = {
moon: {top: ['#3b2c30', '#221a24', '#0b0a14'], cool: '150,180,255', warm: '255,190,130', grain: ['#c9b8ff', '#ffd9b0'], rim: ['170,200,255', '240,230,255', '255,205,150'], cup: ['#5a6aa0', '#d9def2', '#9ea6c8', '#3a3f66'], handle: '#aeb6d8', bokeh: ['160,190,255', '255,200,140']},
fog: {top: ['#3a2a3e', '#231a30', '#0e0a18'], cool: '200,170,255', warm: '255,180,175', grain: ['#e2c4ff', '#ffd2d8'], rim: ['205,180,255', '246,226,255', '255,192,176'], cup: ['#7a62a8', '#e6dcf2', '#b0a2c8', '#4a3a66'], handle: '#c2b4d8', bokeh: ['205,170,255', '255,186,200']}
};
function anchors(W, H) {
const sx = W / 1600, sy = H / 1000, u = Math.min(1.35, Math.max(.42, Math.min(sx, sy))), phone = W <= 720 || W / H < .9;
return {sx, sy, u, phone, cup: phone ? [34, H - 8] : [210 * sx, 985 * sy], candle: phone ? [W - 26, H - 30] : [1400 * sx, 966 * sy]};
}
function paint(cv, pal, W, H, dpr) {
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const c = cv.getContext('2d'), A = anchors(W, H), {sx, sy, u} = A, px = n => n * dpr;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
const ex = W / 2, ey = 1585 * sy, rx = 1300 * Math.max(sx, .32), ry = 775 * sy;
// Table top: one arc of a large round table, the part nearest to you.
c.save(); c.filter = `blur(${px(2.5 * u)}px)`;
c.beginPath(); c.ellipse(ex, ey, rx, ry, 0, Math.PI, 2 * Math.PI); c.lineTo(W + 100, H + 100); c.lineTo(-100, H + 100); c.closePath();
const top = c.createLinearGradient(0, ey - ry - 30 * sy, 0, H);
top.addColorStop(0, pal.top[0]); top.addColorStop(.3, pal.top[1]); top.addColorStop(1, pal.top[2]);
c.fillStyle = top; c.fill(); c.clip();
let g = c.createRadialGradient(300 * sx, 860 * sy, 0, 300 * sx, 860 * sy, 520 * u);
g.addColorStop(0, `rgba(${pal.cool},.22)`); g.addColorStop(1, `rgba(${pal.cool},0)`); c.fillStyle = g; c.fillRect(0, 0, W, H);
g = c.createRadialGradient(1260 * sx, 840 * sy, 0, 1260 * sx, 840 * sy, 560 * u);
g.addColorStop(0, `rgba(${pal.warm},.26)`); g.addColorStop(1, `rgba(${pal.warm},0)`); c.fillStyle = g; c.fillRect(0, 0, W, H);
c.globalAlpha = .06;
for (let i = 0; i < 26; i++) {
c.strokeStyle = pal.grain[i % 2]; c.lineWidth = 1;
c.beginPath(); c.ellipse(ex, ey, Math.max(10, rx - i * 9 * sx), Math.max(10, ry - (i * 6 + 14) * sy), 0, Math.PI * 1.08, Math.PI * 1.92); c.stroke();
}
c.restore();
c.save(); c.filter = `blur(${px(1)}px)`;
const rim = c.createLinearGradient(0, 0, W, 0);
rim.addColorStop(0, `rgba(${pal.rim[0]},.05)`); rim.addColorStop(.25, `rgba(${pal.rim[0]},.45)`);
rim.addColorStop(.55, `rgba(${pal.rim[1]},.3)`); rim.addColorStop(.8, `rgba(${pal.rim[2]},.55)`); rim.addColorStop(1, `rgba(${pal.rim[2]},.08)`);
c.strokeStyle = rim; c.lineWidth = 2;
c.beginPath(); c.ellipse(ex, ey, rx, ry, 0, Math.PI * 1.03, Math.PI * 1.97); c.stroke();
c.restore();
// Your cup (nearest, most out of focus). Drawn in HEL-798 units around its base.
c.save(); c.translate(A.cup[0], A.cup[1]); c.scale(u, u); c.translate(-210, -985); c.filter = `blur(${px(5 * u)}px)`;
c.fillStyle = '#0d0c18'; c.beginPath(); c.ellipse(205, 985, 150, 34, 0, 0, 7); c.fill();
const cup = c.createLinearGradient(100, 0, 320, 0);
cup.addColorStop(0, pal.cup[0]); cup.addColorStop(.35, pal.cup[1]); cup.addColorStop(.7, pal.cup[2]); cup.addColorStop(1, pal.cup[3]);
c.fillStyle = cup; c.beginPath(); c.moveTo(108, 830); c.bezierCurveTo(108, 950, 140, 985, 210, 985); c.bezierCurveTo(280, 985, 312, 950, 312, 830); c.closePath(); c.fill();
c.strokeStyle = pal.handle; c.lineWidth = 16; c.beginPath(); c.arc(320, 885, 34, -Math.PI / 2.2, Math.PI / 2.2); c.stroke();
c.fillStyle = '#3a2416'; c.beginPath(); c.ellipse(210, 830, 102, 18, 0, 0, 7); c.fill();
c.fillStyle = 'rgba(255,200,140,.35)'; c.beginPath(); c.ellipse(240, 828, 46, 7, 0, 0, 7); c.fill();
c.restore();
// Your small candle, answering the one beside her.
c.save(); c.translate(A.candle[0], A.candle[1]); c.scale(u, u); c.translate(-1400, -966); c.filter = `blur(${px(3.5 * u)}px)`;
const glass = c.createLinearGradient(1350, 0, 1450, 0);
glass.addColorStop(0, 'rgba(255,190,130,.25)'); glass.addColorStop(.5, 'rgba(255,225,180,.55)'); glass.addColorStop(1, 'rgba(255,170,110,.22)');
c.fillStyle = glass; c.beginPath(); c.moveTo(1356, 850); c.lineTo(1444, 850); c.lineTo(1440, 952); c.quadraticCurveTo(1400, 966, 1360, 952); c.closePath(); c.fill();
c.fillStyle = 'rgba(255,240,215,.35)'; c.beginPath(); c.ellipse(1400, 850, 44, 9, 0, 0, 7); c.fill();
c.restore();
// Near bokeh (depth of field).
c.save(); c.globalCompositeOperation = 'screen';
const bok = [[60, 120, 70, 0, .1], [1530, 300, 90, 1, .09], [1480, 980, 120, 1, .1], [560, 990, 80, 0, .08], [30, 560, 60, 0, .08]];
for (const [x, y, r0, k, a] of bok) {
const r = r0 * u, X = x * sx, Y = y * sy, col = pal.bokeh[k];
g = c.createRadialGradient(X, Y, r * .6, X, Y, r);
g.addColorStop(0, `rgba(${col},${a})`); g.addColorStop(.9, `rgba(${col},${a * .8})`); g.addColorStop(1, `rgba(${col},0)`);
c.fillStyle = g; c.beginPath(); c.arc(X, Y, r, 0, 7); c.fill();
}
c.restore();
return A;
}
function draw() {
const W = innerWidth, H = innerHeight, tier = +(document.documentElement.dataset.tier || 0);
const dpr = Math.min(tier > 0 ? 1 : 2, window.devicePixelRatio || 1);
let A;
for (const cv of host.querySelectorAll('canvas')) A = paint(cv, PAL[cv.dataset.themeLayer], W, H, dpr);
host.style.setProperty('--u', A.u.toFixed(3));
host.style.setProperty('--steam-x', (A.cup[0] + 5 * A.u).toFixed(1) + 'px'); host.style.setProperty('--steam-y', (A.cup[1] - 330 * A.u).toFixed(1) + 'px');
host.style.setProperty('--candle-x', A.candle[0].toFixed(1) + 'px'); host.style.setProperty('--candle-y', (A.candle[1] - 116 * A.u).toFixed(1) + 'px');
}
return {draw};
})();
+297
View File
@@ -0,0 +1,297 @@
/* HEL-799 · 对坐:共同空间动态小样
Based on the approved HEL-798 still (talk state, 1600×1000) and the HEL-793/795 tokens.
Motion budget: UI feedback 160ms · text 420–680ms · camera spring ≈0.85s · ambient loops 6–30s. */
:root{
color-scheme:dark;
--font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Microsoft YaHei UI","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;
--ease-out:cubic-bezier(.22,1,.36,1);--ease-space:cubic-bezier(.65,0,.25,1);--ease-in-out:cubic-bezier(.65,0,.35,1);
--dur-fast:160ms;--dur-ui:280ms;--dur-text:620ms;--dur-space:850ms;
--view-h:100dvh;--view-top:0px;--safe-bottom:env(safe-area-inset-bottom,0px);
--stage-h:128px;--nav-space:52px;
}
html[data-theme="moon"]{--bg-0:#050918;--text-1:#eef2ff;--text-2:#b9c6e6;--text-3:#94a5c8;--accent:#a9dcff;--accent-soft:#a9dcff1f;--accent-glow:#96d2ff55;--on-accent:#0b1534;--user:#d7e2ff;--glass:#16244e66;--glass-read:#0b1534c4;--glass-line:#b8d4ff26;--surface:#0b1330;--input-a:#16244e70;--input-b:#1c264c70;--input-c:#3a2c3a60;--pool:#050918;--glass-blur:20px}
html[data-theme="fog"]{--bg-0:#0e0820;--text-1:#f6efff;--text-2:#dccbee;--text-3:#bba7d6;--accent:#ebbcff;--accent-soft:#ebbcff24;--accent-glow:#e2aaff5c;--on-accent:#2a0f40;--user:#f1e2ff;--glass:#36226666;--glass-read:#211444c8;--glass-line:#ecd8ff2e;--surface:#24154a;--input-a:#3a245e70;--input-b:#3a265470;--input-c:#5a2c5260;--pool:#0e0820;--glass-blur:28px}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg-0);transition:background-color .9s}
body{font:15px/1.7 var(--font-sans);color:var(--text-1);-webkit-font-smoothing:antialiased;transition:color .6s}
button,input,textarea,select{font:inherit;color:inherit}
button{border:0;background:none;cursor:pointer;transition:transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out),background-color var(--dur-ui) var(--ease-out),color var(--dur-ui) var(--ease-out),box-shadow var(--dur-ui) var(--ease-out)}
button:disabled{opacity:.3;cursor:default}button:active:not(:disabled){transform:scale(.97)}
button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none}
h1,h2,h3,p{margin:0}h2{font-size:22px;font-weight:350}h3{font-size:18px;font-weight:400}strong{font-weight:400}[hidden]{display:none!important}
.symbols,.sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}
.glass{transition:background-color .6s,border-color .6s,box-shadow .6s,transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out);background:var(--glass);border:1px solid var(--glass-line);box-shadow:inset 0 1px 0 #ffffff0d,0 20px 80px #0206162a;backdrop-filter:blur(var(--glass-blur)) saturate(125%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(125%)}
.round{width:40px;height:40px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;flex:none}
.primary{display:inline-flex;gap:10px;align-items:center;justify-content:center;background:var(--accent);color:var(--on-accent);padding:12px 22px;border-radius:30px;box-shadow:0 0 30px var(--accent-glow),inset 0 1px 0 #ffffff60}
.small-link{color:var(--text-2);padding:10px 0;font-size:13px;text-align:left}
.eyebrow{font-size:12px;letter-spacing:.18em;color:var(--text-3);margin-bottom:14px}.quiet{color:var(--text-2);font-size:14px}.quiet span{margin:0 10px}.subtle{font-size:13px;color:var(--text-2)}
/* ── The room: two reference stills on one camera ── */
#scene{position:fixed;inset:0;overflow:hidden;background:#04060f}
.cam{position:absolute;inset:0;transition:transform 1.4s var(--ease-out)}
/* Each plate box covers its whole picture (so a mask never clips it to 1600×1000); .po is the 1600×1000 design origin. */
.plate{position:absolute;will-change:transform;contain:layout style}
#plate-room{left:-640px;top:-112px;width:2240px;height:1260px;transform-origin:640px 112px}
#plate-talk{left:-118px;top:-10px;width:1792px;height:1008px;transform-origin:118px 10px}
#plate-room .po{left:640px;top:112px}#plate-talk .po{left:118px;top:10px}
.po{position:absolute;width:1600px;height:1000px}
.plate .photo{position:absolute;max-width:none;user-select:none;-webkit-user-drag:none}
#plate-room .photo{left:-640px;top:-112px;width:2240px;height:1260px;filter:saturate(.9) brightness(.8) contrast(1.05)}
#plate-talk .photo{left:-118px;top:-10px;width:1792px;height:1008px;filter:saturate(.92) brightness(.9) contrast(1.04)}
#plate-talk{visibility:hidden}
.tint{position:absolute;inset:0;transition:opacity .9s var(--ease-in-out)}
.tint i,.po>i{position:absolute;pointer-events:none;display:block}
html[data-theme="moon"] .tint.fog,html[data-theme="fog"] .tint.moon{opacity:0}
/* Her side, close: the HEL-798 still, unchanged (stage px = plate px). */
#plate-talk .tint .night{inset:0;mix-blend-mode:multiply}
#plate-talk .tint .night::before{content:"";position:absolute;left:-118px;top:-10px;width:118px;bottom:0}
#plate-talk .tint .night2{inset:-10px 0 0;mix-blend-mode:color;-webkit-mask:linear-gradient(90deg,#000 0 40%,transparent 56%);mask:linear-gradient(90deg,#000 0 40%,transparent 56%)}
#plate-talk .tint .beam{left:120px;top:40px;width:560px;height:1040px;transform:skewX(18deg);filter:blur(30px);mix-blend-mode:screen}
#plate-talk .moon .night{background:linear-gradient(100deg,#1c2f7fcc 0%,#1b2c78aa 28%,#1a2a7044 46%,transparent 60%)}
#plate-talk .moon .night::before{background:#1c2f7fcc}
#plate-talk .moon .night2{background:linear-gradient(180deg,#24357ccc 0%,#24357c66 18%,transparent 34%)}
#plate-talk .moon .beam{background:linear-gradient(180deg,#a9c8ff22,#a9c8ff10 55%,transparent)}
#plate-talk .fog .night{background:linear-gradient(100deg,#3b2a7ccc 0%,#3a2874aa 28%,#38266a44 46%,transparent 60%)}
#plate-talk .fog .night::before{background:#3b2a7ccc}
#plate-talk .fog .night2{background:linear-gradient(180deg,#5a3a92cc 0%,#5a3a9266 18%,transparent 34%)}
#plate-talk .fog .beam{background:linear-gradient(180deg,#e6c4ff1c,#e6c4ff0c 55%,transparent);transform:skewX(-8deg)}
/* Her side, far: the room still turned to the same rainy night. Coordinates are plate px. */
#plate-room .tint{inset:auto;left:-640px;top:-112px;width:2240px;height:1260px}
#plate-room .tint .night{inset:0;mix-blend-mode:multiply}
#plate-room .tint .night2{inset:0;mix-blend-mode:color;-webkit-mask:radial-gradient(ellipse 150px 210px at 1915px 459px,transparent 70%,#000 100%),linear-gradient(180deg,#000 0 27%,transparent 40%);mask:radial-gradient(ellipse 150px 210px at 1915px 459px,transparent 70%,#000 100%),linear-gradient(180deg,#000 0 27%,transparent 40%);-webkit-mask-composite:source-in;mask-composite:intersect}
#plate-room .moon .night{background:radial-gradient(ellipse 330px 420px at 1930px 560px,#ffffff 0 30%,#ffffff00 100%),linear-gradient(97deg,#16225cf2 0%,#1a2768e0 30%,#1c2c74b0 50%,#2a3a8a70 62%,#ffffff00 80%),linear-gradient(180deg,#00000000 50%,#1a204c66 100%)}
#plate-room .moon .night2{background:linear-gradient(180deg,#24357ce6 0%,#24357ccc 22%,#24357c55 40%)}
#plate-room .fog .night{background:radial-gradient(ellipse 330px 420px at 1930px 560px,#ffffff 0 30%,#ffffff00 100%),linear-gradient(97deg,#2c1f5ef2 0%,#32246ae0 30%,#3a2a78b0 50%,#4a3a8a70 62%,#ffffff00 80%),linear-gradient(180deg,#00000000 50%,#2a1c4c66 100%)}
#plate-room .fog .night2{background:linear-gradient(180deg,#5a3a92e6 0%,#5a3a92cc 22%,#5a3a9255 40%)}
.plate canvas.rain{position:absolute;left:0;top:0;width:1600px;height:1000px;pointer-events:none}
/* Weather in the window: a slow cloud veil (moon) or folding violet mist (fog). */
.mist{border-radius:50%;filter:blur(38px);mix-blend-mode:screen;opacity:0;transition:opacity 1.2s var(--ease-in-out);animation:mist 34s var(--ease-in-out) infinite alternate}
.mist.m2{animation-duration:47s;animation-delay:-20s}
#plate-talk .mist{left:-60px;top:120px;width:560px;height:120px}#plate-talk .mist.m2{top:250px;left:120px;width:460px}
#plate-room .mist{left:660px;top:20px;width:420px;height:90px}#plate-room .mist.m2{top:120px;left:760px;width:360px}
html[data-theme="moon"] .mist{background:#b9c8ff;opacity:.07}
html[data-theme="fog"] .mist{background:linear-gradient(90deg,#c69cff,#ff9fd8 60%,#9cc0ff);opacity:.32}
@keyframes mist{from{transform:translateX(-60px) scaleY(.8)}to{transform:translateX(90px) scaleY(1.25)}}
.spill{inset:0;mix-blend-mode:screen;animation:breathe-light 9s var(--ease-in-out) infinite alternate}
#plate-talk .spill{background:radial-gradient(ellipse 520px 380px at 1290px 40px,#ffcf8a30,transparent 70%),radial-gradient(ellipse 760px 260px at 1180px 900px,#ffb97a2a,transparent 70%)}
#plate-room .spill{background:radial-gradient(ellipse 360px 300px at 1420px 150px,#ffcf8a38,transparent 70%),radial-gradient(ellipse 600px 240px at 1250px 760px,#ffb97a22,transparent 70%)}
html[data-theme="fog"] .spill{filter:hue-rotate(-28deg) saturate(1.1)}
@keyframes breathe-light{from{opacity:.75}to{opacity:1}}
.flame-glow{width:90px;height:90px;margin:-45px 0 0 -45px;border-radius:50%;background:radial-gradient(circle,#ffd9a0aa,#ffb46a44 35%,transparent 70%);mix-blend-mode:screen;animation:flicker 3.2s steps(1,end) infinite,flame 2.4s var(--ease-in-out) infinite alternate}
.flame-glow+.flame-glow{animation-delay:-1.1s,-.7s}
@keyframes flicker{0%,100%{opacity:.9}17%{opacity:.72}18%{opacity:.95}46%{opacity:.8}47%{opacity:1}79%{opacity:.78}}
@keyframes flame{to{transform:scale(1.12) translateY(-3px)}}
.mote{left:var(--x);top:var(--y);width:4px;height:4px;border-radius:50%;background:#ffe2b8;box-shadow:0 0 8px 2px #ffd59a88;opacity:0;animation:mote var(--d) linear var(--dl) infinite}
@keyframes mote{0%{transform:translate(0,0);opacity:0}15%{opacity:.7}80%{opacity:.5}100%{transform:translate(-40px,-160px);opacity:0}}
.moon-disc{position:absolute;left:0;top:0;width:44px;height:44px;margin:-22px 0 0 -22px;border-radius:50%;background:radial-gradient(circle at 42% 40%,#fbfdff 0%,#e4ecff 55%,#c9d6f5 100%);filter:blur(.6px);box-shadow:0 0 22px 6px #cfe0ff88,0 0 90px 40px #8fb0ff33,0 0 220px 120px #5f7fe022;transform-origin:50% 50%;will-change:transform;transition:opacity 1s var(--ease-in-out)}
html[data-theme="fog"] .moon-disc{opacity:0}
.vignette{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse 62% 74% at 54% 42%,transparent 52%,#050918aa 100%),linear-gradient(180deg,#050918aa 0%,transparent 13%)}
html[data-theme="fog"] .vignette{background:radial-gradient(ellipse 62% 74% at 54% 42%,transparent 52%,#0e0820aa 100%),linear-gradient(180deg,#0e0820aa 0%,transparent 13%)}
/* Your side: the table edge, a cup and a small candle. Drawn for the viewport, never stretched. */
.near{position:absolute;inset:0;pointer-events:none;--near-y:0px;transform:translate3d(var(--px,0px),calc(var(--py,0px) + var(--near-y)),0);transition:transform var(--dur-space) var(--ease-space),opacity var(--dur-space) var(--ease-out),filter var(--dur-space) var(--ease-out)}
.near-c{position:absolute;inset:0;width:100%;height:100%;transition:opacity .9s var(--ease-in-out)}
html[data-theme="moon"] .near-c[data-theme-layer="fog"],html[data-theme="fog"] .near-c[data-theme-layer="moon"]{opacity:0}
.steam{position:absolute;left:var(--steam-x);top:var(--steam-y);width:calc(var(--u) * 40px);height:calc(var(--u) * 170px);margin-left:calc(var(--u) * -20px);border-radius:50%;background:radial-gradient(ellipse at 50% 80%,#d2e1ff33,transparent 70%);filter:blur(calc(var(--u) * 9px));transform-origin:50% 100%;animation:steam 7s var(--ease-in-out) infinite}
.steam.s2{margin-left:calc(var(--u) * 14px);animation-duration:9s;animation-delay:-4s}
@keyframes steam{0%{transform:translateY(20%) scaleX(.7) skewX(0);opacity:0}30%{opacity:.9}70%{opacity:.5}100%{transform:translateY(-30%) scaleX(1.3) skewX(-14deg);opacity:0}}
.candle{position:absolute;left:var(--candle-x);top:var(--candle-y);width:calc(var(--u) * 380px);height:calc(var(--u) * 380px);margin:calc(var(--u) * -190px) 0 0 calc(var(--u) * -190px);border-radius:50%;background:radial-gradient(circle,#ffdca0cc 0,#ffbe7866 12%,#ffaa6420 40%,transparent 70%);mix-blend-mode:screen;animation:flicker 2.8s steps(1,end) infinite,flame 2s var(--ease-in-out) infinite alternate}
.veil{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 40%,#02061433,#020614cc 90%);opacity:0;pointer-events:none;transition:opacity var(--dur-space) var(--ease-out)}
body[data-mode="pane"] .veil{opacity:.75}body[data-mode="record"] .veil{opacity:.35}
body[data-mode="home"] .near{--near-y:3.5vh}
body[data-mode="pane"] .near,body[data-mode="record"] .near{--near-y:7vh;opacity:.7}
#viewport{position:absolute;top:var(--view-top);left:0;right:0;height:var(--view-h);min-height:260px;overflow:hidden}
#world{position:absolute;inset:0}
/* ── Top ── */
.topbar{position:relative;z-index:4;height:84px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 40px}
.identity{display:flex;align-items:baseline;gap:12px;min-width:0;text-shadow:0 1px 12px var(--pool)}
.identity strong{font-size:22px;font-weight:400;letter-spacing:.04em;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.presence{color:var(--text-2);font-size:13px;letter-spacing:.04em;white-space:nowrap;transition:opacity var(--dur-ui) var(--ease-out)}
.glint{flex:none;align-self:center;width:7px;height:7px;background:var(--accent);border-radius:50%;box-shadow:0 0 10px var(--accent);animation:pulse 4.5s var(--ease-in-out) infinite}
@keyframes pulse{50%{box-shadow:0 0 4px var(--accent);opacity:.65}}
.top-actions{display:flex;align-items:center;gap:12px;flex:none;font-size:12px;color:var(--text-2)}
.sample-tag{height:30px;padding:0 13px;border-radius:15px;border:1px dashed var(--glass-line);background:var(--glass);color:var(--text-3);font-size:12px;white-space:nowrap;backdrop-filter:blur(18px)}
.weather small{color:var(--text-3)}
.theme{display:flex;gap:2px;border:1px solid var(--glass-line);padding:3px;border-radius:16px;background:var(--glass);backdrop-filter:blur(18px)}
.theme button{font-size:12px;color:var(--text-3);height:24px;padding:0 11px;border-radius:12px;display:flex;align-items:center;gap:6px;white-space:nowrap}
.theme button i{width:5px;height:5px;border-radius:50%;background:#a9dcff}.theme button[data-theme="fog"] i{background:#ebbcff}
.theme [aria-pressed="true"]{color:var(--text-1);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent-glow)}
.end-talk{display:none;height:30px;padding:0 13px;border-radius:15px;align-items:center;gap:7px;background:var(--glass);border:1px solid var(--glass-line);color:var(--text-1);font-size:12px;white-space:nowrap;backdrop-filter:blur(18px)}
.end-talk svg{width:14px;height:14px}
body[data-mode="talk"] .end-talk,body[data-mode="record"] .end-talk{display:inline-flex;animation:chip-in 420ms var(--ease-out) 200ms both}
@keyframes chip-in{from{opacity:0;transform:translateY(-4px)}}
/* ── Being here (home) ── */
.home-copy{position:absolute;z-index:2;left:6.5vw;top:50%;width:min(420px,32vw);transform:translateY(-44%);transition:opacity 360ms var(--ease-out),transform 520ms var(--ease-out),filter 360ms var(--ease-out);text-shadow:0 2px 18px var(--pool)}
.home-copy h1{font-size:30px;font-weight:300;line-height:1.6;letter-spacing:.02em;margin-bottom:14px}
.left-note{display:block;width:100%;text-align:left;padding:16px 18px;border-radius:20px;margin-top:28px;line-height:1.85;font-size:14px;text-shadow:none}
.note-meta{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--accent);margin-bottom:8px}.note-meta time{color:var(--text-3)}
.home-actions{display:flex;align-items:center;gap:12px;margin-top:14px}.home-actions .round{width:48px;height:48px}
body:not([data-mode="home"]) .home-copy{opacity:0;transform:translate(-24px,-44%);filter:blur(6px);pointer-events:none;transition-duration:220ms,420ms,220ms}
body[data-mode="home"] .home-copy{transition-delay:240ms,200ms,240ms}
.her-hit{position:absolute;z-index:1;left:0;top:0;width:10px;height:10px;border-radius:40% 40% 30% 30%;display:flex;align-items:flex-start;justify-content:center}
.her-hit span{margin-top:-34px;padding:5px 12px;border-radius:14px;font-size:12px;background:var(--glass);border:1px solid var(--glass-line);backdrop-filter:blur(14px);opacity:0;transform:translateY(4px);transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out);pointer-events:none;white-space:nowrap}
.her-hit:focus-visible span{opacity:1;transform:none}
body:not([data-mode="home"]) .her-hit{visibility:hidden}
/* ── Talking: the lines fall on your side of the table, under her gaze ── */
.conversation{position:absolute;z-index:3;left:50%;bottom:calc(var(--nav-space) + var(--safe-bottom));width:min(940px,88vw);transform:translate(-50%,14px);opacity:0;pointer-events:none;transition:opacity 220ms var(--ease-out),transform 420ms var(--ease-out);display:flex;flex-direction:column;align-items:center}
body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-duration:520ms,620ms;transition-delay:300ms}
.stage{position:relative;width:100%;height:var(--stage-h);display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;text-align:center}
.stage::before{content:"";position:absolute;left:4%;right:4%;top:0;bottom:-40%;background:radial-gradient(ellipse 50% 55% at 50% 64%,color-mix(in srgb,var(--pool) 62%,transparent),transparent 100%);pointer-events:none;z-index:-1}
.line{width:100%;display:flex;flex-direction:column;align-items:center;gap:3px}
.line .who{display:inline-flex;align-items:center;gap:7px;font-size:12px;letter-spacing:.08em;color:var(--text-2);max-width:100%}
.line .who b{font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:16em}
.line .who i{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);flex:none}
.line[data-role="user"] .who i{background:transparent;box-shadow:none;border:1px solid var(--user)}
.line .state{font-style:normal;color:var(--text-3);font-size:11px;letter-spacing:0}
.line p{font-size:23px;font-weight:350;line-height:1.5;letter-spacing:.02em;max-width:900px;overflow-wrap:anywhere;text-shadow:0 2px 18px var(--pool),0 0 2px color-mix(in srgb,var(--pool) 60%,transparent);color:var(--text-1)}
.line[data-role="user"] p{color:var(--user);font-size:20px;font-weight:300}
/* Previous line: tag and words on one row, quiet (HEL-798). Fixed size; the hand-off is a crossfade of two copies. */
.line.is-prev{flex-direction:row;justify-content:center;gap:10px;opacity:.6;margin-bottom:4px}
.line.is-prev p{font-size:15px;font-weight:300;color:var(--user);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(680px,72vw)}
.line.is-prev[data-role="her"] p{color:var(--text-2)}
.ink span{display:inline;animation:ink 460ms var(--ease-out) both;animation-delay:calc(var(--i) * 14ms)}
@keyframes ink{from{opacity:0;filter:blur(3px)}}
.line.waiting .state::after{content:"";display:inline-block;width:18px;height:6px;margin-left:5px;background:radial-gradient(circle,var(--accent) 1.5px,transparent 2px) 0 50%/6px 6px repeat-x;animation:dots 1.4s linear infinite;vertical-align:middle;opacity:.8}
@keyframes dots{50%{opacity:.25}}
.reader-bar{display:flex;align-items:center;justify-content:center;gap:4px;height:30px;margin:4px 0 6px;font-size:12px;color:var(--text-3)}
.reader-bar>button{height:28px;padding:0 9px;border-radius:14px;white-space:nowrap;display:inline-flex;align-items:center;gap:5px;color:var(--text-2)}
.reader-bar .icon{width:28px;padding:0;justify-content:center}.reader-bar svg{width:14px;height:14px}
.reader-bar .count{position:relative;min-width:72px;height:28px;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;font-size:11px;color:var(--text-3)}
/* The dwell meter lives under the count: one quiet line, as in the approved still. */
.reader-bar .count::after{content:"";position:absolute;left:0;right:0;bottom:3px;height:2px;border-radius:1px;background:#ffffff1c}
.reader-bar .count i{position:absolute;left:0;right:0;bottom:3px;height:2px;border-radius:1px;background:var(--accent);box-shadow:0 0 6px var(--accent);transform-origin:left;transform:scaleX(0);z-index:1}
body.reading-paused .reader-bar .count i{opacity:.3}body.reading-held .reader-bar .count i{opacity:.5}
.reader-bar .flow[aria-pressed="false"]{color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-glow)}
#stop{color:var(--accent)}
.latest{color:var(--on-accent)!important;background:var(--accent);box-shadow:0 0 16px var(--accent-glow)}
body.single-page .reader-bar .icon,body.single-page .reader-bar .count span{visibility:hidden}
.composer{width:min(640px,100%);height:50px;border-radius:25px;display:flex;align-items:center;gap:6px;padding:0 6px 0 8px;background:linear-gradient(90deg,var(--input-a),var(--input-b) 70%,var(--input-c));border:1px solid var(--glass-line);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);box-shadow:0 10px 40px #02051466,inset 0 1px 0 #ffffff12;transition:border-color var(--dur-ui),box-shadow var(--dur-ui)}
.composer .round{width:38px;height:38px;color:var(--text-2)}
.composer textarea{flex:1;min-width:0;resize:none;height:38px;max-height:88px;border:0;background:transparent;line-height:1.6;padding:8px 4px;outline:none;color:var(--text-1);font-size:15px}
.composer textarea::placeholder{color:var(--text-3)}
.composer:focus-within{border-color:var(--accent-glow);box-shadow:0 0 0 1px var(--accent-soft),0 0 30px var(--accent-soft)}
.voice-button{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;background:var(--accent);color:var(--on-accent);box-shadow:0 0 18px var(--accent-glow);flex:none}
.voice-row{display:flex;align-items:center;justify-content:center;gap:12px;font-size:12px;color:var(--text-2);margin-bottom:6px}.voice-row button{color:var(--accent)}
.voice-wave{display:inline-flex;gap:3px;align-items:center;height:14px}.voice-wave i{width:2px;height:100%;background:var(--accent);border-radius:2px;animation:wave 1.1s var(--ease-in-out) infinite}.voice-wave i:nth-child(2){animation-delay:.15s}.voice-wave i:nth-child(3){animation-delay:.3s}.voice-wave i:nth-child(4){animation-delay:.45s}
@keyframes wave{50%{transform:scaleY(.3)}}
/* ── Navigation: small, translucent, under everything ── */
.navigation{position:absolute;z-index:5;left:50%;bottom:calc(6px + var(--safe-bottom));transform:translateX(-50%);display:flex;gap:4px;padding:3px;border-radius:17px;background:color-mix(in srgb,var(--surface) 40%,transparent);border:1px solid var(--glass-line);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);transition:transform 420ms var(--ease-out),opacity 280ms var(--ease-out)}
.nav-glow{position:absolute;left:0;top:3px;bottom:3px;width:64px;border-radius:13px;background:var(--accent-soft);box-shadow:0 0 14px var(--accent-glow);transition:transform 460ms var(--ease-out),opacity 280ms;pointer-events:none}
.navigation button{position:relative;width:64px;height:26px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:13px;font-size:12px;letter-spacing:.04em;color:var(--text-3)}
.navigation svg{width:14px;height:14px}.navigation [aria-current="page"]{color:var(--text-1)}.navigation [aria-current="page"] svg{color:var(--accent)}
.ref-note{position:absolute;right:40px;bottom:calc(14px + var(--safe-bottom));font-size:11px;color:var(--text-3);opacity:.8;letter-spacing:.04em;pointer-events:none}
.toast{position:absolute;left:50%;top:84px;transform:translate(-50%,-8px);padding:8px 16px;border-radius:20px;font-size:12px;color:var(--text-1);background:var(--glass-read);border:1px solid var(--glass-line);backdrop-filter:blur(14px);opacity:0;pointer-events:none;transition:opacity 280ms var(--ease-out),transform 280ms var(--ease-out);z-index:6;white-space:nowrap;max-width:calc(100vw - 32px);overflow:hidden;text-overflow:ellipsis}
.toast.show{opacity:1;transform:translate(-50%,0)}
/* ── Panels: reading surfaces inside the room ── */
.sheet{position:fixed;inset:auto;margin:0;top:calc(var(--view-top) + 84px);left:5vw;width:min(900px,60vw);height:calc(var(--view-h) - 150px - var(--safe-bottom));max-height:none;max-width:calc(100vw - 24px);padding:0;border-radius:26px;color:var(--text-1);background:var(--glass-read);overflow:hidden;box-shadow:0 25px 100px #02041270,inset 0 1px 0 #ffffff10}
.sheet::backdrop{background:transparent}
.sheet[data-kind="history"],.sheet[data-kind="full"]{width:min(470px,36vw);left:4vw}
.sheet-header{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:22px 26px 16px;border-bottom:1px solid var(--glass-line);height:100px}
.sheet-header .eyebrow{font-size:11px;margin-bottom:3px}
.sheet-content{height:calc(100% - 100px);overflow:auto;overscroll-behavior:contain;padding:22px 26px 32px;scrollbar-gutter:stable;line-height:1.9}
.sheet-content>*{animation:rise 520ms var(--ease-out) both}.sheet-content>:nth-child(2){animation-delay:40ms}.sheet-content>:nth-child(3){animation-delay:80ms}.sheet-content>:nth-child(4){animation-delay:120ms}.sheet-content>:nth-child(n+5){animation-delay:160ms}
@keyframes rise{from{opacity:0;transform:translateY(10px);filter:blur(4px)}}
.sheet-content :is(p,ul){margin-bottom:16px}
.sheet-content .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:15px 0}
.sheet-content .pill{border:1px solid var(--glass-line);border-radius:24px;padding:8px 17px;font-size:13px}
.sheet-content .pill[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);box-shadow:0 0 16px var(--accent-soft)}
.sheet-content .item{width:100%;text-align:left;border-top:1px solid var(--glass-line);padding:20px 4px;display:block;border-radius:6px}
.item small{display:block;color:var(--text-2);margin-top:7px;font-size:13px}.item .date{font-size:11px;letter-spacing:.08em;color:var(--accent);margin-bottom:10px}
.quote{font-size:20px;font-weight:350;border-left:1px solid var(--accent);padding-left:20px;margin:24px 0}
.sheet-content .message{padding:16px 0;border-bottom:1px solid var(--glass-line)}
.message .author{display:flex;align-items:center;gap:7px;color:var(--accent);font-size:12px;margin-bottom:6px;overflow-wrap:anywhere}
.message .author i{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);flex:none}
.message[data-role="user"] .author{color:var(--text-2)}.message[data-role="user"] .author i{background:none;box-shadow:none;border:1px solid var(--user)}
.message p{font-size:15px;white-space:pre-wrap;overflow-wrap:anywhere}.message .incomplete{font-size:12px;color:var(--text-2)}
.sheet-content label{display:block;font-size:13px;color:var(--text-2);margin:18px 0 7px}
.sheet-content input,.sheet-content textarea,.sheet-content select{border:1px solid var(--glass-line);border-radius:12px;background:var(--surface);padding:11px 14px;width:100%;max-width:100%;font-size:15px}
.sheet-content textarea{min-height:90px;resize:vertical}
.sheet-content .notice{padding:14px 17px;border:1px solid var(--glass-line);border-radius:16px;color:var(--text-2);font-size:13px}
.sheet-content .status{color:var(--accent);min-height:26px;font-size:13px}.sheet-content .guide{font-size:13px;color:var(--text-2)}
.sheet-content summary{cursor:pointer;color:var(--accent);padding:10px 0}.sheet-content details{border-top:1px solid var(--glass-line);margin-top:20px}
.sheet-content .error{color:#ffb8c8}
.sheet-content .primary{color:var(--on-accent)}
*{scrollbar-width:thin;scrollbar-color:var(--accent-glow) transparent}
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:var(--accent-glow);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}::-webkit-scrollbar-track{background:transparent}
/* Hover only where a real pointer exists; touch never gets stuck hover states. */
@media(hover:hover) and (pointer:fine){
button:hover:not(:disabled){opacity:.85}.sheet-content .item:hover{color:var(--accent);background:linear-gradient(90deg,var(--accent-soft),transparent 70%)}
.reader-bar>button:hover:not(:disabled),.navigation button:hover{background:#ffffff0d;opacity:1}
.her-hit{cursor:pointer}.her-hit:hover span{opacity:1;transform:none}
}
@media(max-width:1100px){
.weather,.ref-note{display:none}.identity strong{max-width:180px}
.home-copy{width:min(400px,40vw)}
.sheet{width:84vw;left:8vw}.sheet[data-kind="history"],.sheet[data-kind="full"]{width:min(440px,52vw)}
}
/* ── Phone ── */
@media(max-width:720px){
.vignette,html[data-theme="fog"] .vignette{background:radial-gradient(ellipse 95% 70% at 55% 38%,transparent 55%,color-mix(in srgb,var(--bg-0) 66%,transparent) 100%),linear-gradient(180deg,color-mix(in srgb,var(--bg-0) 66%,transparent) 0%,transparent 10%)}
:root{--stage-h:132px;--nav-space:60px}
.topbar{height:56px;padding:0 14px;gap:8px}.identity{gap:8px}.identity strong{font-size:16px;max-width:96px}.presence{display:none}
.top-actions{gap:6px}.sample-tag{height:26px;font-size:10px;padding:0 8px}.theme{padding:2px}.theme button{font-size:11px;height:22px;padding:0 8px;gap:4px}
.end-talk{height:28px;width:28px;padding:0;justify-content:center}.end-talk span{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden}
.ref-note{display:block;top:52px;right:14px;bottom:auto;font-size:10px;opacity:.65}
.home-copy{left:20px;right:20px;top:auto;bottom:calc(var(--nav-space) + 6px + var(--safe-bottom));width:auto;transform:none}
body:not([data-mode="home"]) .home-copy{transform:translateY(16px)}
.home-copy .eyebrow{margin-bottom:8px}.home-copy h1{font-size:22px;line-height:1.55;margin-bottom:8px}.home-copy .quiet{font-size:12px}
.left-note{padding:12px 15px;font-size:13px;margin-top:14px;border-radius:18px;line-height:1.7}.note-meta{font-size:11px;margin-bottom:4px}
.home-copy .small-link{padding:8px 0;font-size:12px}.home-actions{margin-top:4px}.home-actions .primary{padding:10px 20px;font-size:14px}.home-actions .round{width:44px;height:44px}
.conversation{width:calc(100% - 20px)}
.line p{font-size:18px;line-height:1.55}.line[data-role="user"] p{font-size:16px}.line.is-prev p{font-size:13px;max-width:70vw}
.reader-bar{gap:0;margin:2px 0 6px;font-size:11px}.reader-bar>button{padding:0 7px;height:34px}.reader-bar .icon{width:34px}.reader-bar .count{min-width:56px}
.composer{height:48px;padding:0 4px}.composer textarea{font-size:16px}
.navigation{bottom:calc(6px + var(--safe-bottom));padding:3px}.navigation button{width:70px;height:40px;font-size:12px}.nav-glow{width:70px;border-radius:14px}
.toast{top:60px;font-size:11px}
.sheet,.sheet[data-kind="history"],.sheet[data-kind="full"]{left:10px;right:10px;width:calc(100% - 20px);top:calc(var(--view-top) + 60px);height:calc(var(--view-h) - 128px - var(--safe-bottom));border-radius:22px}
.sheet-header{padding:14px 18px;height:80px}.sheet-content{padding:16px 18px 26px;height:calc(100% - 80px)}h2{font-size:20px}.quote{font-size:18px}
body[data-mode="talk"] .latest span{max-width:9em;overflow:hidden;text-overflow:ellipsis}
}
@media(max-width:360px){.sample-tag{font-size:0;width:26px;padding:0}.sample-tag::before{content:"演示";font-size:10px}.identity strong{max-width:70px}}
@media(max-height:700px) and (max-width:720px){
.home-copy h1{font-size:19px}.home-copy .quiet{display:none}.home-copy .left-note{margin-top:8px;padding:9px 13px}.home-copy .left-note br{display:none}
}
/* Candidate only (?frame=media, HEL-802 v2 §2.3): on phones the room sits in a top media box of height --media-h, its
bottom 12% fading into the page; the words and the composer live underneath. No foreground table on phones. */
.frame-note{display:none}html.media-on .frame-note{display:inline}
html.media-on #scene{bottom:auto;height:var(--media-h)}
html.media-on #scene::after{content:"";position:absolute;left:0;right:0;bottom:0;height:12%;background:linear-gradient(180deg,transparent,var(--bg-0));pointer-events:none;transition:background .9s}
html.media-on .near{display:none}
/* Clean masters for image-to-video: no interface; optionally no foreground. */
html.clean #viewport,html.clean #sheet{display:none}html.clean .veil{opacity:0}html.no-fore .near,html.no-fore .vignette{display:none}
/* Short visual viewport = keyboard open or a squat window. Her face yields first, the words never do. */
body.compact{--stage-h:78px;--nav-space:54px}
body.compact .topbar{height:46px}body.compact .weather,body.compact .presence,body.compact .ref-note{display:none}
body.compact .line.is-prev{display:none}body.compact .line p{font-size:16px;line-height:1.55}
body.compact .reader-bar{margin:0 0 4px}
body.compact .navigation button{height:36px}
body.compact .sheet{top:calc(var(--view-top) + 48px);height:calc(var(--view-h) - 104px - var(--safe-bottom))}body.compact .sheet-header{height:66px;padding:8px 18px}body.compact .sheet-content{height:calc(100% - 66px)}
body.compact.typing[data-mode="talk"] .navigation{transform:translate(-50%,150%);opacity:0;pointer-events:none}
body.compact.typing{--nav-space:6px}
body.compact[data-mode="home"] .home-copy .eyebrow,body.compact[data-mode="home"] .home-copy .quiet,body.compact[data-mode="home"] .small-link,body.compact[data-mode="home"] .note-text{display:none}
body.compact[data-mode="home"] .home-copy h1{font-size:18px;margin:0}
body.compact[data-mode="home"] .left-note{margin-top:6px;padding:7px 12px}body.compact[data-mode="home"] .note-meta{margin:0}
body.micro .reader-bar .count,body.micro #read-all{display:none}
body.micro[data-mode="home"] .left-note{display:none}
/* ── Motion preferences and fallbacks ── */
html.no-motion *,html.no-motion *::before,html.no-motion *::after{animation:none!important;transition-duration:1ms!important}
body.suspended *,body.suspended *::before,body.suspended *::after{animation-play-state:paused!important}
/* Keyboard shortcuts (snap): land on the target state with no transition; ambient loops keep running. */
body.snap *,body.snap *::before,body.snap *::after{transition:none!important}
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important}
.home-copy,.conversation,.near,.veil,.navigation,.nav-glow,.cam,.tint,.near-c,.moon-disc{transition:opacity 200ms linear!important}
}
/* Tier 2 (slow device): no backdrop blur, the most expensive compositor effect; no per-frame mask. */
html[data-tier="2"] .glass,html[data-tier="2"] .sheet,html[data-tier="2"] .composer,html[data-tier="2"] .navigation{backdrop-filter:none;-webkit-backdrop-filter:none;background:var(--glass-read)}
html[data-tier="2"] .mote,html[data-tier="2"] .mist{display:none}
+195
View File
@@ -0,0 +1,195 @@
'use strict';
// ── Scene: one room, one camera. ──
// Two approved reference stills of the same person, room and clothes are aligned on her face, so the camera can
// move continuously from "sitting across the room" (room still) to "sitting across the table" (talk still, = the
// approved HEL-798 composition). Only transform and opacity move per frame; the face itself is never warped.
const Scene = (() => {
const html = document.documentElement;
const plate = {room: document.getElementById('plate-room'), talk: document.getElementById('plate-talk')};
const moon = document.getElementById('moon'), cam = document.getElementById('cam'), near = document.getElementById('near'), hit = document.getElementById('her-hit');
// Plate px. FACE: centre of her face in each still. K: her face is K× larger on the talk plate than on the room plate
// (measured from eye spacing and hair width on the two stills: 3.8 native × 1.12 / 1.4).
const FACE = {room: [1275, 347], talk: [856, 315]}, K = 3.04;
const BOUNDS = {room: [-640, -112, 1600, 1148], talk: [-118, -10, 1674, 998]};
const MOON = {room: [1082, 98, 30], talk: [404, 92, 44]};
const HER = [1195, 235, 1445, 700]; // her figure on the room plate, for the tap target
let M = 12; // overscan kept for pointer parallax
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const motionOff = () => reduced.matches || html.classList.contains('no-motion');
const sstep = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const lerp = (a, b, t) => a + (b - a) * t;
// Candidate (HEL-802 v2 §2.3, not approved): on phones the room lives in a 4:5 media box at the top instead of the
// whole screen. H = min(1.25·w, max(w, h − 240), h − 56). Off unless ?frame=media; desktop stays full-viewport.
const q = new URLSearchParams(location.search), media = q.get('frame') === 'media';
html.dataset.frame = media ? 'media' : 'full';
// Clean master export (?clean=1): no interface at all, no parallax overscan; ?fore=0 also drops your table, cup and
// candle; ?fit=height keeps the whole 1000px height of the composition (16:9 masters widen instead of cropping).
const clean = q.get('clean') === '1', fitHeight = q.get('fit') === 'height';
html.classList.toggle('clean', clean); html.classList.toggle('no-fore', clean && q.get('fore') === '0');
if (clean) M = 0;
let vw = innerWidth, vh = innerHeight, tier = 0, boxed = false;
// t: camera (0 = across the room, 1 = across the table). b: how much of the close still shows.
let t = 0, v = 0, target = 0, b = 0, bFrom = 0, bStart = 0, settled = true, maskOn = null;
function layout() { return boxed || vw <= 720 || vw / vh < .9 ? 'phone' : 'desk'; }
function shot(kind) {
const phone = layout() === 'phone';
if (boxed) {
// Whole height of the 1600×1000 composition stays visible (cover by height); the talk still keeps its body
// midline — source x 0.56 of a 16:9 frame = x 907 of the 16:10 composition — at the centre of the box.
const s = Math.max(vw / 1600, vh / 1000);
if (kind === 'room') return {plate: 'room', ax: .6, ay: 347 * s / vh, m: 1};
return {plate: 'talk', ax: .5 + s * (856 - 907) / vw, ay: 315 * s / vh, m: 1};
}
if (kind === 'room') return {plate: 'room', ax: phone ? .6 : 1275 / 1600, ay: phone ? .25 : .347, m: 1};
return {plate: 'talk', ax: phone ? .5 : 856 / 1600, ay: phone ? .27 : .315, m: 1};
}
function cover(s, o, p) {
// How far a plate must shift so its picture still covers the viewport (plus parallax overscan).
const [x0, y0, x1, y1] = BOUNDS[p], c = [0, 0];
const fit = (pos, a0, a1, size, i) => {
const min = size + M - s * a1, max = -M - s * a0;
if (min > max) { c[i] = (min + max) / 2 - pos; return; }
if (pos < min) c[i] = min - pos; else if (pos > max) c[i] = max - pos;
};
fit(o[0], x0, x1, vw, 0); fit(o[1], y0, y1, vh, 1);
return c;
}
function rest(kind) {
const sh = shot(kind), base = (fitHeight ? vh / 1000 : Math.max(vw / 1600, vh / 1000)) * sh.m, f = FACE[sh.plate];
const o = [sh.ax * vw - base * f[0], sh.ay * vh - base * f[1]];
const c = cover(base, o, sh.plate);
return {s: base, o: [o[0] + c[0], o[1] + c[1]]};
}
// Camera at parameter x: zoom in log-space, face travels in a straight line on screen.
function at(x) {
const A = rest('room'), B = rest('talk');
const sRB = B.s * K;
const phA = [A.o[0] + A.s * FACE.room[0], A.o[1] + A.s * FACE.room[1]];
const phB = [B.o[0] + B.s * FACE.talk[0], B.o[1] + B.s * FACE.talk[1]];
const sR = A.s * Math.pow(sRB / A.s, x), sT = sR / K;
const ph = [lerp(phA[0], phB[0], x), lerp(phA[1], phB[1], x)];
const oR = [ph[0] - sR * FACE.room[0], ph[1] - sR * FACE.room[1]];
const oT = [ph[0] - sT * FACE.talk[0], ph[1] - sT * FACE.talk[1]];
const cR = cover(sR, oR, 'room'), cT = cover(sT, oT, 'talk'), w = sstep(.35, .8, x);
const c = [lerp(cR[0], cT[0], w), lerp(cR[1], cT[1], w)];
return {room: {s: sR, o: [oR[0] + c[0], oR[1] + c[1]]}, talk: {s: sT, o: [oT[0] + c[0], oT[1] + c[1]]}};
}
const put = (el, tr) => { el.style.transform = `translate3d(${tr.o[0].toFixed(2)}px,${tr.o[1].toFixed(2)}px,0) scale(${tr.s.toFixed(5)})`; };
const map = (tr, p) => [tr.o[0] + tr.s * p[0], tr.o[1] + tr.s * p[1]];
function apply() {
const off = motionOff();
// Reduced motion: both stills stay at rest and simply crossfade.
const cam0 = off ? {room: rest('room'), talk: null} : at(t);
if (off) cam0.talk = rest('talk');
put(plate.room, cam0.room); put(plate.talk, cam0.talk);
const blend = off ? b : t;
const useMask = !off && tier < 2;
const op = useMask ? sstep(.08, .45, blend) : sstep(off ? 0 : .3, off ? 1 : .8, blend);
plate.talk.style.opacity = op.toFixed(3);
plate.talk.style.visibility = op < .002 ? 'hidden' : 'visible';
plate.room.style.visibility = blend > .999 ? 'hidden' : 'visible';
// The close still opens out from her face: a soft-edged circle that grows until it reaches the corners.
// (Mask coordinates are in the plate box, which starts at the picture's corner: face + (118, 10).)
const r = 150 + 1450 * sstep(.1, .97, blend);
const wantMask = useMask && blend < .985 && op > .002;
if (wantMask) {
// ...intersected with soft picture edges, which tighten to nothing as the picture reaches the screen edges.
const e = (260 * (1 - sstep(.7, .985, blend))).toFixed(0);
const edge = d => `linear-gradient(${d},transparent 0,#000 ${e}px,#000 calc(100% - ${e}px),transparent 100%)`;
const m = `radial-gradient(circle at ${FACE.talk[0] + 118}px ${FACE.talk[1] + 10}px,#000 ${r.toFixed(0)}px,transparent ${(r + 520).toFixed(0)}px),${edge('90deg')},${edge('180deg')}`;
const s = plate.talk.style;
s.webkitMaskImage = m; s.maskImage = m;
if (!maskOn) { s.webkitMaskComposite = 'source-in, source-in'; s.maskComposite = 'intersect'; }
maskOn = true;
} else if (maskOn !== false) { plate.talk.style.webkitMaskImage = 'none'; plate.talk.style.maskImage = 'none'; maskOn = false; }
// The moon is far away: it drifts less than the room and hands over between the two windows.
const mw = off ? (b > .5 ? 1 : 0) : sstep(.2, .8, t);
const mr = map(cam0.room, MOON.room), mt = map(cam0.talk, MOON.talk);
const size = lerp(MOON.room[2] * cam0.room.s, MOON.talk[2] * cam0.talk.s, mw);
moon.style.transform = `translate3d(${lerp(mr[0], mt[0], mw).toFixed(1)}px,${lerp(mr[1], mt[1], mw).toFixed(1)}px,0) scale(${(size / 44).toFixed(4)})`;
// Tap target over her (only used while you are across the room).
const a = map(cam0.room, [HER[0], HER[1]]), z = map(cam0.room, [HER[2], HER[3]]);
Object.assign(hit.style, {left: a[0] + 'px', top: a[1] + 'px', width: (z[0] - a[0]) + 'px', height: (z[1] - a[1]) + 'px'});
Rain.view(cam0, op);
}
// Critically damped spring: interruptible, keeps its velocity when the target changes mid-way.
const W = 7.2;
function step(dt) {
if (motionOff()) {
t = target; v = 0;
const k = Math.min(1, (performance.now() - bStart) / 200);
b = lerp(bFrom, target, k);
settled = k >= 1;
return;
}
// Fixed small sub-steps keep the spring in real time even when frames arrive late.
for (let left = dt; left > 0; left -= 1 / 240) {
const h = Math.min(left, 1 / 240), a = W * W * (target - t) - 2 * W * v;
v += a * h; t += v * h;
}
if (Math.abs(target - t) < 4e-4 && Math.abs(v) < 4e-3) { t = target; v = 0; settled = true; }
b = t;
}
function go(mode, instant = false) {
const nt = mode === 'talk' || mode === 'record' ? 1 : 0;
html.dataset.camera = nt ? 'close' : 'far';
if (instant) { t = target = b = nt; v = 0; settled = true; apply(); return; }
if (nt === target && settled) return;
bFrom = b; bStart = performance.now();
target = nt; settled = false;
}
// Pointer parallax (fine pointers only): far things move a little, your table the other way.
const fine = matchMedia('(hover: hover) and (pointer: fine)');
addEventListener('pointermove', e => {
if (!fine.matches || motionOff() || e.pointerType !== 'mouse') return;
const px = e.clientX / vw - .5, py = e.clientY / vh - .5;
cam.style.transform = `translate3d(${(-px * 16).toFixed(1)}px,${(-py * 10).toFixed(1)}px,0)`;
near.style.setProperty('--px', (px * 22).toFixed(1) + 'px'); near.style.setProperty('--py', (py * 10).toFixed(1) + 'px');
}, {passive: true});
// Frame loop + automatic quality tiers (average frame interval while settled).
let last = 0, samples = [], forced = false;
function frame(now) {
const dt = last ? Math.min(.12, (now - last) / 1000) : 0; last = now;
if (!settled) { step(dt); apply(); }
Rain.draw(now, tier);
if (!forced && settled && dt && !document.hidden) {
samples.push(dt); if (samples.length > 120) samples.shift();
if (samples.length === 120) {
const avg = samples.reduce((x, y) => x + y) / 120;
if (avg > .026 && tier < 2) { setTier(tier + 1); samples = []; }
}
}
requestAnimationFrame(frame);
}
function setTier(n, force = false) { tier = n; forced = forced || force; html.dataset.tier = String(n); Near.draw(); apply(); }
function resize() {
const vv = window.visualViewport, w = innerWidth, h = vv ? vv.height : innerHeight;
boxed = media && w <= 720;
html.classList.toggle('media-on', boxed);
vw = w; vh = boxed ? Math.round(Math.min(1.25 * w, Math.max(w, h - 240), h - 56) * 10) / 10 : innerHeight;
html.style.setProperty('--media-h', boxed ? vh + 'px' : '100%');
Near.draw(); apply();
}
addEventListener('resize', resize);
document.addEventListener('visibilitychange', () => { last = 0; samples = []; });
requestAnimationFrame(frame);
return {
go, setTier, resize, apply,
// Review hook: freeze the camera at any point of the move (used for the frame strips in evidence/).
peek(x) { t = target = b = x; v = 0; settled = true; apply(); },
get tier() { return tier; },
info: () => {
// Her face on screen (forehead to chin, from the close still's transform), for overlap checks.
const m = /translate3d\(([-\d.]+)px, ?([-\d.]+)px.*scale\(([-\d.]+)\)/.exec(plate.talk.style.transform) || [0, 0, 0, 0];
const s = +m[3], cx = +m[1] + s * FACE.talk[0], cy = +m[2] + s * FACE.talk[1];
return {t: +t.toFixed(4), target, b: +b.toFixed(4), settled, tier, layout: layout(), frame: boxed ? {w: vw, h: vh} : null, face: {x: +cx.toFixed(1), y: +cy.toFixed(1)}, talkOpacity: +plate.talk.style.opacity, roomVisible: plate.room.style.visibility !== 'hidden', mask: !!maskOn,
head: {left: cx - 200 * s, right: cx + 200 * s, top: cy - 300 * s, bottom: cy + 140 * s}};
}
};
})();
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="zh-CN" data-theme="moon">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; form-action 'none'; base-uri 'none'">
<title>CyberGril · 对坐 · HEL-799 共同空间动态小样</title>
<style>{{STYLE}}</style>
</head>
<body data-mode="home">
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></symbol>
<symbol id="i-talk" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 1 3.5 6.6L4 20l1.2-3.6A8 8 0 0 1 4 12z"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M4 7h16M4 12h10M4 17h13"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M9 6v12M15 6v12"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.5v13l10-6.5z"/></symbol>
<symbol id="i-prev" viewBox="0 0 24 24"><path d="m14.5 6-6 6 6 6"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24"><path d="m9.5 6 6 6-6 6"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9.5 6 6 6-6"/></symbol>
</defs></svg>
<!-- One room, one camera. Far: her room and the rainy city. Middle: her. Near: your table, cup and candle. -->
<div id="scene" aria-hidden="true">
<div class="cam" id="cam">
<div class="plate" id="plate-room" data-plate="room"><div class="po">
<img class="photo" src="{{ROOM}}" alt="">
<div class="tint moon"><i class="night"></i><i class="night2"></i></div>
<div class="tint fog"><i class="night"></i><i class="night2"></i></div>
<canvas class="rain" width="800" height="500"></canvas>
<i class="mist m1"></i><i class="mist m2"></i>
<i class="flame-glow" style="left:931px;top:500px"></i>
<i class="spill"></i>
</div></div>
<div class="plate" id="plate-talk" data-plate="talk"><div class="po">
<img class="photo" src="{{TALK}}" alt="">
<div class="tint moon"><i class="night"></i><i class="night2"></i><i class="beam"></i></div>
<div class="tint fog"><i class="night"></i><i class="night2"></i><i class="beam"></i></div>
<canvas class="rain" width="800" height="500"></canvas>
<i class="mist m1"></i><i class="mist m2"></i>
<i class="flame-glow" style="left:89px;top:738px"></i><i class="flame-glow" style="left:-12px;top:704px"></i>
<i class="spill"></i>
<i class="mote" style="--x:1180px;--y:330px;--d:17s;--dl:-3s"></i><i class="mote" style="--x:1290px;--y:240px;--d:21s;--dl:-9s"></i><i class="mote" style="--x:1400px;--y:420px;--d:19s;--dl:-14s"></i><i class="mote" style="--x:1230px;--y:520px;--d:24s;--dl:-6s"></i>
</div></div>
<i class="moon-disc" id="moon"></i>
</div>
<div class="near" id="near">
<canvas class="near-c" data-theme-layer="moon"></canvas>
<canvas class="near-c" data-theme-layer="fog"></canvas>
<i class="steam s1"></i><i class="steam s2"></i>
<i class="candle"></i>
</div>
<div class="vignette"></div>
<div class="veil"></div>
</div>
<div id="viewport">
<header class="topbar">
<div class="identity"><span class="glint"></span><strong data-name>晚晚</strong><span class="presence" id="presence">在窗边</span></div>
<div class="top-actions">
<button id="rehearse" class="sample-tag">独立小样 · 演示数据</button>
<span class="weather">上海 · 小雨 14° <small>示例</small></span>
<div class="theme" role="group" aria-label="空间主题"><button data-theme="moon" aria-pressed="true"><i></i>月光</button><button data-theme="fog" aria-pressed="false"><i></i>雾光</button></div>
<button class="end-talk" id="close-talk"><svg><use href="#i-close"/></svg><span>结束交谈</span></button>
</div>
</header>
<main id="world" aria-label="相处空间">
<section class="home-copy" aria-label="此刻">
<p class="eyebrow">此 刻</p><h1>她在窗边,<br>雨声不大。</h1><p class="quiet">安静,有点倦 <span>·</span> 你们最近挺熟络</p>
<button class="left-note glass" id="shared-note"><span class="note-meta"><span>她留给你的</span><time>今天 15:52</time></span><span class="note-text">楼下花店的洋桔梗开了。<br>我挑了一小束,放在窗边。</span></button>
<button class="small-link" id="days-link">你不在时,她分享过的小事 <span>›</span></button>
<div class="home-actions"><button class="primary" id="start-talk"><svg><use href="#i-talk"/></svg>和她说话</button><button class="round glass" id="home-voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></div>
</section>
<button class="her-hit" id="her-hit" aria-label="走近她,开始交谈"><span>和她说话</span></button>
<section class="conversation" id="conversation" aria-label="当前对话" inert>
<div class="voice-row" id="voice-row" hidden><span class="voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>语音界面演示 · 没有收音</span><button id="end-voice">结束演示</button></div>
<div class="stage" id="stage" aria-hidden="true"></div>
<div class="reader-bar">
<button id="toggle-flow" class="flow" aria-pressed="true"><svg><use href="#i-pause"/></svg><span>暂停</span></button>
<button id="previous" class="icon" aria-label="上一句"><svg><use href="#i-prev"/></svg></button>
<span id="page-count" class="count">1 / 1</span>
<button id="next" class="icon" aria-label="下一句"><svg><use href="#i-next"/></svg></button>
<button id="read-all">全文</button>
<button id="stop" hidden>停止回复</button>
<button id="latest" class="latest" hidden><svg><use href="#i-down"/></svg><span>回到读到的地方</span></button>
</div>
<form class="composer" id="composer"><button type="button" class="round" id="history" aria-label="完整聊天记录"><svg><use href="#i-lines"/></svg></button><textarea id="draft" rows="1" maxlength="4000" placeholder="想和她说点什么…" aria-label="想和她说的话"></textarea><button type="submit" class="send round" aria-label="发送演示消息"><svg><use href="#i-arrow"/></svg></button><button type="button" class="voice-button" id="voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></form>
</section>
</main>
<nav class="navigation" aria-label="主要区域">
<span class="nav-glow" aria-hidden="true"></span>
<button data-nav="home" aria-current="page"><svg><use href="#i-moon"/></svg><span>此刻</span></button><button data-nav="talk"><svg><use href="#i-talk"/></svg><span>交谈</span></button><button data-nav="days"><svg><use href="#i-book"/></svg><span>日子</span></button><button data-nav="manage"><svg><use href="#i-sun"/></svg><span>设置</span></button>
</nav>
<p class="ref-note">人物为旧参考静帧 · 不代表新日常<span class="frame-note"> · 4:5 容器候选</span> · 未接入真实动态</p>
<p class="toast" id="toast" role="status" aria-live="polite"></p>
</div>
<dialog id="sheet" class="sheet glass" aria-labelledby="sheet-title"><header class="sheet-header"><div><p class="eyebrow" id="sheet-kicker"></p><h2 id="sheet-title"></h2></div><button class="round" id="close-sheet" aria-label="关闭浮层"><svg><use href="#i-close"/></svg></button></header><div id="sheet-content" class="sheet-content"></div></dialog>
<p id="announcement" class="sr" aria-live="polite" aria-atomic="true"></p>
<script>{{SCRIPT}}</script>
</body></html>
+456
View File
@@ -0,0 +1,456 @@
'use strict';
// Conversation, panels and modes. Reading logic carried over from the verified HEL-793/795 prototype (PR#8);
// what changed is where it lives: in the same room, across the table, instead of inside her window.
// Local fixtures only. CSP forbids connections; nothing is persisted.
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
const html = document.documentElement;
const sheet = $('#sheet'), content = $('#sheet-content'), stage = $('#stage');
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
const motionOff = () => reducedQuery.matches || html.classList.contains('no-motion');
const EASE = 'cubic-bezier(.22,1,.36,1)';
const state = {
mode: 'home', name: '晚晚', relation: '朋友', turn: 0, cursor: 0, head: 0,
flow: true, held: new Set(), busy: false, voice: false, token: 0, dialog: null, returnMode: 'home',
dwell: 0, dwellTotal: 0, createdNew: false
};
let messages = [
{id: -3, turn: -2, role: 'user', text: '淡色的花看起来很安静。', status: 'complete', life: 0},
{id: -2, turn: -2, role: 'her', text: '楼下花店的洋桔梗开了。我挑了一小束,放在窗边。', status: 'complete', life: 0},
{id: -1, turn: -1, role: 'her', text: '我们就这样一起听一会儿雨吧。', status: 'complete', life: 1},
{id: 1, turn: 0, role: 'user', text: '外面下雨了,你那边听得见吗?', status: 'complete'},
{id: 2, turn: 0, role: 'her', text: '听得见,打在玻璃上很轻。你也坐一会儿吧,陪我听一会儿雨。', status: 'complete'}
];
let nextId = 3, streamTimer = 0, closing = null, trigger = null, toastTimer = 0;
const SHORT = '刚刚看完一小节,正好想歇一会儿。你呢,今天过得怎么样?';
const LONG = '今天望着窗外的雨,想到小时候放学回家的那条路。那时候总觉得路很长,下雨时又不舍得走快,想听雨落在不同屋檐上的声音。后来搬到高一点的地方,雨声变远了,能看见的灯却多了。我还是会在这种时候把手边的事放一放,看看窗外,给自己泡一杯热茶。\n\n有时候,一天里也没有发生什么大事。可能只是花店换了橱窗,或者听到一首特别喜欢的歌。但如果能把这些细小的事情讲给你听,这一天就像多了一盏亮着的灯。\n\n我也想知道你那边的天气。如果你今天有点累,我们可以慢慢聊,不急着把所有的话一次说完。等你想说的时候,我就在这里。最后这一句也完整保留:愿今晚的雨声让你睡得安稳。';
const FIXTURES = [
{title: '窗边的洋桔梗', time: '今天 · 15:52', text: '楼下花店的洋桔梗开了。我挑了一小束,放在窗边。', memory: '你说,淡色的花看起来很安静。'},
{title: '一起听过的雨', time: '昨天 · 21:16', text: '昨晚我们都没有急着说话,只是听了一会儿雨。', memory: '没有话的时候,也可以一起待一会儿。'}
];
const escapeHTML = v => String(v).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const graphemes = text => typeof Intl.Segmenter === 'function' ? [...new Intl.Segmenter('zh', {granularity: 'grapheme'}).segment(text)].map(s => s.segment) : Array.from(text);
const who = m => m.role === 'user' ? '你' : state.name;
function toast(text) {
const t = $('#toast'); t.textContent = text; t.classList.add('show');
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
}
function announce(text) { $('#announcement').textContent = text; }
function setName(name) {
state.name = name;
$$('[data-name]').forEach(n => { n.textContent = name; n.title = name; });
present('jump');
}
// ── Space: the camera moves you closer or back; the UI for each place fades in once you have arrived. ──
// Keyboard shortcuts land on the target state at once: CSS transitions are suspended for two frames
// and any finite animation already under way jumps to its end.
function snap() {
document.body.classList.add('snap');
for (const a of document.getAnimations()) if (a.effect && a.effect.getTiming().iterations !== Infinity) a.finish();
requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('snap')));
}
function setMode(mode, {instant = false} = {}) {
if (instant) snap();
state.mode = mode; document.body.dataset.mode = mode;
Scene.go(mode, instant);
$('.home-copy').inert = mode !== 'home'; $('#conversation').inert = mode !== 'talk'; $('#her-hit').inert = mode !== 'home';
$$('.navigation button').forEach(n => n.dataset.nav === (mode === 'pane' || mode === 'record' ? state.dialogNav : mode) ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current'));
moveNavGlow(); updatePresence();
if (mode === 'talk') present(instant ? 'instant' : 'jump');
}
function updatePresence() {
const close = state.mode === 'talk' || state.mode === 'record';
$('#presence').textContent = state.busy && close ? '在窗边 · 正在回应(演示)' : state.voice && close ? '语音界面演示 · 没有收音' : close ? '在窗边 · 看着你' : '在窗边';
}
// The glow only slides (transform); every nav item has the same width, set in CSS.
function moveNavGlow() {
const cur = $('.navigation [aria-current]'), glow = $('.nav-glow');
if (!cur) { glow.style.opacity = 0; return; }
glow.style.opacity = 1; glow.style.transform = `translateX(${cur.offsetLeft - 3}px)`;
}
function openTalk(focus = false) { if (state.mode !== 'talk') setMode('talk'); if (focus) $('#draft').focus({preventScroll: true}); }
// ── Reading: replies flow on by themselves, one or two lines at a time. ──
function chunks(text) {
const compact = document.body.classList.contains('compact');
const width = Math.max(220, Math.min(stage.clientWidth, 900) - 24), font = compact ? 16 : innerWidth <= 720 ? 18 : 23;
const perLine = Math.floor(width / font), max = Math.max(14, Math.min(60, Math.floor(perLine * (compact ? 1.6 : 2))));
const units = graphemes(text), out = []; let line = '';
for (const ch of units) {
if (ch === '\n') { if (line.trim()) { out.push(line); line = ''; } continue; }
// Keep closing punctuation with its sentence; never produce an empty line.
if (!line && out.length && /[。!?!?,、;:,;:」』))”]/.test(ch)) { out[out.length - 1] += ch; continue; }
line += ch;
const n = graphemes(line).length;
// Short sentences travel together (as in the approved still); a line ends at a sentence once it has some weight.
if (/[。!?!?]/.test(ch) && n >= Math.min(22, max * .45)) { out.push(line); line = ''; }
else if (n >= max) {
const cut = Math.max(line.lastIndexOf(','), line.lastIndexOf('、'), line.lastIndexOf(';'));
if (cut > max * .55) { out.push(line.slice(0, cut + 1)); line = line.slice(cut + 1); } else { out.push(line); line = ''; }
}
}
if (line.trim()) out.push(line);
return out;
}
function pages() {
const list = [];
for (const m of messages.filter(m => m.turn === state.turn)) {
const parts = chunks(m.text);
// The newest chunk of a reply still being written is held back (it may still grow or merge with the next
// sentence), so a line never changes or re-animates after it has appeared.
if (m.status === 'generating' && parts.length) parts.pop();
parts.forEach((text, index) => list.push({message: m, text, index, key: `${m.id}:${index}`}));
}
return list;
}
function dwellFor(page) {
const n = graphemes(page.text).length;
return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150));
}
function lineEl(page, current, animate = true) {
const el = document.createElement('div');
el.className = 'line' + (current ? ' is-current' : ' is-prev');
el.dataset.role = page.message.role; el.dataset.key = page.key;
const label = page.message.role === 'user' ? '你' : state.name;
const chars = graphemes(page.text);
const ink = current && animate && !motionOff() && page.message.role === 'her';
const body = ink ? chars.map((c, i) => `<span style="--i:${Math.min(i, 48)}">${escapeHTML(c)}</span>`).join('') : escapeHTML(page.text);
el.innerHTML = `<span class="who"><i></i><b>${escapeHTML(label)}</b><em class="state"></em></span><p class="${ink ? 'ink' : ''}">${body}</p>`;
return el;
}
function present(how = 'advance') {
const list = pages();
state.cursor = Math.max(0, Math.min(state.cursor, list.length - 1));
state.head = Math.max(state.head, state.cursor);
updateControls(list);
const page = list[state.cursor];
if (!page) { stage.replaceChildren(); return; }
const prev = list[state.cursor - 1];
const want = [prev, page].filter(Boolean);
const have = [...stage.querySelectorAll('.line:not(.leaving)')];
const keys = have.map(n => n.dataset.key);
state.dwell = 0; state.dwellTotal = dwellFor(page);
announce(`${who(page.message)}:${page.text}`);
if (how === 'instant') {
// Keyboard review: show the target lines as they are — no slide, no re-played ink.
stage.replaceChildren(...want.map(p => lineEl(p, p === page, false)));
refreshState(); return;
}
if (keys.join() === want.map(p => p.key).join()) { refreshState(); return; }
const animate = !motionOff() && state.mode === 'talk';
// Forward by one: the finished line lifts toward the "previous" slot while a small copy settles there; the oldest drifts away.
// Both copies keep a fixed font size (no per-frame re-typesetting); only transform/opacity move.
if (how === 'advance' && animate && keys[keys.length - 1] === (prev && prev.key)) {
const first = new Map(have.map(n => [n, n.getBoundingClientRect()]));
const stageRect = stage.getBoundingClientRect();
for (const n of have) if (n.dataset.key !== prev.key) leave(n, first.get(n), stageRect);
const keep = have.find(n => n.dataset.key === prev.key), was = first.get(keep);
const settled = lineEl(prev, false), fresh = lineEl(page, true);
pin(keep, was, stageRect);
stage.append(settled, fresh);
const to = settled.getBoundingClientRect();
if (to.height) {
const shrink = parseFloat(getComputedStyle(settled.querySelector('p')).fontSize) / parseFloat(getComputedStyle(keep.querySelector('p')).fontSize);
leave(keep, was, stageRect, false, to.top - was.top, shrink);
settled.animate([{opacity: 0, transform: `translateY(${Math.round((was.top - to.top) * .35)}px)`}, {opacity: getComputedStyle(settled).opacity, transform: 'none'}], {duration: 620, easing: EASE});
} else leave(keep, was, stageRect); // compact screens hide the previous line
fresh.animate([{opacity: 0, transform: 'translateY(16px)', filter: 'blur(8px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 680, easing: EASE});
} else {
// Jumps (review, new turn, resize) crossfade instead of sliding, so direction never lies.
const stageRect = stage.getBoundingClientRect();
for (const n of have) animate ? leave(n, n.getBoundingClientRect(), stageRect, true) : n.remove();
want.forEach(p => {
const el = lineEl(p, p === page); stage.append(el);
if (animate) el.animate([{opacity: 0, filter: 'blur(6px)'}, {opacity: 1, filter: 'blur(0)'}], {duration: 520, delay: 90, easing: EASE, fill: 'backwards'});
});
}
refreshState();
}
// Take a line out of the flow at its current place, so the remaining lines lay out once at their final size.
function pin(node, rect, stageRect) {
node.classList.add('leaving');
Object.assign(node.style, {position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px', transformOrigin: '50% 0'});
}
function leave(node, rect, stageRect, quick = false, lift = quick ? -4 : -14, shrink = 1) {
pin(node, rect, stageRect);
node.animate([{opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: `translateY(${lift}px) scale(${shrink})`, filter: 'blur(5px)'}],
{duration: quick ? 240 : 520, easing: EASE, fill: 'forwards'}).finished.then(() => node.remove()).catch(() => node.remove());
}
function refreshState() {
const cur = stage.querySelector('.line.is-current:not(.leaving)'); if (!cur) return;
const list = pages(), atEnd = state.cursor >= list.length - 1;
const page = list[state.cursor];
const s = cur.querySelector('.state');
s.textContent = page && page.message.status === 'cancelled' && page.index === chunks(page.message.text).length - 1 ? '未完成 · 已取消' : atEnd && state.busy ? '还在说' : '';
cur.classList.toggle('waiting', atEnd && state.busy);
}
function updateControls(list = pages()) {
const n = list.length;
$('#previous').disabled = state.cursor <= 0;
$('#next').disabled = state.cursor >= n - 1;
$('#page-count').innerHTML = `<span>${n ? `${state.cursor + 1} / ${n}${state.busy ? '…' : ''}` : '0 / 0'}</span><i></i>`;
document.body.classList.toggle('single-page', n <= 1 && !state.busy);
$('#stop').hidden = !state.busy;
const t = $('#toggle-flow');
t.setAttribute('aria-pressed', String(state.flow));
t.innerHTML = state.flow ? '<svg><use href="#i-pause"/></svg><span>暂停</span>' : '<svg><use href="#i-play"/></svg><span>继续</span>';
const unread = n - 1 - state.cursor;
const latest = $('#latest');
latest.hidden = state.flow || state.cursor >= state.head;
latest.querySelector('span').textContent = unread > 0 ? `回到读到的地方 · 后面还有 ${unread} 句` : '回到读到的地方';
document.body.classList.toggle('reading-paused', !state.flow);
updatePresence();
}
function go(delta, manual = true, instant = false) {
const n = pages().length;
const to = Math.max(0, Math.min(state.cursor + delta, n - 1));
if (to === state.cursor) return;
state.cursor = to;
present(instant ? 'instant' : delta === 1 ? 'advance' : 'jump');
}
function back(instant = false) { if (state.flow) setFlow(false, true); go(-1, true, instant); updateControls(); }
function setFlow(on, quiet = false) {
state.flow = on; state.dwell = 0;
updateControls();
if (!quiet) toast(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看');
}
// Reading clock. Holds (hover, selection, open panels, hidden tab) freeze it without changing the user's choice.
let lastTick = 0;
function tick(now) {
const dt = lastTick ? Math.min(100, now - lastTick) : 0; lastTick = now;
if (state.mode === 'talk' && state.flow && !state.held.size && !document.hidden) {
const list = pages(), page = list[state.cursor];
if (page) {
if (state.dwell < state.dwellTotal) state.dwell += dt;
if (state.dwell >= state.dwellTotal && state.cursor < list.length - 1) go(1, false);
}
}
const bar = $('#page-count i');
if (bar) bar.style.transform = `scaleX(${state.dwellTotal ? Math.min(1, state.dwell / state.dwellTotal) : 0})`;
document.body.classList.toggle('reading-held', state.held.size > 0 && state.flow);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
const hold = (why, on) => { on ? state.held.add(why) : state.held.delete(why); };
// ── Simulated reply stream (sentence by sentence; not a real SSE connection). ──
function cancelReply(silent = false) {
const wasBusy = state.busy;
state.token++; clearTimeout(streamTimer); state.busy = false;
messages.filter(m => m.status === 'generating').forEach(m => { m.status = 'cancelled'; if (!m.text) m.text = '(回复在内容出现前取消)'; });
present('refresh');
if (wasBusy && !silent) toast('已停止。已出现的部分保留,并标为未完成');
}
function beginReply(text, reply = SHORT, after = null) {
cancelReply(true);
const resumed = !state.flow;
state.turn++; state.flow = true; state.cursor = 0; state.head = 0;
messages.push({id: nextId++, turn: state.turn, role: 'user', text, status: 'complete'});
present('jump');
if (resumed) toast('新的一轮会自动呈现,随时可以暂停');
streamReply(reply, after);
}
function streamReply(reply, after = null) {
const token = ++state.token;
const message = {id: nextId++, turn: state.turn, role: 'her', text: '', status: 'generating'};
messages.push(message); state.busy = true; updateControls(); refreshState();
const parts = reply.match(/[^。!?!?\n]+[。!?!?]*|\n+/gu) || [reply]; let index = 0;
function append() {
if (token !== state.token) return;
message.text += parts[index++];
if (index >= parts.length) { state.busy = false; message.status = 'complete'; }
updateControls(); refreshState();
if (state.dialog === 'history') renderHistory();
if (state.busy) streamTimer = setTimeout(append, 520); else if (after) after();
}
streamTimer = setTimeout(append, 900);
}
// ── Panels. Native dialog restores focus; the room steps back while one is open. ──
function showSheet(kind, title, kicker, markup, nav = state.dialogNav) {
hold('sheet', true);
if (closing) { closing.cancel(); closing = null; }
state.dialogNav = nav;
const target = (kind === 'history' || kind === 'full') && (state.mode === 'talk' || state.mode === 'record') ? 'record' : 'pane';
if (!sheet.open) { trigger = document.activeElement; state.returnMode = state.mode; sheet.showModal(); setMode(target); }
else if (state.mode !== target) setMode(target);
else { $$('.navigation button').forEach(n => n.dataset.nav === nav ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current')); moveNavGlow(); }
state.dialog = kind; sheet.dataset.kind = kind;
$('#sheet-title').textContent = title; $('#sheet-kicker').textContent = kicker; content.innerHTML = markup; content.scrollTop = 0;
if (!motionOff()) sheet.animate([{opacity: 0, transform: 'translateY(18px) scale(.985)', filter: 'blur(6px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 460, easing: EASE});
$('#close-sheet').focus({preventScroll: true});
}
function finishClose(instant = false) {
sheet.getAnimations().forEach(a => a.cancel()); if (sheet.open) sheet.close();
state.dialog = null; closing = null; hold('sheet', false); state.dialogNav = null;
setMode(['pane', 'record'].includes(state.returnMode) ? 'home' : state.returnMode, {instant});
if (trigger && trigger.isConnected && !trigger.inert) trigger.focus({preventScroll: true});
}
function closeSheet(instant = false) {
if (!sheet.open) return;
if (instant) { if (closing) closing.cancel(); finishClose(true); return; }
if (closing) return;
if (motionOff()) { finishClose(); return; }
closing = sheet.animate([{opacity: 1, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: 'translateY(14px) scale(.985)', filter: 'blur(4px)'}], {duration: 240, easing: EASE, fill: 'forwards'});
closing.finished.then(() => finishClose()).catch(() => {});
}
function renderHistory() {
const keep = content.scrollTop;
content.innerHTML = '<p class="guide">本小样中的文字均为虚构。内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>' + messages.map(m => `<article class="message" data-role="${m.role}" data-life="${m.life ?? ''}"><div class="author"><i></i>${escapeHTML(who(m))}</div><p>${escapeHTML(m.text || '正在回应…')}</p>${m.status === 'cancelled' ? '<span class="incomplete">已停止 · 这是未完成片段</span>' : ''}</article>`).join('');
content.scrollTop = keep;
}
function showHistory() { showSheet('history', '你们说过的话', '完整记录 · 演示', '', 'talk'); renderHistory(); }
function showFull() {
const cur = pages()[state.cursor]; if (!cur) return;
const m = cur.message;
showSheet('full', '这一段,慢慢看', `${who(m)} · 演示全文`, `<p class="guide">整段完整可读。关闭后回到刚才那一句。</p><article class="message" data-role="${m.role}"><div class="author"><i></i>${escapeHTML(who(m))}</div><p>${escapeHTML(m.text)}</p>${m.status === 'cancelled' ? '<span class="incomplete">这段回复已停止,内容未完成。</span>' : ''}</article>`, 'talk');
}
function showDays(memory = false) {
if (state.createdNew) { showSheet('days', memory ? '我们的回忆' : '她的日子', '初次见面 · 演示', `<div class="row"><button class="pill" data-action="days" aria-pressed="${!memory}">她的日子</button><button class="pill" data-action="memories" aria-pressed="${memory}">我们的回忆</button></div><p>刚刚见面,还没有分享过的日子与共同回忆。</p><p class="guide">真实生活和记忆尚未接入,小样不会自动编出她未分享的经历。</p>`, 'days'); return; }
showSheet('days', '留在日子里的微光', '生活与共同回忆 · 演示', `<div class="row"><button class="pill" data-action="days" aria-pressed="${!memory}">她的日子</button><button class="pill" data-action="memories" aria-pressed="${memory}">我们的回忆</button></div><p class="guide">只放她已经向你分享的事。以下两条都是虚构的演示记录。</p>${FIXTURES.map((f, i) => `<button class="item" data-event="${i}"><div class="date">${f.time} · 已分享</div><h3>${memory ? f.memory : f.title}</h3><small>${memory ? '与这一天相连' : f.text}</small></button>`).join('')}<p class="subtle">更多真实生活、记忆和成长,等待后续功能接入。</p>`, 'days');
}
function eventDetail(i) {
const f = FIXTURES[i]; if (!f) return;
showSheet('event', f.title, '已分享的生活 · 演示', `<button class="small-link" data-action="days">‹ 回到她的日子</button><p class="subtle">${f.time}</p><div class="quote">${f.text}</div><h3>与你有关的回忆</h3><p>${f.memory}</p><button class="pill" data-event-history="${i}">看那时说的话</button><p class="guide">演示故事,不是真实的后台生活。</p>`, 'days');
}
function showManage() {
showSheet('manage', '慢慢安排你们的世界', '设置 · 演示', `<p class="guide">这里只体验入口和操作关系。所有更改只在这次小样中保留。</p><button class="item" data-action="profile"><h3>她与最初的底色</h3><small>出生底色只读,成长另行积累</small></button><button class="item" data-action="relationship"><h3>你们的关系</h3><small>称呼与相处边界,可以慢慢调整</small></button><button class="item" data-action="city"><h3>城市与天气</h3><small>跟随你选择的城市 · 本小样天气为示例</small></button><button class="item" data-action="appearance"><h3>外观素材</h3><small>换外观,保留同一个她</small></button><button class="item" data-action="backup"><h3>备份与恢复</h3><small>完整导出与每日备份 · 尚未接入</small></button><button class="item" data-action="services"><h3>语音与视觉观察</h3><small>开关、状态与时效 · 尚未接入</small></button><button class="item" data-action="display"><h3>空间与显示</h3><small>两套主题、动效强度</small></button>`, 'manage');
}
function showInfo(kind) {
const info = {
profile: ['她与最初的底色', '出生资料在创建后保留为只读底色,每轮重新提供给模型。个性的诠释、少量初始喜好与后来的成长分别保留。', '本小样不生成排盘、性格或成长,也不读取真实档案。相关真实能力仍须后续补齐。'],
appearance: ['外观素材', '替换外观前应先检查素材、预览新旧效果,再明确启用。记忆、生日、关系和成长仍属于同一个她。', '本小样只使用已认可的两张参考静帧,没有导入素材、人物动态或实时口型。'],
backup: ['备份与恢复', '产品仍需每日自动备份,并支持包含人物、场景、设定与记忆的完整导出和恢复。', '备份尚未接入。本小样没有执行或伪装任何导出、备份、恢复操作。'],
services: ['语音与视觉观察', '语音由你主动开启,结束后停止收音。视觉观察可关闭,过期观察不再当作眼前事实。', '本小样不申请麦克风或摄像头权限,不采集声音与影像,也不连接任何服务。']
}[kind];
showSheet(kind, info[0], '入口示意 · 尚未接入', `<button class="small-link" data-action="manage">‹ 返回设置</button><p>${info[1]}</p><p class="notice">${info[2]}</p>${kind === 'services' ? '<button class="pill" data-action="voice">体验语音界面演示</button>' : ''}`, 'manage');
}
function showRelationship() {
showSheet('relationship', '你们的关系', '演示表单', `<button class="small-link" data-action="manage">‹ 返回设置</button><form id="relation-form"><label for="relation">现在的关系</label><select id="relation"><option ${state.relation === '朋友' ? 'selected' : ''}>朋友</option><option ${state.relation === '恋人' ? 'selected' : ''}>恋人</option></select><label for="boundaries">相处边界</label><textarea id="boundaries" maxlength="500">${escapeHTML(state.boundaries || '彼此保留自己的时间,有想法可以直接说。')}</textarea><p class="guide">修改关系不会改写出生底色、过去的记忆与信任。</p><div class="row"><button class="primary" type="submit">确认本次演示选择</button><button class="pill" type="button" data-action="manage">取消</button></div><p id="form-status" class="status" role="status"></p></form>`, 'manage');
}
function showCity() {
showSheet('city', '她窗外的城市', '城市选择 · 演示', `<button class="small-link" data-action="manage">‹ 返回设置</button><form id="city-form"><label for="city">选择一个城市</label><select id="city"><option>上海</option><option>杭州</option><option>成都</option></select><p class="guide">正式产品将按城市跟随当地天气与作息。这里仅演示选择反馈,没有查询天气;窗外的雨是界面氛围。</p><button class="primary" type="submit">保存演示选择</button><p id="form-status" class="status" role="status"></p></form>`, 'manage');
}
function showDisplay() {
showSheet('display', '空间与显示', '显示偏好 · 小样', `<button class="small-link" data-action="manage">‹ 返回设置</button><p>同一个房间、同一套布局,切换整片空间的光、雾、窗外天色与材质。</p><div class="row"><button class="pill" data-theme="moon" aria-pressed="${html.dataset.theme === 'moon'}">靛蓝月光</button><button class="pill" data-theme="fog" aria-pressed="${html.dataset.theme === 'fog'}">蓝紫雾光</button></div><button class="pill" data-action="motion">${html.classList.contains('no-motion') ? '恢复空间动效' : '减少动效'}</button><p class="guide">系统“减少动态效果”同样生效:镜头不再推近拉远,只做淡入淡出,雨停在一帧。设备较慢时会自动降低雨的分辨率并去掉毛玻璃。</p>`, 'manage');
}
function showRehearsal() {
showSheet('rehearsal', '试试这些相处片段', '小样试用 · 全部为演示', `<p>点她或「和她说话」走近;「结束交谈」或「此刻」退回原处。回复会自动一句接一句呈现,想停下时点「暂停」,往回看用左右箭头,「全文」看整段。</p><div class="row"><button class="pill" data-scenario="long">一段长回复</button><button class="pill" data-scenario="short">短句回复</button><button class="pill" data-scenario="incoming">连续来两段话</button><button class="pill" data-scenario="waiting">生成中与停止</button></div><p class="guide">每句停留随长度变化(约 2.4–8 秒),鼠标停在文字上会先停住计时,移开继续。新发消息总会自动呈现。</p><details><summary>试试初次见面与长名字</summary><p class="guide">只创建临时演示身份,不连接或重置现有伴侣。</p><button class="pill" data-action="create">重新演示初次创建</button></details><details><summary>验证说明与边界</summary><p class="guide">人物是两张获批参考静帧,走近的过程是镜头在两张照片之间对齐她的脸并推近,不是人物视频;她不会眨眼、呼吸或说话,也没有口型。文字、生活与回忆都是虚构。没有连接模型、语音、摄像头、天气或备份服务。窗外的雨、城市灯光、烛光和热气是界面氛围,不代表真实天气。</p><p class="guide">相处画面里她在沙发上看书,那只是旧参考静帧,用来示意你们在同一间房里的位置;看书已不是新的默认日常,按时段、天气和穿搭准备的新日常素材还没有生成。</p><p class="guide">地址加 <code>?frame=media</code> 可在手机宽度下试 HEL-802 v2 的候选取景:房间放进顶部 4:5 媒体容器,对话与输入在容器下方,手机不画前景桌杯。这只是候选,未获认可。</p></details>`);
}
function showCreation() {
showSheet('create', '先给她一个名字', '初次创建 · 临时演示', `<form id="create-form"><label for="new-name">你想怎么叫她</label><input id="new-name" maxlength="40" autocomplete="off" required placeholder="由你起名,1–20 字"><label for="new-relation">你们从什么关系开始</label><select id="new-relation" required><option value="" selected disabled>由你选择</option><option value="朋友">朋友</option><option value="恋人">恋人</option></select><p class="guide">出生资料、排盘与底色说明沿用既有创建流程,本次不模拟生成。确认仅进入这次虚构小样,不会接触真实档案。</p><div class="row"><button class="primary" type="submit">以这个名字体验小样</button><button class="pill" type="button" data-action="dismiss">取消</button></div><p id="creation-error" class="error" role="status"></p></form>`);
}
function showVoice() {
showSheet('voice', '体验语音界面', '没有收音的演示', `<p>这里仅演示语音开启、结束的界面。不会申请麦克风权限,也不会播放或识别声音。</p><div class="row"><button class="primary" data-action="start-voice">开始界面演示</button><button class="pill" data-action="dismiss">暂不开启</button></div>`, 'talk');
}
function runScenario(kind) {
if (sheet.open) { state.returnMode = 'talk'; finishClose(); }
openTalk();
if (kind === 'incoming') beginReply('你刚才想和我说什么?', '窗边的花开了一点。', () => streamReply('还有,今天的雨声很轻。我想把这片安静也分一点给你。'));
else if (kind === 'long') beginReply('慢慢讲,我想听听你今天的想法。', LONG);
else if (kind === 'waiting') beginReply('可以和我多说一会儿吗?', LONG);
else beginReply('你在做什么?', SHORT);
}
function applyTheme(theme, instant = false) {
if (!['moon', 'fog'].includes(theme)) return;
if (instant) snap();
html.dataset.theme = theme; Rain.setTheme(theme, instant || motionOff());
$$('button[data-theme]').forEach(n => n.setAttribute('aria-pressed', String(n.dataset.theme === theme)));
}
document.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.theme) { applyTheme(b.dataset.theme); return; }
if (b.dataset.nav) {
const mode = b.dataset.nav;
if (mode === 'home' || mode === 'talk') { if (sheet.open) { state.returnMode = mode; closeSheet(); } else mode === 'home' ? setMode('home') : openTalk(); }
else if (mode === 'days') showDays(); else showManage();
return;
}
if (b.dataset.event !== undefined) { eventDetail(Number(b.dataset.event)); return; }
if (b.dataset.eventHistory !== undefined) {
const i = Number(b.dataset.eventHistory); showHistory();
content.insertAdjacentHTML('afterbegin', `<button class="small-link" data-event="${i}">‹ 返回这一天</button>`);
content.querySelector(`[data-life="${i}"]`)?.scrollIntoView({block: 'center'}); return;
}
if (b.dataset.scenario) { runScenario(b.dataset.scenario); return; }
const actions = {
days: () => showDays(), memories: () => showDays(true), manage: showManage, relationship: showRelationship, city: showCity, display: showDisplay, create: showCreation, voice: showVoice, dismiss: closeSheet,
motion: () => { html.classList.toggle('no-motion'); Scene.resize(); showDisplay(); },
'start-voice': () => { state.returnMode = 'talk'; finishClose(); openTalk(); state.voice = true; document.body.classList.add('has-voice'); $('#voice-row').hidden = false; updateControls(); toast('语音界面演示已开启 · 没有收音'); }
};
const a = b.dataset.action;
if (actions[a]) actions[a](); else if (['profile', 'appearance', 'backup', 'services'].includes(a)) showInfo(a);
});
$('#start-talk').onclick = () => openTalk();
$('#her-hit').onclick = () => openTalk();
$('#shared-note').onclick = () => { openTalk(); showHistory(); };
$('#days-link').onclick = () => showDays();
$('#close-talk').onclick = () => { if (sheet.open) { state.returnMode = 'home'; closeSheet(); } else setMode('home'); };
$('#history').onclick = showHistory; $('#read-all').onclick = showFull; $('#close-sheet').onclick = () => closeSheet(); $('#rehearse').onclick = showRehearsal;
$('#home-voice').onclick = showVoice; $('#voice').onclick = showVoice;
$('#end-voice').onclick = () => { state.voice = false; document.body.classList.remove('has-voice'); $('#voice-row').hidden = true; cancelReply(true); updateControls(); toast('语音界面演示已结束'); };
$('#previous').onclick = () => back();
$('#next').onclick = () => go(1);
$('#toggle-flow').onclick = () => setFlow(!state.flow);
$('#latest').onclick = () => { state.cursor = state.head; present('jump'); setFlow(true, true); };
$('#stop').onclick = () => cancelReply();
$('#composer').addEventListener('submit', e => { e.preventDefault(); const text = $('#draft').value.trim(); if (!text) { $('#draft').focus(); return; } $('#draft').value = ''; autoSize(); beginReply(text, /长|故事|慢慢|完整|多说/.test(text) ? LONG : SHORT); });
function autoSize() { const d = $('#draft'); d.style.height = 'auto'; d.style.height = Math.min(d.scrollHeight, 88) + 'px'; }
$('#draft').addEventListener('input', autoSize);
$('#draft').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); $('#composer').requestSubmit(); } });
$('#draft').addEventListener('focus', () => document.body.classList.add('typing'));
$('#draft').addEventListener('blur', () => document.body.classList.remove('typing'));
stage.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hold('hover', true); });
stage.addEventListener('pointerleave', () => hold('hover', false));
document.addEventListener('selectionchange', () => { const s = getSelection(); hold('select', !!(String(s).length && stage.contains(s.anchorNode))); });
sheet.addEventListener('cancel', e => { e.preventDefault(); closeSheet(true); }); // Escape
document.addEventListener('submit', e => {
if (e.target.id === 'relation-form') { e.preventDefault(); state.relation = $('#relation').value; state.boundaries = $('#boundaries').value; $('#form-status').textContent = '演示选择已确认。记忆和底色保持原样。'; }
else if (e.target.id === 'city-form') { e.preventDefault(); $('.weather').innerHTML = `${escapeHTML($('#city').value)} · 小雨 14° <small>示例</small>`; $('#form-status').textContent = '已保存演示选择;这里没有查询真实天气。'; }
else if (e.target.id === 'create-form') {
e.preventDefault(); const name = $('#new-name').value.trim(), relation = $('#new-relation').value;
if (!name || Array.from(name).length > 20 || /[<>\x00-\x1f\x7f]/.test(name) || !relation) { $('#creation-error').textContent = '名字请用 1–20 字,不含尖括号和控制符,并选择关系。'; return; }
cancelReply(true); messages = []; state.createdNew = true; state.turn++; state.cursor = 0; state.head = 0; state.flow = true;
messages.push({id: nextId++, turn: state.turn, role: 'her', text: '你好。很高兴在这里见到你。', status: 'complete'});
state.relation = relation; state.returnMode = 'talk'; setName(name); finishClose(); openTalk(); toast('临时演示身份已建立 · 未接触真实档案');
}
});
// Keyboard shortcuts are frequent, deliberate jumps: they show the target state at once (snap), unlike clicks and auto-flow.
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !sheet.open && state.mode === 'talk') { setMode('home', {instant: true}); $('#start-talk').focus({preventScroll: true}); }
if (state.mode === 'talk' && !sheet.open && document.activeElement !== $('#draft')) {
if (e.key === 'ArrowLeft') { snap(); back(true); } else if (e.key === 'ArrowRight') { snap(); go(1, true, true); } else if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); setFlow(!state.flow); }
}
});
document.addEventListener('visibilitychange', () => document.body.classList.toggle('suspended', document.hidden));
// Keyboard / short screens: the visual viewport decides how much room the lines get.
function fitViewport() {
Scene.resize();
const vv = window.visualViewport, h = vv ? vv.height : innerHeight, t = vv ? vv.offsetTop : 0;
html.style.setProperty('--view-h', `${h}px`); html.style.setProperty('--view-top', `${t}px`);
document.body.classList.toggle('compact', h < 610);
document.body.classList.toggle('micro', h < 420);
// Re-chunk without losing the reading position: keep the same character offset in the same message.
const cur = pages()[state.cursor];
const anchor = cur ? {id: cur.message.id, offset: chunks(cur.message.text).slice(0, cur.index).join('').length} : null;
requestAnimationFrame(() => {
if (anchor) { const list = pages(); let used = 0; for (let i = 0; i < list.length; i++) { if (list[i].message.id !== anchor.id) continue; if (used + list[i].text.length > anchor.offset) { state.cursor = i; break; } used += list[i].text.length; } }
stage.replaceChildren(); present('jump'); moveNavGlow();
});
}
window.visualViewport?.addEventListener('resize', fitViewport); window.visualViewport?.addEventListener('scroll', fitViewport); addEventListener('resize', fitViewport);
// Optional start parameters for review links and recordings: ?theme=fog&mode=talk&scene=long&tier=2
const params = new URLSearchParams(location.search);
applyTheme(params.get('theme') || 'moon', true);
if (params.get('tier')) Scene.setTier(+params.get('tier'), true);
Scene.resize();
fitViewport(); present('jump'); moveNavGlow();
if (params.get('mode') === 'talk') setMode('talk', {instant: true});
if (params.get('scene')) setTimeout(() => runScenario(params.get('scene')), 600);
window.__proto = {state, pages, runScenario, setMode, applyTheme, showDays, showManage, showHistory, showFull, closeSheet, setFlow, go, scene: () => Scene.info()};
+93
View File
@@ -0,0 +1,93 @@
# Minimal Chrome DevTools Protocol driver for headless Edge (Windows PowerShell 5.1).
$ErrorActionPreference = 'Stop'
$script:edge = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
$script:id = 0
$script:events = New-Object System.Collections.ArrayList
function Start-Browser([int]$port = 9333) {
$profile = Join-Path $env:TEMP "edge-hel793-$port"
$script:proc = Start-Process $script:edge -ArgumentList "--headless=new", "--remote-debugging-port=$port", "--user-data-dir=$profile", "--no-first-run", "--hide-scrollbars", "--force-color-profile=srgb", "--autoplay-policy=no-user-gesture-required", "about:blank" -PassThru
for ($i = 0; $i -lt 50; $i++) {
try { $targets = Invoke-RestMethod "http://127.0.0.1:$port/json" -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 }
}
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.Options.KeepAliveInterval = [TimeSpan]::FromSeconds(30)
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
$script:proc.Id
}
function Stop-Browser { try { $script:ws.Dispose() } catch {}; if ($script:proc -and -not $script:proc.HasExited) { Stop-Process -Id $script:proc.Id -Force } }
function Receive-Message {
$buffer = New-Object byte[] 1048576
$ms = New-Object System.IO.MemoryStream
do {
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $buffer)
$r = $script:ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
$ms.Write($buffer, 0, $r.Count)
} while (-not $r.EndOfMessage)
[Text.Encoding]::UTF8.GetString($ms.ToArray()) | ConvertFrom-Json
}
function Send-Cdp([string]$method, $params = @{}) {
$script:id++
$myId = $script:id
$json = @{ id = $myId; method = $method; params = $params } | ConvertTo-Json -Depth 10 -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
while ($true) {
$m = Receive-Message
if ($m.id -eq $myId) { if ($m.error) { throw "$method failed: $($m.error.message)" }; return $m.result }
if ($m.method) { Handle-Event $m }
}
}
function Handle-Event($m) {
if ($m.method -eq 'Page.screencastFrame') {
if ($script:recording) { [void]$script:frames.Add(@{ data = $m.params.data; t = $m.params.metadata.timestamp }) }
Send-Ack $m.params.sessionId
} else { [void]$script:events.Add($m.method) }
}
function Send-Ack($sid) {
$script:id++
$json = @{ id = $script:id; method = 'Page.screencastFrameAck'; params = @{ sessionId = $sid } } | ConvertTo-Json -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
}
function Invoke-Js([string]$expr, [switch]$await) {
$r = Send-Cdp 'Runtime.evaluate' @{ expression = $expr; returnByValue = $true; awaitPromise = [bool]$await }
if ($r.exceptionDetails) { throw "JS error: $($r.exceptionDetails.exception.description)" }
$r.result.value
}
function Set-View([int]$w, [int]$h, [switch]$mobile, [double]$dpr = 1) {
[void](Send-Cdp 'Emulation.setDeviceMetricsOverride' @{ width = $w; height = $h; deviceScaleFactor = $dpr; mobile = [bool]$mobile })
if ($mobile) { [void](Send-Cdp 'Emulation.setTouchEmulationEnabled' @{ enabled = $true; maxTouchPoints = 5 }) }
else { [void](Send-Cdp 'Emulation.setTouchEmulationEnabled' @{ enabled = $false }) }
}
function Open-Page([string]$url) {
[void](Send-Cdp 'Page.enable')
[void](Send-Cdp 'Page.navigate' @{ url = $url })
Wait-Ms 1500
}
# Keep pumping events (screencast frames) while waiting.
function Wait-Ms([int]$ms) {
$until = [DateTime]::Now.AddMilliseconds($ms)
while ([DateTime]::Now -lt $until) {
[void](Send-Cdp 'Runtime.evaluate' @{ expression = '0'; returnByValue = $true })
Start-Sleep -Milliseconds 40
}
}
function Save-Shot([string]$path) {
$r = Send-Cdp 'Page.captureScreenshot' @{ format = 'png' }
[IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($r.data))
}
function Start-Rec([int]$maxW = 1440, [int]$maxH = 900) {
$script:frames = New-Object System.Collections.ArrayList
$script:recording = $true
[void](Send-Cdp 'Page.startScreencast' @{ format = 'jpeg'; quality = 82; maxWidth = $maxW; maxHeight = $maxH; everyNthFrame = 1 })
}
function Stop-Rec {
[void](Send-Cdp 'Page.stopScreencast')
$script:recording = $false
$script:frames
}
+36
View File
@@ -0,0 +1,36 @@
param([string]$video, [string]$png, [int]$cols = 4, [int]$count = 12, [int]$thumbW = 480)
# Decodes the finished WebM in a browser and tiles evenly spaced frames, proving the file plays and showing motion over time.
. "$PSScriptRoot\cdp.ps1"
$dir = Split-Path $video
$page = Join-Path $dir 'contact.html'
Set-Content -Path $page -Encoding utf8 -Value '<!doctype html><body style="margin:0;background:#000"><video id=v muted playsinline></video><canvas id=c></canvas></body>'
$script:proc = Start-Process $script:edge -ArgumentList '--headless=new', '--remote-debugging-port=9337', "--user-data-dir=$env:TEMP\edge-hel799-contact", '--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required', 'about:blank' -PassThru
for ($i = 0; $i -lt 50; $i++) { try { $targets = Invoke-RestMethod 'http://127.0.0.1:9337/json' -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 } }
$t = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$t.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
try {
Open-Page ('file:///' + ($page -replace '\\', '/'))
$src = 'file:///' + ((Resolve-Path $video).Path -replace '\\', '/')
$js = @"
(async () => {
const v = document.getElementById('v'); v.src = '$src';
await new Promise((ok, no) => { v.onloadeddata = ok; v.onerror = () => no(new Error('decode failed')); });
// WebM from MediaRecorder has no duration header; seek to the end once to learn it.
if (!isFinite(v.duration)) { v.currentTime = 1e6; await new Promise(ok => v.ondurationchange = ok); await new Promise(ok => { v.onseeked = ok; v.currentTime = 0; }); }
const n = $count, cols = $cols, tw = $thumbW, th = Math.round(tw * v.videoHeight / v.videoWidth), rows = Math.ceil(n / cols);
const c = document.getElementById('c'); c.width = cols * tw; c.height = rows * (th + 22); const x = c.getContext('2d');
x.fillStyle = '#000'; x.fillRect(0, 0, c.width, c.height); x.font = '14px sans-serif';
for (let i = 0; i < n; i++) {
const t = v.duration * (i + .5) / n;
await new Promise(ok => { v.onseeked = ok; v.currentTime = t; });
const cx = (i % cols) * tw, cy = Math.floor(i / cols) * (th + 22);
x.drawImage(v, cx, cy + 22, tw, th); x.fillStyle = '#fff'; x.fillText(t.toFixed(1) + 's', cx + 6, cy + 16);
}
return JSON.stringify({duration: v.duration, w: v.videoWidth, h: v.videoHeight, png: c.toDataURL('image/png').split(',')[1]});
})()
"@
$r = Invoke-Js $js -await | ConvertFrom-Json
[IO.File]::WriteAllBytes($png, [Convert]::FromBase64String($r.png))
"duration=$([math]::Round($r.duration,1))s size=$($r.w)x$($r.h)"
} finally { Stop-Browser; Remove-Item $page -ErrorAction SilentlyContinue }
+87
View File
@@ -0,0 +1,87 @@
param([string]$name, [int]$w = 1440, [int]$h = 900, [switch]$mobile, [string]$query = '', [string]$steps, [string]$out = "$PWD\rec")
# Records a scripted walkthrough as WebM. $steps: lines of "wait <ms>" | "tap <css>" | "js <expr>" | "type <css>|<text>" | "shot <file>".
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$frameDir = Join-Path $out "frames-$name"
if (Test-Path $frameDir) { Remove-Item $frameDir -Recurse -Force }
New-Item -ItemType Directory -Force $frameDir | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\index.html").Path -replace '\\', '/') + $query
# Recording-only touch marker so viewers can see where a tap happened. Not part of the prototype.
$tapJs = @'
window.__tap = sel => { const el = document.querySelector(sel); if (!el) throw new Error('missing ' + sel);
const r = el.getBoundingClientRect(), d = document.createElement('div');
d.style.cssText = `position:fixed;z-index:99999;left:${r.left + r.width / 2 - 18}px;top:${r.top + r.height / 2 - 18}px;width:36px;height:36px;border-radius:50%;border:2px solid #fff;background:#ffffff40;pointer-events:none;transition:opacity .5s,transform .5s`;
document.body.append(d); requestAnimationFrame(() => { d.style.transform = 'scale(1.6)'; d.style.opacity = '0'; }); setTimeout(() => d.remove(), 700); el.click(); return 1; };
'@
$null = Start-Browser 9335
try {
Set-View $w $h -mobile:$mobile -dpr 1
Open-Page $url
Wait-Ms 1200
[void](Invoke-Js $tapJs)
Start-Rec 1280 900
foreach ($line in ($steps -split "`n")) {
$line = $line.Trim(); if (-not $line) { continue }
$cmd, $arg = $line -split ' ', 2
switch ($cmd) {
'wait' { Wait-Ms ([int]$arg) }
'tap' { [void](Invoke-Js "__tap('$arg')"); Wait-Ms 60 }
'js' { [void](Invoke-Js $arg) }
'type' { $sel, $text = $arg -split '\|', 2; [void](Invoke-Js "(()=>{const e=document.querySelector('$sel');e.focus();e.value='$text';e.dispatchEvent(new Event('input'));return 1})()") }
'shot' { Save-Shot (Join-Path $out $arg) }
'log' { Write-Output ("{0}: {1}" -f $arg, (Invoke-Js $arg)) }
}
}
$frames = Stop-Rec
} finally { Stop-Browser }
# Write frames + timing manifest.
$t0 = $frames[0].t; $list = @()
for ($i = 0; $i -lt $frames.Count; $i++) {
$f = $frames[$i]; $file = 'f{0:D5}.jpg' -f $i
[IO.File]::WriteAllBytes((Join-Path $frameDir $file), [Convert]::FromBase64String($f.data))
$list += @{ f = $file; t = [math]::Round(($f.t - $t0) * 1000) }
}
$dur = $list[-1].t
"frames=$($frames.Count) duration=${dur}ms fps=$([math]::Round($frames.Count / ($dur / 1000), 1))"
$manifest = ($list | ConvertTo-Json -Compress)
# Encode in a second headless browser: draw frames on a canvas at their real timing and capture with MediaRecorder.
$encoder = Join-Path $frameDir 'encode.html'
Set-Content -Path $encoder -Encoding utf8 -Value '<!doctype html><canvas id=c></canvas>'
$script:edgeArgs = $null
$profile = Join-Path $env:TEMP 'edge-hel799-enc'
$script:proc = Start-Process $script:edge -ArgumentList '--headless=new', '--remote-debugging-port=9336', "--user-data-dir=$profile", '--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required', 'about:blank' -PassThru
for ($i = 0; $i -lt 50; $i++) { try { $targets = Invoke-RestMethod 'http://127.0.0.1:9336/json' -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 } }
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
try {
Open-Page ('file:///' + ($encoder -replace '\\', '/'))
$js = @"
(async () => {
const list = $manifest, base = location.href.replace(/encode\.html.*$/, '');
const load = f => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = base + f; });
const first = await load(list[0].f), c = document.getElementById('c');
c.width = first.naturalWidth; c.height = first.naturalHeight; const x = c.getContext('2d');
x.drawImage(first, 0, 0);
const stream = c.captureStream(30), chunks = [];
const rec = new MediaRecorder(stream, {mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 3000000});
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
const done = new Promise(ok => rec.onstop = ok);
rec.start(1000);
const start = performance.now(); let next = await load(list[1] ? list[1].f : list[0].f);
for (let i = 1; i < list.length; i++) {
const img = next; const pre = list[i + 1] ? load(list[i + 1].f) : null;
const wait = list[i].t - (performance.now() - start); if (wait > 0) await new Promise(r => setTimeout(r, wait));
x.drawImage(img, 0, 0); if (pre) next = await pre;
}
await new Promise(r => setTimeout(r, 400)); rec.stop(); await done;
const blob = new Blob(chunks, {type: 'video/webm'});
return await new Promise(ok => { const fr = new FileReader(); fr.onload = () => ok(fr.result.split(',')[1]); fr.readAsDataURL(blob); });
})()
"@
$b64 = Invoke-Js $js -await
[IO.File]::WriteAllBytes((Join-Path $out "$name.webm"), [Convert]::FromBase64String($b64))
"wrote $name.webm ($([math]::Round((Get-Item (Join-Path $out "$name.webm")).Length / 1MB, 2)) MB)"
} finally { Stop-Browser }
+36
View File
@@ -0,0 +1,36 @@
param([int]$w = 1600, [int]$h = 1000, [switch]$mobile, [double]$dpr = 1, [string]$query = '', [string]$steps = '', [string]$out = "$PSScriptRoot\..\evidence", [int]$port = 9341, [switch]$reduced)
# Scripted screenshots in headless Edge. $steps: lines of
# wait <ms> | js <expr> | log <expr> | shot <file> | key <Escape|ArrowLeft|ArrowRight> | click <css> | type <css>|<text>
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\index.html").Path -replace '\\', '/') + $query
$null = Start-Browser $port
try {
Set-View $w $h -mobile:$mobile -dpr $dpr
if ($reduced) { [void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @(@{ name = 'prefers-reduced-motion'; value = 'reduce' }) }) }
[void](Send-Cdp 'Runtime.enable')
Open-Page $url
Wait-Ms 800
[void](Invoke-Js "window.__errors=[];addEventListener('error',e=>__errors.push(String(e.message)));1")
foreach ($line in ($steps -split "`n")) {
$line = $line.Trim(); if (-not $line) { continue }
$cmd, $arg = $line -split ' ', 2
switch ($cmd) {
'wait' { Wait-Ms ([int]$arg) }
'js' { [void](Invoke-Js $arg) }
'log' { Write-Output ("{0} => {1}" -f $arg, (Invoke-Js $arg)) }
'shot' { Save-Shot (Join-Path $out $arg); Write-Output "shot $arg" }
'key' {
$codes = @{ Escape = 27; ArrowLeft = 37; ArrowRight = 39 }
[void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'rawKeyDown'; key = $arg; code = $arg; windowsVirtualKeyCode = $codes[$arg] })
[void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'keyUp'; key = $arg; code = $arg; windowsVirtualKeyCode = $codes[$arg] })
}
'click' {
$r = Invoke-Js "(()=>{const e=document.querySelector('$arg');const r=e.getBoundingClientRect();return JSON.stringify([r.left+r.width/2,r.top+r.height/2])})()" | ConvertFrom-Json
foreach ($t in 'mousePressed', 'mouseReleased') { [void](Send-Cdp 'Input.dispatchMouseEvent' @{ type = $t; x = $r[0]; y = $r[1]; button = 'left'; clickCount = 1 }) }
}
'type' { $sel, $text = $arg -split '\|', 2; [void](Invoke-Js "(()=>{const e=document.querySelector('$sel');e.focus();e.value='$text';e.dispatchEvent(new Event('input'));return 1})()") }
}
}
Write-Output ("errors => " + (Invoke-Js 'JSON.stringify(window.__errors)'))
} finally { Stop-Browser }
+19
View File
@@ -0,0 +1,19 @@
param([string[]]$files, [string]$out, [int]$cols = 3, [int]$cellW = 640, [string[]]$labels = @())
# Lays screenshots out on one contact sheet (System.Drawing; no extra tools).
Add-Type -AssemblyName System.Drawing
$imgs = $files | ForEach-Object { [Drawing.Image]::FromFile((Resolve-Path $_).Path) }
$cellH = [int]($cellW * $imgs[0].Height / $imgs[0].Width)
$rows = [math]::Ceiling($imgs.Count / $cols)
$bmp = New-Object Drawing.Bitmap ($cols * $cellW + ($cols + 1) * 8), ($rows * ($cellH + 30) + 8)
$g = [Drawing.Graphics]::FromImage($bmp)
$g.Clear([Drawing.Color]::FromArgb(10, 12, 24)); $g.InterpolationMode = 'HighQualityBicubic'
$font = New-Object Drawing.Font 'Microsoft YaHei', 12
for ($i = 0; $i -lt $imgs.Count; $i++) {
$x = 8 + ($i % $cols) * ($cellW + 8); $y = 8 + [math]::Floor($i / $cols) * ($cellH + 30)
$g.DrawImage($imgs[$i], $x, $y, $cellW, $cellH)
$label = if ($labels.Count -gt $i) { $labels[$i] } else { [IO.Path]::GetFileNameWithoutExtension($files[$i]) }
$g.DrawString($label, $font, [Drawing.Brushes]::White, $x, $y + $cellH + 4)
}
$bmp.Save($out, [Drawing.Imaging.ImageFormat]::Png)
$imgs | ForEach-Object { $_.Dispose() }
"wrote $out"
+176
View File
@@ -0,0 +1,176 @@
param([string]$out = "$PSScriptRoot\..\evidence")
# Behavioural checks in headless Edge (real input events via CDP). Each check records pass/fail with observed values.
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\index.html").Path -replace '\\', '/')
$results = New-Object System.Collections.ArrayList
function Check([string]$name, [bool]$ok, $detail) { [void]$results.Add([ordered]@{ check = $name; pass = $ok; detail = $detail }); "{0} {1} {2}" -f ($(if ($ok) { 'PASS' } else { 'FAIL' })), $name, ($detail | ConvertTo-Json -Compress -Depth 5) }
function J($e) { Invoke-Js $e | ConvertFrom-Json }
function Key($k) { $c = @{ Escape = 27; ArrowLeft = 37; ArrowRight = 39 }; foreach ($t in 'rawKeyDown', 'keyUp') { [void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = $t; key = $k; code = $k; windowsVirtualKeyCode = $c[$k] }) } }
function Click($sel) {
$r = J "(()=>{const e=document.querySelector('$sel');const r=e.getBoundingClientRect();return JSON.stringify([r.left+r.width/2,r.top+r.height/2])})()"
foreach ($t in 'mouseMoved', 'mousePressed', 'mouseReleased') { [void](Send-Cdp 'Input.dispatchMouseEvent' @{ type = $t; x = $r[0]; y = $r[1]; button = 'left'; clickCount = 1 }) }
}
# Per-frame recorder of the camera, so continuity can be checked frame by frame.
$rec = "window.__f=[];(function l(ts){const i=Scene.info();__f.push([ts||performance.now(),i.t,i.talkOpacity,i.roomVisible?1:0,document.body.dataset.mode]);if(__f.length<2000)requestAnimationFrame(l)})();1"
$finite = 'document.getAnimations().filter(a=>a.playState==="running"&&a.effect&&a.effect.getTiming().iterations!==Infinity).length'
$null = Start-Browser 9351
try {
Set-View 1600 1000
Open-Page "${url}?theme=moon&tier=0"; Wait-Ms 1200
# A. Space: mouse click walks you closer (continuous), and back.
[void](Invoke-Js $rec)
Click '#start-talk'; Wait-Ms 150
$a = J 'JSON.stringify(Scene.info())'
Wait-Ms 2600
$b = J 'JSON.stringify(Object.assign(Scene.info(),{conv:getComputedStyle(document.querySelector("#conversation")).opacity,mode:document.body.dataset.mode}))'
Check 'click-enter-moves-camera-gradually' ($a.t -gt 0.02 -and $a.t -lt 0.9 -and $b.t -eq 1 -and $b.settled -and [double]$b.conv -gt .99) @{ at150ms = $a.t; at2750ms = $b.t; conversationOpacity = $b.conv }
$f = J 'JSON.stringify(__f)'
$maxStep = 0; $gap = 0
for ($i = 1; $i -lt $f.Count; $i++) { $maxStep = [math]::Max($maxStep, [math]::Abs($f[$i][1] - $f[($i - 1)][1]) / [math]::Max(.001, ($f[$i][0] - $f[($i - 1)][0]) / 1000)); if ($f[$i][2] -lt .999 -and $f[$i][3] -eq 0) { $gap++ } }
Check 'enter-no-jump-no-black-frame' ($maxStep -lt 4 -and $gap -eq 0) @{ frames = $f.Count; maxCameraSpeedPerSecond = [math]::Round($maxStep, 4); framesWithNeitherStillFullyShown = $gap }
Save-Shot "$out\desktop-talk-moon.png"
$pt = J 'JSON.stringify([...document.querySelectorAll(".plate .photo")].map(p=>p.getAnimations().length))'
$tr = J 'JSON.stringify([document.querySelector("#plate-room").style.transform,document.querySelector("#plate-talk").style.transform])'
Check 'photos-never-warped-or-animated' (($pt | Measure-Object -Sum).Sum -eq 0 -and ($tr -join ' ') -notmatch 'scale\([^)]*,|skew|rotate|matrix') @{ photoAnimations = $pt; plateTransforms = $tr }
# B. Rapid toggle: leave while still walking in; it turns around smoothly from where it is.
Click '#close-talk'; Wait-Ms 1600
[void](Invoke-Js $rec)
Click '#start-talk'; Wait-Ms 260
$m1 = J 'JSON.stringify(Scene.info())'
Click '#close-talk'; Wait-Ms 60
$m2 = J 'JSON.stringify(Scene.info())'
Click '#start-talk'; Wait-Ms 120; Click '#close-talk'; Wait-Ms 1800
$m3 = J 'JSON.stringify(Object.assign(Scene.info(),{mode:document.body.dataset.mode,talkVis:getComputedStyle(document.querySelector("#plate-talk")).visibility}))'
$f = J 'JSON.stringify(__f)'
$maxStep = 0; $gap = 0
for ($i = 1; $i -lt $f.Count; $i++) { $maxStep = [math]::Max($maxStep, [math]::Abs($f[$i][1] - $f[($i - 1)][1]) / [math]::Max(.001, ($f[$i][0] - $f[($i - 1)][0]) / 1000)); if ($f[$i][2] -lt .999 -and $f[$i][3] -eq 0) { $gap++ } }
Check 'rapid-toggle-redirects-smoothly' ($m1.t -gt 0.05 -and $m1.t -lt .95 -and [math]::Abs($m2.t - $m1.t) -lt .2 -and $m3.t -eq 0 -and $m3.mode -eq 'home' -and $maxStep -lt 4 -and $gap -eq 0) @{ midway = $m1.t; justAfterTurn = $m2.t; final = $m3.t; mode = $m3.mode; maxCameraSpeedPerSecond = [math]::Round($maxStep, 4); framesWithNeitherStillFullyShown = $gap; talkPlate = $m3.talkVis }
# C. Keyboard: Escape and arrows land at once (HEL-795 rule), no long animation, no re-played ink.
Click '#start-talk'; Wait-Ms 1800
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 9000
Key 'ArrowLeft'; Wait-Ms 80
$k1 = J "JSON.stringify({finite:$finite,c:__proto.state.cursor,flow:__proto.state.flow,ink:document.querySelectorAll('#stage .is-current .ink').length,latest:!document.querySelector('#latest').hidden})"
Check 'arrow-left-instant' ($k1.finite -eq 0 -and $k1.ink -eq 0 -and -not $k1.flow -and $k1.latest) $k1
Key 'ArrowRight'; Wait-Ms 80
$k2 = J "JSON.stringify({finite:$finite,c:__proto.state.cursor,ink:document.querySelectorAll('#stage .is-current .ink').length})"
Check 'arrow-right-instant' ($k2.finite -eq 0 -and $k2.ink -eq 0 -and $k2.c -eq $k1.c + 1) $k2
Key 'Escape'; Wait-Ms 80
$k3 = J "JSON.stringify(Object.assign(Scene.info(),{finite:$finite,mode:document.body.dataset.mode,homeOpacity:getComputedStyle(document.querySelector('.home-copy')).opacity}))"
Check 'escape-leaves-instantly' ($k3.finite -eq 0 -and $k3.t -eq 0 -and $k3.mode -eq 'home' -and [double]$k3.homeOpacity -gt .99) @{ finiteAnimations = $k3.finite; camera = $k3.t; mode = $k3.mode; homeOpacity = $k3.homeOpacity }
Save-Shot "$out\desktop-home-moon.png"
# D. Reading (carried over from PR#8): auto to the end, pause/review/return, send resumes, identity, cancel, history.
Click '#start-talk'; Wait-Ms 1500
[void](Invoke-Js "__proto.runScenario('long')")
for ($i = 0; $i -lt 70; $i++) { Wait-Ms 1000; $s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy})'; if (-not $s.busy -and $s.c -eq $s.n - 1) { break } }
$s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy,flow:__proto.state.flow})'
Check 'auto-continues-long-reply-to-last-line' ($s.c -eq $s.n - 1 -and -not $s.busy -and $s.flow) @{ seconds = $i + 1; final = $s }
$c = J 'JSON.stringify((()=>{const p=__proto.pages();const m=p[p.length-1].message;return {same:p.filter(x=>x.message===m).map(x=>x.text).join("")===m.text.replace(/\n/g,""),lines:p.length}})())'
Check 'all-text-preserved-in-lines' $c.same $c
Save-Shot "$out\desktop-long-end.png"
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 6000
Click '#toggle-flow'; $a = J '__proto.state.cursor'; Wait-Ms 6000; $b = J '__proto.state.cursor'
Check 'pause-holds-position' ($a -eq $b) @{ before = $a; after6s = $b }
Click '#previous'; Wait-Ms 800
$l = J 'JSON.stringify({hidden:document.querySelector("#latest").hidden,text:document.querySelector("#latest").textContent,c:__proto.state.cursor,head:__proto.state.head})'
Check 'review-back-offers-return' (-not $l.hidden) $l
Save-Shot "$out\desktop-review.png"
Click '#latest'; Wait-Ms 9000
$r = J 'JSON.stringify({c:__proto.state.cursor,flow:__proto.state.flow})'
Check 'return-resumes-flow' ($r.flow -and $r.c -gt $l.head) @{ head = $l.head; now = $r }
[void](Invoke-Js "__proto.setFlow(false,true)")
[void](Invoke-Js "(()=>{const d=document.querySelector('#draft');d.value='你在做什么?';document.querySelector('#composer').requestSubmit();return 1})()"); Wait-Ms 6000
$r = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,flow:__proto.state.flow,toast:document.querySelector("#toast").textContent,draft:document.querySelector("#draft").value})'
Check 'send-gives-feedback-and-resumes-auto' ($r.flow -and $r.c -ge 1 -and $r.draft -eq '') $r
$who = J 'JSON.stringify([...document.querySelectorAll("#stage .line:not(.leaving)")].map(l=>({role:l.dataset.role,label:l.querySelector(".who b").textContent})))'
Check 'identity-labelled' (@($who | Where-Object { ($_.role -eq 'user' -and $_.label -eq '你') -or ($_.role -eq 'her' -and $_.label -eq '晚晚') }).Count -eq @($who).Count) $who
[void](Invoke-Js "__proto.runScenario('waiting')"); Wait-Ms 3500; Click '#stop'; Wait-Ms 600
$x = J 'JSON.stringify((()=>{const m=__proto.pages().map(p=>p.message).pop();return {status:m.status,len:m.text.length,busy:__proto.state.busy}})())'
Check 'cancel-keeps-partial-marked-incomplete' ($x.status -eq 'cancelled' -and $x.len -gt 0 -and -not $x.busy) $x
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 7000; $a = J '__proto.state.cursor'
Click '#history'; Wait-Ms 3000; $b = J '__proto.state.cursor'
$mid = J 'JSON.stringify(Object.assign(Scene.info(),{mode:document.body.dataset.mode}))'
Save-Shot "$out\desktop-history-moon.png"
Click '#close-sheet'; Wait-Ms 1200
$c = J 'JSON.stringify({c:__proto.state.cursor,mode:document.body.dataset.mode})'
Check 'history-holds-she-stays-close-then-returns' ($a -eq $b -and $c.mode -eq 'talk' -and $mid.mode -eq 'record' -and $mid.t -eq 1) @{ before = $a; whileOpen = $b; cameraWhileOpen = $mid.t; after = $c }
# E. Entries lead somewhere (demo content, clearly marked).
$titles = @()
foreach ($step in @("document.querySelector('[data-nav=days]').click()", "document.querySelector('[data-event=\""0\""]').click()", "document.querySelector('[data-event-history]').click()", "document.querySelector('[data-nav=manage]').click()", "document.querySelector('[data-action=relationship]').click()", "document.querySelector('[data-action=manage]').click()", "document.querySelector('[data-action=backup]').click()", "document.querySelector('[data-action=manage]').click()", "document.querySelector('[data-action=services]').click()")) {
[void](Invoke-Js $step); Wait-Ms 700; $titles += (J 'JSON.stringify(document.querySelector("#sheet-kicker").textContent+" / "+document.querySelector("#sheet-title").textContent)')
}
Check 'entries-open-demo-content' ($titles.Count -eq 9 -and ($titles | Where-Object { -not $_ }).Count -eq 0) $titles
Save-Shot "$out\desktop-manage-moon.png"
[void](Invoke-Js "window.__mic=0;if(navigator.mediaDevices){navigator.mediaDevices.getUserMedia=()=>{__mic++;return Promise.reject()}};1")
[void](Invoke-Js "document.querySelector('[data-action=voice]').click()"); Wait-Ms 600
[void](Invoke-Js "document.querySelector('[data-action=start-voice]').click()"); Wait-Ms 1500
$v = J 'JSON.stringify({mic:__mic,row:!document.querySelector("#voice-row").hidden,presence:document.querySelector("#presence").textContent})'
Check 'voice-demo-never-asks-microphone' ($v.mic -eq 0 -and $v.row) $v
Click '#end-voice'; Wait-Ms 400
# F. Themes: same structure and positions, different light.
[void](Invoke-Js "__proto.setMode('talk',{instant:true})"); Wait-Ms 800
$p1 = J 'JSON.stringify(["#composer",".navigation","#stage","#close-talk"].map(s=>{const r=document.querySelector(s).getBoundingClientRect();return [Math.round(r.left),Math.round(r.top),Math.round(r.width)]}))'
Click '[data-theme=fog]'; Wait-Ms 1600
$p2 = J 'JSON.stringify(["#composer",".navigation","#stage","#close-talk"].map(s=>{const r=document.querySelector(s).getBoundingClientRect();return [Math.round(r.left),Math.round(r.top),Math.round(r.width)]}))'
$acc = J 'JSON.stringify(getComputedStyle(document.documentElement).getPropertyValue("--accent").trim())'
Check 'themes-keep-structure' ((($p1 | ConvertTo-Json -Compress) -eq ($p2 | ConvertTo-Json -Compress)) -and $acc -eq '#ebbcff') @{ moon = $p1; fog = $p2; accent = $acc }
Save-Shot "$out\desktop-talk-fog.png"
[void](Invoke-Js "__proto.setMode('home',{instant:true})"); Wait-Ms 1200
Save-Shot "$out\desktop-home-fog.png"
# G. Layout at every size: her face, the lines, the composer and the navigation never cover each other.
$sizes = @(@(1600, 1000, 0), @(1440, 900, 0), @(1280, 720, 0), @(390, 844, 1), @(360, 740, 1), @(320, 568, 1), @(390, 430, 1))
foreach ($size in $sizes) {
$mob = [bool]$size[2]
Set-View $size[0] $size[1] -mobile:$mob -dpr 1; Open-Page "${url}?theme=moon&tier=0"; Wait-Ms 900
[void](Invoke-Js "__proto.setMode('talk',{instant:true})"); [void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 1500
if ($size[1] -lt 500) { [void](Invoke-Js "document.querySelector('#draft').focus();document.body.classList.add('typing');1") }
$worst = @{ face = 999; composer = 999; nav = 999; right = 999 }
for ($i = 0; $i -lt 14; $i++) {
Wait-Ms 1500
$g = J 'JSON.stringify((()=>{const ls=[...document.querySelectorAll("#stage .line:not(.leaving)")].filter(l=>getComputedStyle(l).display!=="none");if(!ls.length)return null;const R=e=>e.getBoundingClientRect();const top=Math.min(...ls.map(l=>R(l.querySelector("p")).top)),bottom=Math.max(...ls.map(l=>R(l).bottom)),right=Math.max(...ls.map(l=>R(l.querySelector("p")).right));const c=R(document.querySelector("#composer")),n=R(document.querySelector(".navigation")),h=Scene.info().head,navShown=getComputedStyle(document.querySelector(".navigation")).opacity>.5;return {face:top-h.bottom,composer:c.top-bottom,nav:navShown?n.top-c.bottom:99,right:innerWidth-right}})())'
if ($g) { foreach ($k in 'face', 'composer', 'nav', 'right') { $worst[$k] = [math]::Min($worst[$k], $g.$k) } }
}
if ($size[0] -eq 390 -and $size[1] -eq 430) { Save-Shot "$out\mobile-keyboard-430.png" }
if ($size[0] -eq 320) { Save-Shot "$out\mobile-320-talk.png" }
$ok = $worst.face -ge -4 -and $worst.composer -ge 0 -and $worst.nav -ge 0 -and $worst.right -ge 0
Check "no-overlap-while-flowing-$($size[0])x$($size[1])" $ok @{ minGapBelowHerChinPx = [math]::Round($worst.face, 1); minGapAboveComposerPx = [math]::Round($worst.composer, 1); minGapComposerToNavPx = [math]::Round($worst.nav, 1); minRightMarginPx = [math]::Round($worst.right, 1) }
}
# H. Long name on a phone.
Set-View 390 844 -mobile -dpr 2; Open-Page "${url}?theme=fog&tier=0"; Wait-Ms 1000
[void](Invoke-Js "document.querySelector('#rehearse').click()"); Wait-Ms 500
[void](Invoke-Js "document.querySelector('[data-action=create]').click()"); Wait-Ms 500
[void](Invoke-Js "(()=>{document.querySelector('#new-name').value='星河与晚风之间慢慢醒来的小月亮';document.querySelector('#new-relation').value='朋友';document.querySelector('#create-form').requestSubmit();return 1})()"); Wait-Ms 2500
$ov = J 'JSON.stringify((()=>{const b=document.querySelector("#stage .is-current .who b"),t=document.querySelector(".identity strong");return {name:b.textContent,lineFits:b.getBoundingClientRect().right<=innerWidth,topFits:t.getBoundingClientRect().right<=innerWidth}})())'
Check 'long-name-contained' ($ov.lineFits -and $ov.topFits) $ov
Save-Shot "$out\mobile-long-name.png"
# I. Reduced motion: no camera travel, a short crossfade only; ambient loops off.
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @(@{ name = 'prefers-reduced-motion'; value = 'reduce' }) })
Set-View 1600 1000; Open-Page "${url}?theme=moon&tier=0"; Wait-Ms 1000
$s0 = J 'JSON.stringify(document.querySelector("#plate-room").style.transform)'
Click '#start-talk'; Wait-Ms 90
$rm = J 'JSON.stringify(Object.assign(Scene.info(),{room:document.querySelector("#plate-room").style.transform,loops:document.getAnimations().filter(a=>a.playState==="running"&&a.effect.getTiming().iterations===Infinity).length}))'
Wait-Ms 400
$rm2 = J 'JSON.stringify(Scene.info())'
Check 'reduced-motion-crossfade-only' ($rm.room -eq $s0 -and $rm.t -eq 1 -and $rm.talkOpacity -gt 0 -and $rm.talkOpacity -lt 1 -and $rm.loops -eq 0 -and $rm2.talkOpacity -eq 1) @{ roomUnmoved = ($rm.room -eq $s0); at90ms = $rm.talkOpacity; at490ms = $rm2.talkOpacity; ambientLoops = $rm.loops }
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @() })
# J. Slow-device tier: no per-frame mask, no backdrop blur, half-resolution rain.
Open-Page "${url}?theme=fog&tier=2"; Wait-Ms 1000
Click '#start-talk'; Wait-Ms 300
$t2 = J 'JSON.stringify(Object.assign(Scene.info(),{blur:getComputedStyle(document.querySelector("#composer")).backdropFilter,rain:document.querySelector("#plate-room canvas").width}))'
Check 'low-tier-fallback' ($t2.tier -eq 2 -and -not $t2.mask -and $t2.blur -eq 'none' -and $t2.rain -eq 800) $t2
Wait-Ms 1500
Save-Shot "$out\desktop-tier2-fog.png"
} finally { Stop-Browser }
$results | ConvertTo-Json -Depth 7 | Set-Content "$out\verification.json" -Encoding utf8
$p = @($results | Where-Object { $_.pass }).Count
"$p / $($results.Count) passed"
+141
View File
@@ -0,0 +1,141 @@
"""Check requirement provenance and handoff completeness; no service or network access."""
import argparse
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / 'docs/requirements'
def read(path):
return json.loads(path.read_text(encoding='utf-8'))
def check(handoff=None, base_ref=None):
errors = []
baseline = read(DOCS / 'baseline.json')
rows = baseline['requirements']
ids = [r['id'] for r in rows]
known = set(ids)
evidence = {}
for path in sorted((DOCS / 'sources').glob('comment-evidence*.json')):
for comment in read(path):
old = evidence.get(comment['id'])
if old and old != comment:
errors.append(f'同一来源评论出现不同记录:{comment["id"]}')
evidence[comment['id']] = comment
if len(ids) != len(known):
errors.append('重复的需求编号')
text = (DOCS / 'current.md').read_text(encoding='utf-8')
headings = re.findall(r'^## ((?:CG|VIS|AM|UX|GOV|DEL)-\d{3}) · ', text, re.M)
if headings != ids:
errors.append('完整正文与结构化需求编号不一致')
for row in rows:
key = row['id']
if not re.fullmatch(r'(?:CG|VIS|AM|UX|GOV|DEL)-\d{3}', key):
errors.append(f'非法编号:{key}')
if not row.get('topic') or not row.get('conclusion') or not row.get('source_comment_ids'):
errors.append(f'缺少要求或来源:{key}')
if row.get('conclusion','') not in text or row.get('boundary','') not in text:
errors.append(f'完整正文缺少要求原意或边界:{key}')
for source in row.get('source_comment_ids', []):
if source not in evidence or source not in text:
errors.append(f'来源缺失:{key}/{source}')
if row.get('source_document') and not (DOCS / row['source_document']).is_file():
errors.append(f'来源文档缺失:{key}')
for name, expected in read(DOCS / 'source-manifest.json').items():
path = DOCS / 'sources' / name
if path.parent != DOCS / 'sources' or not path.is_file():
errors.append(f'来源文件缺失或路径异常:{name}')
elif hashlib.sha256(path.read_bytes()).hexdigest() != expected:
errors.append(f'历史来源已改变:{name}')
coverage = read(DOCS / 'coverage.json')
coverage_ids = [r['requirement_id'] for r in coverage['items']]
if coverage.get('baseline_version') != baseline['version']:
errors.append('对照表版本过期')
if len(coverage_ids) != len(set(coverage_ids)) or set(coverage_ids) != known:
errors.append('对照表遗漏、重复或包含不存在的要求')
for row in coverage['items']:
if not row.get('status') or not row.get('next_step') or not isinstance(row.get('evidence'), list):
errors.append(f'对照项缺少状态、证据栏或承接:{row.get("requirement_id")}')
changes = read(DOCS / 'changes.json')['entries']
if not changes or changes[-1].get('version') != baseline['version']:
errors.append('当前版本没有变更记录')
for change in changes:
source = evidence.get(change.get('approval_comment_id'), {})
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
errors.append('需求变更未关联白栖知的原始评论')
if change.get('approved_by') != baseline['owner_id'] or not change.get('summary') or not change.get('impact'):
errors.append('变更记录缺少批准者、说明或影响')
if not change.get('affected_ids') or not set(change['affected_ids']) <= known:
errors.append('变更影响编号缺失或无效')
if base_ref:
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]*', base_ref):
errors.append('非法比较引用')
else:
valid=subprocess.run(['git','rev-parse','--verify',base_ref+'^{commit}'],cwd=ROOT,capture_output=True)
if valid.returncode:
errors.append('比较引用不存在;不能跳过版本检查')
else:
def previous(name):
result=subprocess.run(['git','show',f'{base_ref}:docs/requirements/{name}'],cwd=ROOT,capture_output=True,text=True)
return json.loads(result.stdout) if not result.returncode else None
old=previous('baseline.json')
if old:
old_changes=previous('changes.json')['entries']
if changes[:len(old_changes)] != old_changes:
errors.append('历史变更记录不能改写或删除')
previous_manifest=previous('source-manifest.json')
current_manifest=read(DOCS / 'source-manifest.json')
if any(current_manifest.get(k)!=v for k,v in previous_manifest.items()):
errors.append('历史来源必须保留;后续来源以新文件追加')
if baseline != old:
if baseline['version'] == old['version']:
errors.append('需求有变但版本未更新')
if len(changes)<=len(old_changes):
errors.append('需求有变但没有追加用户授权记录')
old_approvals={c['approval_comment_id'] for c in old_changes}
if changes[-1]['approval_comment_id'] in old_approvals:
errors.append('新需求变更不能重复使用旧批准评论')
if handoff:
data = read(Path(handoff))
for key in ['baseline_version','read_commit','requirement_ids','deliverable','acceptance','deferred','requirement_changes','verification']:
if key not in data:
errors.append(f'交接缺少:{key}')
if data.get('baseline_version') != baseline['version']:
errors.append('交接使用过期基准')
if not re.fullmatch(r'[a-f0-9]{40}', data.get('read_commit','')):
errors.append('交接未记录实际仓库提交')
refs=data.get('requirement_ids', [])
if not refs or not set(refs) <= known or len(refs) != len(set(refs)):
errors.append('交接要求编号缺失、重复或无效')
for key in ['acceptance','deferred','requirement_changes','verification']:
if not isinstance(data.get(key), list):
errors.append(f'交接字段应为列表:{key}')
if not data.get('deliverable') or not data.get('acceptance') or not data.get('verification'):
errors.append('交接缺少交付、验收或验证说明')
for change in data.get('requirement_changes', []):
source = evidence.get(change.get('approval_comment_id'), {})
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
errors.append('交接需求变更缺少用户来源;先更新正式变更记录')
return errors, len(ids)
if __name__ == '__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--handoff', help='JSON交接文件')
parser.add_argument('--base-ref', help='与已获取的目标分支比较版本及历史来源')
args=parser.parse_args()
try:
errors, count=check(args.handoff, args.base_ref)
except (KeyError, TypeError, ValueError, OSError) as exc:
print(f'需求材料无法验证:{exc}', file=sys.stderr)
sys.exit(1)
if errors:
print('\n'.join(errors),file=sys.stderr)
sys.exit(1)
print(f'需求结构检查通过:{count}项;来源和覆盖完整。仍须人工核对授权语义与实际体验。')
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/
+35
View File
@@ -0,0 +1,35 @@
CyberGril S1 前端(HEL-748)
独立 React / TypeScript / Vite 工程。Python 核心保持原样;不是新的后端。视觉基于获批 HEL-746 R2 的 Token、组件样式和两张参考静帧,页面由真实 React 组件实现,没有嵌入原型 HTML。
```bash
cd web
npm ci
npm run dev # 仅本机,终端给出 URL
# 普通入口 / 说明真实服务未接通;点入口或访问 /?demo=1
npm run typecheck
npm run lint
npm test
npm run build
npm run preview # 仅本机预览生产构建
```
Node 22+ 推荐(本轮 Node 24.16.0 / npm 11.13.0 验证),锁文件已提交。不需要 API Key、模型或 GPU。没有运行时 CDN 请求。
首次进入演示需填写名字、生辰、关系并确认。创建保存在浏览器 `cybergril.demo.v1`;刷新保留同一位档案。没有切换或清空重建入口。`cg.theme` 保留主题,`sessionStorage` 的 `cg.draft` 保留交流草稿。资料只在当前浏览器/站点 origin;它不代表服务器已创建、记忆已入库或后台仍在生活。保存不可用时会报错,不伪装成功。损坏资料会显示恢复说明,不自动覆盖。
演示场景入口可载入固定的三天/长记录、模拟离开返回、主动消息、断线和操作失败。只有明确 `?demo=1` 才有该入口,不能切换隐私规则。首建后生活默认空白;固定样例须主动载入,不自行编造离线生活。
本地演示 JSON 可导出、恢复同一位档案;恢复需输入“恢复”,先下载恢复前数据。它不含真实数据库和媒体文件,不能称为后台完整备份。素材导入只校验演示 JSON 的结构与标识,媒体格式/时长/人物一致性均未检测,清单中的 URL 不会加载。可用示例在 `public/demo-material.json`。
浏览器测试:先构建,再在已安装 Python Playwright 和 Chromium 及其依赖的环境执行:
```bash
python3 web/tests/browser_check.py # 在仓库根目录
python3 web/tests/import_check.py
python3 -m pytest -q # 现有 Python 核心,仓库根目录
```
脚本在随机 loopback 端口同步测试静态产物,结束关闭浏览器和服务;截图写入 `docs/frontend/screenshots/`(不进入前端产物)。没有持续预览服务或部署。真实手机、Safari、真实麦克风/摄像头、ASR/TTS、后台生活和口型均未测试。
接线边界、交付与测试记录见 `docs/frontend/IMPLEMENTATION.md`。
+5
View File
@@ -0,0 +1,5 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import hooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default ts.config(js.configs.recommended, ...ts.configs.recommended, {files:['src/**/*.{ts,tsx}'],languageOptions:{globals:globals.browser},plugins:{'react-hooks':hooks},rules:{...hooks.configs.recommended.rules}});
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN" data-theme="moon"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark"><title>CyberGril · 一扇亮着的窗</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+3328
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
{
"name": "cybergril-web", "version": "0.1.0", "private": true, "type": "module",
"scripts": {"dev": "vite --host 127.0.0.1", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "lint": "eslint src", "test": "vitest run", "preview": "vite preview --host 127.0.0.1"},
"dependencies": {"react": "18.3.1", "react-dom": "18.3.1"},
"devDependencies": {"@types/react": "18.3.20", "@types/react-dom": "18.3.6", "typescript": "5.8.3", "vite": "6.3.6", "vitest": "3.1.2", "eslint": "9.25.1", "@eslint/js": "9.25.1", "typescript-eslint": "8.31.1", "eslint-plugin-react-hooks": "5.2.0", "globals": "16.0.0"}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="10" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-chat" viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 8-8h0a8 8 0 0 1 0 16H4l2.3-2.6A8 8 0 0 1 4 12z" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-heart" viewBox="0 0 24 24"><path d="M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-pin" viewBox="0 0 24 24"><path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 1 1 13 0c0 5.4-6.5 11-6.5 11z" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="10" r="2.3" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-image" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3.5 16l5-5 4 4 3-3 5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="M4 7.5l8-4 8 4v9l-8 4-8-4z M4 7.5l8 4 8-4M12 11.5v9" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0zM12 16.5V21" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M12 3a7 7 0 1 0 8.5 9.3A6 6 0 0 1 12 3z" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5z M9 10h6M9 14h6M9 18h3" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 4l9 16H3z M12 10v4M12 17v.5" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-ok" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="1.9"/></symbol>
<symbol id="i-sparkle" viewBox="0 0 24 24"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="M12 4v11M7 10.5l5 5 5-5M5 20h14" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-up" viewBox="0 0 24 24"><path d="M12 15V4M7 8.5l5-5 5 5M5 20h14" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 15a4.5 4.5 0 0 1 .6-9 5.5 5.5 0 0 1 10.3 2.1A3.5 3.5 0 0 1 17.5 15z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M9 18l-1 2.5M13 18l-1 2.5M17 18l-1 2.5" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-send" viewBox="0 0 24 24"><path d="M4 12l16-8-6 16-2.5-6.5z" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+5
View File
@@ -0,0 +1,5 @@
{
"manifest_version": 1,
"appearance_id": "local-demo-v2",
"clips": [{"id": "reading-demo", "src": "not-loaded-in-S1", "scene": {"mode": "daily", "activity": "reading", "time_band": "evening", "weather": "clear", "outfit": "home"}}]
}
+33
View File
@@ -0,0 +1,33 @@
import type { ServerEvent } from '../domain/model';
export interface ChatAdapter {
send(text: string, turnId: string, epoch: number, signal: AbortSignal, emit: (event: ServerEvent) => void, fail?: boolean): Promise<void>;
}
export function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { if (signal.aborted) {
reject(new DOMException('Cancelled', 'AbortError'));
return;
} const stop = () => { clearTimeout(timer); reject(new DOMException('Cancelled', 'AbortError')); }; const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, ms); signal.addEventListener('abort', stop, { once: true }); });
}
export class DemoAdapter implements ChatAdapter {
async send(_text: string, turnId: string, epoch: number, signal: AbortSignal, emit: (e: ServerEvent) => void, fail = false) {
await delay(500, signal);
if (fail)
throw new Error('没发出去,你写的话还在。');
const reply = '我听见了。我们可以慢慢聊,不用急着把话说完。';
for (const chunk of reply.match(/.{1,5}/gu) || []) {
await delay(90, signal);
emit({ t: 'chat.delta', turn_id: turnId, epoch, delta: chunk });
}
emit({ t: 'chat.final', turn_id: turnId, epoch, utterance: { text: reply, speech_tags: [] } });
}
}
// Transport-independent gate: also rejects deliberately late events from non-cooperative adapters.
export class TurnGate {
private controller: AbortController | null = null;
private current = '';
epoch = 1;
begin(id: string) { this.cancel(); this.current = id; this.controller = new AbortController(); return this.controller.signal; }
accepts(event: ServerEvent) { return event.epoch === this.epoch && event.turn_id === this.current && !this.controller?.signal.aborted; }
cancel() { this.controller?.abort(); this.current = ''; }
resync() { this.cancel(); this.epoch++; }
}
+62
View File
@@ -0,0 +1,62 @@
import { defaultSettings, type DemoState, type Identity } from '../domain/model';
export const STORAGE_KEY = 'cybergril.demo.v1';
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' && !Array.isArray(value); }
function str(v: unknown, max = 20000): v is string { return typeof v === 'string' && v.length <= max; }
function strings(v: unknown): v is string[] { return Array.isArray(v) && v.length < 1000 && v.every(x => str(x, 100)); }
export function parseState(text: string, sameIdentity?: Identity): DemoState {
if (text.length > 5000000)
throw new Error('文件太大,请选择 5MB 以内的本地演示 JSON。');
let x: unknown;
try { x=JSON.parse(text); } catch { throw new Error('文件不是有效的 JSON,请重新选择本地演示导出文件。'); }
if (!object(x) || x.version !== 1 || !object(x.identity) || !object(x.settings))
throw new Error('这不是兼容的演示文件。');
const i = x.identity, s = x.settings;
if (!str(i.id, 100) || !str(i.name, 20) || !i.name.trim() || !str(i.birth, 10) || !str(i.hour, 5) || !['朋友', '恋人'].includes(String(i.relation)) || !str(i.nickname, 40) || !str(i.boundary, 2000))
throw new Error('档案资料不完整。');
if (sameIdentity && (i.id !== sameIdentity.id || i.birth !== sameIdentity.birth || i.hour !== sameIdentity.hour || i.name !== sameIdentity.name))
throw new Error('这份演示文件属于另一位她,不能恢复。');
if (!str(s.city, 40) || !str(s.appearance, 100) || typeof s.reduced !== 'boolean' || !object(s.services) || !Object.values(s.services).every(v => str(v, 2000)))
throw new Error('设置格式不正确。');
for (const key of ['events', 'memories', 'messages'])
if (!Array.isArray(x[key]) || (x[key] as unknown[]).length > 5000)
throw new Error('演示记录格式不正确。');
const events = x.events as unknown[], memories = x.memories as unknown[], messages = x.messages as unknown[];
if (!events.every(e => object(e) && str(e.id, 100) && str(e.title, 200) && str(e.text) && str(e.time, 100) && str(e.place, 200) && ['done', 'now', 'plan'].includes(String(e.kind)) && typeof e.shared === 'boolean' && strings(e.memoryIds)))
throw new Error('日子记录不完整。');
if (!memories.every(m => object(m) && str(m.id, 100) && str(m.title, 200) && str(m.text) && str(m.group, 100) && str(m.source, 200) && strings(m.lifeIds)))
throw new Error('回忆记录不完整。');
if (!messages.every(m => object(m) && str(m.id, 100) && ['user', 'companion'].includes(String(m.role)) && str(m.text) && strings(m.lifeIds) && (!m.status || ['pending', 'failed', 'sent', 'cancelled'].includes(String(m.status)))))
throw new Error('聊天记录不完整。');
// Reconstruct the schema: do not preserve arbitrary imported object keys/prototypes.
return { version: 1, identity: { id: i.id, name: i.name, birth: i.birth, hour: i.hour, relation: i.relation as Identity['relation'], nickname: i.nickname, boundary: i.boundary }, settings: { city: s.city, appearance: s.appearance, reduced: s.reduced, services: Object.fromEntries(Object.keys(defaultSettings.services).map(k => [k, String((s.services as Record<string, unknown>)[k] || '')])) }, events: events.map(e => { const v = e as DemoState['events'][number]; return { id: v.id, title: v.title, text: v.text, time: v.time, place: v.place, kind: v.kind, shared: v.shared === true, memoryIds: [...v.memoryIds] }; }), memories: memories.map(m => { const v = m as DemoState['memories'][number]; return { id: v.id, title: v.title, text: v.text, group: v.group, source: v.source, lifeIds: [...v.lifeIds] }; }), messages: messages.map(m => { const v = m as DemoState['messages'][number]; return { id: v.id, role: v.role, text: v.text, lifeIds: [...v.lifeIds], status: v.status === 'pending' ? 'cancelled' : v.status, turnId: typeof v.turnId === 'string' ? v.turnId : undefined, proactive: v.proactive === true }; }), lastVisit: typeof x.lastVisit === 'number' ? x.lastVisit : Date.now() };
}
export function loadState(): {
state: DemoState | null;
error: string;
} {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return { state: raw ? parseState(raw) : null, error: '' };
}
catch {
return { state: null, error: '本机演示资料暂时读不出来。原资料未覆盖,请允许浏览器本地存储后重新打开。' };
}
}
export function persistState(state: DemoState, fail = false) {
if (fail)
throw new Error('这次没存好,你的修改还在,可以再试一次。');
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
catch {
throw new Error('本机存储不可用或空间不足,你的修改还在。请检查浏览器设置后重试。');
}
}
export function exportDemo(state: DemoState) {
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url;
a.download = `CyberGril-demo-${state.identity.id}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+111
View File
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DemoState, Message, Theme } from '../domain/model';
import { publicView } from '../domain/privacy';
import { loadState, persistState } from '../adapters/storage';
import { sampleDays } from '../demo/fixtures';
import { CreationWizard } from '../manage/CreationWizard';
import { ManagePanel } from '../manage/ManagePanel';
import { LifePanel } from '../life/LifePanel';
import { ChatPanel } from '../chat/ChatPanel';
import { Background } from '../stage/Background';
import { FallbackPresenter } from '../stage/Presenter';
import { Button, Icon, Status } from '../shared/ui';
const demo = new URLSearchParams(location.search).get('demo') === '1';
export function App() {
const [initial] = useState(loadState), [state, setState] = useState<DemoState | null>(initial.state), [creating, setCreating] = useState(!initial.state), [area, setArea] = useState('now'), [theme, setThemeValue] = useState<Theme>(() => { try {
return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon';
}
catch {
return 'moon';
} }), [toast, setToast] = useState(''), [controls, setControls] = useState(false), [fail, setFail] = useState(false), [offline, setOffline] = useState(false), [reconnecting, setReconnecting] = useState(false), [welcome, setWelcome] = useState(!!initial.state && Date.now() - initial.state.lastVisit > 86400000), [reach, setReach] = useState(false), [unread, setUnread] = useState(false), [log, setLog] = useState(false), [focusLife, setFocusLife] = useState(''), [returnTo, setReturnTo] = useState('now'), [presence, setPresence] = useState('在你面前'), [voice, setVoice] = useState(false), [observation, setObservation] = useState('');
const latest = useRef(state);
latest.current = state;
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null), reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((text: string) => { setToast(text); if (toastTimer.current)
clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(''), 5000); }, []);
const save = useCallback((next: DemoState) => { persistState(next); setState(next); latest.current = next; }, []);
const changeMessages = useCallback((messages: Message[]) => { const prev = latest.current; if (!prev)
return; const next = { ...prev, messages }; latest.current = next; setState(next); try {
persistState(next);
}
catch (e) {
notify((e as Error).message);
} }, [notify]);
const setTheme = useCallback((t: Theme) => { setThemeValue(t); try {
localStorage.setItem('cg.theme', t);
}
catch {
notify('主题已切换,但浏览器没有允许保存偏好。');
} }, [notify]);
const view = state ? publicView(state) : null;
const navigate = useCallback((next: string) => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') {
setReturnTo('now');
setFocusLife('');
setUnread(false);
} if (next !== 'talk') {
setLog(false);
setVoice(false);
} }, []);
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
useEffect(() => { document.body.className = [creating && demo ? 'creating' : '', area === 'talk' ? 'talk' : '', !creating && ['days', 'settings'].includes(area) ? 'paned' : '', voice ? 'voice' : '', log ? 'log' : '', offline ? 'offline' : '', welcome ? 'welcoming' : '', state?.events.length === 0 ? 'data-empty' : ''].join(' '); }, [area, creating, voice, log, offline, welcome, state]);
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
useEffect(() => { const visibility = () => { document.documentElement.classList.toggle('paused', document.hidden); if (document.hidden && latest.current) {
try {
persistState({ ...latest.current, lastVisit: Date.now() });
}
catch { /* Keep memory draft. */ }
} }; document.addEventListener('visibilitychange', visibility); return () => { document.removeEventListener('visibilitychange', visibility); if (toastTimer.current)
clearTimeout(toastTimer.current); if (reconnectTimer.current)
clearTimeout(reconnectTimer.current); }; }, []);
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating)
return; if (controls) {
setControls(false);
return;
} if (welcome) {
setWelcome(false);
return;
} if (log) {
setLog(false);
return;
} navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [controls, welcome, log, creating, navigate]);
function openEventChat(id: string) { setFocusLife(id); setReturnTo('days'); setLog(true); setArea('talk'); }
function closeTalk() { setArea(returnTo); setLog(false); setVoice(false); }
function loadSamples(long = false) { if (!state)
return; try {
save(sampleDays(state, long));
notify('已载入固定演示记录,没有生成真实生活。');
}
catch (e) {
notify((e as Error).message);
} }
function proactive() { if (!state)
return; let next = state.events.length ? state : sampleDays(state); const event = publicView(next).events.find(e => e.id === 'rain'); if (!event)
return; next = { ...next, messages: [...next.messages.filter(m => m.id !== 'reach-demo'), { id: 'reach-demo', role: 'companion', text: '窗外的雨停了,想跟你说一声。', lifeIds: [event.id], proactive: true }] }; try {
save(next);
setReach(true);
setUnread(true);
setControls(false);
}
catch (e) {
notify((e as Error).message);
} }
function reconnect() { setReconnecting(true); if (reconnectTimer.current)
clearTimeout(reconnectTimer.current); reconnectTimer.current = setTimeout(() => { setReconnecting(false); if (fail) {
notify('暂时还没连上,再试一次。');
return;
} setOffline(false); notify('模拟恢复完成,已重新同步。草稿仍在,可自行发送。'); }, 700); }
const scene = { mode: area === 'talk' ? 'talk' as const : 'daily' as const, activity: view?.events.find(e => e.kind === 'now') ? '在窗边读书' : '刚搬进来,安静待着', time_band: 'evening', weather: 'demo', outfit: 'home' };
return <><Background /><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{demo ? '本机演示 · 无真实后台' : '服务尚未接通'}</span></div><div className="env"><span className="chip glass place"><Icon name="pin"/>{state?.settings.city || '杭州'}</span><span className="chip glass weather"><Icon name="cloud"/>雨后 · 模拟</span><span className="theme-switch glass">{(['moon', 'fog'] as Theme[]).map(t => <button key={t} data-t={t} aria-label={t === 'moon' ? '靛蓝月光' : '蓝紫雾光'} aria-pressed={theme === t} onClick={() => setTheme(t)}><i /><span>{t === 'moon' ? '月光' : '雾光'}</span></button>)}</span></div></header>
{!demo ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><p>真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。</p><a className="btn-primary" href="?demo=1">进入本机演示</a></div></main> : initial.error ? <main className="entry"><div className="card"><h1>资料暂时读不出来</h1><Status text={initial.error} error/><p>请先检查浏览器存储权限,或备份浏览器中的原始资料后再处理。不会自动清空或覆盖。</p></div></main> : <>
{creating && <CreationWizard fail={fail} onCreate={save} onEnter={() => setCreating(false)}/>}
{!creating && state && view && <><FallbackPresenter scene={scene} presence={presence} observation={observation} onClose={area === 'talk' ? closeTalk : () => navigate('now')} closeLabel={returnTo === 'days' ? '返回日子' : '回到此刻'}/>
{area === 'now' && !welcome && <main className="home"><div className="eyebrow">夜色里 · 一扇亮着的窗</div><h1 className="whisper">{state.events.length ? '夜深了,慢慢说。' : '刚住进来,一切都慢慢来。'}</h1><div className="meta"><span><Icon name="moon"/>心情平静 · 演示</span><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div>{view.messages.filter(m => m.role === 'companion').length > 0 && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>她留给你的</b><span>演示留言</span></span><p>{[...view.messages].reverse().find(m => m.role === 'companion')?.text}</p></button>}<button className="away" onClick={() => navigate('days')}>你不在时 · 看她已经分享的日子 <Icon name="next"/></button><div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button><button className="btn-round glass" aria-label="进入模拟语音" onClick={() => { navigate('talk'); notify('点输入框右侧麦克风,可开启无收音的语音模拟。'); }}><Icon name="mic"/></button></div><p className="first-note">{state.identity.name}今晚刚住进来。这里还没有共同的日子。</p><p className="demo-label">本机演示 · 没有真实后台生活</p></main>}
{welcome && area === 'now' && <section className="welcome glass"><div className="eyebrow">回来坐一会儿</div><h2>你不在的这两天</h2><p className="sub">固定演示摘要 · 只展示已分享记录</p><ul>{view.events.filter(e => e.kind !== 'plan').slice(0, 3).map(e => <li key={e.id}><time>{e.time}</time><span>{e.title}</span></li>)}</ul><p className="note">演示中网页关闭不会产生新的生活,服务器后台尚未接通。</p><div className="acts"><Button primary onClick={() => { navigate('talk'); setLog(true); }}>看她留的话</Button><Button onClick={() => navigate('days')}>看她的日子</Button><button className="btn-text" onClick={() => setWelcome(false)}>先待一会儿</button></div></section>}
<LifePanel state={state} open={area === 'days'} onClose={() => navigate('now')} onChat={openEventChat}/><ManagePanel state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={save} fail={fail} theme={theme} setTheme={setTheme}/>
{area === 'talk' && <ChatPanel state={state} onChange={changeMessages} onPresence={setPresence} onVoice={setVoice} onObservation={setObservation} onClose={closeTalk} offline={offline} fail={fail} log={log} setLog={setLog} focusLife={focusLife}/>}
{offline && <div className={`netbar show glass ${reconnecting ? '' : 'failed'}`} role="status"><span>{reconnecting ? '信号不太好,正在重新连上…' : '模拟断线,草稿还在'}</span><button className="btn-ghost" onClick={reconnect}>再试一次</button></div>}
{reach && <aside className="reach glass show"><div className="rh"><b>{state.identity.name}</b>主动说的 · 模拟</div><p>窗外的雨停了,想跟你说一声。</p><div className="basis">来自已分享的「听了一下午的雨」</div><div className="acts"><Button primary onClick={() => { setReach(false); setUnread(false); setReturnTo(area); setArea('talk'); setFocusLife('rain'); setLog(true); }}>回她</Button><Button onClick={() => setReach(false)}>稍后再看</Button></div></aside>}
<nav className="nav glass" aria-label="主导航">{[['now', 'moon', '此刻'], ['talk', 'chat', '交谈'], ['days', 'book', '日子'], ['settings', 'gear', '设置']].map(([id, icon, label]) => <button className={`nav-item ${id === 'talk' && unread ? 'unread' : ''}`} key={id} aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}<i className="badge"/></button>)}</nav></>}
<div className={`demo ${controls ? 'open' : ''}`}><button onClick={() => setControls(!controls)} aria-expanded={controls}>演示场景<span className="dl"> · 非真实能力</span></button>{controls && <div className="menu"><h5>仅作用于本机演示,不请求真实服务</h5>{!creating && <div className="g"><button onClick={() => loadSamples()}>载入三天记录</button><button onClick={() => loadSamples(true)}>长记录</button><button onClick={() => { loadSamples(); setWelcome(true); setArea('now'); setControls(false); }}>模拟两天后返回</button><button onClick={proactive}>模拟主动消息</button><button onClick={() => { setOffline(true); setVoice(false); setControls(false); }}>模拟断线</button></div>}<h5>失败与恢复演示</h5><div className="g"><button aria-pressed={fail} onClick={() => setFail(!fail)}>{fail ? '关闭操作失败' : '下次操作模拟失败'}</button></div><p>失败开关控制创建、发送、保存和重连。关闭后可重试。没有切换隐私规则或清空重建入口。</p></div>}</div>
</>}{toast && <div className="toast show glass" role="status">{toast}</div>}</>;
}
+111
View File
@@ -0,0 +1,111 @@
import { newId } from '../domain/id';
import { useEffect, useRef, useState } from 'react';
import type { DemoState, Message } from '../domain/model';
import { DemoAdapter, TurnGate } from '../adapters/demo';
import { publicView } from '../domain/privacy';
import { Icon, Button, Modal } from '../shared/ui';
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: {
state: DemoState;
onChange: (messages: Message[]) => void;
onPresence: (p: string) => void;
onVoice: (v: boolean) => void;
onObservation: (s: string) => void;
onClose: () => void;
offline: boolean;
fail: boolean;
log: boolean;
setLog: (b: boolean) => void;
focusLife: string;
}) {
const [draft, setDraft] = useState(() => { try {
return sessionStorage.getItem('cg.draft') || '';
}
catch {
return '';
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
live.current = state.messages;
const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion');
const change = (messages: Message[]) => { live.current = messages; onChange(messages); };
function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); }
useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]);
useEffect(() => { try {
sessionStorage.setItem('cg.draft', draft);
}
catch { /* Draft remains in memory. */ } }, [draft]);
useEffect(() => { if (offline) {
gate.current.resync();
setBusy(false);
setReply('');
setVoice(false);
onVoice(false);
voiceController.current?.abort();
} }, [offline, onVoice]);
useEffect(() => { if (!vision)
return; onObservation('模拟 · 刚刚看到,未采集影像'); const t = setTimeout(() => onObservation('模拟 · 已过期,不再当作眼前'), 30000); return () => clearTimeout(t); }, [vision, onObservation]);
useEffect(() => { if (log) {
const el = focusLife ? logRef.current?.querySelector(`[data-life="${CSS.escape(focusLife)}"]`) : null;
if (el)
el.scrollIntoView({ block: 'center' });
else if (logRef.current)
logRef.current.scrollTop = logRef.current.scrollHeight;
} }, [log, focusLife, view.messages.length]);
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
return; if (event.t === 'chat.delta') {
setReply(x => x + event.delta);
onPresence('在说话');
}
else {
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
setReply('');
setBusy(false);
onPresence(voice ? '在听你说(模拟)' : '在你面前');
} }, fail);
}
catch (e) {
if (signal.aborted)
return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
setNotice((e as Error).message);
onPresence('在你面前');
} }
const setVoiceMode = () => { if (voice) {
endVoice();
return;
} setPermission('mic'); };
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || '演示回复 · 没有连接模型、语音或记忆后台'}</div></div>
{voice && <div className="voicebar glass"><span className="live"><i /></span><span className="vstate">{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button className="btn-ghost" onClick={() => { stop(); setNotice(fail?'刚才没听清,你再说一遍?(模拟识别失败)':'她停下来了,在等你。'); }}>打断她</button><button className="end" onClick={endVoice}>结束语音</button></div>}
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (vision) {
setVision(false);
onObservation('窗户关上了');
}
else
setPermission('vision'); }}><Icon name="eye"/></button><textarea aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault();
void send();
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">本机演示记录</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
setDenied(false);
setVoice(true);
onVoice(true);
voiceController.current = new AbortController();
onPresence('在听你说(模拟)');
setNotice('模拟语音没有收音,可继续用文字输入。');
}
else {
if (fail) {
onObservation('模拟 · 看不清,视觉服务未接通');
setNotice('模拟观察失败,文字仍可使用。');
}
else
setVision(true);
} setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。</Modal>}
<button hidden className="sr" onClick={onClose}>退出交流</button></>;
}
+31
View File
@@ -0,0 +1,31 @@
import { defaultSettings, type DemoState, type Identity } from '../domain/model';
export function freshState(identity: Identity): DemoState {
return { version: 1, identity, settings: structuredClone(defaultSettings), events: [], memories: [], messages: [], lastVisit: Date.now() };
}
// Loaded only through an explicit demo action; never a fabricated background life loop.
export function sampleDays(state: DemoState, long = false): DemoState {
const events: DemoState['events'] = [
{ id: 'book', title: '读到了书里的那片海', text: '翻到讲海的那一页,忽然想起你说过喜欢安静的地方。', time: '10月21日 19:40', place: '卧室 · 窗边', kind: 'now', shared: true, memoryIds: ['habit'] },
{ id: 'tea', title: '给自己泡了一杯热茶', text: '杯子捧在手里,夜晚就慢了下来。', time: '10月21日 16:20', place: '卧室', kind: 'done', shared: true, memoryIds: [] },
{ id: 'rain', title: '听了一下午的雨', text: '雨滴在玻璃上跑得一快一慢,很像一段没有歌词的曲子。', time: '10月20日 15:30', place: '卧室 · 窗边', kind: 'done', shared: true, memoryIds: ['rain-memory'] },
{ id: 'secret', title: '未分享的秘密散步', text: '这段未分享的生活不能出现在任何视图里。', time: '10月21日 13:00', place: '私密地点', kind: 'done', shared: false, memoryIds: ['secret-memory'] },
{ id: 'plan', title: '明天想读完这一章', text: '如果精神好,就再读一会儿。计划也可能会变。', time: '10月22日 20:00', place: '卧室', kind: 'plan', shared: true, memoryIds: [] }
];
if (long)
for (let i = 0; i < 40; i++)
events.push({ ...events[1], id: `old-${i}`, title: `更早的一杯茶 · ${i + 1}`, time: `10月${19 - Math.floor(i / 4)}日 16:20` });
const memories: DemoState['memories'] = [
{ id: 'habit', title: '留一盏小灯', text: '安静也可以是一起待着的一种方式。', group: '你们的小习惯', source: '演示 · 10月18日的聊天', lifeIds: ['book'] },
{ id: 'rain-memory', title: '第一次聊起雨声', text: '你说雨声让你觉得安心。', group: '我们一起的', source: '演示 · 10月20日的聊天', lifeIds: ['rain'] },
{ id: 'know', title: '你喜欢安静的地方', text: '是你亲口讲给她听的。', group: '她知道的你', source: '演示 · 10月18日你说的', lifeIds: [] },
{ id: 'growth', title: '慢慢学会留白', text: '后来她开始喜欢,在一句话之后留一点安静。', group: '她的成长', source: '演示 · 共同经历', lifeIds: ['book'] },
{ id: 'secret-memory', title: '秘密散步的回忆', text: '隐私测试内容', group: '我们一起的', source: '私密', lifeIds: ['secret'] }
];
const messages: DemoState['messages'] = [
{ id: 'hello', role: 'companion', text: '外面下了一阵雨,现在安静下来了。', lifeIds: ['rain'] },
{ id: 'u1', role: 'user', text: '你在看什么?', lifeIds: [] },
{ id: 'c1', role: 'companion', text: events[0].text, lifeIds: ['book'] },
{ id: 'hidden-message', role: 'companion', text: '秘密散步的间接引用', lifeIds: ['secret'] }
];
return { ...state, events, memories, messages: [...messages, ...state.messages.filter(m => m.turnId)] };
}
+10
View File
@@ -0,0 +1,10 @@
// Demo identity and turn identifiers must also work on the first intranet HTTP preview.
// getRandomValues is available there even where randomUUID requires a secure context.
export function newId(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = [...bytes].map(v => v.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
+86
View File
@@ -0,0 +1,86 @@
export type Theme = 'moon' | 'fog';
export type Relation = '朋友' | '恋人';
export interface Identity {
id: string;
name: string;
birth: string;
hour: string;
relation: Relation;
nickname: string;
boundary: string;
}
export interface Settings {
city: string;
appearance: string;
reduced: boolean;
services: Record<string, string>;
}
export interface LifeEvent {
id: string;
title: string;
text: string;
time: string;
place: string;
kind: 'done' | 'now' | 'plan';
shared: boolean;
memoryIds: string[];
}
export interface Memory {
id: string;
title: string;
text: string;
group: string;
source: string;
lifeIds: string[];
}
export interface Message {
id: string;
role: 'user' | 'companion';
text: string;
lifeIds: string[];
turnId?: string;
status?: 'pending' | 'failed' | 'sent' | 'cancelled';
proactive?: boolean;
}
export interface DemoState {
version: 1;
identity: Identity;
settings: Settings;
events: LifeEvent[];
memories: Memory[];
messages: Message[];
lastVisit: number;
}
export interface SceneDesc {
mode: 'daily' | 'talk';
activity: string;
time_band: string;
weather: string;
outfit: string;
}
export type ServerEvent = {
t: 'chat.delta';
epoch: number;
turn_id: string;
delta: string;
} | {
t: 'chat.final';
epoch: number;
turn_id: string;
utterance: {
text: string;
speech_tags: string[];
};
};
export const defaultSettings: Settings = { city: '杭州', appearance: 'reference-v1', reduced: false, services: { '对话模型': '', 'ASR': '', 'TTS': '', '视觉观察': '' } };
export function validateCreation(name: string, birth: string, relation: string, now = new Date()): string {
if (!name.trim() || name.trim().length > 20 || (/[<>]/u.test(name) || [...name].some(c => c.charCodeAt(0) < 32)))
return '请为她起一个 1–20 字的名字,不含控制字符或尖括号。';
const d = new Date(birth + 'T00:00:00');
const limit = new Date(now.getFullYear() - 18, now.getMonth(), now.getDate());
if (!/^\d{4}-\d{2}-\d{2}$/.test(birth) || !Number.isFinite(d.getTime()) || d > limit || d.getFullYear() < 1900)
return '请填写有效的出生日期,且已满 18 岁。';
if (!['朋友', '恋人'].includes(relation))
return '请亲自选择你们的关系。';
return '';
}
+12
View File
@@ -0,0 +1,12 @@
import type { DemoState, LifeEvent } from './model';
// Fail closed, including unknown references. Real API must apply this BEFORE transmission.
export const sharedEvents = (events: LifeEvent[]) => events.filter(e => e.shared === true);
export function publicView(state: DemoState): DemoState {
const events = sharedEvents(state.events);
const ids = new Set(events.map(e => e.id));
const allowed = (refs: string[]) => refs.every(id => ids.has(id));
const memories = state.memories.filter(m => allowed(m.lifeIds));
const memoryIds = new Set(memories.map(m => m.id));
return { ...state, events: events.map(e => ({ ...e, memoryIds: e.memoryIds.filter(id => memoryIds.has(id)) })), memories, messages: state.messages.filter(m => allowed(m.lifeIds)) };
}
export function eventById(state: DemoState, id: string) { return publicView(state).events.find(e => e.id === id); }
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef, useState } from 'react';
import type { DemoState } from '../domain/model';
import { publicView } from '../domain/privacy';
import { Button, Icon } from '../shared/ui';
export function LifePanel({ state, open, onClose, onChat }: {
state: DemoState;
open: boolean;
onClose: () => void;
onChat: (id: string) => void;
}) {
const view = publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18);
const list = useRef<HTMLDivElement>(null);
useEffect(() => { if (open) {
const params = new URLSearchParams(location.hash.slice(1));
const id = params.get('event');
if (id) {
setTab('life');
setSelection(id);
}
} }, [open]);
const event = tab === 'life' ? view.events.find(e => e.id === selection) : undefined, memory = tab === 'memory' ? view.memories.find(m => m.id === selection) : undefined;
const select = (id: string, t = tab) => { setTab(t); setSelection(id); };
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><h2>日子</h2><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => { setTab(t); setSelection(''); }}>{l}</button>)}</div><span className="grow"/><span className="tag-demo">演示数据</span><button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><div className="list" ref={list}>
{tab === 'life' ? <><div className="away-card"><b>你不在时</b>这里只显示她已经讲给你听的事。演示没有真实后台生活。</div><div className="legend"><span><i className="k done"/>已发生</span><span><i className="k now"/>正在</span><span><i className="k plan"/>打算</span></div>{view.events.length === 0 ? <div className="empty">她今晚刚住进来,还没有发生什么。<small>演示资料可以从「演示场景」载入。</small></div> : view.events.slice(0, limit).map((e, i) => <div key={e.id}>{(i === 0 || view.events[i - 1].time.split(' ')[0] !== e.time.split(' ')[0]) && <div className="grp">{e.kind === 'plan' ? '之后的打算' : e.time.split(' ')[0]}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sub"><span className="tag">已经讲过</span>{e.memoryIds.length > 0 && <span className="tag accent">和你有关</span>}</span></span></button></div>)}{limit < view.events.length && <div className="more"><Button onClick={() => setLimit(x => x + 12)}>看更早的日子</Button></div>}</> : view.memories.length === 0 ? <div className="empty">聊着聊着就会有了。</div> : ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'].map(group => <div key={group}><div className="grp">{group}</div>{view.memories.filter(m => m.group === group).map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}>{m.title}<small>{m.source}</small></button>)}</div>)}
</div><article className="detail"><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>{event ? <><div className="d-kicker"><i className={`k ${event.kind}`}/>{event.kind === 'now' ? '正在' : event.kind === 'plan' ? '打算 · 计划可能会变' : '已经发生'}<span className="tag">已经讲给你听</span></div><h3 className="d-title">{event.title}</h3><div className="d-meta">{event.time} · {event.place}</div><blockquote className="quote">{event.text}<small>{view.identity.name}说过的话 · 演示</small></blockquote><div className="d-sec">和这件事有关的回忆</div><div className="links">{event.memoryIds.map(id => { const m = view.memories.find(x => x.id === id); return m && <button className="link-chip" key={id} onClick={() => select(id, 'memory')}><Icon name="link"/>{m.title}</button>; })}</div><div className="d-sec">那时候的聊天</div><Button onClick={() => onChat(event.id)}>看那段聊天</Button><div className="d-foot">这是模拟生活里的一条记录,不是真实发生的事。画面只按状态展示,不会反过来改写生活。</div></> : memory ? <><div className="d-kicker">{memory.group} · 演示回忆</div><h3 className="d-title">{memory.title}</h3><div className="d-meta">{memory.source}</div><blockquote className="quote">{memory.text}</blockquote>{memory.group === '她的成长' && <div className="growth"><div><h4>最初就有的</h4>细腻、慢热</div><div><h4>后来慢慢有的</h4>{memory.text}</div></div>}<div className="d-sec">来自这些日子</div><div className="links">{memory.lifeIds.map(id => { const e = view.events.find(x => x.id === id); return e && <button className="link-chip" key={id} onClick={() => select(id, 'life')}>{e.title}</button>; })}</div></> : selection ? <div className="empty">这条记录暂时不可见。<small>只会展示她已分享的事。</small></div> : <div className="d-empty"><div><Icon name="book"/>选一件小事,慢慢读。</div></div>}</article></div></section>;
}
+5
View File
@@ -0,0 +1,5 @@
import ReactDOM from 'react-dom/client';
import { App } from './app/App';
import './styles/tokens.css';
import './styles/components.css';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
+47
View File
@@ -0,0 +1,47 @@
import { newId } from '../domain/id';
import { useState } from 'react';
import { validateCreation, type Identity } from '../domain/model';
import { freshState } from '../demo/fixtures';
import { delay } from '../adapters/demo';
import { BaseColor, Button, Field, Status } from '../shared/ui';
import type { DemoState } from '../domain/model';
export function CreationWizard({ onCreate, onEnter, fail }: {
onCreate: (s: DemoState) => void;
onEnter: () => void;
fail: boolean;
}) {
const [step, setStep] = useState(0), [name, setName] = useState(''), [birth, setBirth] = useState('2000-10-18'), [hour, setHour] = useState('亥时'), [relation, setRelation] = useState(''), [nickname, setNickname] = useState(''), [agreed, setAgreed] = useState(false), [error, setError] = useState(''), [phase, setPhase] = useState(-1), [busy, setBusy] = useState(false);
const labels = ['出生资料', '她的底色', '你们的关系', '最终确认'];
function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
setError(err);
return;
} setError(''); setStep(s => s + 1); }
async function create() { const err = validateCreation(name, birth, relation); if (err || !agreed) {
setError(err || '请先确认你已了解。');
return;
} setBusy(true); setError(''); try {
const c = new AbortController();
for (let p = 0; p < 3; p++) {
setPhase(p);
await delay(280, c.signal);
if (fail && p === 1)
throw new Error('这一步没完成,资料都还在。可以重试,或返回修改。');
}
const identity: Identity = { id: newId(), name: name.trim(), birth, hour, relation: relation as Identity['relation'], nickname: nickname.trim(), boundary: '尊重彼此的节奏' };
onCreate(freshState(identity));
setStep(4);
}
catch (e) {
setError((e as Error).message);
}
finally {
setBusy(false);
} }
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
{step === 0 && <><h2>先认识她的开始</h2><p className="lead">名字由你起,生辰也可以在确认前修改。</p><Field label="她的名字"><input className="input" value={name} maxLength={20} onChange={e => setName(e.target.value)} autoComplete="off"/></Field><Field label="出生日期"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰"><select className="select" value={hour} onChange={e => setHour(e.target.value)}>{['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'].map(h => <option key={h}>{h}</option>)}</select></Field><Button onClick={() => { setBirth(`${1990 + Math.floor(Math.random() * 13)}-${String(1 + Math.floor(Math.random() * 12)).padStart(2, '0')}-18`); }}>自动生成一组生辰</Button></>}
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor /></>}
{step === 2 && <><h2>由你选择关系</h2><p className="lead">没有预选答案。以后可以在设置中确认修改,记忆会保留。</p><div className="seg">{['朋友', '恋人'].map(r => <button aria-pressed={relation === r} key={r} onClick={() => setRelation(r)}>{r}</button>)}</div><Field label="她怎么称呼你(可选)"><input className="input" maxLength={40} value={nickname} onChange={e => setNickname(e.target.value)}/></Field></>}
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>我明白:这是本机演示;创建后底色只读,只保留一位伴侣。</label>{phase >= 0 && <ul className="phases">{['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)'].map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{step === 4 && <div className="born"><div className="halo"/><h2>{name.trim()}住进来了</h2><p className="lead">刚开始,还没有共同的日子。聊着聊着就会有了。</p></div>}
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary onClick={next}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
}
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from 'react';
import type { DemoState, Settings, Theme } from '../domain/model';
import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme }: {
state: DemoState;
open: boolean;
onClose: () => void;
onSave: (s: DemoState) => void;
fail: boolean;
theme: Theme;
setTheme: (t: Theme) => void;
}) {
const [section, setSection] = useState('her'), [detail, setDetail] = useState(false), [draft, setDraft] = useState(() => ({ settings: structuredClone(state.settings), identity: structuredClone(state.identity) })), [message, setMessage] = useState(''), [error, setError] = useState(false), [busy, setBusy] = useState(false), [confirm, setConfirm] = useState(''), [confirmText, setConfirmText] = useState(''), [search, setSearch] = useState(''), [restore, setRestore] = useState<DemoState | null>(null), [material, setMaterial] = useState(''), [check, setCheck] = useState('');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
useEffect(()=>{if(message) feedback.current?.scrollIntoView({block:'center'});},[message]);
const latestState = useRef(state); latestState.current = state;
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: draft.settings.city!==state.settings.city, services: JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced};
const dirty = !!dirtySections[section], anyDirty = Object.values(dirtySections).some(Boolean);
useEffect(() => { if (!open) {
request.current?.abort();
setBusy(false);
} }, [open]);
useEffect(() => () => { request.current?.abort(); }, []);
useEffect(() => { if (!anyDirty)
return; const before = (e: BeforeUnloadEvent) => { e.preventDefault(); }; window.addEventListener('beforeunload', before); return () => window.removeEventListener('beforeunload', before); }, [anyDirty]);
const settings = (value: Partial<Settings>) => setDraft(d => ({ ...d, settings: { ...d.settings, ...value } }));
const reset = () => { request.current?.abort(); generation.current++; setBusy(false); setDraft(d => ({identity: section==='relation'?structuredClone(state.identity):d.identity, settings: {...d.settings, ...(section==='city'?{city:state.settings.city}:section==='services'?{services:structuredClone(state.settings.services)}:section==='display'?{reduced:state.settings.reduced}:{})}})); setMessage('已取消,恢复为上次保存的内容。'); setError(false); };
async function save(confirmed = false) { if (section==='relation' && draft.identity.relation !== state.identity.relation && !confirmed) {
setConfirm('relation');
return;
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage('正在保存到本机…'); try {
await delay(450, ctrl.signal);
if (fail)
throw new Error('这次没存好,你的修改还在,可以再试一次。');
const current=latestState.current;
const next = {...current, identity: section==='relation'?draft.identity:current.identity, settings:{...current.settings, ...(section==='city'?{city:draft.settings.city}:section==='services'?{services:draft.settings.services}:section==='display'?{reduced:draft.settings.reduced}:{})}};
onSave(next);
setMessage('已保存在本机演示中。');
}
catch (e) {
if (ctrl.signal.aborted)
return;
setError(true);
setMessage((e as Error).message);
}
finally {
if (!ctrl.signal.aborted)
setBusy(false);
} }
async function importBackup(file?: File) { const gen = ++generation.current; setRestore(null); if (!file)
return; try {
if (file.size > 5000000)
throw new Error('请选 5MB 以内的演示 JSON。');
const next = parseState(await file.text(), state.identity);
if (gen !== generation.current)
return;
setRestore(next);
setMessage('完整性、版本和同一位档案检查通过。可以确认恢复。');
setError(false);
}
catch (e) {
if (gen !== generation.current)
return;
setError(true);
setMessage('无法恢复:' + (e as Error).message);
} }
async function importMaterial(file?: File) { const gen = ++generation.current; setMaterial(''); setCheck(''); if (!file)
return; try {
if (file.size > 100000)
throw new Error('演示清单不能超过 100KB。');
const x: unknown = JSON.parse(await file.text());
if (!x || typeof x !== 'object' || !('manifest_version' in x) || x.manifest_version !== 1 || !('appearance_id' in x) || typeof x.appearance_id !== 'string' || !/^[\w-]{1,60}$/.test(x.appearance_id) || !('clips' in x) || !Array.isArray(x.clips) || !x.clips.length)
throw new Error('需要 manifest_version:1、合法 appearance_id 和非空 clips。');
if (gen !== generation.current)
return;
setMaterial(x.appearance_id);
setCheck('✓ JSON 结构通过 · ✓ 版本通过 · 媒体格式、时长、人脸一致性:未真实检测,不应用外部媒体。');
}
catch (e) {
if (gen !== generation.current)
return;
setCheck('检查没通过:' + (e instanceof SyntaxError?'清单不是有效的 JSON,请重新选择。':(e as Error).message));
} }
const switchSection = (s: string) => { request.current?.abort(); generation.current++; setBusy(false); setSection(s); setDetail(true); setMessage(''); };
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">仅本机演示</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor /><div className="growth"><div><h4>最初就有的</h4>细腻、慢热(示例)</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
{section === 'relation' && <><Field label="你们的关系"><select className="select" value={draft.identity.relation} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, relation: e.target.value as '朋友' | '恋人' } }))}><option>朋友</option><option>恋人</option></select></Field><Field label="她怎么称呼你"><input className="input" value={draft.identity.nickname} maxLength={40} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, nickname: e.target.value } }))}/></Field><Field label="相处边界"><textarea className="textarea" maxLength={2000} value={draft.identity.boundary} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, boundary: e.target.value } }))}/></Field><p className="d-foot">改关系需要再次确认,已有记忆与信任都保留,底色不变。</p></>}
{section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
{section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{section === 'display' && <><div className="opt-row">{(['moon', 'fog'] as Theme[]).map(t => <button className="opt" data-t={t} key={t} aria-pressed={theme === t} onClick={() => setTheme(t)}><div className="sw"/><b>{t === 'moon' ? '靛蓝月光' : '蓝紫雾光'}</b><span>立即切换,保留你的选择</span></button>)}</div><label className="switch-row">减少动效<input type="checkbox" checked={draft.settings.reduced} onChange={e => settings({ reduced: e.target.checked })}/></label><p className="d-foot">也会自动尊重系统的减少动效设置。切到其他网页时暂停背景动画。</p></>}
<div ref={feedback}><Status text={message} error={error}/></div>{['relation', 'city', 'services', 'display'].includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'relation')
void save(true); if (which === 'appearance') {
try {
if (fail)
throw new Error('这次没存好,请再试一次。');
const next = { ...state, settings: { ...state.settings, appearance: material } };
onSave(next);
settings({ appearance: material });
setMessage('演示外观标识已更新,身份与记忆保留。');
setError(false);
}
catch (e) {
setMessage((e as Error).message);
setError(true);
}
} if (which === 'restore' && restore) {
try {
if (fail)
throw new Error('恢复没完成,原资料仍保留,可以重试。');
exportDemo(state);
onSave(restore);
setDraft({ settings: structuredClone(restore.settings), identity: structuredClone(restore.identity) });
setRestore(null);
setMessage('本地演示资料已恢复;恢复前的资料已先导出。');
setError(false);
}
catch (e) {
setMessage((e as Error).message);
setError(true);
}
} }}>{confirm === 'restore' ? <>恢复会覆盖当前本机演示资料。操作前会先下载当前资料,档案身份和底色须相同。<Field label="输入“恢复”以继续"><input className="input" value={confirmText} onChange={e => setConfirmText(e.target.value)}/></Field></> : <>记忆与信任都保留,八字底色不变。{confirm === 'appearance' ? '本次只更新演示标识,不接入未经校验的外部素材。' : ''}</>}</Modal>}
</section>;
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useId, cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
export function Icon({ name }: {
name: string;
}) { return <svg className="ico" viewBox="0 0 24 24" aria-hidden="true"><use href={`/assets/icons.svg#i-${name}`}/></svg>; }
export function Button({ children, onClick, disabled = false, primary = false, small = true }: {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
primary?: boolean;
small?: boolean;
}) { return <button className={`${primary ? 'btn-primary' : 'btn-ghost'}${small ? ' sm' : ''}`} disabled={disabled} onClick={onClick}>{children}</button>; }
export function Status({ text, error = false }: {
text: string;
error?: boolean;
}) { return text ? <div role={error ? 'alert' : 'status'} className={`status show ${error ? 'fail' : 'ok'}`}>{text}</div> : null; }
export function Field({ label, children, hint }: {
label: string;
children: ReactNode;
hint?: string;
}) { const id = useId(); return <label className="field" htmlFor={id}><span id={`${id}-label`}>{label}</span>{isValidElement(children) ? cloneElement(children as ReactElement<{
id: string;
'aria-labelledby': string;
}>, { id, 'aria-labelledby': `${id}-label` }) : children}{hint && <small>{hint}</small>}</label>; }
export function Modal({ title, children, onClose, onConfirm, confirm = '确认', danger = false, disabled = false }: {
title: string;
children: ReactNode;
onClose: () => void;
onConfirm: () => void;
confirm?: string;
danger?: boolean;
disabled?: boolean;
}) {
const root = useRef<HTMLDivElement>(null);
const close = useRef(onClose);
close.current = onClose;
useEffect(() => { const prev = document.activeElement as HTMLElement | null; (root.current?.querySelector<HTMLElement>('input,button:not(:disabled)') || root.current)?.focus(); const key = (e: KeyboardEvent) => { if (e.key === 'Escape') {
e.stopImmediatePropagation();
close.current();
} if (e.key === 'Tab') {
const nodes = root.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input');
if (nodes?.length) {
const first = nodes[0], last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
}
else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
} }; document.addEventListener('keydown', key, true); return () => { document.removeEventListener('keydown', key, true); prev?.focus(); }; }, []);
return <><div className="scrim open" onClick={onClose}/><div className={`modal glass open ${danger ? 'danger' : ''}`} ref={root} tabIndex={-1} role="dialog" aria-modal="true" aria-label={title}><div className="mi"><Icon name={danger ? 'warn' : 'sparkle'}/></div><h3>{title}</h3><div className="mb">{children}</div><div className="acts"><Button onClick={onClose}>取消</Button><Button primary disabled={disabled} onClick={onConfirm}>{confirm}</Button></div></div></>;
}
export function BaseColor() { return <><div className="card"><h4><Icon name="lock"/>出生底色 · 演示预览</h4><div className="pillars">{['年柱', '月柱', '日柱', '时柱'].map(p => <div key={p}><small>{p}</small><b>待接</b></div>)}</div><div className="lean"><span>细腻</span><span>慢热</span><span>有自己的节奏</span></div><p>这些是演示倾向,不是固定台词。真实排盘待接 Python 八字编译器,不以示例代替计算。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读,成长另存。</p></>; }
+3
View File
@@ -0,0 +1,3 @@
export function Background() {
return <div className="space" aria-hidden="true"><div className="layer">{['moon', 'fog'].map(theme => <div className={`scene ${theme}`} key={theme}><div className="sky"/>{theme === 'moon' ? <><div className="moon-halo"/><div className="moon-disc"/></> : <>{[1, 2, 3].map(i => <div className={`ribbon r${i}`} key={i}/>)}</>}{['far', 'near'].map((depth, index) => <div className={`skyline ${depth}`} key={depth}><svg viewBox="0 0 1440 400" preserveAspectRatio="none"><defs><linearGradient id={`${theme}-${depth}`} x1="0" y1="0" x2="0" y2="1"><stop stopColor={theme === 'moon' ? '#121f46' : '#33215c'}/><stop offset="1" stopColor={theme === 'moon' ? '#060c22' : '#170e32'}/></linearGradient></defs>{Array.from({ length: 28 }, (_, i) => { const h = 45 + (i * 71 + index * 37) % 180; return <g key={i}><rect x={i * 54} y={400 - h} width={40 + i % 3 * 4} height={h} fill={`url(#${theme}-${depth})`}/>{Array.from({ length: 3 }, (_, j) => <rect key={j} x={i * 54 + 9 + j * 9} y={420 - h} width="3" height="6" fill="#b1cbff" opacity=".3"/>)}</g>; })}</svg></div>)}{[1, 2, 3, 4].map(i => <div className={`mist m${i}`} key={i}/>)}{[1, 2, 3].map(i => <div className={`bokeh b${i}`} key={i}/>)}</div>)}</div><div className="vignette"/><div className="depth-veil"/></div>;
}
+15
View File
@@ -0,0 +1,15 @@
import { useState } from 'react';
import type { SceneDesc } from '../domain/model';
import { Icon } from '../shared/ui';
export interface PresenterProps {
scene: SceneDesc;
onClose: () => void;
closeLabel: string;
presence: string;
observation: string;
}
// Only consumes scene. No life writes, no synthesis, no microphone/camera APIs.
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation }: PresenterProps) {
const [failed, setFailed] = useState(false);
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">演示状态</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>来自演示生活状态<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
}
+747
View File
@@ -0,0 +1,747 @@
/* Approved HEL-746 R2 component styles; edit here, never add override sheets. */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:var(--bg-0);transition:background-color var(--dur-theme) var(--ease-in-out)}
body{font-family:var(--font-sans);color:var(--text-1);font-size:var(--fs-body);line-height:1.7;-webkit-font-smoothing:antialiased;letter-spacing:.01em}
button,input,select,textarea{font:inherit;color:inherit}
button{cursor:pointer;background:none;border:0}
button:disabled{cursor:not-allowed;opacity:.42}
svg{display:block}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
.ico{width:18px;height:18px;flex:none}
/* ============================================================
背景空间(R1 原样)
============================================================ */
.space{position:fixed;inset:0;overflow:hidden;transition:transform var(--dur-stage) var(--ease-out),filter var(--dur-stage) var(--ease-out)}
.layer{position:absolute;inset:-6%;will-change:transform;transition:transform 1.2s var(--ease-out)}
.scene{position:absolute;inset:0;opacity:0;transition:opacity var(--dur-theme) var(--ease-in-out)}
html[data-theme="moon"] .scene.moon,html[data-theme="fog"] .scene.fog{opacity:1}
.scene.moon .sky{position:absolute;inset:0;background:radial-gradient(120% 80% at 24% 12%,#24386f 0%,rgba(36,56,111,0) 55%),linear-gradient(180deg,#081028 0%,#0e1a3e 46%,#16264f 72%,#0b1430 100%)}
.scene.fog .sky{position:absolute;inset:0;background:radial-gradient(90% 70% at 12% 92%,rgba(176,108,220,.55) 0%,rgba(176,108,220,0) 60%),radial-gradient(80% 60% at 92% 8%,rgba(92,92,210,.45) 0%,rgba(92,92,210,0) 60%),linear-gradient(170deg,#1a1240 0%,#2b1d5c 45%,#3e2b78 70%,#1c1338 100%)}
.moon-disc{position:absolute;left:17%;top:14%;width:78px;height:78px;border-radius:50%;filter:blur(.4px);background:radial-gradient(circle at 50% 50%,#ffffff 0%,#f4f8ff 50%,#e2ecff 64%,rgba(226,236,255,.5) 68%,rgba(226,236,255,0) 71%);box-shadow:0 0 60px 18px rgba(170,205,255,.28),0 0 220px 90px rgba(120,160,255,.18);animation:moonBreath 14s var(--ease-drift) infinite alternate;transition:opacity var(--dur-stage)}
.moon-halo{position:absolute;left:calc(17% - 276px);top:calc(14% - 276px);width:630px;height:630px;border-radius:50%;background:radial-gradient(circle,rgba(160,200,255,.20) 0%,rgba(160,200,255,.06) 40%,rgba(160,200,255,0) 70%);transition:opacity var(--dur-stage)}
@keyframes moonBreath{from{filter:blur(.4px) brightness(.9);transform:scale(1)}to{filter:blur(.4px) brightness(1.05);transform:scale(1.03)}}
.ribbon{position:absolute;border-radius:50%;filter:blur(48px);opacity:.5}
.ribbon.r1{left:-10%;top:18%;width:80%;height:22%;background:linear-gradient(90deg,rgba(150,110,255,0),rgba(190,140,255,.75),rgba(255,160,220,.0));transform:rotate(-8deg);animation:ribbon1 38s var(--ease-drift) infinite alternate}
.ribbon.r2{right:-14%;top:44%;width:70%;height:18%;background:linear-gradient(90deg,rgba(120,140,255,0),rgba(150,160,255,.65),rgba(230,170,255,0));transform:rotate(10deg);animation:ribbon2 46s var(--ease-drift) infinite alternate}
.ribbon.r3{left:20%;bottom:-6%;width:70%;height:30%;background:radial-gradient(closest-side,rgba(240,170,255,.55),rgba(240,170,255,0));animation:ribbon1 52s var(--ease-drift) infinite alternate-reverse}
@keyframes ribbon1{from{transform:translate3d(-4%,0,0) rotate(-8deg) scaleY(1)}to{transform:translate3d(6%,-3%,0) rotate(-3deg) scaleY(1.25)}}
@keyframes ribbon2{from{transform:translate3d(3%,0,0) rotate(10deg)}to{transform:translate3d(-7%,4%,0) rotate(4deg)}}
.skyline{position:absolute;left:0;right:0;bottom:0;height:46%}
.skyline svg{width:100%;height:100%}
.scene.moon .skyline.far{opacity:.34;filter:blur(2px)}
.scene.moon .skyline.near{opacity:.42;filter:blur(1px);height:30%}
.scene.fog .skyline.far{opacity:.22;filter:blur(3px)}
.scene.fog .skyline.near{opacity:.34;filter:blur(1.8px);height:32%}
.skyline .win{animation:winBlink 9s steps(1) infinite}
@keyframes winBlink{0%,100%{opacity:1}50%{opacity:.35}}
.mist{position:absolute;left:-30%;width:160%;border-radius:50%;filter:blur(40px)}
.scene.moon .mist{background:radial-gradient(closest-side,rgba(150,180,240,.22),rgba(150,180,240,0))}
.scene.fog .mist{background:radial-gradient(closest-side,rgba(205,170,255,.34),rgba(205,170,255,0))}
.mist.m1{bottom:2%;height:26%;animation:mistA 64s linear infinite alternate}
.mist.m2{bottom:18%;height:18%;animation:mistB 48s linear infinite alternate}
.mist.m3{bottom:-8%;height:30%;animation:mistA 80s linear infinite alternate-reverse}
.scene.fog .mist.m4{top:30%;height:40%;opacity:.7;animation:mistB 70s linear infinite alternate}
@keyframes mistA{from{transform:translate3d(-6%,0,0)}to{transform:translate3d(8%,-2%,0)}}
@keyframes mistB{from{transform:translate3d(6%,0,0)}to{transform:translate3d(-8%,2%,0)}}
#motes{position:absolute;inset:0;width:100%;height:100%}
.bokeh{position:absolute;border-radius:50%;filter:blur(22px)}
.scene.moon .bokeh{background:rgba(170,210,255,.13)}
.scene.fog .bokeh{background:rgba(240,190,255,.16)}
.bokeh.b1{left:3%;top:64%;width:180px;height:180px}
.bokeh.b2{right:4%;top:6%;width:120px;height:120px}
.bokeh.b3{left:44%;bottom:-4%;width:240px;height:240px;opacity:.7}
.vignette{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 45%,rgba(0,0,0,0) 55%,rgba(0,0,8,.55) 100%)}
.depth-veil{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity var(--dur-stage) var(--ease-out);background:radial-gradient(70% 70% at 50% 42%,rgba(0,0,0,0) 30%,rgba(2,4,16,.55) 100%)}
.window-glow{position:fixed;pointer-events:none;border-radius:50%;filter:blur(30px);background:radial-gradient(closest-side,var(--window-glow),rgba(0,0,0,0));transition:all var(--dur-stage) var(--ease-out),background var(--dur-theme)}
body.talk .space{transform:scale(1.06)}
body.talk .depth-veil{opacity:1}
body.talk .moon-disc,body.talk .moon-halo{opacity:.35}
/* 进入日子 / 设置:空间轻推近 1.03 并压暗,仍在同一主题空间里 */
body.paned .space{transform:scale(1.03)}
body.paned .depth-veil{opacity:.7}
body.creating .space{transform:scale(1.02);filter:saturate(.9)}
body.creating .depth-veil{opacity:.5}
/* ============================================================
通用组件
============================================================ */
.glass{background:var(--glass);border:1px solid var(--glass-line);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(var(--glass-sat));backdrop-filter:blur(var(--glass-blur)) saturate(var(--glass-sat));box-shadow:inset 0 1px 0 var(--glass-hi),0 18px 50px -20px rgba(0,0,10,.55);transition:background-color var(--dur-theme) var(--ease-in-out),border-color var(--dur-theme) var(--ease-in-out),box-shadow var(--dur-ui)}
html[data-theme="fog"] .glass{box-shadow:inset 0 1px 0 var(--glass-hi),inset 0 0 36px rgba(210,170,255,.10),0 18px 50px -20px rgba(10,0,30,.55)}
.glass.read{background:var(--glass-read)}
.chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 14px;border-radius:var(--radius-pill);font-size:var(--fs-meta);color:var(--text-2);white-space:nowrap}
.chip svg{width:15px;height:15px;opacity:.85}
.tag-demo{display:inline-flex;align-items:center;height:22px;padding:0 9px;border-radius:var(--radius-pill);font-size:var(--fs-tiny);color:var(--text-3);border:1px dashed var(--glass-line);white-space:nowrap}
.tag{display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 9px;border-radius:var(--radius-pill);font-size:var(--fs-tiny);color:var(--text-2);background:rgba(255,255,255,.06);white-space:nowrap}
.tag.accent{color:var(--accent);background:var(--accent-soft)}
.tag.ok{color:var(--ok);background:rgba(159,232,207,.1)}
.tag.warn{color:var(--warn);background:rgba(255,215,156,.1)}
.tag.danger{color:var(--danger);background:rgba(255,159,179,.1)}
.tag svg{width:12px;height:12px}
.btn-primary{position:relative;display:inline-flex;align-items:center;justify-content:center;gap:10px;height:52px;padding:0 26px;border-radius:var(--radius-pill);background:var(--accent);color:var(--on-accent);font-weight:500;font-size:15.5px;letter-spacing:.04em;box-shadow:0 0 0 1px rgba(255,255,255,.25) inset,0 0 26px -2px var(--accent-glow),0 10px 30px -12px var(--accent-glow);transition:transform var(--dur-fast) var(--ease-out),box-shadow var(--dur-ui),background-color var(--dur-theme),opacity var(--dur-ui)}
.btn-primary:hover:not(:disabled){box-shadow:0 0 0 1px rgba(255,255,255,.3) inset,0 0 38px 2px var(--accent-glow),0 10px 30px -12px var(--accent-glow)}
.btn-primary:disabled{box-shadow:none}
.btn-primary svg{width:18px;height:18px}
.btn-primary.sm,.btn-ghost.sm,.btn-danger.sm{height:40px;padding:0 18px;font-size:14px}
.btn-ghost{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:48px;padding:0 22px;border-radius:var(--radius-pill);border:1px solid var(--glass-line);color:var(--text-1);font-size:14.5px;transition:background-color var(--dur-ui),transform var(--dur-fast) var(--ease-out)}
.btn-ghost:hover:not(:disabled){background:var(--accent-soft)}
.btn-ghost svg{width:16px;height:16px}
.btn-danger{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:48px;padding:0 22px;border-radius:var(--radius-pill);border:1px solid var(--danger);color:var(--danger);font-size:14.5px;transition:background-color var(--dur-ui)}
.btn-danger:hover:not(:disabled){background:rgba(255,159,179,.1)}
.btn-text{display:inline-flex;align-items:center;gap:6px;color:var(--text-2);font-size:13.5px;padding:4px 2px}
.btn-text:hover{color:var(--text-1)}
.btn-text svg{width:14px;height:14px}
.btn-primary:active:not(:disabled),.btn-ghost:active:not(:disabled),.btn-round:active,.nav-item:active,.btn-danger:active{transform:scale(.97)}
.btn-round{width:52px;height:52px;border-radius:50%;display:grid;place-items:center;color:var(--text-1);flex:none;transition:transform var(--dur-fast) var(--ease-out),background-color var(--dur-ui),color var(--dur-ui)}
.btn-round svg{width:21px;height:21px}
.btn-round:hover{background:var(--accent-soft)}
.btn-round.sm{width:40px;height:40px}
.btn-round.sm svg{width:18px;height:18px}
:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.spin{width:16px;height:16px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;animation:spin .9s linear infinite;flex:none}
@keyframes spin{to{transform:rotate(360deg)}}
/* 表单 */
.field{display:block;margin:0 0 18px}
.field > span{display:block;font-size:13px;color:var(--text-2);margin-bottom:7px}
.field small{display:block;font-size:12px;color:var(--text-3);margin-top:6px;line-height:1.6}
.field.err small.msg{color:var(--danger)}
.input,.select,.textarea{width:100%;height:46px;border-radius:var(--radius-field);background:var(--field);border:1px solid var(--field-line);padding:0 14px;font-size:15px;color:var(--text-1);outline:none;transition:border-color var(--dur-ui),box-shadow var(--dur-ui),background-color var(--dur-theme)}
.textarea{height:auto;min-height:96px;padding:12px 14px;line-height:1.7;resize:vertical}
.select{appearance:none;background-image:linear-gradient(45deg,transparent 50%,var(--text-3) 50%),linear-gradient(135deg,var(--text-3) 50%,transparent 50%);background-position:calc(100% - 20px) 20px,calc(100% - 15px) 20px;background-size:5px 5px;background-repeat:no-repeat}
.select option{background:var(--bg-1);color:var(--text-1)}
.input:focus,.select:focus,.textarea:focus{border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
.field.err .input,.field.err .select{border-color:var(--danger)}
.input[readonly]{color:var(--text-2);background:transparent;border-style:dashed}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.seg{display:inline-flex;padding:3px;border-radius:var(--radius-pill);gap:2px;background:var(--field);border:1px solid var(--field-line)}
.seg button{height:38px;padding:0 18px;border-radius:var(--radius-pill);font-size:14px;color:var(--text-2);transition:background-color var(--dur-ui),color var(--dur-ui)}
.seg button[aria-pressed="true"]{color:var(--on-accent);background:var(--accent);box-shadow:0 0 16px -4px var(--accent-glow)}
.check{display:flex;gap:10px;align-items:flex-start;font-size:14px;color:var(--text-2);cursor:pointer;line-height:1.6}
.check input{appearance:none;width:20px;height:20px;border-radius:6px;border:1.5px solid var(--glass-line);flex:none;margin-top:1px;display:grid;place-items:center;transition:background-color var(--dur-ui),border-color var(--dur-ui)}
.check input:checked{background:var(--accent);border-color:var(--accent)}
.check input:checked::after{content:"";width:9px;height:5px;border-left:2px solid var(--on-accent);border-bottom:2px solid var(--on-accent);transform:rotate(-45deg) translate(1px,-1px)}
.toggle{width:44px;height:26px;border-radius:13px;background:rgba(255,255,255,.12);position:relative;flex:none;transition:background-color var(--dur-ui)}
.toggle::after{content:"";position:absolute;left:3px;top:3px;width:20px;height:20px;border-radius:50%;background:#fff;transition:transform var(--dur-ui) var(--ease-out)}
.toggle[aria-pressed="true"]{background:var(--accent)}
.toggle[aria-pressed="true"]::after{transform:translateX(18px)}
.progress{height:6px;border-radius:3px;background:rgba(255,255,255,.08);overflow:hidden}
.progress i{display:block;height:100%;width:0;border-radius:3px;background:var(--accent);box-shadow:0 0 12px var(--accent-glow);transition:width 400ms var(--ease-out)}
/* 状态行(进行中 / 成功 / 失败) */
.status{display:none;align-items:center;gap:8px;font-size:13.5px;padding:10px 14px;border-radius:14px;margin:12px 0 0}
.status.show{display:flex}
.status.busy{color:var(--text-2);background:rgba(255,255,255,.04)}
.status.ok{color:var(--ok);background:rgba(159,232,207,.08)}
.status.fail{color:var(--danger);background:rgba(255,159,179,.08)}
.status svg{width:16px;height:16px;flex:none}
.status .grow{flex:1}
/* ============================================================
顶栏
============================================================ */
.topbar{position:fixed;top:22px;left:32px;right:32px;display:flex;align-items:center;justify-content:space-between;z-index:30;pointer-events:none;transition:opacity var(--dur-panel)}
.topbar > *{pointer-events:auto}
.who{display:flex;align-items:center;gap:12px}
.who .name{font-size:19px;font-weight:400;letter-spacing:.14em}
.dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px 2px var(--accent-glow);animation:dotPulse 3.6s var(--ease-drift) infinite}
body.offline .dot{background:var(--text-3);box-shadow:none;animation:none}
@keyframes dotPulse{0%,100%{opacity:.65}50%{opacity:1}}
.who .state{font-size:var(--fs-meta);color:var(--text-3)}
.env{display:flex;align-items:center;gap:10px}
.clock{font-family:var(--font-num);font-weight:300;font-size:22px;letter-spacing:.04em;color:var(--text-1);margin-left:4px}
.clock small{font-size:12px;color:var(--text-3);margin-left:6px;font-family:var(--font-sans)}
.theme-switch{display:flex;padding:3px;border-radius:var(--radius-pill);gap:2px}
.theme-switch button{height:28px;padding:0 12px;border-radius:var(--radius-pill);font-size:12.5px;color:var(--text-3);display:flex;align-items:center;gap:6px;transition:color var(--dur-ui),background-color var(--dur-ui)}
.theme-switch button i{width:9px;height:9px;border-radius:50%;display:block}
.theme-switch button[data-t="moon"] i{background:linear-gradient(135deg,#d9ecff,#5a7fd6)}
.theme-switch button[data-t="fog"] i{background:linear-gradient(135deg,#f3c8ff,#6c4fd0)}
.theme-switch button[aria-pressed="true"]{color:var(--text-1);background:var(--accent-soft);box-shadow:0 0 14px -4px var(--accent-glow)}
body.creating .topbar .who,body.creating .topbar .env > :not(.theme-switch){opacity:0;pointer-events:none}
/* 断线横幅 */
.netbar{position:fixed;left:50%;top:70px;transform:translate(-50%,-10px);z-index:48;display:flex;align-items:center;gap:10px;height:42px;padding:0 8px 0 16px;border-radius:var(--radius-pill);font-size:13.5px;color:var(--text-1);opacity:0;pointer-events:none;transition:opacity var(--dur-ui),transform var(--dur-ui) var(--ease-out)}
.netbar.show{opacity:1;transform:translate(-50%,0);pointer-events:auto}
.netbar .spin{color:var(--warn)}
.netbar .warn-dot{width:8px;height:8px;border-radius:50%;background:var(--danger);display:none}
.netbar.failed .spin{display:none}
.netbar.failed .warn-dot{display:block}
.netbar .btn-ghost{height:30px;padding:0 12px;font-size:12.5px;display:none}
.netbar.failed .btn-ghost{display:inline-flex}
.netbar small{color:var(--text-3);font-size:12px}
/* ============================================================
她的视窗(舞台)
============================================================ */
.portal{position:fixed;z-index:10;overflow:hidden;
--pw:clamp(300px,27vw,430px); --ph:calc(var(--pw) * 1.3);
--pl:calc(100vw - var(--pw) - 14vw); --pt:calc((100vh - var(--ph)) / 2 - 8px);
--pr:calc(var(--pw) / 2) calc(var(--pw) / 2) 26px 26px;
left:var(--pl);top:var(--pt);width:var(--pw);height:var(--ph);border-radius:var(--pr);
box-shadow:0 0 0 1px var(--glass-line),0 0 0 8px rgba(255,255,255,.025),0 40px 90px -30px rgba(0,0,10,.8),0 0 80px -20px var(--window-glow);
transition:left var(--dur-stage) var(--ease-out),top var(--dur-stage) var(--ease-out),width var(--dur-stage) var(--ease-out),height var(--dur-stage) var(--ease-out),border-radius var(--dur-stage) var(--ease-out),box-shadow var(--dur-theme),opacity var(--dur-panel),filter var(--dur-panel)}
.portal img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:opacity 620ms var(--ease-in-out) 120ms,transform var(--dur-stage) var(--ease-out)}
.portal .img-room{object-position:88% 42%;transform:scale(1.16);transform-origin:80% 42%}
.portal .img-talk{object-position:54% 28%;opacity:0;transform:scale(1.08);transform-origin:54% 30%}
.portal .life{position:absolute;inset:0;animation:kenburns 26s var(--ease-drift) infinite alternate}
@keyframes kenburns{from{transform:scale(1) translate3d(0,0,0)}to{transform:scale(1.045) translate3d(-1.2%,-.6%,0)}}
.portal .tint{position:absolute;inset:0;pointer-events:none;transition:background var(--dur-theme)}
html[data-theme="moon"] .portal .tint{background:linear-gradient(180deg,rgba(40,70,150,.20) 0%,rgba(10,20,50,0) 35%,rgba(8,14,40,.55) 100%)}
html[data-theme="fog"] .portal .tint{background:linear-gradient(180deg,rgba(140,90,220,.24) 0%,rgba(40,20,80,0) 40%,rgba(30,14,60,.55) 100%)}
.portal .rain{position:absolute;inset:0;pointer-events:none;opacity:.22;background-image:repeating-linear-gradient(104deg,rgba(255,255,255,.0) 0 9px,rgba(255,255,255,.35) 9px 10px,rgba(255,255,255,0) 10px 22px);background-size:220px 220px;animation:rain 1.6s linear infinite;mix-blend-mode:screen;mask-image:linear-gradient(180deg,#000 0%,rgba(0,0,0,.2) 60%,transparent 80%)}
@keyframes rain{to{background-position:-60px 220px}}
.portal .doing{position:absolute;left:18px;right:18px;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:8px;transition:opacity var(--dur-ui)}
.portal .doing .chip{background:rgba(8,10,28,.38);color:var(--text-1);border-color:rgba(255,255,255,.14)}
.portal .mark{position:absolute;right:18px;bottom:58px;font-size:10.5px;color:rgba(255,255,255,.55);letter-spacing:.06em;transition:opacity var(--dur-ui)}
.portal .close{position:absolute;top:16px;right:16px;height:36px;padding:0 14px 0 12px;border-radius:var(--radius-pill);display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-1);background:rgba(8,10,28,.36);border:1px solid rgba(255,255,255,.16);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);opacity:0;pointer-events:none;transform:translateY(-6px);transition:opacity var(--dur-ui),transform var(--dur-ui) var(--ease-out)}
.portal .close svg{width:16px;height:16px}
.portal .topleft{position:absolute;left:18px;top:16px;display:flex;flex-direction:column;align-items:flex-start;gap:8px;opacity:0;transition:opacity var(--dur-ui)}
.portal .topleft .chip{background:rgba(8,10,28,.42);color:var(--text-1);border:1px solid rgba(255,255,255,.12);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
.observe{display:none;max-width:min(420px,60vw);white-space:normal;height:auto !important;min-height:32px;padding:6px 14px !important;line-height:1.5}
.observe.show{display:inline-flex}
.observe .eye{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok);flex:none}
.observe.stale{opacity:.7}
.observe.stale .eye{background:var(--warn);box-shadow:none}
.observe.fail .eye{background:var(--danger);box-shadow:none}
.observe.asking .eye{background:var(--text-3);box-shadow:none}
body.talk .portal{--pw:min(64vw, calc((100vh - 360px) * 1.72)); --ph:calc(100vh - 360px); --pl:calc(50vw - var(--pw) / 2 + var(--shift)); --pt:72px; --pr:var(--radius-stage) var(--radius-stage) var(--radius-stage) var(--radius-stage)}
body.talk.voice .portal{--pw:min(64vw, calc((100vh - 412px) * 1.72)); --ph:calc(100vh - 412px)}
body{--shift:0px}
body.talk.log{--shift:-200px}
body.talk .portal .img-room{opacity:0;transform:scale(2.1);transform-origin:84% 38%}
body.talk .portal .img-talk{opacity:1;transform:scale(1)}
body.talk .portal .doing{opacity:0}
body.talk .portal .mark{bottom:18px}
body.talk .portal .close{opacity:1;pointer-events:auto;transform:none;transition-delay:280ms}
body.talk .portal .topleft{opacity:1}
body.offline .portal{filter:saturate(.55) brightness(.7)}
/* 日子 / 设置时:窗缩小留在右侧,她仍在同一个空间里 */
body.paned .portal{--pw:var(--mini-pw); --ph:calc(var(--pw) * 1.3); --pl:calc(var(--pane-l) + var(--pane-w) + (100vw - var(--pane-l) - var(--pane-w) - var(--pw)) / 2); --pt:calc(var(--pane-t) + 28px)}
body.paned .portal .doing{left:10px;right:10px;bottom:10px}
body.paned .portal .doing .tag-demo,body.paned .portal .mark{display:none}
body.paned .portal .doing .chip{height:28px;padding:0 10px;font-size:12px}
.mini-cap{position:fixed;z-index:11;left:calc(var(--pane-l) + var(--pane-w) + (100vw - var(--pane-l) - var(--pane-w) - var(--mini-pw)) / 2);top:calc(var(--pane-t) + 28px + var(--mini-pw) * 1.3 + 16px);width:var(--mini-pw);text-align:center;font-size:12.5px;color:var(--text-3);line-height:1.6;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out)}
body.paned .mini-cap{opacity:1;pointer-events:auto;transition-delay:240ms}
.mini-cap b{display:block;font-weight:400;color:var(--text-2);font-size:13px}
.mini-cap button{margin-top:8px}
body.creating .portal,body.creating .window-glow{opacity:0;pointer-events:none}
/* ============================================================
首页
============================================================ */
.home{position:fixed;left:9vw;top:50%;width:min(440px,34vw);transform:translateY(-50%);z-index:12;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-stage) var(--ease-out),filter var(--dur-panel)}
body.talk .home,body.paned .home,body.creating .home{opacity:0;transform:translate(-40px,-50%) scale(.96);filter:blur(6px);pointer-events:none}
.home .eyebrow{font-size:var(--fs-meta);color:var(--text-3);letter-spacing:.2em;margin-bottom:14px}
.home .whisper{font-size:var(--fs-whisper);font-weight:300;line-height:1.5;letter-spacing:.04em;color:var(--text-1);text-wrap:balance}
.home .meta{margin-top:14px;color:var(--text-2);font-size:14px;display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center}
.home .meta span{display:inline-flex;align-items:center;gap:6px}
.letter{margin-top:34px;padding:18px 20px 16px;border-radius:var(--radius-card);display:block;width:100%;text-align:left;transition:transform var(--dur-ui) var(--ease-out),background-color var(--dur-theme),border-color var(--dur-theme)}
.letter:hover{transform:translateY(-2px)}
.letter .lh{display:flex;align-items:center;justify-content:space-between;font-size:var(--fs-meta);color:var(--text-3);margin-bottom:6px}
.letter .lh b{font-weight:500;color:var(--accent);display:inline-flex;align-items:center;gap:6px}
.letter .lh b::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent-glow)}
.letter.read .lh b{color:var(--text-2)}
.letter.read .lh b::before{background:var(--text-3);box-shadow:none}
.letter p{color:var(--text-1);font-size:15px;line-height:1.75;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.away{margin-top:12px;display:flex;align-items:center;gap:8px;font-size:13.5px;color:var(--text-2);padding:2px 4px;text-align:left}
.away svg{width:14px;height:14px}
.away:hover{color:var(--text-1)}
.cta{margin-top:30px;display:flex;align-items:center;gap:12px}
.cta .btn-round{border:1px solid var(--glass-line)}
.first-note{margin-top:22px;font-size:13.5px;color:var(--text-2);line-height:1.8;display:none}
body.data-empty .letter,body.data-empty .away,body.data-empty .home .meta{display:none}
body.data-empty .first-note{display:block}
/* 返回时的「你不在的这两天」 */
.welcome{position:fixed;left:9vw;top:50%;width:min(460px,36vw);transform:translate(-20px,-50%);z-index:22;border-radius:26px;padding:26px 26px 22px;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out)}
body.welcoming .welcome{opacity:1;transform:translate(0,-50%);pointer-events:auto}
body.welcoming .home{opacity:0;pointer-events:none;filter:blur(4px)}
.welcome .eyebrow{font-size:12.5px;color:var(--text-3);letter-spacing:.18em}
.welcome h2{font-size:24px;font-weight:300;letter-spacing:.04em;margin:8px 0 4px}
.welcome .sub{font-size:13.5px;color:var(--text-2)}
.welcome ul{list-style:none;margin:16px 0 4px}
.welcome li{display:grid;grid-template-columns:76px 1fr;gap:10px;padding:10px 0;border-top:1px solid rgba(255,255,255,.06);font-size:14.5px}
.welcome li time{color:var(--text-3);font-size:12.5px;font-family:var(--font-num)}
.welcome li small{display:block;color:var(--text-3);font-size:12px}
.welcome .note{font-size:12px;color:var(--text-3);margin:10px 0 16px;line-height:1.6}
.welcome .acts{display:flex;gap:10px;flex-wrap:wrap}
/* 主动消息(首页 / 区域页 中的轻提醒) */
.reach{position:fixed;z-index:24;width:min(340px,80vw);border-radius:20px 20px 20px 6px;padding:14px 16px 12px;opacity:0;transform:translateY(10px) scale(.98);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out);
left:calc(100vw - clamp(300px,27vw,430px) - 14vw + 16px);width:calc(clamp(300px,27vw,430px) - 32px);bottom:calc((100vh - clamp(300px,27vw,430px) * 1.3) / 2 + 64px)}
body.paned .reach{left:auto;right:28px;bottom:104px;width:340px;border-radius:20px}
.reach.show{opacity:1;transform:none;pointer-events:auto}
.reach .rh{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3)}
.reach .rh b{font-weight:500;color:var(--accent)}
.reach p{font-size:14.5px;line-height:1.7;margin:6px 0 6px}
.reach .basis{font-size:11.5px;color:var(--text-3);display:flex;align-items:center;gap:6px}
.reach .basis svg{width:12px;height:12px}
.reach .acts{display:flex;gap:8px;margin-top:10px}
.reach .acts .btn-primary{height:36px;padding:0 16px;font-size:13.5px}
.reach .acts .btn-ghost{height:36px;padding:0 14px;font-size:13px}
/* ============================================================
交流
============================================================ */
.dialog{position:fixed;left:calc(50vw + var(--shift));bottom:176px;width:min(760px,74vw);transform:translate(-50%,16px);text-align:center;z-index:12;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-stage) var(--ease-out),left var(--dur-stage) var(--ease-out),bottom var(--dur-panel) var(--ease-out)}
body.talk .dialog{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-delay:200ms,0ms,0ms,0ms}
body.talk.voice .dialog{bottom:228px}
.dialog .you{font-size:14px;color:var(--text-3);margin-bottom:6px;min-height:1.6em}
.dialog .you em{font-style:normal;color:var(--text-3);margin-right:8px}
.dialog .you .partial{color:var(--text-2)}
.dialog .you .failed{color:var(--danger);margin-left:8px;font-size:12.5px}
.dialog .you .retry{color:var(--accent);font-size:12.5px;margin-left:6px;text-decoration:underline;text-underline-offset:3px}
.dialog .her{font-size:var(--fs-line);line-height:1.65;font-weight:400;color:var(--text-1);text-shadow:0 2px 18px rgba(0,0,10,.6);min-height:1.65em;transition:opacity var(--dur-ui)}
.dialog .her em{font-style:normal;font-size:13px;color:var(--accent);margin-right:10px;letter-spacing:.1em;vertical-align:2px}
.dialog .her.cut{opacity:.55}
.dialog .her .caret{display:inline-block;width:2px;height:1em;background:var(--accent);margin-left:3px;vertical-align:-2px;animation:caret 1s steps(1) infinite}
.dialog .her .dots{display:inline-flex;gap:5px;vertical-align:middle}
.dialog .her .dots i{width:6px;height:6px;border-radius:50%;background:var(--text-2);animation:dots 1.2s var(--ease-drift) infinite}
.dialog .her .dots i:nth-child(2){animation-delay:.15s}.dialog .her .dots i:nth-child(3){animation-delay:.3s}
@keyframes dots{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-3px)}}
@keyframes caret{50%{opacity:0}}
.dialog .hint{margin-top:8px;font-size:12px;color:var(--text-3);min-height:1.5em;transition:opacity var(--dur-ui)}
.composer{position:fixed;left:calc(50vw + var(--shift));bottom:98px;width:min(680px,80vw);height:60px;border-radius:var(--radius-pill);transform:translate(-50%,20px);opacity:0;pointer-events:none;z-index:14;display:flex;align-items:center;gap:4px;padding:0 6px;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-stage) var(--ease-out),left var(--dur-stage) var(--ease-out),background-color var(--dur-theme),border-color var(--dur-theme)}
body.talk .composer{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-delay:260ms,0ms,0ms}
.composer .btn-round{width:46px;height:46px}
.composer textarea{flex:1;height:46px;max-height:90px;resize:none;border:0;background:none;outline:none;padding:10px 8px;font-size:15px;color:var(--text-1);min-width:0}
.composer textarea::placeholder{color:var(--text-3)}
.composer .send{color:var(--text-2)}
.composer .send.ready{color:var(--accent)}
.composer .cam.on{color:var(--accent);background:var(--accent-soft)}
body.offline .composer .send{opacity:.4;pointer-events:none}
.mic{position:relative;width:46px;height:46px;border-radius:50%;display:grid;place-items:center;flex:none;background:var(--accent);color:var(--on-accent);box-shadow:0 0 22px -4px var(--accent-glow);transition:transform var(--dur-fast) var(--ease-out),background-color var(--dur-theme)}
.mic svg{width:20px;height:20px}
.mic .ic-stop{display:none}
.mic::after{content:"";position:absolute;inset:-6px;border-radius:50%;border:1.5px solid var(--accent);opacity:0}
body.voice .mic .ic-mic{display:none}
body.voice .mic .ic-stop{display:block}
body.v-listen .mic::after{animation:ring 1.4s var(--ease-out) infinite}
.mic.denied{background:rgba(255,255,255,.08);color:var(--text-3);box-shadow:none}
.mic .slash{position:absolute;width:26px;height:2px;background:var(--danger);transform:rotate(-45deg);display:none;border-radius:1px}
.mic.denied .slash{display:block}
@keyframes ring{from{opacity:.8;transform:scale(.9)}to{opacity:0;transform:scale(1.35)}}
.wave{display:flex;align-items:center;gap:3px;height:20px}
.wave i{width:3px;height:10px;border-radius:2px;background:currentColor;animation:wave 1s var(--ease-drift) infinite}
.wave i:nth-child(2){animation-delay:.15s}.wave i:nth-child(3){animation-delay:.3s}.wave i:nth-child(4){animation-delay:.45s}
@keyframes wave{0%,100%{height:5px}50%{height:18px}}
.wave.still i{animation:none;height:4px}
/* 语音会话条:会话中始终可见「结束语音」 */
.voicebar{position:fixed;left:calc(50vw + var(--shift));bottom:168px;transform:translate(-50%,10px);z-index:15;height:48px;padding:0 6px 0 16px;border-radius:var(--radius-pill);display:flex;align-items:center;gap:12px;font-size:13.5px;opacity:0;pointer-events:none;transition:opacity var(--dur-ui),transform var(--dur-ui) var(--ease-out),left var(--dur-stage) var(--ease-out)}
body.talk.voice .voicebar{opacity:1;transform:translate(-50%,0);pointer-events:auto}
.voicebar .live{display:flex;align-items:center;gap:8px;color:var(--text-2);font-family:var(--font-num)}
.voicebar .live i{width:8px;height:8px;border-radius:50%;background:var(--danger);box-shadow:0 0 8px var(--danger);animation:dotPulse 1.6s infinite}
.voicebar .vstate{display:flex;align-items:center;gap:8px;color:var(--text-1);min-width:120px}
.voicebar .vstate .wave{color:var(--accent);height:16px}
.voicebar .btn-ghost{height:36px;padding:0 14px;font-size:13px}
.voicebar .end{height:36px;padding:0 16px;border-radius:var(--radius-pill);background:rgba(255,159,179,.14);color:var(--danger);font-size:13px;display:inline-flex;align-items:center;gap:6px}
.voicebar .end svg{width:12px;height:12px}
/* 完整聊天记录 */
.log-panel{position:fixed;right:24px;top:72px;bottom:98px;width:380px;border-radius:24px;z-index:20;display:flex;flex-direction:column;opacity:0;transform:translateX(36px);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out),background-color var(--dur-theme)}
body.log .log-panel{opacity:1;transform:none;pointer-events:auto}
.log-panel header{display:flex;align-items:center;justify-content:space-between;padding:18px 18px 10px 22px}
.log-panel header h2{font-size:16px;font-weight:500;letter-spacing:.08em}
.log-panel .scroll{flex:1;overflow:auto;padding:4px 22px 20px;scrollbar-width:thin}
.day{font-size:var(--fs-tiny);color:var(--text-3);text-align:center;margin:12px 0 8px;letter-spacing:.12em}
.msg{margin:10px 0;max-width:88%;scroll-margin:80px}
.msg .who-l{font-size:var(--fs-tiny);color:var(--text-3);margin-bottom:3px}
.msg p{white-space:pre-wrap;font-size:14.5px;line-height:1.7;padding:9px 14px;border-radius:16px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.06);transition:box-shadow var(--dur-panel),border-color var(--dur-panel)}
.msg.me{margin-left:auto;text-align:right}
.msg.me p{background:var(--accent-soft);border-color:transparent;text-align:left;display:inline-block}
.msg.fail p{border:1px dashed var(--danger);background:transparent}
.msg .tagm{font-size:10.5px;color:var(--text-3);margin-left:6px}
.msg .tagm.proactive{color:var(--accent)}
.msg.hl p{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.msg .basis{font-size:11px;color:var(--text-3);margin-top:4px}
.log-btn.on{color:var(--accent);background:var(--accent-soft)}
/* ============================================================
底部导航
============================================================ */
.nav{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);height:60px;padding:6px;border-radius:var(--radius-pill);display:flex;gap:2px;z-index:25;transition:opacity var(--dur-panel),transform var(--dur-panel) var(--ease-out)}
body.creating .nav{opacity:0;pointer-events:none;transform:translate(-50%,20px)}
.nav-item{position:relative;width:76px;height:48px;border-radius:var(--radius-pill);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-size:11.5px;color:var(--text-2);letter-spacing:.08em;transition:color var(--dur-ui),background-color var(--dur-ui),transform var(--dur-fast) var(--ease-out)}
.nav-item svg{width:19px;height:19px}
.nav-item[aria-current="page"]{color:var(--text-1);background:var(--accent-soft)}
.nav-item[aria-current="page"] svg{color:var(--accent);filter:drop-shadow(0 0 6px var(--accent-glow))}
.nav-item .badge{position:absolute;top:6px;right:20px;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent-glow);display:none}
.nav-item.unread .badge{display:block}
/* ============================================================
区域面板:日子 / 设置(同一主题空间里升起的阅读面)
============================================================ */
.pane{position:fixed;z-index:16;left:var(--pane-l);top:var(--pane-t);bottom:var(--pane-b);width:var(--pane-w);border-radius:28px;display:flex;flex-direction:column;overflow:hidden;opacity:0;transform:translateY(24px) scale(.985);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out),background-color var(--dur-theme)}
.pane.open{opacity:1;transform:none;pointer-events:auto;transition-delay:80ms}
.pane > header{display:flex;align-items:center;gap:16px;padding:18px 18px 14px 28px;border-bottom:1px solid rgba(255,255,255,.06);flex:none}
.pane > header h2{font-size:var(--fs-title);font-weight:400;letter-spacing:.1em;white-space:nowrap}
.pane > header .grow{flex:1}
.pane > header .tabs{margin-left:8px}
.pane .presence{display:none;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3)}
.pane .presence i{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent-glow)}
.tabs{display:inline-flex;padding:3px;border-radius:var(--radius-pill);gap:2px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06)}
.tabs button{height:34px;padding:0 16px;border-radius:var(--radius-pill);font-size:13.5px;color:var(--text-2);letter-spacing:.06em;transition:background-color var(--dur-ui),color var(--dur-ui)}
.tabs button[aria-selected="true"]{color:var(--text-1);background:var(--accent-soft);box-shadow:0 0 14px -6px var(--accent-glow)}
.pane .cols{flex:1;display:grid;grid-template-columns:400px 1fr;min-height:0;position:relative}
.pane .list{overflow:auto;padding:14px 18px 28px 22px;border-right:1px solid rgba(255,255,255,.06);scrollbar-width:thin;overscroll-behavior:contain}
.pane .detail{overflow:auto;padding:26px 34px 34px;scrollbar-width:thin;overscroll-behavior:contain;position:relative}
.pane .back{display:none}
/* 生活时间线 */
.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--text-3);margin:4px 0 10px}
.legend span{display:inline-flex;align-items:center;gap:6px}
.k{width:9px;height:9px;border-radius:50%;flex:none;display:inline-block}
.k.done{background:var(--text-2)}
.k.now{background:var(--accent);box-shadow:0 0 10px var(--accent-glow);animation:dotPulse 2.4s infinite}
.k.plan{border:1.5px dashed var(--text-2);background:transparent}
.grp{font-size:12px;color:var(--text-3);letter-spacing:.14em;margin:18px 4px 6px;display:flex;justify-content:space-between}
.ev{display:grid;grid-template-columns:52px 14px 1fr;gap:8px;align-items:start;width:100%;text-align:left;padding:10px 10px;border-radius:14px;font-size:14.5px;line-height:1.6;transition:background-color var(--dur-ui)}
.ev:hover{background:rgba(255,255,255,.04)}
.ev[aria-current="true"]{background:var(--accent-soft)}
.ev time{font-family:var(--font-num);font-size:12.5px;color:var(--text-3);padding-top:2px}
.ev .k{margin-top:7px}
.ev .tt{display:block}
.ev .sub{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px}
.ev.told .tt{color:var(--text-2)}
.ev.plan .tt{color:var(--text-2);font-style:normal}
.ev.now .tt{color:var(--text-1)}
.away-card{border-radius:18px;padding:14px 16px;background:var(--accent-soft);margin:6px 0 4px;font-size:13.5px;color:var(--text-2);line-height:1.7;transition:box-shadow var(--dur-panel)}
.away-card b{display:block;font-weight:500;color:var(--text-1);font-size:14.5px;margin-bottom:2px}
.away-card.flash{box-shadow:0 0 0 2px var(--accent),0 0 24px -4px var(--accent-glow)}
.more{display:flex;justify-content:center;margin:16px 0 4px}
.empty{padding:40px 12px;text-align:center;color:var(--text-2);font-size:14.5px;line-height:1.8}
.empty .glyph{width:54px;height:54px;margin:0 auto 14px;border-radius:50%;border:1px dashed var(--glass-line);display:grid;place-items:center;color:var(--accent)}
.empty .glyph svg{width:22px;height:22px}
.empty small{display:block;color:var(--text-3);font-size:12.5px;margin-top:6px}
/* 详情 */
.d-empty{height:100%;display:grid;place-items:center;text-align:center;color:var(--text-3);font-size:14px}
.d-empty .glyph{width:64px;height:64px;margin:0 auto 12px;border-radius:50%;background:radial-gradient(circle,var(--accent-soft),transparent 70%);display:grid;place-items:center;color:var(--accent)}
.d-kicker{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3)}
.d-title{font-size:24px;font-weight:300;letter-spacing:.03em;line-height:1.5;margin:10px 0 6px}
.d-meta{font-size:13px;color:var(--text-3);display:flex;flex-wrap:wrap;gap:6px 16px}
.quote{margin:22px 0 6px;padding:16px 20px;border-left:2px solid var(--accent);background:rgba(255,255,255,.035);border-radius:0 16px 16px 0;font-size:15.5px;line-height:1.85;color:var(--text-1)}
.quote small{display:block;color:var(--text-3);font-size:12px;margin-top:8px}
.d-sec{font-size:12.5px;color:var(--text-3);letter-spacing:.14em;margin:26px 0 10px}
.links{display:flex;flex-wrap:wrap;gap:8px}
.link-chip{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:6px 14px;border-radius:14px;border:1px solid var(--glass-line);font-size:13.5px;color:var(--text-1);text-align:left;transition:background-color var(--dur-ui)}
.link-chip:hover{background:var(--accent-soft)}
.link-chip svg{width:14px;height:14px;color:var(--accent);flex:none}
.d-foot{margin-top:28px;padding-top:14px;border-top:1px solid rgba(255,255,255,.06);font-size:12px;color:var(--text-3);line-height:1.7}
.pending{border:1px dashed var(--warn);color:var(--warn);border-radius:14px;padding:10px 14px;font-size:12.5px;line-height:1.7;margin-top:14px}
/* 回忆 */
.mem{display:block;width:100%;text-align:left;padding:12px 14px;border-radius:16px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.06);margin:8px 0;font-size:14.5px;line-height:1.65;transition:background-color var(--dur-ui),border-color var(--dur-ui)}
.mem:hover{background:rgba(255,255,255,.06)}
.mem[aria-current="true"]{border-color:var(--accent);background:var(--accent-soft)}
.mem small{display:block;color:var(--text-3);font-size:12px;margin-top:3px}
.growth{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:6px}
.growth > div{padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.06)}
.growth h4{font-weight:400;font-size:12.5px;color:var(--text-3);letter-spacing:.12em;margin-bottom:6px}
.growth ul{list-style:none;font-size:14px;line-height:1.9}
.growth li small{color:var(--text-3);font-size:11.5px;margin-left:6px}
/* 设置 */
.pane-manage .cols{grid-template-columns:220px 1fr}
.snav{overflow:auto;padding:14px 12px 24px 16px;border-right:1px solid rgba(255,255,255,.06)}
.snav button{display:flex;align-items:center;gap:10px;width:100%;height:44px;padding:0 14px;border-radius:14px;font-size:14px;color:var(--text-2);text-align:left;transition:background-color var(--dur-ui),color var(--dur-ui)}
.snav button svg{width:17px;height:17px;flex:none}
.snav button:hover{background:rgba(255,255,255,.04)}
.snav button[aria-current="true"]{color:var(--text-1);background:var(--accent-soft)}
.snav button[aria-current="true"] svg{color:var(--accent)}
.snav button .dirty{margin-left:auto;width:6px;height:6px;border-radius:50%;background:var(--warn);display:none}
.snav button.is-dirty .dirty{display:block}
.snav hr{border:0;border-top:1px solid rgba(255,255,255,.06);margin:10px 8px}
.sec-body{display:none}
.sec-body.on{display:block}
.sec-body h3{font-size:22px;font-weight:300;letter-spacing:.06em;margin-bottom:4px}
.sec-body .lead{font-size:13.5px;color:var(--text-2);margin-bottom:22px;line-height:1.75;max-width:560px}
.card{border-radius:18px;padding:18px 20px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);margin:0 0 14px}
.card h4{font-size:13px;font-weight:400;color:var(--text-3);letter-spacing:.12em;margin-bottom:10px;display:flex;align-items:center;gap:8px}
.card h4 svg{width:14px;height:14px}
.pillars{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:4px 0 10px}
.pillars div{text-align:center;padding:10px 4px;border-radius:12px;background:rgba(255,255,255,.04)}
.pillars small{display:block;font-size:11.5px;color:var(--text-3)}
.pillars b{display:block;font-weight:300;font-size:22px;letter-spacing:.12em}
.lean{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 10px}
.lean span{padding:6px 14px;border-radius:var(--radius-pill);border:1px solid var(--glass-line);font-size:14px}
.kv{display:grid;grid-template-columns:120px 1fr;gap:8px 16px;font-size:14px}
.kv dt{color:var(--text-3)}
.lock{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-3)}
.lock svg{width:13px;height:13px}
.actions{position:sticky;bottom:-34px;margin:22px -34px -34px;padding:16px 34px 20px;display:flex;align-items:center;gap:10px;background:linear-gradient(180deg,rgba(0,0,0,0),var(--glass-read) 30%);z-index:2}
.actions .grow{flex:1;font-size:12.5px;color:var(--text-3)}
.actions .grow.dirty{color:var(--warn)}
.svc{display:grid;grid-template-columns:1fr auto;gap:10px 14px;align-items:center;padding:14px 0;border-top:1px solid rgba(255,255,255,.06)}
.svc:first-of-type{border-top:0}
.svc .nm{font-size:14.5px}
.svc .nm small{display:block;color:var(--text-3);font-size:12px}
.svc .input{grid-column:1 / -1;height:42px;font-size:14px;font-family:var(--font-num)}
.svc .res{grid-column:1 / -1;font-size:12.5px;display:none}
.svc .res.show{display:flex;align-items:center;gap:6px}
.svc .res.ok{color:var(--ok)}.svc .res.fail{color:var(--danger)}.svc .res.busy{color:var(--text-2)}
.checklist{list-style:none;margin:10px 0 0;font-size:13.5px}
.checklist li{display:flex;align-items:center;gap:10px;padding:6px 0;color:var(--text-2)}
.checklist li i{width:18px;height:18px;border-radius:50%;display:grid;place-items:center;flex:none;border:1px solid var(--glass-line);font-style:normal;font-size:11px}
.checklist li.ok i{background:rgba(159,232,207,.15);border-color:var(--ok);color:var(--ok)}
.checklist li.fail{color:var(--danger)}
.checklist li.fail i{background:rgba(255,159,179,.15);border-color:var(--danger);color:var(--danger)}
.checklist li.run i{border-color:var(--accent);border-right-color:transparent;animation:spin .9s linear infinite}
.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
.compare figure{border-radius:14px;overflow:hidden;position:relative;aspect-ratio:4/3;background:rgba(255,255,255,.04)}
.compare img{width:100%;height:100%;object-fit:cover}
.compare figcaption{position:absolute;left:10px;bottom:8px;font-size:12px;padding:2px 10px;border-radius:999px;background:rgba(8,10,28,.55)}
.compare .new img{filter:hue-rotate(-14deg) saturate(1.15) brightness(1.05)}
.bk{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid rgba(255,255,255,.06);font-size:14px}
.bk:first-of-type{border-top:0}
.bk small{color:var(--text-3);font-size:12px;margin-left:8px}
.opt-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:8px 0}
.opt{border-radius:18px;padding:12px;text-align:left;border:1px solid var(--glass-line);transition:border-color var(--dur-ui),box-shadow var(--dur-ui)}
.opt .sw{height:64px;border-radius:12px;margin-bottom:10px}
.opt[data-t="moon"] .sw{background:radial-gradient(circle at 30% 30%,#e9f2ff 0 7%,rgba(160,200,255,.25) 12%,transparent 30%),linear-gradient(180deg,#0b1636,#1c2d5f)}
.opt[data-t="fog"] .sw{background:radial-gradient(80% 60% at 20% 90%,rgba(220,150,255,.7),transparent 70%),linear-gradient(170deg,#1c1342,#43307a)}
.opt[aria-pressed="true"]{border-color:var(--accent);box-shadow:0 0 18px -6px var(--accent-glow)}
.opt b{font-weight:500;font-size:14px}.opt span{display:block;font-size:12px;color:var(--text-3)}
.switch-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid rgba(255,255,255,.06);font-size:14.5px}
.switch-row small{display:block;color:var(--text-3);font-size:12px}
.city-list{list-style:none;margin:8px 0 0;border-radius:14px;overflow:hidden;border:1px solid var(--field-line)}
.city-list li button{display:flex;justify-content:space-between;width:100%;padding:11px 14px;font-size:14px;text-align:left;border-top:1px solid rgba(255,255,255,.05)}
.city-list li:first-child button{border-top:0}
.city-list li button:hover,.city-list li button[aria-pressed="true"]{background:var(--accent-soft)}
.city-list small{color:var(--text-3)}
/* ============================================================
首建
============================================================ */
.create{position:fixed;inset:0;z-index:60;display:grid;grid-template-columns:minmax(0,1fr) minmax(460px,560px);gap:6vw;align-items:center;padding:84px 9vw 48px;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out)}
body.creating .create{opacity:1;pointer-events:auto}
.create .story{max-width:440px}
.create .story .eyebrow{font-size:13px;color:var(--text-3);letter-spacing:.2em;margin-bottom:14px}
.create .story h1{font-size:var(--fs-whisper);font-weight:300;line-height:1.5;letter-spacing:.04em}
.create .story p{margin-top:16px;color:var(--text-2);font-size:14.5px;line-height:1.85}
.create .story .single{margin-top:22px;display:flex;gap:10px;font-size:12.5px;color:var(--text-3);line-height:1.7}
.create .story .single svg{width:16px;height:16px;flex:none;margin-top:3px;color:var(--accent)}
.wiz{border-radius:28px;display:flex;flex-direction:column;max-height:calc(100vh - 132px);overflow:hidden}
.wiz .steps{display:flex;gap:6px;padding:20px 28px 0}
.wiz .steps span{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.1);transition:background-color var(--dur-ui),box-shadow var(--dur-ui)}
.wiz .steps span.on{background:var(--accent);box-shadow:0 0 8px var(--accent-glow)}
.wiz .steplabel{padding:10px 28px 0;font-size:12px;color:var(--text-3);letter-spacing:.12em}
.wiz .wbody{overflow:auto;padding:10px 28px 8px;flex:1}
.wiz .wbody h2{font-size:22px;font-weight:300;letter-spacing:.06em;margin:4px 0 6px}
.wiz .wbody .lead{font-size:13.5px;color:var(--text-2);line-height:1.8;margin-bottom:20px}
.wiz .wfoot{display:flex;align-items:center;gap:10px;padding:16px 28px 22px;border-top:1px solid rgba(255,255,255,.06)}
.wiz .wfoot .grow{flex:1}
.wstep{display:none;animation:stepIn var(--dur-panel) var(--ease-out)}
.wstep.on{display:block}
@keyframes stepIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:none}}
.wstep.back-in{animation-name:stepBack}
@keyframes stepBack{from{opacity:0;transform:translateX(-16px)}to{opacity:1;transform:none}}
.gen{display:flex;align-items:center;gap:10px;margin:-4px 0 18px}
.sum{border-radius:16px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);padding:4px 18px;margin-bottom:14px}
.sum .r{display:flex;justify-content:space-between;gap:12px;padding:11px 0;border-top:1px solid rgba(255,255,255,.06);font-size:14px}
.sum .r:first-child{border-top:0}
.sum .r span:first-child{color:var(--text-3)}
.sum .r b{font-weight:400;text-align:right}
.phases{list-style:none;margin:8px 0 0}
.phases li{display:flex;align-items:center;gap:12px;padding:10px 0;font-size:14.5px;color:var(--text-3)}
.phases li i{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--glass-line);flex:none;display:grid;place-items:center;font-style:normal;font-size:11px}
.phases li.run{color:var(--text-1)}
.phases li.run i{border-color:var(--accent);border-right-color:transparent;animation:spin .9s linear infinite}
.phases li.ok{color:var(--text-2)}
.phases li.ok i{border-color:var(--ok);color:var(--ok);background:rgba(159,232,207,.12)}
.phases li.fail{color:var(--danger)}
.phases li.fail i{border-color:var(--danger);color:var(--danger)}
.born{text-align:center;padding:16px 0 8px}
.born .halo{width:96px;height:96px;margin:6px auto 18px;border-radius:50%;background:radial-gradient(circle,var(--window-glow) 0%,var(--accent-soft) 45%,transparent 70%);display:grid;place-items:center}
.born .halo i{width:12px;height:12px;border-radius:50%;background:var(--accent);box-shadow:0 0 22px 6px var(--accent-glow)}
.born h2{font-size:24px !important}
.born p{color:var(--text-2);font-size:14px;line-height:1.85;margin-top:6px}
/* ============================================================
通用弹层:确认 / 授权
============================================================ */
.scrim{position:fixed;inset:0;z-index:70;background:rgba(2,4,16,.45);opacity:0;pointer-events:none;transition:opacity var(--dur-ui)}
.scrim.open{opacity:1;pointer-events:auto}
.modal{position:fixed;left:50%;top:50%;z-index:72;width:min(460px,calc(100vw - 32px));border-radius:26px;padding:26px 26px 22px;transform:translate(-50%,-46%) scale(.97);opacity:0;pointer-events:none;transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out)}
.modal.open{opacity:1;transform:translate(-50%,-50%);pointer-events:auto}
.modal .mi{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent);margin-bottom:14px}
.modal.danger .mi{background:rgba(255,159,179,.12);color:var(--danger)}
.modal .mi svg{width:20px;height:20px}
.modal h3{font-size:19px;font-weight:400;letter-spacing:.04em;margin-bottom:8px}
.modal .mb{font-size:14px;color:var(--text-2);line-height:1.8}
.modal .mb ul{margin:8px 0 0 18px}
.modal .confirm-field{margin-top:14px}
.modal .acts{display:flex;justify-content:flex-end;gap:10px;margin-top:22px}
.modal .acts button{height:44px}
.toast{max-width:calc(100vw - 24px);text-align:center;position:fixed;left:50%;top:76px;transform:translate(-50%,-8px);z-index:80;padding:8px 16px;border-radius:var(--radius-pill);font-size:13px;color:var(--text-1);opacity:0;pointer-events:none;transition:opacity var(--dur-ui),transform var(--dur-ui) var(--ease-out);display:flex;align-items:center;gap:8px;white-space:nowrap}
.toast.show{opacity:1;transform:translate(-50%,0)}
.toast.ok::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--ok)}
.toast.fail::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--danger)}
/* ============================================================
演示控制台(原型专用,非产品界面)
============================================================ */
.demo{position:fixed;left:20px;bottom:24px;z-index:90}
.demo > button{height:34px;padding:0 14px;border-radius:999px;border:1px dashed var(--glass-line);font-size:12px;color:var(--text-3);background:rgba(4,6,18,.55);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.demo .menu{position:absolute;left:0;bottom:44px;width:300px;max-height:calc(100vh - 120px);overflow:auto;border-radius:18px;padding:12px;display:none;background:var(--glass-solid);border:1px solid var(--glass-line);box-shadow:0 20px 60px -20px #000}
.demo.open .menu{display:block}
.demo h5{font-weight:400;font-size:11.5px;color:var(--text-3);letter-spacing:.14em;margin:10px 4px 6px}
.demo h5:first-child{margin-top:2px}
.demo .g{display:flex;flex-wrap:wrap;gap:6px}
.demo .g button{height:30px;padding:0 10px;border-radius:10px;font-size:12px;color:var(--text-2);background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.06)}
.demo .g button:hover{color:var(--text-1);background:var(--accent-soft)}
.demo .g button[aria-pressed="true"]{color:var(--on-accent);background:var(--accent)}
.demo p{font-size:11px;color:var(--text-3);margin:10px 4px 0;line-height:1.6}
/* ============================================================
手机(≤720px)
============================================================ */
@media (max-width:720px){
:root{--fs-whisper:21px;--fs-line:17px}
.topbar{top:14px;left:18px;right:14px}
.who .name{font-size:17px}
.who .state,.env .chip.place,.env .chip.weather,.env .tag-demo,.clock small{display:none}
.env{gap:6px}
.clock{font-size:17px}
.theme-switch button{padding:0 9px;height:26px;font-size:12px}
.theme-switch button span{display:none}
.moon-disc{left:5%;top:12%;width:36px;height:36px}
.moon-halo{left:calc(5% - 182px);top:calc(12% - 182px);width:400px;height:400px}
.netbar{top:56px;max-width:calc(100vw - 24px);font-size:12.5px}
.netbar small{display:none}
.portal{--pw:min(74vw,330px); --ph:calc(var(--pw) * 1.22); --pl:calc(50vw - var(--pw) / 2); --pt:70px}
body.talk .portal{--pw:calc(100vw - 24px); --ph:min(52vh, calc(var(--pw) * 1.2)); --pl:12px; --pt:58px}
body.talk.voice .portal{--pw:calc(100vw - 24px); --ph:min(42vh, calc(var(--pw) * 1.1))}
body.talk.log{--shift:0px}
body.paned .portal{opacity:0;pointer-events:none;--pw:min(74vw,330px); --ph:calc(var(--pw) * 1.22); --pl:calc(50vw - var(--pw) / 2); --pt:70px}
.mini-cap{display:none}
.portal .doing{left:12px;right:12px;bottom:12px}
.portal .doing .tag-demo{display:none}
.portal .mark{right:14px;bottom:50px}
.observe{max-width:calc(100vw - 80px)}
.home{left:22px;right:22px;width:auto;top:calc(70px + min(74vw,330px) * 1.22 + 22px);transform:none}
body.talk .home,body.paned .home,body.creating .home{transform:translateY(20px) scale(.97)}
.home .eyebrow,.home .meta{display:none}
.letter{margin-top:14px;padding:14px 16px 12px}
.letter p{font-size:14px}
.away{margin-top:8px;font-size:13px}
.cta{position:static;margin-top:16px}
.cta .btn-primary{flex:1;height:50px}
.cta .btn-round{width:50px;height:50px}
.first-note{font-size:13px}
.welcome{left:12px;right:12px;width:auto;top:auto;bottom:90px;transform:translateY(20px);max-height:calc(100vh - 160px);overflow:auto;padding:22px 20px 18px;background:var(--glass-solid)}
body.welcoming .welcome{transform:none}
.welcome li{grid-template-columns:64px 1fr}
.reach{left:12px;right:12px;width:auto;bottom:164px;border-radius:20px}
body.paned .reach{left:12px;right:12px;bottom:96px}
.dialog{left:50vw;top:calc(58px + min(52vh, calc((100vw - 24px) * 1.2)) + 18px);bottom:auto;width:calc(100vw - 40px)}
body.talk.voice .dialog{top:calc(58px + min(42vh, calc((100vw - 24px) * 1.1)) + 14px);bottom:auto}
.dialog .you{font-size:13px}
.composer{left:50vw;bottom:98px;width:calc(100vw - 24px);height:56px}
.composer .btn-round{width:40px;height:40px}
.composer .btn-round svg{width:19px;height:19px}
.mic{width:42px;height:42px}
.voicebar{left:50vw;bottom:162px;width:calc(100vw - 24px);gap:8px;padding:0 6px 0 12px}
.voicebar .vstate{min-width:0;flex:1;font-size:13px}
.voicebar .live span,.voicebar .vstate .wave{display:none}
.voicebar .vstate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
.nav{bottom:16px;height:58px;width:calc(100vw - 24px);justify-content:space-between}
.nav-item{flex:1;width:auto}
.nav-item .badge{right:calc(50% - 18px)}
.log-panel{left:0;right:0;top:auto;bottom:0;width:auto;height:74vh;border-radius:26px 26px 0 0;z-index:45;transform:translateY(100%);opacity:1}
body.log .log-panel{transform:none}
.log-panel,.pane,.wiz,.modal{background:var(--glass-solid)}
.window-glow{display:none}
body.talk .moon-disc,body.talk .moon-halo{opacity:0}
/* 区域面板 → 全屏层;列表与详情改为推入 */
.pane{left:8px;right:8px;width:auto;top:54px;bottom:84px;border-radius:24px}
.pane > header{flex-wrap:wrap;gap:10px;padding:14px 14px 12px 18px}
.pane > header h2{font-size:18px}
.pane > header .tabs{order:3;width:100%;margin:0;display:flex}
.pane > header .tabs button{flex:1}
.pane > header .tag-demo{display:none}
.pane .presence{display:flex}
.pane .cols,.pane-manage .cols{grid-template-columns:1fr}
.pane .list,.snav{border-right:0;padding:8px 12px 24px 14px}
.pane .detail{position:absolute;inset:0;transform:translateX(102%);transition:transform var(--dur-panel) var(--ease-out);background:var(--surface);padding:12px 18px 28px;z-index:3}
.pane.has-detail .detail{transform:none}
.pane .back{display:inline-flex;margin:0 0 8px -4px}
.d-empty{display:none}
.d-title{font-size:21px}
.quote{font-size:15px}
.growth{grid-template-columns:1fr}
.actions{bottom:-28px;margin:22px -18px -28px;padding:14px 18px 18px;background:var(--surface)}
.actions .grow{display:none}
.actions button{flex:1}
.row2{grid-template-columns:1fr}
.kv{grid-template-columns:96px 1fr}
.pillars b{font-size:19px}
.compare{grid-template-columns:1fr 1fr}
.create{display:block;padding:56px 0 0;overflow:auto}
.create .story{padding:4px 22px 14px;max-width:none}
.create .story h1{font-size:21px}
.create .story p{font-size:13.5px;margin-top:8px}
.create .story .single{display:none}
.wiz,.wiz .wbody{overflow:visible}
.wiz{margin:0 8px;max-height:none;border-radius:24px 24px 0 0;min-height:calc(100vh - 190px)}
.wiz .wbody{padding:8px 18px 8px}
.wiz .steps{padding:16px 18px 0}
.wiz .steplabel{padding:8px 18px 0}
.wiz .wfoot{position:sticky;bottom:0;padding:12px 18px 18px;background:var(--surface)}
.wiz .wfoot .grow{display:none}
.wiz .wfoot button{flex:1}
.create.at-story .story{display:block}
.demo{left:50%;bottom:auto;top:16px;transform:translateX(-50%)}
.demo .dl{display:none}
.demo .menu{left:50%;transform:translateX(-50%);bottom:auto;top:40px;width:min(300px,calc(100vw - 24px));max-height:70vh}
.demo > button{height:28px;padding:0 10px;font-size:11px}
}
@media (max-width:1100px) and (min-width:721px){
:root{--pane-w:calc(100vw - 10vw); --mini-pw:0px}
body.paned .portal{opacity:0;pointer-events:none}
.mini-cap{display:none}
.pane .presence{display:flex}
.pane .cols{grid-template-columns:340px 1fr}
.pane-manage .cols{grid-template-columns:200px 1fr}
}
/* ============================================================
减少动效 / 隐藏页暂停
============================================================ */
html.reduced *, html.reduced *::before, html.reduced *::after{animation:none !important}
html.reduced .portal, html.reduced .portal img, html.reduced .space, html.reduced .home, html.reduced .dialog, html.reduced .composer, html.reduced .log-panel, html.reduced .pane, html.reduced .pane .detail, html.reduced .welcome, html.reduced .reach, html.reduced .modal, html.reduced .voicebar, html.reduced .create{transition-duration:200ms !important;transition-delay:0ms !important}
html.reduced body.talk .space, html.reduced body.paned .space{transform:none}
html.reduced .k.now{box-shadow:0 0 10px var(--accent-glow)}
html.reduced .wave i{height:8px}
html.paused *, html.paused *::before, html.paused *::after{animation-play-state:paused !important}
[hidden]{display:none!important}
p,button,h1,h2,h3,dd{overflow-wrap:anywhere}
.entry{position:fixed;inset:0;display:grid;place-items:center;padding:24px;z-index:60;overflow:auto}
.entry .card{max-width:540px;background:var(--glass-read)}
.entry h1{font-size:var(--fs-whisper);font-weight:300}
.entry p{color:var(--text-2);margin:18px 0}
.error-text{color:var(--danger);font-size:13px;margin-top:12px}
.demo-label{font-size:11px;color:var(--text-3)}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition-duration:0.01ms!important;transition-delay:0ms!important}}
body:has(textarea:focus) .space *{animation-play-state:paused!important}
+43
View File
@@ -0,0 +1,43 @@
/* Authoritative HEL-746 R2 tokens; approved geometry and both themes. */
/* ============================================================
Design tokens — 延续 HEL-744 R1,未改首页已认可的比例与字号
R2 新增:阅读底 --glass-read、状态色 --ok/--warn/--danger、--z-*
============================================================ */
:root{
--font-sans: "PingFang SC","HarmonyOS Sans SC","MiSans","Microsoft YaHei UI","Microsoft YaHei","Noto Sans SC",system-ui,sans-serif;
--font-num: "SF Pro Display","Segoe UI Variable Display","Segoe UI",var(--font-sans);
--fs-whisper: 30px; --fs-title: 20px; --fs-line: 20px; --fs-body: 15px; --fs-meta: 13px; --fs-tiny: 11.5px;
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:24px; --space-6:32px; --space-7:48px;
--radius-pill:999px; --radius-card:20px; --radius-stage:28px; --radius-field:14px;
--ease-out: cubic-bezier(.22,1,.36,1);
--ease-in-out: cubic-bezier(.65,0,.35,1);
--ease-drift: cubic-bezier(.45,.05,.55,.95);
--dur-fast: 160ms; --dur-ui: 280ms; --dur-panel: 420ms; --dur-stage: 720ms; --dur-theme: 1100ms;
/* 区域面板(日子 / 设置)几何:电脑 */
--pane-l: 5vw; --pane-w: min(1000px, 66vw); --pane-t: 84px; --pane-b: 104px;
--mini-pw: clamp(170px, 14vw, 230px);
}
html[data-theme="moon"]{
--bg-0:#060a1c; --bg-1:#0d1838; --bg-2:#1b2c5e;
--text-1:#eaf0ff; --text-2:rgba(214,226,255,.72); --text-3:rgba(196,210,250,.48);
--accent:#a9dcff; --accent-soft:rgba(169,220,255,.16); --accent-glow:rgba(150,210,255,.55); --on-accent:#08142e;
--glass:rgba(150,180,255,.075); --glass-strong:rgba(28,44,92,.52); --glass-line:rgba(205,222,255,.18); --glass-hi:rgba(255,255,255,.10);
--glass-blur:18px; --glass-sat:120%; --glass-solid:rgba(12,20,50,.88);
--glass-read:rgba(10,18,46,.62); --surface:#0b1330; --field:rgba(255,255,255,.05); --field-line:rgba(205,222,255,.16);
--window-glow:rgba(255,196,140,.20);
--ok:#9fe8cf; --warn:#ffd79c; --danger:#ff9fb3;
}
html[data-theme="fog"]{
--bg-0:#110b26; --bg-1:#22174a; --bg-2:#43307a;
--text-1:#f3ecff; --text-2:rgba(234,222,255,.74); --text-3:rgba(222,206,255,.50);
--accent:#ebbcff; --accent-soft:rgba(235,188,255,.18); --accent-glow:rgba(226,170,255,.55); --on-accent:#2a0f40;
--glass:rgba(210,180,255,.11); --glass-strong:rgba(60,38,108,.50); --glass-line:rgba(236,218,255,.17); --glass-hi:rgba(255,240,255,.14);
--glass-blur:30px; --glass-sat:145%; --glass-solid:rgba(38,22,74,.88);
--glass-read:rgba(34,20,66,.60); --surface:#24154a; --field:rgba(255,240,255,.06); --field-line:rgba(236,218,255,.16);
--window-glow:rgba(255,170,190,.22);
--ok:#a8f0d8; --warn:#ffdcae; --danger:#ffa8c8;
}
+205
View File
@@ -0,0 +1,205 @@
"""Actual Chromium checks against the production build. Requires Python Playwright.
Starts an ephemeral loopback HTTP server and always closes server/browser before exit.
"""
import functools
import http.server
import json
import sys
from pathlib import Path
import threading
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parents[0] / 'docs' / 'frontend' / 'screenshots'
OUT.mkdir(parents=True, exist_ok=True)
results = []
errors = []
requests = []
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *_): pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Quiet, directory=str(ROOT / 'dist')))
thread = threading.Thread(target=server.serve_forever)
thread.start()
url = f'http://127.0.0.1:{server.server_port}'
def passed(text):
results.append(text)
print('PASS', text, flush=True)
def click(page, name, exact=True):
(page.locator('.demo > button') if name == '演示场景 · 非真实能力' else page.get_by_role('button', name=name, exact=exact)).click()
def nav(page, name):
page.get_by_role('navigation', name='主导航').get_by_role('button', name=name, exact=True).click()
page.wait_for_timeout(500)
if name == '设置' and page.viewport_size['width'] <= 720 and page.locator('.pane.open.has-detail').count():
page.locator('.pane.open .back').click()
def scenario(page, name):
click(page, '演示场景 · 非真实能力' if page.viewport_size['width'] > 720 else '演示场景 · 非真实能力')
click(page, name)
def shot(page, name):
page.wait_for_timeout(800)
if page.locator('.toast').count():
expect(page.locator('.toast')).not_to_be_visible(timeout=6000)
page.screenshot(path=str(OUT / f'{name}.png'))
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=['--no-sandbox'])
sizes=[(1440,900),(1280,800),(390,844)]
if len(sys.argv)>1: sizes=[size for size in sizes if str(size[0])==sys.argv[1]]
for width, height in sizes:
context = browser.new_context(viewport={'width': width, 'height': height}, reduced_motion='reduce')
page = context.new_page()
page.set_default_timeout(7000)
page.on('pageerror', lambda e: errors.append(str(e)))
page.on('request', lambda r: requests.append(r.url))
page.goto(url)
expect(page.get_by_text('真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。')).to_be_visible()
expect(page.get_by_role('button', name='演示场景', exact=False)).to_have_count(0)
page.get_by_role('link', name='进入本机演示').click()
shot(page, f'{width}-creation-moon')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('名字')
page.get_by_label('她的名字', exact=True).fill('小满')
page.get_by_label('出生日期', exact=True).fill('2020-01-01')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('18')
page.get_by_label('出生日期', exact=True).fill('2000-10-18')
click(page, '下一步'); click(page, '下一步')
expect(page.get_by_role('button', name='朋友', exact=True)).to_have_attribute('aria-pressed', 'false')
expect(page.get_by_role('button', name='恋人', exact=True)).to_have_attribute('aria-pressed', 'false')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('选择')
click(page, '朋友'); click(page, '下一步')
expect(page.get_by_role('button', name='确认创建', exact=True)).to_be_disabled()
page.get_by_role('checkbox').check()
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
click(page, '确认创建')
expect(page.get_by_role('alert')).to_contain_text('资料都还在')
shot(page, f'{width}-creation-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '再试一次'); click(page, '去看看她')
expect(page.locator('.who .name')).to_have_text('小满')
page.reload()
expect(page.get_by_role('button', name='和她说话', exact=True)).to_be_visible()
expect(page.locator('.create')).to_have_count(0)
passed(f'{width}: ordinary entry, creation validation/failure/retry and reopen')
scenario(page, '载入三天记录'); click(page, '演示场景 · 非真实能力')
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
click(page, label)
shot(page, f'{width}-home-{theme}')
nav(page, '日子')
if width <= 720 and page.locator('.pane.open.has-detail').count():
page.locator('.pane.open .back').click()
expect(page.locator('body')).not_to_contain_text('秘密散步')
expect(page.locator('body')).not_to_contain_text('私密地点')
click(page, '读到了书里的那片海', exact=False)
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
shot(page, f'{width}-days-{theme}')
click(page, '留一盏小灯')
expect(page.get_by_role('heading', name='留一盏小灯')).to_be_visible()
click(page, '读到了书里的那片海', exact=False)
click(page, '看那段聊天')
expect(page.locator('.log-panel')).to_be_visible()
shot(page, f'{width}-talk-{theme}')
click(page, '返回日子')
expect(page.get_by_role('heading', name='读到了书里的那片海')).to_be_visible()
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='关系', exact=True).click()
page.get_by_label('她怎么称呼你', exact=True).fill('小伙伴')
click(page, '取消修改')
expect(page.get_by_label('她怎么称呼你', exact=True)).to_have_value('')
page.get_by_label('你们的关系', exact=True).select_option('恋人')
click(page, '保存')
expect(page.get_by_role('dialog')).to_contain_text('记忆与信任都保留')
click(page, '取消')
expect(page.get_by_label('你们的关系', exact=True)).to_have_value('恋人')
click(page, '保存'); page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
shot(page, f'{width}-manage-{theme}')
page.get_by_label('你们的关系', exact=True).select_option('朋友')
click(page, '保存'); page.get_by_role('dialog').get_by_role('button', name='确认', exact=True).click()
expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
nav(page, '此刻')
page.reload()
expect(page.locator('html')).to_have_attribute('data-theme', 'fog')
passed(f'{width}: both themes, home/talk/days/memory return, relation confirm/cancel/save')
# Direct private event deep link must not reveal anything, including its linked memory.
page.goto(url + '/?demo=1#event=secret'); nav(page, '日子')
expect(page.locator('article.detail')).to_contain_text('这条记录暂时不可见')
expect(page.locator('body')).not_to_contain_text('秘密散步')
nav(page, '交谈')
composer=page.get_by_role('textbox', name='想和她说的话')
composer.fill('第一轮'); click(page, '发送'); click(page, '取消本轮回复')
page.wait_for_timeout(1700)
click(page, '完整记录')
expect(page.locator('.log-panel')).not_to_contain_text('我听见了。')
click(page, '关闭记录')
composer.fill('切出后旧回复'); click(page, '发送'); nav(page, '此刻')
page.wait_for_timeout(1600); nav(page, '交谈'); click(page, '完整记录')
expect(page.locator('.log-panel')).not_to_contain_text('我听见了。')
click(page, '关闭记录')
composer.fill('中文输入法')
composer.evaluate("el=>el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true,isComposing:true}))")
expect(composer).to_have_value('中文输入法')
composer.press('Shift+Enter'); expect(composer).to_have_value('中文输入法\n')
composer.fill('<img src=x onerror=alert(1)>')
composer.press('Enter'); page.wait_for_timeout(1600)
click(page, '完整记录')
expect(page.locator('.log-panel img')).to_have_count(0)
expect(page.locator('.log-panel')).to_contain_text('<img src=x onerror=alert(1)>')
click(page, '关闭记录')
passed(f'{width}: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text')
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
composer.fill('失败重试'); click(page, '发送')
expect(page.locator('.dialog .failed')).to_be_visible()
shot(page, f'{width}-send-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '重试'); page.wait_for_timeout(1600)
expect(page.locator('.dialog .failed')).to_have_count(0)
click(page, '开始模拟语音'); click(page, '开始模拟')
expect(page.get_by_role('button', name='结束语音', exact=True)).to_be_in_viewport()
shot(page, f'{width}-voice')
click(page, '结束语音')
expect(page.locator('.voicebar .end')).to_have_count(0)
click(page, '视觉观察'); click(page, '开始模拟')
expect(page.locator('.observe')).to_contain_text('未采集影像')
click(page, '视觉观察')
composer.fill('断线保留草稿')
scenario(page, '模拟断线')
expect(composer).to_have_value('断线保留草稿')
expect(page.get_by_role('button', name='发送', exact=True)).to_be_disabled()
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
click(page, '再试一次'); page.wait_for_timeout(900)
expect(page.locator('.netbar')).to_be_visible()
shot(page, f'{width}-offline-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '再试一次'); expect(page.locator('.netbar')).to_have_count(0)
expect(composer).to_have_value('断线保留草稿')
nav(page, '设置')
page.get_by_role('navigation', name='设置分区').get_by_role('button', name='城市', exact=True).click()
page.locator('.city-list button').filter(has_text='上海').click()
scenario(page, '下次操作模拟失败'); click(page, '演示场景 · 非真实能力')
click(page, '保存'); expect(page.get_by_role('alert')).to_contain_text('你的修改还在'); expect(page.get_by_role('alert')).to_be_in_viewport()
shot(page, f'{width}-save-failure')
scenario(page, '关闭操作失败'); click(page, '演示场景 · 非真实能力')
click(page, '重试保存'); expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
expect(page.locator('.place')).to_contain_text('上海')
passed(f'{width}: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry')
nav(page, '此刻'); scenario(page, '模拟两天后返回')
expect(page.locator('.welcome')).not_to_contain_text('秘密')
shot(page, f'{width}-return')
click(page, '先待一会儿'); scenario(page, '模拟主动消息'); click(page, '回她')
expect(page.locator('.log-panel')).to_contain_text('她主动说的')
passed(f'{width}: privacy-filtered away summary and proactive message')
context.close()
assert not errors, errors
assert not [r for r in requests if not r.startswith(url)], requests
passed('No uncaught browser errors, external requests, microphone or camera requests')
browser.close()
finally:
server.shutdown(); server.server_close(); thread.join()
(ROOT.parents[0] / 'docs' / 'frontend' / (f'browser-results-{sys.argv[1]}.json' if len(sys.argv)>1 else 'browser-results.json')).write_text(json.dumps({'checks':results,'errors':errors,'requests':len(requests)},ensure_ascii=False,indent=2))
+29
View File
@@ -0,0 +1,29 @@
import { newId } from '../src/domain/id';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { publicView, eventById } from '../src/domain/privacy';
import { validateCreation } from '../src/domain/model';
import { freshState, sampleDays } from '../src/demo/fixtures';
import { loadState, parseState, persistState } from '../src/adapters/storage';
import { DemoAdapter, TurnGate } from '../src/adapters/demo';
const identity = { id: 'same-person', name: '小满', birth: '2000-01-01', hour: '亥时', relation: '朋友' as const, nickname: '', boundary: '' };
const seed = () => sampleDays(freshState(identity));
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe('approved business rules', () => {
it('fails closed on private/unknown references throughout public data', () => { const s = seed(); s.memories.push({ id: 'unknown', title: '未知引用', text: '不能展示', group: '我们一起的', source: 'x', lifeIds: ['missing'] }); const view = publicView(s); const text = JSON.stringify(view); for (const secret of ['秘密散步', '私密地点', '隐私测试内容', '未知引用'])
expect(text).not.toContain(secret); expect(eventById(s, 'secret')).toBeUndefined(); expect(view.events).toHaveLength(4); expect(view.memories.some(m => m.id === 'habit')).toBe(true); expect(view.messages.some(m => m.id === 'c1')).toBe(true); });
it('unknown sharing status is hidden', () => { const s = seed(); delete (s.events[0] as Partial<typeof s.events[number]>).shared; expect(publicView(s).events.some(e => e.id === 'book')).toBe(false); expect(publicView(s).memories.some(m => m.id === 'habit')).toBe(false); });
it('requires real name, adult birth and explicit relationship', () => { expect(validateCreation(' ', '2000-01-01', '朋友')).toContain('名字'); expect(validateCreation('小满', '2000-01-01', '')).toContain('关系'); expect(validateCreation('小满', '2020-01-01', '朋友')).toContain('18'); expect(validateCreation('小满', '2000-01-01', '朋友')).toBe(''); });
});
describe('local persistence and restore boundary', () => {
it('reopens the same creation and preserves drafts when save fails', () => { const data = new Map<string, string>(); vi.stubGlobal('localStorage', { getItem: (k: string) => data.get(k) || null, setItem: (k: string, v: string) => data.set(k, v) }); const s = seed(); persistState(s); expect(loadState().state?.identity).toEqual(identity); expect(() => persistState({ ...s, settings: { ...s.settings, city: '上海' } }, true)).toThrow(); expect(loadState().state?.settings.city).toBe('杭州'); });
it('corrupt storage produces a visible boundary instead of new creation', () => { vi.stubGlobal('localStorage', { getItem: () => '{invalid' }); expect(loadState().state).toBeNull(); expect(loadState().error).not.toBe(''); });
it('rejects another companion, malformed records and oversized files', () => { const s = seed(); expect(() => parseState(JSON.stringify({ ...s, identity: { ...identity, id: 'other' } }), identity)).toThrow('另一位'); expect(() => parseState(JSON.stringify({ ...s, messages: [{ text: 'invalid' }] }), identity)).toThrow(); expect(() => parseState(' '.repeat(5000001))).toThrow('太大'); });
it('untrusted text stays text, arbitrary keys are dropped, pending turns cancelled on restore', () => { const s = seed(); s.messages.push({ id: 'script', role: 'companion', text: '<img src=x onerror=alert(1)>', lifeIds: [], status: 'pending' }); const parsed = parseState(JSON.stringify({ ...s, script: 'evil' }), identity); expect(parsed.messages.at(-1)?.text).toContain('<img'); expect(parsed.messages.at(-1)?.status).toBe('cancelled'); expect(parsed).not.toHaveProperty('script'); });
});
describe('turn/epoch cancellation', () => {
it('rejects old results after new turn, cancel, exit, and resync', () => { const g = new TurnGate(); const a = g.begin('a'); const e = { t: 'chat.delta' as const, epoch: 1, turn_id: 'a', delta: '旧' }; expect(g.accepts(e)).toBe(true); g.begin('b'); expect(a.aborted).toBe(true); expect(g.accepts(e)).toBe(false); g.cancel(); expect(g.accepts({ ...e, turn_id: 'b' })).toBe(false); g.begin('b'); g.resync(); g.begin('c'); expect(g.accepts({ ...e, turn_id: 'c' })).toBe(false); });
it('aborts simulator without reviving a late reply', async () => { vi.useFakeTimers(); const c = new AbortController(), emit = vi.fn(); const p = new DemoAdapter().send('hello', 'a', 1, c.signal, emit).catch(e => e.name); c.abort(); await vi.runAllTimersAsync(); expect(await p).toBe('AbortError'); expect(emit).not.toHaveBeenCalled(); });
it('simulated send failure is retryable without fabricating a reply', async () => { vi.useFakeTimers(); const emit = vi.fn(); const p = new DemoAdapter().send('hello', 'a', 1, new AbortController().signal, emit, true).catch(e => e.message); await vi.runAllTimersAsync(); expect(await p).toContain('没发出去'); expect(emit).not.toHaveBeenCalled(); });
});
it('creates valid local ids without secure-context randomUUID',()=>{vi.stubGlobal('crypto',{getRandomValues:(a:Uint8Array)=>{a.fill(7);return a;}});expect(newId()).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);});
+102
View File
@@ -0,0 +1,102 @@
"""Browser checks for destructive confirmations and unsafe file handling; no backend."""
import functools
import http.server
import json
from pathlib import Path
import threading
from playwright.sync_api import sync_playwright, expect
ROOT=Path(__file__).resolve().parents[1]
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self,*_): pass
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),functools.partial(Quiet,directory=str(ROOT/'dist')))
thread=threading.Thread(target=server.serve_forever);thread.start()
url=f'http://127.0.0.1:{server.server_port}'
identity={'id':'demo-person','name':'小满','birth':'2000-01-01','hour':'亥时','relation':'朋友','nickname':'','boundary':'尊重彼此'}
state={'version':1,'identity':identity,'settings':{'city':'杭州','appearance':'reference-v1','reduced':False,'services':{'对话模型':'','ASR':'','TTS':'','视觉观察':''}},'events':[],'memories':[],'messages':[],'lastVisit':9999999999999}
checks=[]
def fileinput(page,label,data):
page.get_by_label(label,exact=True).set_input_files({'name':'demo.json','mimeType':'application/json','buffer':json.dumps(data).encode()})
def click(page,name): page.get_by_role('button',name=name,exact=True).click()
def section(page,name):page.get_by_role('navigation',name='设置分区').get_by_role('button',name=name,exact=True).click()
try:
with sync_playwright() as p:
browser=p.chromium.launch(headless=True,args=['--no-sandbox'])
ctx=browser.new_context(viewport={'width':1280,'height':800},accept_downloads=True)
ctx.add_init_script('if(!localStorage.getItem("cybergril.demo.v1")) localStorage.setItem("cybergril.demo.v1",'+json.dumps(json.dumps(state))+')')
page=ctx.new_page();page.set_default_timeout(7000)
outgoing=[];page.on('request',lambda r:outgoing.append(r.url))
page.goto(url+'/?demo=1')
page.get_by_role('navigation',name='主导航').get_by_role('button',name='设置',exact=True).click()
section(page,'本地演示导出')
label='恢复同一位她的本地演示 JSON'
other={**state,'identity':{**identity,'id':'other'}}
fileinput(page,label,other);expect(page.get_by_role('alert')).to_contain_text('另一位')
imported={**state,'settings':{**state['settings'],'city':'成都'},'messages':[{'id':'hostile','role':'companion','text':'<img src=x onerror=alert(1)>','lifeIds':[]}]}
fileinput(page,label,imported);click(page,'检查通过,继续恢复')
dialog=page.get_by_role('dialog');expect(dialog.get_by_role('button',name='确认',exact=True)).to_be_disabled()
page.get_by_label('输入“恢复”以继续',exact=True).fill('恢复')
with page.expect_download() as d:dialog.get_by_role('button',name='确认',exact=True).click()
assert d.value.suggested_filename.startswith('CyberGril-demo-')
expect(page.get_by_role('status').filter(has_text='本地演示资料已恢复')).to_be_visible()
section(page,'她');expect(page.locator('.kv')).to_contain_text('小满')
checks.append('Reject foreign identity; same-identity restore requires typed confirmation and pre-restore export')
section(page,'外观素材')
fileinput(page,'选择本地素材清单(演示 JSON)',{'manifest_version':1,'appearance_id':'<script>','clips':[1]})
expect(page.get_by_role('alert')).to_contain_text('检查没通过')
fileinput(page,'选择本地素材清单(演示 JSON)',{'manifest_version':1,'appearance_id':'new-look','clips':[{'src':'https://example.invalid/evil.js'}]})
expect(page.get_by_role('status').filter(has_text='未真实检测')).to_be_visible()
click(page,'应用演示外观标识');click(page,'取消')
expect(page.get_by_text('当前素材:reference-v1',exact=False)).to_be_visible()
click(page,'应用演示外观标识');page.get_by_role('dialog').get_by_role('button',name='确认',exact=True).click()
expect(page.get_by_text('当前素材:new-look',exact=False)).to_be_visible()
original=page.evaluate('JSON.parse(localStorage.getItem("cybergril.demo.v1")).identity')
assert original==identity
section(page,'外接服务')
page.get_by_label('对话模型地址',exact=True).fill('https://example.invalid/model')
page.locator('.svc').first.get_by_role('button',name='测试连接',exact=True).click()
expect(page.get_by_role('status').filter(has_text='未真实检测')).to_be_visible()
assert not [r for r in outgoing if not r.startswith(url)],outgoing
checks.append('Invalid manifests rejected; external media never loaded; appearance confirm/cancel preserves identity; service test has no network')
# Cancel a save by switching section while its simulated request is outstanding.
section(page,'城市');page.locator('.city-list button').filter(has_text='上海').click();click(page,'保存');section(page,'她')
page.wait_for_timeout(700)
assert page.evaluate('JSON.parse(localStorage.getItem("cybergril.demo.v1")).settings.city')=='成都'
section(page,'城市');click(page,'保存');expect(page.get_by_role('status').filter(has_text='已保存在本机演示')).to_be_visible()
assert page.evaluate('JSON.parse(localStorage.getItem("cybergril.demo.v1")).settings.services["对话模型"]')==''
checks.append('Changing section aborts outstanding save; saving city does not commit an unsaved service draft')
page.get_by_role('navigation',name='主导航').get_by_role('button',name='交谈',exact=True).click();click(page,'完整记录')
expect(page.locator('.log-panel')).to_contain_text('<img src=x onerror=alert(1)>')
expect(page.locator('.log-panel img')).to_have_count(0)
checks.append('Imported model text rendered as text, never as HTML')
page.get_by_role('navigation',name='主导航').get_by_role('button',name='此刻',exact=True).click()
page.locator('.demo > button').click();click(page,'长记录');page.locator('.demo > button').click()
page.get_by_role('navigation',name='主导航').get_by_role('button',name='日子',exact=True).click()
expect(page.locator('.ev')).to_have_count(18)
click(page,'看更早的日子');expect(page.locator('.ev')).to_have_count(30)
page.locator('.ev').nth(10).click()
scroll=page.locator('.list').evaluate('el=>el.scrollTop')
click(page,'看那段聊天');click(page,'返回日子')
assert page.locator('.list').evaluate('el=>el.scrollTop')==scroll
checks.append('Long timeline paginates 18 then 12 and retains exact scroll position after chat return')
ctx.close()
bad=browser.new_context();bad.add_init_script('localStorage.setItem("cybergril.demo.v1","{broken")');page=bad.new_page();page.goto(url+'/?demo=1')
expect(page.get_by_role('heading',name='资料暂时读不出来')).to_be_visible();expect(page.locator('.create')).to_have_count(0)
assert page.evaluate('localStorage.getItem("cybergril.demo.v1")')=='{broken'
checks.append('Corrupt persistence never falls back to overwriting first creation')
bad.close()
plain=browser.new_context(viewport={'width':1280,'height':800})
plain.add_init_script('Object.defineProperty(crypto,"randomUUID",{value:undefined})')
page=plain.new_page();page.goto(url+'/?demo=1')
page.get_by_label('她的名字',exact=True).fill('初夏')
click(page,'下一步');click(page,'下一步');click(page,'朋友');click(page,'下一步')
page.get_by_role('checkbox').check();click(page,'确认创建');click(page,'去看看她')
expect(page.locator('.who .name')).to_have_text('初夏')
page.get_by_role('navigation',name='主导航').get_by_role('button',name='交谈',exact=True).click()
page.get_by_role('textbox',name='想和她说的话').fill('HTTP 预览兼容')
click(page,'发送');expect(page.locator('.dialog .her')).to_contain_text('我听见了',timeout=7000)
checks.append('Creation and chat work when secure-context randomUUID is unavailable')
plain.close();browser.close()
finally:
server.shutdown();server.server_close();thread.join()
(ROOT.parents[0]/'docs/frontend/import-results.json').write_text(json.dumps(checks,ensure_ascii=False,indent=2))
for c in checks:print('PASS',c)
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"noEmit":true,"skipLibCheck":true,"resolveJsonModule":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"noUnusedLocals":true,"noUnusedParameters":true},"include":["src"]}