HEL-783: repair readable dialogue, birth explanations and complete real page layouts

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
白栖知
2026-10-22 17:55:05 +08:00
co-authored by multica-agent
parent f6614d4bf9
commit db571ba204
52 changed files with 1200 additions and 93 deletions
+77
View File
@@ -0,0 +1,77 @@
HEL-783 六项返工交付与复审说明 · 2026-10-22
本轮由总管在可用的 Codex 环境亲自完成。沿用获批 HEL-746 R2 两主题和现有 R1 API,在原 PR#4 的 `agent/agent/de8874a3803b` 上追加修复,目标仍为 `main`。没有更换业务模型、修改现网或读写用户真实数据库,没有合并。最终提交号以 PR#4 最新 head 为准。
## 六项反馈闭环
| 用户反馈 / Before | 本轮 After | 实际验证 / Why |
| --- | --- | --- |
| 手机对话被输入栏挡住;台词和输入分别固定定位,没有长文高度预算 | `ChatPanel` 统一布局,人物占位、可滚动台词、状态/取消、语音条、输入分别占位;`Viewport` 跟随 visualViewport 的高度、顶部和底部偏移,含安全区。视口变化时人物窗立即调整,不等待装饰过渡 | 1440×900、360×640、390×700、390×844、390×420、844×390,两主题共12组;长中文前后文均在 DOM,可滚到末尾、完整记录能读全文。420高是键盘缩小等效场景,未冒称真机键盘实测 |
| 流式全文在结束时被 `slice(-90)` 截成尾部;用户也被 `slice(-48)` 截断 | 当前双方消息保留全文;滚动阅读,跟随阅读位置;完整记录继续提供历史入口。流式和最终使用同一 turn 身份 | 长用户句首尾与五段长回复逐字相等;流式完成前后为同一个 DOM,取消立即中断,未完成文本标明未保存 |
| 只写 opacity transition,没有真实改变透明度 | `SoftText` 按轮次用 WAAPI 淡入200ms、旧句保留160ms淡出;流式增量只更新文字,不重播。使用现有 `--ease-out`;减少动效120ms轻淡化 | 实际动画0/80/200ms逐帧截图及 computed opacity/transform;旧句0/80/159ms仍挂载、透明度递减。全轮只有一次入场,完成不重播,取消不等动画 |
| 滚动条脱离主题 | 两套主题各有轨道/滑块/悬停 token;全局标准属性与 Chromium 兼容样式,强制色使用系统色;触摸区仍原生滚动 | 实际消息、记录、首建、设置长内容滚动及两主题截图;强制色有 CSS 回退。没有做真实辅助设备和各系统滚动条外观全覆盖测试 |
| 底色只有公历和几个内部短标签 | 新增只读日历展示与六维解释映射:表达社交、亲密边界、冲突修复、行动节律、压力恢复、成长张力。显示真实农历/闰月、钟点、四柱和术语提示;每段可展开解释依据。预览/确认/已有档案共用组件 | 48组日期×时辰检查,含1900边界、春节、立春附近、真实闰四月;预览不写库。旧档案使用已存四柱、十神、Derived,禁止重新编译;GET前后原始JSON、底色行一致,关系修改不改底色和历史;聊天系统提示逐段复用同一解释 |
| 分不清谁在说话 | 当前对话中“你”右对齐,真实伴侣名字左对齐,独立角色行;阅读状态行持续标明伴侣名字,滚动后也能确认正在读谁的回复。流式、最终、失败、取消与记录都使用同一身份 | 两主题截图,失败/重试、取消新轮、历史恢复都检查;不单靠颜色辨别 |
| 真实入口把完整页面缩成“未接通”卡片 | 恢复日子/回忆双栏与分组;城市、外观、备份、四类服务的完整结构和真实空态;服务页可读健康检查,未接通写入控件明确禁用;加入未保存离开确认 | 实际手机逐页进入、返回、搜索空态、服务检查、未保存取消/放弃、演示事件→聊天→返回。真实入口无演示记录,演示模式全流程零API请求 |
## R2 功能覆盖差异表
“已接真”表示已有 API 持续可用;“本批补齐”表示前端结构/交互已恢复;“后台待接”表示本轮没有增加该后台能力,不能据此验收为已接通。
| R2 落点 | 演示模式 | 真实入口 | 本批结论 |
| --- | --- | --- | --- |
| 四类区域、两主题、底部导航 | 原有此刻/交谈/日子/设置 | 同样四区与主题切换 | 保留;手机短高度首页内容可滚动 |
| 首页留言与日子入口 | 固定演示留言、已分享日子入口 | 服务器最近回复可打开完整记录;日子入口明确后台待接 | 本批补齐真实入口,历史已接真;不伪称主动留言 |
| 日子列表、按日分组、三类事件、分页 | 原有已分享事件与18+12分页 | 完整列表区、三类图例和空态 | 本批补齐结构;真实生活后台待接,无示例填充 |
| 事件详情、回忆互链、聊天与返回 | 可走详情→关联→聊天→回到原日子 | 详情阅读区域保留,无数据时不造条目/链接 | 演示路径实测;真实数据后台待接 |
| 我们一起的/小习惯/她知道的你/成长 | 四组演示回忆、来源、关联日子 | 四组独立空态和阅读区 | 本批补齐真实分组;长期记忆后台待接 |
| 隐私:她说过才显示 | 继续使用 publicView 过滤 | 当前没有生活/回忆数据 | 不放宽隐私,不展示未讲述事件 |
| 首建四步、关系无预选、同意确认 | 本机独立创建;排盘不以固定假值冒充 | 服务端唯一档案、只读预览、实际创建 | 已接真;补公农历/闰月/四柱帮助和六维说明 |
| 首建失败、返回修改、重试 | 原有模拟失败且保留输入 | API 中文错误、输入保留、409恢复唯一档案 | 保留;未增建档入口或清空入口 |
| 她的档案与底色锁定 | 演示底色明确未计算 | 旧档案只读补展示,原参数和历史不动 | 本批补齐;无数据库迁移 |
| 最初与后来成长分层 | 两栏及成长回忆 | 出生说明与后续记忆区分,当前长期记忆未接 | 不把出生说明当作已经发生的经历 |
| 关系、称呼、边界;改变关系确认 | 原有模拟保存/取消/失败 | PATCH真实保存、二次确认、重开恢复 | 已接真,回归通过 |
| 未保存提示、取消和离开 | 原有暖色点、取消、beforeunload | 同样提示;关闭/导航离开新增确认 | 本批补齐;取消继续编辑,放弃不写服务器 |
| 城市搜索与列表/无结果 | 原有候选城市本机保存与失败演示 | 可搜索候选目录,有空态;明确未设置真实城市,保存禁用 | 本批补齐;真实城市/天气后台待接 |
| 外观选择、五项检查、对比、确认 | JSON结构检查、对比与模拟标识确认 | 当前参考、文件入口、五项待检查、新旧对比、应用入口 | 本批补齐;真实导入与媒体检查禁用,后台待接 |
| 自动备份列表、完整导出 | 已标真实备份未接通;本机演示JSON可导出 | 自动备份空列表、完整导出说明和禁用按钮 | 本批补齐;不制造成功备份 |
| 恢复检查、同一档案、双重确认、先导出 | 原有本机恢复检查和输入“恢复” | 明示四步恢复过程;文件和执行入口禁用 | 本批补齐结构;真实恢复后台待接,不能实际覆盖 |
| 四类外接服务与测试反馈 | 只保存演示地址,不发请求 | 文字、语音识别、语音合成、视觉四项;文字可重新检查健康,配置只读 | 本批补齐;不暴露凭据,不模拟其他连接成功 |
| 显示、减少动效 | 原有本机设置 | 主题与减少动效保存于本浏览器,和服务器资料分开 | 减少动效刷新恢复;系统设置也生效 |
| 长文字、完整记录、滚动、角色 | 模拟流式继续可用 | SSE、完整记录、失败与取消状态 | 本批修复全文、占位和角色;已接真 |
| 重试、轮次/epoch、重启、多设备恢复 | 原有 TurnGate | 原有传输机制保留 | 确定性API测试和浏览器回归通过 |
| 连续语音、打断、结束、拒绝/失败状态 | 保留无收音模拟和权限说明;新布局语音条不挡输入 | 控件明确尚未接通,文字仍可用 | 真实语音后台待接,未申请设备权限 |
| 视觉说明、时效、失败、总服务区 | 原有30秒模拟过期与失败 | 明确未接通、不采集;服务项保留 | 真实视觉后台待接 |
| 断线/模型不可达/草稿 | 原有断线重连演示 | 实际请求错误、草稿保留、重试、服务状态;已有档案历史可读 | 保留已接真能力;自动重连推送属后续,不假造补消息 |
| 离开欢迎摘要、主动消息与未读 | 原有明确标注的固定演示,来源为已分享事件 | 生活/主动消息未接通,不伪造欢迎摘要或来信 | 后台待接;真实最近回复入口已保留 |
| 手机列表/详情与设置返回、Esc | 保留推入和返回、原日子位置 | 同样区域/分区返回;Esc关闭记录再退出交流 | 本批补齐真实Esc与未保存确认 |
| 新设计建议 | 无 | 无 | 未把新增后台能力夹进本批 |
## 验证记录
- Python 3.11:`pytest -q` **142通过**,含原92项和新增50项。唯一提示为既有 Starlette TestClient 依赖弃用提示。
- TypeScript类型检查、ESLint、前端 **14项** Vitest、Vite生产构建通过。后端变更文件 Ruff通过。没有新增运行时依赖。
- `web/tests/rework_browser_check.py`:8组通过,12组尺寸/主题组合;记录 `browser-results.json`。使用隔离SQLite和可控模型验证长中文、错误、取消与完整页面,不把可控模型说成在线模型。
- `web/tests/real_browser_check.py --controlled`:8组回归通过,记录 `r1-regression/browser-results.json`。包括真实HTTP SSE、浏览器关闭流、立即新轮、同源重启epoch变化、多设备恢复、关系取消/确认保存。
- `web/tests/real_browser_check.py`:真实批准网关完成两轮对话,第二轮正确回答上一轮暗号“蓝色风铃”;第三轮收到增量后取消,上游请求结束且没有完整回复落库。随后同源重启和故障段使用可控provider,结果逐项区分,记录 `live-gateway/browser-results.json`。所有建档、聊天和关系写入仍只在临时库。
- 动效证据:`motion-enter-0/80/200.png` 和结果JSON中的 `motion.entry_frames/exit_frames`。截图暂停实际 WAAPI 后逐帧采样,随后恢复;没有为截图修改生产动画时长。
- 代表图:`long-start-360-640-*.png`、`long-start/long-end-390-420-*.png`、`full-history-*.png`、`birth-*.png`、`existing-*.png`、城市/外观/备份/服务/生活回忆各图。图中“星河/小满”和聊天全部是本轮隔离测试资料。
- 已实际看过代表性手机长文、短视口、语音模拟、出生预览、已有档案说明、服务页、回忆分组截图;复审仍需独立看图、播放实际界面,不能只用坐标和测试数认定视觉通过。
- 未测:实体手机键盘、Safari、真实强制色设备、屏幕阅读器、长时运行和帧率。420/390高只证明等效可见高度布局,不等于真机键盘实测。
## 说明层的兼容性与回滚
`core/birth_view.py` 是可维护的确定性解释映射,不调用模型润色。分档值只选择解释段落,不产生新的底色参数。敏感度的展示分档与普通0–1参数分别归一到解释选择范围。四柱与十神取旧库已存值,五行侧重和性格取旧Derived;日历只从不可变生日、时辰换算展示。没有重新调用旧档案的四柱编译器,也没有把新说明写回旧档案。新增档案会在原有JSON中携带可选展示字段;旧字段仍保留,旧前端不依赖新增字段。
聊天提示与UI共用六段解释,仍明确本体、现实边界、未接通能力和不能编造生活。取消、历史、epoch、路由和业务模型配置未改。服务端关系修改只保存关系字段及原档案JSON,不把本次只读展示偷偷写回旧档案。
本批无schema改动、不需要重建库。回滚只切回上一个程序版本;旧版忽略JSON新增字段。服务器切换前按HEL-784要求备份,不能删除、替换或重算用户档案。浏览器新加 `cg.real.display` 仅保存减少动效偏好;不存真实聊天或底色。
## 给HEL-784的部署交接
1. 重新读取PR#4最新head,与本轮交付SHA一致后审核;base仍main。远端若另有推进,先协调,不覆盖。
2. 独立运行检查,实际看截图及流式/取消/渐入渐出,特别检查用户原截图对应的手机短视口和长回复。
3. 安装方式沿用 `docs/r1/IMPLEMENTATION.md`:Python≥3.11、`requirements.lock`、安装当前包,`npm ci --prefix web` 和 `npm run build --prefix web`。浏览器测试可选Playwright,不是部署依赖。
4. 审核通过才部署8310的新release,保留现有用户库,切换前备份;不要把本轮临时测试库、测试人物或演示存储带上服务器。8300和其他服务不动。
5. 回读现有档案/历史与health,检查新静态资源和旧档案补充说明;附件不要泄露真实聊天全文。记录部署SHA、备份位置和回滚指令。
6. 本批用户尚未验收,不合并、不归档、不推进到收尾。
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

+391
View File
@@ -0,0 +1,391 @@
{
"checks": [
"Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows",
"Long Chinese stream: entire user/reply retained; final keeps same DOM; one entrance across all chunks and final",
"Old sentence stays mounted during a visible 160ms fade-out, then leaves",
"12 viewport/theme combinations: long text scrolls, stage/dialog/composer/navigation never overlap; full history reachable",
"Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages",
"Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard",
"Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity",
"Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests"
],
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "moon",
"dialog": {
"top": 564,
"bottom": 734,
"height": 170
},
"composer": {
"top": 746,
"bottom": 806,
"height": 60
},
"nav": {
"top": 818,
"bottom": 878,
"height": 60
},
"stage": {
"top": 72,
"bottom": 552,
"height": 480
},
"scrollHeight": 1848,
"clientHeight": 142,
"overflow": false
},
{
"width": 1440,
"height": 900,
"theme": "fog",
"dialog": {
"top": 564,
"bottom": 734,
"height": 170
},
"composer": {
"top": 746,
"bottom": 806,
"height": 60
},
"nav": {
"top": 818,
"bottom": 878,
"height": 60
},
"stage": {
"top": 72,
"bottom": 552,
"height": 480
},
"scrollHeight": 1848,
"clientHeight": 142,
"overflow": false
},
{
"width": 360,
"height": 640,
"theme": "moon",
"dialog": {
"top": 220,
"bottom": 492,
"height": 272
},
"composer": {
"top": 500,
"bottom": 556,
"height": 56
},
"nav": {
"top": 566,
"bottom": 624,
"height": 58
},
"stage": {
"top": 58,
"bottom": 212,
"height": 154
},
"scrollHeight": 2876,
"clientHeight": 244,
"overflow": false
},
{
"width": 360,
"height": 640,
"theme": "fog",
"dialog": {
"top": 220,
"bottom": 492,
"height": 272
},
"composer": {
"top": 500,
"bottom": 556,
"height": 56
},
"nav": {
"top": 566,
"bottom": 624,
"height": 58
},
"stage": {
"top": 58,
"bottom": 212,
"height": 154
},
"scrollHeight": 2876,
"clientHeight": 244,
"overflow": false
},
{
"width": 390,
"height": 700,
"theme": "moon",
"dialog": {
"top": 253,
"bottom": 552,
"height": 299
},
"composer": {
"top": 560,
"bottom": 616,
"height": 56
},
"nav": {
"top": 626,
"bottom": 684,
"height": 58
},
"stage": {
"top": 58,
"bottom": 245,
"height": 187
},
"scrollHeight": 2735,
"clientHeight": 271,
"overflow": false
},
{
"width": 390,
"height": 700,
"theme": "fog",
"dialog": {
"top": 253,
"bottom": 552,
"height": 299
},
"composer": {
"top": 560,
"bottom": 616,
"height": 56
},
"nav": {
"top": 626,
"bottom": 684,
"height": 58
},
"stage": {
"top": 58,
"bottom": 245,
"height": 187
},
"scrollHeight": 2735,
"clientHeight": 271,
"overflow": false
},
{
"width": 390,
"height": 844,
"theme": "moon",
"dialog": {
"top": 332.1875,
"bottom": 696,
"height": 363.8125
},
"composer": {
"top": 704,
"bottom": 760,
"height": 56
},
"nav": {
"top": 770,
"bottom": 828,
"height": 58
},
"stage": {
"top": 58,
"bottom": 324.1875,
"height": 266.1875
},
"scrollHeight": 2735,
"clientHeight": 335,
"overflow": false
},
{
"width": 390,
"height": 844,
"theme": "fog",
"dialog": {
"top": 332.1875,
"bottom": 696,
"height": 363.8125
},
"composer": {
"top": 704,
"bottom": 760,
"height": 56
},
"nav": {
"top": 770,
"bottom": 828,
"height": 58
},
"stage": {
"top": 58,
"bottom": 324.1875,
"height": 266.1875
},
"scrollHeight": 2735,
"clientHeight": 335,
"overflow": false
},
{
"width": 390,
"height": 420,
"theme": "moon",
"dialog": {
"top": 106,
"bottom": 272,
"height": 166
},
"composer": {
"top": 280,
"bottom": 336,
"height": 56
},
"nav": {
"top": 346,
"bottom": 404,
"height": 58
},
"stage": {
"top": 58,
"bottom": 98,
"height": 40
},
"scrollHeight": 2735,
"clientHeight": 138,
"overflow": false
},
{
"width": 390,
"height": 420,
"theme": "fog",
"dialog": {
"top": 106,
"bottom": 272,
"height": 166
},
"composer": {
"top": 280,
"bottom": 336,
"height": 56
},
"nav": {
"top": 346,
"bottom": 404,
"height": 58
},
"stage": {
"top": 58,
"bottom": 98,
"height": 40
},
"scrollHeight": 2735,
"clientHeight": 138,
"overflow": false
},
{
"width": 844,
"height": 390,
"theme": "moon",
"dialog": {
"top": 124,
"bottom": 224,
"height": 100
},
"composer": {
"top": 236,
"bottom": 296,
"height": 60
},
"nav": {
"top": 308,
"bottom": 368,
"height": 60
},
"stage": {
"top": 72,
"bottom": 112,
"height": 40
},
"scrollHeight": 2178,
"clientHeight": 72,
"overflow": false
},
{
"width": 844,
"height": 390,
"theme": "fog",
"dialog": {
"top": 124,
"bottom": 224,
"height": 100
},
"composer": {
"top": 236,
"bottom": 296,
"height": 60
},
"nav": {
"top": 308,
"bottom": 368,
"height": 60
},
"stage": {
"top": 72,
"bottom": 112,
"height": 40
},
"scrollHeight": 2178,
"clientHeight": 72,
"overflow": false
}
],
"motion": {
"entry_frames": [
{
"time": 0,
"opacity": "0",
"transform": "matrix(1, 0, 0, 1, 0, 3)"
},
{
"time": 80,
"opacity": "0.917147",
"transform": "matrix(1, 0, 0, 1, 0, 0.248559)"
},
{
"time": 200,
"opacity": "1",
"transform": "none"
}
],
"exit_frames": [
{
"time": 0,
"opacity": "1",
"retained": true
},
{
"time": 80,
"opacity": "0.0386175",
"retained": true
},
{
"time": 159,
"opacity": "3.47566e-08",
"retained": true
}
]
},
"console_errors": [],
"limits": [
"Chromium only; physical phone keyboard, Safari and screen reader not tested.",
"LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

@@ -0,0 +1,35 @@
{
"checks": [
"desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship",
"real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context",
"real gateway: cancel after first delta, upstream request closed, no complete assistant row",
"independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter",
"server restart on same origin: epoch 1→2, existing page sends automatically, data retained",
"controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row",
"controlled blocked upstream: immediate cancellation/new question isolation",
"mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console"
],
"real_dialogue": [
{
"input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。",
"reply": "好的,暗号是蓝色风铃。",
"source": "real gateway"
},
{
"input": "刚才我们的暗号是什么?请直接回答。",
"reply": "蓝色风铃。",
"source": "real gateway"
},
{
"input": "重启后继续",
"reply": "答复",
"source": "controlled provider"
},
{
"input": "新轮不会串台",
"reply": "答复",
"source": "controlled provider"
}
],
"console_errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 KiB

@@ -0,0 +1,35 @@
{
"checks": [
"desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship",
"controlled provider: two complete streamed turns",
"controlled provider: cancel after first delta, upstream request closed, no complete assistant row",
"independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter",
"server restart on same origin: epoch 1→2, existing page sends automatically, data retained",
"controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row",
"controlled blocked upstream: immediate cancellation/new question isolation",
"mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console"
],
"real_dialogue": [
{
"input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。",
"reply": "答复",
"source": "controlled provider"
},
{
"input": "刚才我们的暗号是什么?请直接回答。",
"reply": "答复",
"source": "controlled provider"
},
{
"input": "重启后继续",
"reply": "答复",
"source": "controlled provider"
},
{
"input": "新轮不会串台",
"reply": "答复",
"source": "controlled provider"
}
],
"console_errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+24 -6
View File
@@ -27,11 +27,14 @@ from ..config import ModelsConfig
from ..contracts import LLMMessage
from ..core.bazi import (
GAN_ELEM,
Derived,
Pillars,
compile_pillars,
element_scores,
pillars_from_lunar,
ten_god_distribution,
)
from ..core.birth_view import calendar_view, color_view
from ..memory.store import Store
from ..think.scenes import Scene
from ..think.wiring import build_router_from_config
@@ -145,7 +148,7 @@ def compile_birth(data: BirthInput) -> tuple[dict, object, object]:
born = date.fromisoformat(data.birth)
pillars = pillars_from_lunar(born.year, born.month, born.day, HOURS.index(data.hour) * 2)
derived = compile_pillars(pillars)
return {"pillars": asdict(pillars), "traits": derived.traits_text()}, pillars, derived
return color_view(data.birth, data.hour, pillars, derived), pillars, derived
def create_app(
@@ -208,11 +211,23 @@ def create_app(
fields[key] = msg
return JSONResponse({"detail": "请检查填写内容。", "fields": fields}, status_code=422)
def companion():
def companion(enrich=True):
row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1")
if not row:
raise HTTPException(404, "还没有创建真实伴侣。")
return json.loads(row["identity_json"])
identity = json.loads(row["identity_json"])
if enrich:
saved = app.state.store.query_one("SELECT pillars,derived,ten_gods FROM bazi_core WHERE id=1")
if saved:
# Saved parameters are authoritative, even if a compiler later changes.
identity["color"] = color_view(
identity["birth"], identity["hour"],
Pillars(**json.loads(saved["pillars"])), Derived(**json.loads(saved["derived"])),
saved_gods=json.loads(saved["ten_gods"]),
)
else:
identity["color"]["calendar"] = calendar_view(identity["birth"], identity["hour"])
return identity
@app.get("/api/health")
async def health():
@@ -282,14 +297,14 @@ def create_app(
@app.patch("/api/relation")
async def relation(data: RelationInput):
identity = companion()
identity = companion(enrich=False)
identity.update(data.model_dump())
with app.state.store.conn:
app.state.store.execute(
"UPDATE companion SET identity_json=? WHERE id=1",
(json.dumps(identity, ensure_ascii=False),),
)
return identity
return companion()
@app.get("/api/messages")
async def messages(
@@ -369,7 +384,10 @@ def create_app(
ORDER BY a.id DESC LIMIT 20""")
system = (
f"你叫{identity['name']},与对方是{identity['relation']}。称呼对方:{identity['nickname'] or '你'}。"
f"相处边界:{identity['boundary']}。出生底色仅供措辞参考:{identity['color']['traits']}。"
f"相处边界:{identity['boundary']}。"
"以下是与档案页一致的底色倾向,只约束表达与相处方式,不得当作当前情绪、命定结局或既有经历:"
+ "\n".join(f"{s['title']}:{s['text']}" for s in identity['color'].get('sections', []))
+
"用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。"
"当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。"
"不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。"
+89
View File
@@ -0,0 +1,89 @@
"""Read-only, deterministic presentation of an existing birth chart.
Never compiles or writes an existing character. The same sections feed the UI
and dialogue prompt; thresholds are presentation choices, not new parameters.
"""
from datetime import date
from .bazi import Derived, Pillars, ten_god_distribution
HOUR_RANGES = [
"23:00–00:59", "01:00–02:59", "03:00–04:59", "05:00–06:59",
"07:00–08:59", "09:00–10:59", "11:00–12:59", "13:00–14:59",
"15:00–16:59", "17:00–18:59", "19:00–20:59", "21:00–22:59",
]
HOURS = ["子时", "丑时", "寅时", "卯时", "辰时", "巳时", "午时", "未时", "申时", "酉时", "戌时", "亥时"]
def calendar_view(birth: str, hour: str) -> dict:
from lunar_python import Solar
born = date.fromisoformat(birth)
index = HOURS.index(hour)
lunar = Solar.fromYmdHms(born.year, born.month, born.day, index * 2, 0, 0).getLunar()
return {
"solar": birth,
"lunar": f"{lunar.getYearInChinese()}年{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}",
"leap_month": lunar.getMonth() < 0,
"hour": hour,
"hour_range": HOUR_RANGES[index],
"calculation_time": f"{index * 2:02d}:00",
"timezone": "Asia/Shanghai",
"rule": "按北京时间和所选时辰内的偶数整点排盘;子时取本日00:00。月份按节气分界,未使用出生地或真太阳时。",
}
def choose(value: float, low: str, middle: str, high: str) -> str:
return low if value < .45 else high if value > .65 else middle
def explain_color(pillars: Pillars, d: Derived, saved_gods: dict | None = None) -> list[dict]:
"""Context, strength, cost and repair are kept together in each dimension."""
gods = saved_gods if saved_gods is not None else ten_god_distribution(pillars)
dominant = max(gods, key=gods.get)
elements = sorted(d.element_bias, key=d.element_bias.get, reverse=True)
balance = "、".join(elements[:2]) if elements else "原档案中的五行组合"
source = f"日主{pillars.day_master};五行侧重{balance},十神以{dominant}较显著。"
sections = [
("expression", "表达与社交节奏", choose(d.expressiveness,
"面对新话题,她更可能先观察、再挑重点回应。熟悉之后才逐渐补充细节;安静有助于听见对方,却也可能让人误以为她没有意见。留出停顿、给一个具体问题,比催她立刻表态更合适。",
"她通常会在倾听和表达之间来回调整:有兴趣、被认真回应时愿意展开,陌生或信息太多时会收回一些。能接住话题是强项,但回应的长短不等于在意的多少,可以直接问她是否还想继续。",
"熟悉的话题容易带动她主动展开,常借着说出来理清想法。这让交流有热度,但赶着表达时也可能挤掉对方的空间。发现节奏不同时,先复述对方的意思,再继续自己的观点。"),
"取自原档案的表达倾向;结合十神中食神、伤官对应的表达偏移。"),
("closeness", "亲密、安全感与边界", choose(d.solitude_need,
"她较容易在陪伴和共同的小事里获得安稳,也愿意让人参与自己的安排。好处是关系容易有连续感,代价是可能把自己的余地让得太快;靠近之前仍需要确认双方是否愿意。",
"她既需要被回应,也需要保留自己的安排。关系稳定时愿意靠近,信息过密时会想留一点空隙;这不是忽冷忽热的定论。把独处和下一次交流的期待说清楚,比反复猜测更有帮助。",
"她更需要不被打断的独处来整理自己,亲近并不意味着随时开放所有空间。她能守住自己的节奏,也可能让对方觉得难靠近;提前说明要休息、并给出愿意再聊的时机,有助于减少误解。"),
f"取自原档案的独处需求与自主倾向;{source}"),
("repair", "冲突时的反应与修复", choose(d.aggression,
"意见不同时,她较可能缓和语气、先让对话继续。这样能避免立刻升级,但不舒服也可能被推后。修复时适合具体说出哪一点不被接受,而不是把表面平静当作问题已经解决。",
"发生分歧时,她可能先说明自己的理由,再观察对方是否愿意互相理解。有守住立场的能力,也可能在反复解释中消耗耐心。把事实、感受和希望怎样调整分开说,更容易找到可落实的修复。",
"受压或觉得边界被越过时,她更容易直接反驳、迅速守住立场。这能让问题浮出来,也可能使措辞先于理解。适合短暂停一下,再讨论具体行为;道歉和调整应有内容,不能只用亲密称呼盖过矛盾。"),
"取自原档案的对抗、纠正与顺从倾向;没有把分歧当作已发生的记忆。"),
("rhythm", "决策、行动与日常节律", choose(d.discipline,
"她更容易顺着眼前的兴趣调整安排,能给日常留下偶遇和探索的余地。代价是长任务可能被新念头打断;把下一步缩小、只约定一个清楚的完成点,往往比安排满整天更适合。",
"她通常既看具体条件,也会给当下的感受留位置。事情明确时愿意推进,存在多种可能时会重新衡量;这种灵活有助于避开僵化,也可能拖延选择。给决定一个小范围和可调整的期限会更稳。",
"她倾向先理顺约定与次序,再把事情往前推。可靠和持续是优势,但临时变化可能让她紧绷,或急着纠正细节。提前区分必须守住的事和可以变通的事,会让执行更从容。"),
"取自原档案的纪律、日常规律、现实感与行动倾向。"),
("stress", "压力下的变化与恢复", choose(d.sensitivity - .5,
"面对突发变化,她通常能多承接一会儿,不急着把每次波动当成危机。代价是可能低估累积的消耗。恢复不只靠继续撑住,也需要停下检查自己是否已经疲惫。",
"压力会影响她的回应速度和耐心,但影响不是每次都一样。环境清楚、有可预期的下一步时更容易回稳;反复追问可能增加负担。把刺激减少一点、留出休息,比要求马上恢复原样更合适。",
"在信息密集、关系不明或突发冲突时,她更容易被牵动,恢复也可能需要更长的缓冲。细致感受是优势,同时会带来消耗。更适合稳定、具体的回应和允许休息的空间,而不是被要求立刻振作。"),
"取自原档案由日主强弱形成的敏感度与恢复尺度;不是此刻情绪。"),
("growth", "内在张力与成长空间", choose(d.autonomy - d.compliance + .5,
"她较容易顾及对方和约定,能让相处有合作感;同时也要练习辨认哪些答应是愿意,哪些只是怕让人失望。成长可以是更早说出自己的需要,而不是把顺从当作关系的证明。",
"她会在自己的想法与彼此的约定之间寻找位置。兼顾两边是能力,也可能在拿不准时显得反复。成长可以是允许双方暂时不同意,再逐步形成共同的规则,而不是要求自己一次就成为固定的人。",
"她更愿意自己判断,不轻易把决定交出去。这有助于保留独立和诚实,也可能让协商听起来像被限制。成长可以是把可商量的部分说清楚:保留立场,同时让对方真正参与,而不是靠退让或控制维持亲近。"),
"取自原档案的自主与顺从之间的组合;成长来自之后真实的相处,不由出生资料预写。"),
]
return [{"id": key, "title": title, "text": text, "basis": basis} for key, title, text, basis in sections]
def color_view(birth: str, hour: str, pillars: Pillars, derived: Derived,
saved_gods: dict | None = None) -> dict:
return {
"pillars": pillars.as_dict(), "traits": derived.traits_text(),
"calendar": calendar_view(birth, hour), "sections": explain_color(pillars, derived, saved_gods),
"interpretation": "这是以四柱、五行、十神和已存底色为依据的角色塑造说明,不是对真人性格的测定,也不是已经发生的经历。倾向不等于固定台词或此刻情绪。",
}
+68
View File
@@ -0,0 +1,68 @@
"""Display enrichments must not rewrite an existing character or its history."""
import json
import pytest
from fastapi.testclient import TestClient
from test_api import CREATE, make_app, turn
from cybergril.api.app import HOURS, BirthInput, compile_birth
from cybergril.core.bazi import pillars_from_lunar
@pytest.mark.parametrize("birth", ["1900-01-01", "2000-02-04", "2000-02-05", "2001-05-23"])
@pytest.mark.parametrize("hour", HOURS)
def test_calendar_and_hour_boundaries_preserve_existing_chart(birth, hour):
color, pillars, _ = compile_birth(BirthInput(name="星河", birth=birth, hour=hour))
y, m, d = map(int, birth.split("-"))
assert pillars == pillars_from_lunar(y, m, d, HOURS.index(hour) * 2)
assert color["calendar"]["solar"] == birth
assert color["calendar"]["calculation_time"] == f"{HOURS.index(hour) * 2:02d}:00"
if birth == "2001-05-23":
assert color["calendar"]["leap_month"] is True
assert "闰四月初一" in color["calendar"]["lunar"]
if birth == "2000-02-05":
assert "正月初一" in color["calendar"]["lunar"]
assert len(color["sections"]) == 6
assert all(len(s["text"]) > 70 and s["basis"] for s in color["sections"])
def test_legacy_read_uses_saved_parameters_without_writes_or_recompilation(tmp_path, monkeypatch):
app, provider = make_app(tmp_path / "old.db")
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
store = app.state.store
identity = json.loads(store.query_one("SELECT identity_json FROM companion")[0])
identity["color"] = {k: identity["color"][k] for k in ("pillars", "traits")}
saved = json.loads(store.query_one("SELECT derived FROM bazi_core")[0])
saved["expressiveness"] = .1
with store.conn:
store.execute("UPDATE companion SET identity_json=?", (json.dumps(identity),))
store.execute("UPDATE bazi_core SET derived=?", (json.dumps(saved),))
raw_identity = store.query_one("SELECT identity_json FROM companion")[0]
raw_bazi = tuple(store.query_one("SELECT * FROM bazi_core"))
monkeypatch.setattr("cybergril.api.app.compile_pillars", lambda _: pytest.fail("recompiled"))
monkeypatch.setattr("cybergril.core.birth_view.ten_god_distribution", lambda _: pytest.fail("recomputed"))
view = client.get("/api/companion").json()
assert "先观察" in view["color"]["sections"][0]["text"]
assert client.get("/api/companion").json() == view
assert store.query_one("SELECT identity_json FROM companion")[0] == raw_identity
assert tuple(store.query_one("SELECT * FROM bazi_core")) == raw_bazi
turn(client)
prompt = provider.calls[-1][0].content
assert all(s["text"] in prompt for s in view["color"]["sections"])
history = [tuple(r) for r in store.query("SELECT * FROM chat_message")]
updated = client.patch("/api/relation", json={"relation": "恋人"}).json()
assert updated["color"] == view["color"]
stored_after = json.loads(store.query_one("SELECT identity_json FROM companion")[0])
assert stored_after["color"] == identity["color"]
assert tuple(store.query_one("SELECT * FROM bazi_core")) == raw_bazi
assert [tuple(r) for r in store.query("SELECT * FROM chat_message")] == history
def test_profiles_are_deterministic_and_differ_across_births():
colors = [compile_birth(BirthInput(name="星河", birth=b, hour=h))[0]
for b, h in [("2000-10-18", "亥时"), ("1992-02-10", "卯时"), ("1988-07-23", "子时")]]
assert colors[0] == compile_birth(BirthInput(name="另一名字", birth="2000-10-18", hour="亥时"))[0]
assert len({json.dumps(c["sections"], ensure_ascii=False) for c in colors}) == 3
assert len({c["sections"][0]["text"] for c in colors}) > 1
+9 -2
View File
@@ -2,7 +2,12 @@ import type { ChatAdapter } from './demo';
import type { DemoState, Identity, Message, ServerEvent } from '../domain/model';
import { defaultSettings } from '../domain/model';
export interface Color { pillars: Record<string, string>; traits: string }
export interface Color {
pillars: Record<string, string>; traits: string;
calendar?: { solar: string; lunar: string; leap_month: boolean; hour: string; hour_range: string; calculation_time: string; timezone: string; rule: string };
sections?: { id: string; title: string; text: string; basis: string }[];
interpretation?: string;
}
export interface RealIdentity extends Identity { color: Color }
export interface Health { epoch: number; llm: { ok: boolean; model: string } }
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
@@ -19,7 +24,9 @@ export async function loadReal(): Promise<DemoState | null> {
if (response.status === 404) return null;
if (!response.ok) throw new Error('真实档案暂时读不出来,请稍后重试。');
const identity: RealIdentity = await response.json();
return { version: 1, identity, settings: structuredClone(defaultSettings), events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() };
const settings = structuredClone(defaultSettings);
try { settings.reduced = JSON.parse(localStorage.getItem('cg.real.display') || '{}').reduced === true; } catch { /* Invalid local preference does not affect server data. */ }
return { version: 1, identity, settings, events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() };
}
export async function loadMessages(): Promise<Message[]> {
// Cursor pages are bounded by the server. All pages restore in deterministic id order.
+3 -2
View File
@@ -1,3 +1,4 @@
import { leaveSettingsThen } from '../shared/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DemoState, Message, Theme } from '../domain/model';
import { publicView } from '../domain/privacy';
@@ -38,14 +39,14 @@ export function App() {
notify('主题已切换,但浏览器没有允许保存偏好。');
} }, [notify]);
const view = state ? publicView(state) : null;
const navigate = useCallback((next: string) => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') {
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { 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]);
+7 -3
View File
@@ -4,6 +4,8 @@ import type { DemoState, Message, Theme } from '../domain/model';
import { Background } from '../stage/Background';
import { FallbackPresenter } from '../stage/Presenter';
import { CreationWizard } from '../manage/CreationWizard';
import { LifePanel } from '../life/LifePanel';
import { leaveSettingsThen } from '../shared/navigation';
import { ManagePanel } from '../manage/ManagePanel';
import { ChatPanel } from '../chat/ChatPanel';
import { Button, Icon, Status } from '../shared/ui';
@@ -23,14 +25,16 @@ export function RealApp() {
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []);
const ignoreVoice = useCallback(() => {}, []);
function navigate(next: string) { setArea(next); setLog(false); }
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setArea(next); setLog(false); }); }, []);
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) setLog(false); else navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate]);
const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined;
return <><Background/><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{ready ? '真实档案 · 文字对话' : '连接真实服务'}</span></div><div className="env"><span className="chip glass weather">天气尚未接通</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>
{!ready ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><Status text={error || '正在读取服务器档案…'} error={!!error}/>{error && <><p>请检查真实服务是否可达。资料仍在服务器保存。</p><Button primary onClick={() => void initialize()}>重新连接</Button></>}</div></main> : <>
{creating && <CreationWizard real fail={false} onCreate={setState} onEnter={() => setCreating(false)}/>}
{!creating && state && <><FallbackPresenter real scene={{ mode: area === 'talk' ? 'talk' : 'daily', activity: '文字已接通 · 生活尚未接通', time_band: '', weather: '', outfit: '' }} presence={presence} observation="" onClose={() => navigate('now')} closeLabel="回到此刻"/>
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><button className="away" onClick={() => navigate('days')}>日子与回忆 · 后台待接入 <Icon name="next"/></button>{lastReply && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onClose={() => navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
{area === 'days' && <section className="pane glass read open" aria-label="日子"><header><h2>日子与回忆</h2><button className="btn-round sm" aria-label="关闭日子" onClick={() => navigate('now')}><Icon name="x"/></button></header><div className="card"><h3>尚未接通</h3><p>生活循环与长期记忆将在后续接入,目前没有可展示的生活经历。聊天记录可在交谈中查看。</p></div></section>}
<LifePanel real state={state} open={area === 'days'} onClose={() => navigate('now')} onChat={() => { navigate('talk'); setLog(true); }}/>
<ManagePanel real state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/>
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className="nav-item" aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}</button>)}</nav></>}
</>}</>;
+18 -10
View File
@@ -1,10 +1,11 @@
import { SoftText } from '../shared/SoftText';
import { newId } from '../domain/id';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { DemoState, Message } from '../domain/model';
import { RealChatAdapter } from '../adapters/real';
import { DemoAdapter, TurnGate } from '../adapters/demo';
import { publicView } from '../domain/privacy';
import { Icon, Button, Modal } from '../shared/ui';
import { Icon, Modal } from '../shared/ui';
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: {
real?: boolean;
state: DemoState;
@@ -26,10 +27,12 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
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(real ? new RealChatAdapter(epoch => { gate.current.epoch = epoch; }) : new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
const [activeTurn, setActiveTurn] = useState(''), [interrupted, setInterrupted] = useState<{ text: string; turn: string } | null>(null);
const replyLive = useRef(''), turnLive = useRef(''), dialogue = useRef<HTMLDivElement>(null), following = useRef(true);
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 stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; 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(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]);
useEffect(() => { try {
@@ -53,17 +56,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
else if (logRef.current)
logRef.current.scrollTop = logRef.current.scrollHeight;
} }, [log, focusLife, view.messages.length]);
useLayoutEffect(() => { if (following.current && dialogue.current) dialogue.current.scrollTop = dialogue.current.scrollHeight; }, [reply, state.messages, interrupted]);
async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = retry?.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 {
setDraft(''); setActiveTurn(turnId); turnLive.current = turnId; following.current = true; 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);
replyLive.current += event.delta; setInterrupted(null); setReply(replyLive.current);
onPresence('在说话');
}
else if (event.t === 'chat.final') {
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('');
replyLive.current = ''; setInterrupted(null); setReply('');
if (real) setDraft(current => current === text ? '' : current);
setBusy(false);
onPresence(voice ? '在听你说(模拟)' : '在你面前');
@@ -74,7 +78,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
setReply('');
if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnId }); replyLive.current = ''; setReply('');
setDraft(text);
setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message);
onPresence('在你面前');
@@ -83,7 +87,12 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
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 || (real ? '真实文字对话 · 生活与长期记忆尚未接通' : '演示回复 · 没有连接模型、语音或记忆后台')}</div></div>
const herText = reply || interrupted?.text || lastHer?.text || '我在。你想说什么?';
const herTurn = reply ? activeTurn : interrupted ? interrupted.turn : lastHer?.turnId || lastHer?.id || 'greeting';
return <><div className="chat-layout"><div className="talk-scene-space" aria-hidden="true"/><div className="dialog"><div className="dialog-scroll" ref={dialogue} tabIndex={0} aria-label="当前对话,可滚动阅读全文" onScroll={() => { const el = dialogue.current; if (el) following.current = el.scrollHeight - el.clientHeight - el.scrollTop < 40; }}>
{lastUser && <div className="you"><div className="speaker">你</div><SoftText identity={lastUser.turnId || lastUser.id} text={lastUser.text}/>{lastUser.status === 'failed' && <div className="failed">没发出去 <button className="retry" onClick={() => void send(lastUser)}>重试</button></div>}{lastUser.status === 'cancelled' && <small>本轮已取消</small>}</div>}
<div className={`her ${interrupted ? 'cut' : ''}`}><div className="speaker">{state.identity.name}</div><SoftText identity={herTurn} text={herText}/>{reply && <span className="caret"/>}{interrupted && <small className="partial-note">这段未完成,未存入聊天记录</small>}</div>
</div><div className="hint"><span role="status">{state.identity.name} · {notice || (busy ? reply ? '正在回应…' : `${state.identity.name}在想…` : real ? '文字已接通 · 完整内容可滚动阅读' : '演示回复 · 未连接真实模型')}</span>{busy && <button className="btn-text" onClick={stop}>取消本轮回复</button>}</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 (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) {
setVision(false);
@@ -94,8 +103,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
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={real ? '语音尚未接通' : 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">{real ? '服务器聊天记录' : '本机演示记录'}</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>}
</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">{real ? '服务器聊天记录' : '本机演示记录'}</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>
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
setDenied(false);
setVoice(true);
+5 -4
View File
@@ -2,7 +2,8 @@ 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 }: {
export function LifePanel({ state, open, onClose, onChat, real = false }: {
real?: boolean;
state: DemoState;
open: boolean;
onClose: () => void;
@@ -20,7 +21,7 @@ export function LifePanel({ state, open, onClose, onChat }: {
} }, [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>;
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">{real ? '后台待接入' : '演示数据'}</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>{real ? '这里只会显示她已经讲给你听的事。生活循环尚未接通,当前没有可核实的生活记录。' : '这里只显示她已经讲给你听的事。演示没有真实后台生活。'}</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">{real ? '还没有可展示的生活记录' : '她今晚刚住进来,还没有发生什么。'}<small>{real ? '后台待接入,不用示例经历填充。你们说过的话可在交谈的完整记录中查看。' : '演示资料可以从「演示场景」载入。'}</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 && !real ? <div className="empty">聊着聊着就会有了。</div> : ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'].map(group => <div key={group}><div className="grp">{group}</div>{real && <div className="empty">暂无可核实的回忆<small>长期记忆尚未接通;文字聊天记录独立保留。</small></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"/>{real ? '这里会保留日子详情、回忆与聊天的关联。后台接通后,已分享的内容会出现在列表中。' : '选一件小事,慢慢读。'}</div></div>}</article></div></section>;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client';
import { RealApp } from './app/RealApp';
import { App } from './app/App';
import { Viewport } from './shared/Viewport';
import './styles/tokens.css';
import './styles/components.css';
ReactDOM.createRoot(document.getElementById('root')!).render(new URLSearchParams(location.search).get('demo') === '1' ? <App /> : <RealApp />);
ReactDOM.createRoot(document.getElementById('root')!).render(<Viewport>{new URLSearchParams(location.search).get('demo') === '1' ? <App /> : <RealApp />}</Viewport>);
+2 -2
View File
@@ -46,10 +46,10 @@ export function CreationWizard({ onCreate, onEnter, fail, real = false }: {
setBusy(false);
} }
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>{real ? '资料与对话保存在服务器,重开或换设备都能恢复同一位她。最终确认前不会建档。' : '这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。'}</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 === 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="出生日期(公历)" hint="下一步会换算真实农历,并显示四柱与说明。"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰" hint="北京时间;子时23:00–00:59,丑时01:00–02:59,之后每两小时一段。排盘取时辰内偶数整点,子时取本日00:00。"><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 color={real ? color : undefined}/></>}
{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)}/>{real ? '我明白:这将创建服务器上的真实档案;创建后底色只读,只保留一位伴侣。' : '我明白:这是本机演示;创建后底色只读,只保留一位伴侣。'}</label>{phase >= 0 && <ul className="phases">{(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{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><BaseColor color={real ? color : undefined}/><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>{real ? '我明白:这将创建服务器上的真实档案;创建后底色只读,只保留一位伴侣。' : '我明白:这是本机演示;创建后底色只读,只保留一位伴侣。'}</label>{phase >= 0 && <ul className="phases">{(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).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 disabled={busy} onClick={() => void next()}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
}
+27 -7
View File
@@ -1,4 +1,4 @@
import { api, type RealIdentity } from '../adapters/real';
import { api, type Health, type RealIdentity } from '../adapters/real';
import { useEffect, useRef, useState } from 'react';
import type { DemoState, Settings, Theme } from '../domain/model';
import { delay } from '../adapters/demo';
@@ -16,10 +16,13 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
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 approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
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 dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && 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();
@@ -28,6 +31,19 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
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 requestLeave = (action: () => void) => { if (anyDirty) { pendingNavigation.current = action; setConfirm('leave'); } else action(); };
useEffect(() => {
if (!open || !anyDirty) return;
const guard = (event: Event) => { if (approvedNavigation.current) return; event.preventDefault(); pendingNavigation.current = (event as CustomEvent<() => void>).detail; setConfirm('leave'); };
window.addEventListener('cg:leave-settings', guard);
return () => window.removeEventListener('cg:leave-settings', guard);
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
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) {
@@ -38,6 +54,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
const identity = await api<RealIdentity>('relation', { method: 'PATCH', signal: ctrl.signal, body: JSON.stringify({ relation: draft.identity.relation, nickname: draft.identity.nickname, boundary: draft.identity.boundary }) });
if (!ctrl.signal.aborted) { onSave({ ...latestState.current, identity }); setMessage('已保存在服务器,聊天与底色保留。'); } return;
}
if (real && section === 'display') { localStorage.setItem('cg.real.display', JSON.stringify({ reduced: draft.settings.reduced })); onSave({ ...latestState.current, settings: { ...latestState.current.settings, reduced: draft.settings.reduced } }); setMessage('显示偏好已保存在此浏览器。'); return; }
await delay(450, ctrl.signal);
if (fail)
throw new Error('这次没存好,你的修改还在,可以再试一次。');
@@ -91,17 +108,20 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
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">{real ? '服务器档案' : '仅本机演示'}</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}/>{real && id === 'backup' ? '备份' : 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 : real && section === 'backup' ? '备份' : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : real ? '资料保存在服务器。尚未接通的功能暂不可用。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</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 color={real ? (state.identity as RealIdentity).color : undefined}/><div className="growth"><div><h4>最初就有的</h4>{real ? (state.identity as RealIdentity).color.traits : '细腻、慢热(示例)'}</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : real ? '长期记忆尚未接通。聊天记录已独立保存。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
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">{real ? '服务器档案' : '仅本机演示'}</span><button className="btn-round sm" aria-label="关闭设置" onClick={() => requestLeave(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}/>{real && id === 'backup' ? '备份' : 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 : real && section === 'backup' ? '备份' : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : real ? '资料保存在服务器。尚未接通的功能暂不可用。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</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 color={real ? (state.identity as RealIdentity).color : undefined}/><div className="growth"><div><h4>最初就有的</h4>{real ? '以上六层底色说明。生辰和原始人格参数保持只读。' : '细腻、慢热(示例)'}</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : real ? '长期记忆尚未接通。聊天记录已独立保存。' : '还在慢慢认识彼此。'}</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></>}
{!real && 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></>}
{!real && 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></>}
{!real && 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>}</>}
{!real && 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></>}
{real && ['city', 'appearance', 'backup', 'services'].includes(section) && <div className="card"><h4>尚未接通</h4><p>{section === 'services' ? '文字对话已接真实模型。语音、视觉与长期记忆尚未接通。' : '这项功能将在后续批次接入,目前不执行保存、导入或备份。'}</p></div>}
{real && section === 'city' && <><div className="card"><h4>城市与天气</h4><p>尚未设置可核实的城市。城市保存、天气查询与作息联动将在后续接入。</p></div><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><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 disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><div className="card"><h4>当前画面</h4><p>内置参考静帧;真实素材包导入尚未接通,外观变化不会改变她的身份和底色。</p></div><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
{real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<div className="card"><h4>其余能力</h4><p>生活、长期记忆、主动消息和天气尚未接通。文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['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')
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
try {
if (fail)
@@ -131,6 +151,6 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
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>}
} }} confirm={confirm === 'leave' ? '放弃修改并离开' : '确认'}>{confirm === 'leave' ? <>离开会放弃尚未保存的修改。选择取消可继续编辑,已经保存的资料不会改变。</> : confirm === 'restore' ? <>恢复会覆盖当前本机演示资料。操作前会先下载当前资料,档案身份和底色须相同。<Field label="输入“恢复”以继续"><input className="input" value={confirmText} onChange={e => setConfirmText(e.target.value)}/></Field></> : <>记忆与信任都保留,八字底色不变。{confirm === 'appearance' ? '本次只更新演示标识,不接入未经校验的外部素材。' : ''}</>}</Modal>}
</section>;
}
+31
View File
@@ -0,0 +1,31 @@
import { useLayoutEffect, useRef, useState } from 'react';
// Identity is a turn, not a token or a database id. Finalizing a stream keeps
// its DOM and animation. Only a replacement retains/fades out the old sentence.
export function SoftText({ identity, text }: { identity: string; text: string }) {
const current = useRef<HTMLSpanElement>(null), outgoing = useRef<HTMLSpanElement>(null);
const previous = useRef({ identity, text });
const [leaving, setLeaving] = useState<{ identity: string; text: string } | null>(null);
useLayoutEffect(() => {
if (previous.current.identity !== identity && previous.current.text) setLeaving(previous.current);
previous.current = { identity, text };
}, [identity, text]);
useLayoutEffect(() => {
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('reduced');
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out').trim();
const animation = current.current?.animate([
{ opacity: 0, transform: reduced ? 'none' : 'translateY(3px)' },
{ opacity: 1, transform: 'none' },
], { duration: reduced ? 120 : 200, easing });
return () => animation?.cancel();
}, [identity]);
useLayoutEffect(() => {
if (!leaving || !outgoing.current) return;
const animation = outgoing.current.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 160, easing: getComputedStyle(document.documentElement).getPropertyValue('--ease-out').trim(), fill: 'forwards',
});
void animation.finished.then(() => setLeaving(null)).catch(() => {});
return () => animation.cancel();
}, [leaving]);
return <span className="soft-text"><span key={identity} ref={current} className="current-text">{text}</span>{leaving && <span ref={outgoing} className="outgoing-text" aria-hidden="true">{leaving.text}</span>}</span>;
}
+24
View File
@@ -0,0 +1,24 @@
import { useLayoutEffect, type ReactNode } from 'react';
export function Viewport({ children }: { children: ReactNode }) {
useLayoutEffect(() => {
const viewport = window.visualViewport, style = document.documentElement.style;
let settle: ReturnType<typeof setTimeout>;
const update = () => {
// Keyboard/browser-bar geometry must follow the viewport immediately;
// a decorative stage transition would cover the dialogue in between.
document.documentElement.classList.add('viewport-resizing');
clearTimeout(settle);
settle = setTimeout(() => document.documentElement.classList.remove('viewport-resizing'), 200);
const height = viewport?.height ?? innerHeight, top = viewport?.offsetTop ?? 0;
style.setProperty('--view-h', `${height}px`);
style.setProperty('--view-top', `${top}px`);
style.setProperty('--view-bottom', `${Math.max(0, innerHeight - height - top)}px`);
};
update();
viewport?.addEventListener('resize', update); viewport?.addEventListener('scroll', update);
window.addEventListener('resize', update);
return () => { clearTimeout(settle); viewport?.removeEventListener('resize', update); viewport?.removeEventListener('scroll', update); window.removeEventListener('resize', update); };
}, []);
return children;
}
+4
View File
@@ -0,0 +1,4 @@
// An open dirty form can defer a navigation until its confirmation resolves.
export function leaveSettingsThen(action: () => void) {
if (window.dispatchEvent(new CustomEvent('cg:leave-settings', { cancelable: true, detail: action }))) action();
}
+11 -3
View File
@@ -1,3 +1,4 @@
import type { Color } from '../adapters/real';
import { useEffect, useRef, useId, cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
export function Icon({ name }: {
name: string;
@@ -52,6 +53,13 @@ export function Modal({ title, children, onClose, onConfirm, confirm = '确认',
} }; 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({ color }: { color?: { pillars: Record<string, string>; traits: string } } = {}) {
if (color) return <><div className="card"><h4><Icon name="lock"/>出生底色 · 真实排盘</h4><div className="pillars">{[['year','年柱'], ['month','月柱'], ['day','日柱'], ['time','时柱']].map(([key, label]) => <div key={key}><small>{label}</small><b>{color.pillars[key]}</b></div>)}</div><p>{color.traits}。底色是倾向,保留诠释与成长的空间。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读。</p></>;
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></>; }
export function BaseColor({ color }: { color?: Color } = {}) {
if (color) return <><div className="card birth-color"><h4><Icon name="lock"/>出生底色 · 真实排盘</h4>
{color.calendar && <dl className="kv calendar"><dt>公历</dt><dd>{color.calendar.solar}</dd><dt>农历</dt><dd>{color.calendar.lunar}{color.calendar.leap_month && <span className="tag accent">闰月</span>}</dd><dt>出生时辰</dt><dd>{color.calendar.hour} · {color.calendar.hour_range}</dd><dt>排盘时间</dt><dd>北京时间 {color.calendar.calculation_time}</dd></dl>}
<div className="pillars">{[['year','年柱'], ['month','月柱'], ['day','日柱'], ['time','时柱']].map(([key, label]) => <div key={key}><small>{label}</small><b>{color.pillars[key]}</b></div>)}</div>
<details className="birth-help"><summary>四柱、日主和农历,分别是什么?</summary><p>四柱是出生年、月、日、时的四组干支,合起来为八个字。日柱的第一个字叫日主,是解释组合的参照。五行描述组合的侧重,十神描述各天干与日主的关系,不是十种好坏标签。</p><p>{color.calendar?.rule || '月柱按节气划分,与农历月份不是同一回事。'}</p></details>
<p className="d-foot">{color.interpretation || '当前档案尚未提供分层说明,请更新服务后重试。原底色保留。'}</p>
<div className="color-sections">{color.sections?.map(section => <section key={section.id}><h4>{section.title}</h4><p>{section.text}</p><details><summary>这段说明的依据</summary><p>{section.basis}</p></details></section>)}</div>
</div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读;后来的成长另存。</p></>;
return <><div className="card"><h4><Icon name="lock"/>出生底色 · 演示预览</h4><div className="pillars">{['年柱', '月柱', '日柱', '时柱'].map(p => <div key={p}><small>{p}</small><b>待接</b></div>)}</div><p>演示不执行排盘,不用固定八字或性格代替计算。普通入口会显示真实公历、农历、四柱和六层底色说明。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读,成长另存。</p></>;
}
+65 -47
View File
@@ -1,5 +1,11 @@
/* Approved HEL-746 R2 component styles; edit here, never add override sheets. */
*{box-sizing:border-box;margin:0;padding:0}
*{box-sizing:border-box;margin:0;padding:0;scrollbar-width:thin;scrollbar-color:var(--scroll-thumb) var(--scroll-track)}
html{color-scheme:dark}
*::-webkit-scrollbar{width:8px;height:8px}
*::-webkit-scrollbar-track{background:var(--scroll-track);border-radius:8px}
*::-webkit-scrollbar-thumb{background:var(--scroll-thumb);border:2px solid var(--scroll-track);border-radius:8px}
@media(hover:hover) and (pointer:fine){*::-webkit-scrollbar-thumb:hover{background:var(--scroll-hover)}}
@media(forced-colors:active){*{scrollbar-color:auto}*::-webkit-scrollbar-thumb{background:ButtonText}*::-webkit-scrollbar-track{background:Canvas}}
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}
@@ -144,7 +150,7 @@ html[data-theme="fog"] .glass{box-shadow:inset 0 1px 0 var(--glass-hi),inset 0 0
/* ============================================================
顶栏
============================================================ */
.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{position:fixed;top:calc(var(--view-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}
@@ -186,6 +192,7 @@ body.creating .topbar .who,body.creating .topbar .env > :not(.theme-switch){opac
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%}
html.viewport-resizing .portal{transition:none}
.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)}}
@@ -209,8 +216,8 @@ html[data-theme="fog"] .portal .tint{background:linear-gradient(180deg,rgba(140
.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.talk .portal{--pw:min(64vw, calc(var(--talk-stage-h) * 1.72)); --ph:var(--talk-stage-h); --pl:calc(50vw - var(--pw) / 2 + var(--shift)); --pt:calc(var(--view-top) + 72px); --pr:var(--radius-stage) var(--radius-stage) var(--radius-stage) var(--radius-stage)}
body.talk.voice{--talk-stage-h:clamp(64px,calc(var(--view-h) - 472px),488px)}
body{--shift:0px}
body.talk.log{--shift:-200px}
body.talk .portal .img-room{opacity:0;transform:scale(2.1);transform-origin:84% 38%}
@@ -289,27 +296,30 @@ body.paned .reach{left:auto;right:28px;bottom:104px;width:340px;border-radius:20
/* ============================================================
交流
============================================================ */
.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}
.chat-layout{position:fixed;top:calc(var(--view-top) + 72px);height:calc(var(--view-h) - 166px - var(--safe-bottom));left:calc(50vw + var(--shift));transform:translateX(-50%);width:min(760px,74vw);z-index:12;display:flex;flex-direction:column;gap:12px;pointer-events:none}
.talk-scene-space{height:var(--talk-stage-h);flex:none}
.dialog{min-height:0;flex:1;display:flex;flex-direction:column;pointer-events:auto}
.dialog-scroll{min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding:4px 12px;overflow-anchor:none}
.dialog .you{font-size:15px;color:var(--text-2);margin:0 0 14px 12%;text-align:right}
.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);margin-right:8%;min-height:1.65em}
.speaker{display:block;font-size:13px;font-weight:500;letter-spacing:.1em;color:var(--accent);margin-bottom:4px}
.dialog .you .speaker{color:var(--text-2)}
.dialog .failed{color:var(--danger);font-size:13px}
.dialog .retry{color:var(--accent);font-size:13px;margin-left:6px;text-decoration:underline;text-underline-offset:3px}
.dialog .her.cut{opacity:.65}
.soft-text{display:block;position:relative;white-space:pre-wrap;overflow-wrap:anywhere}
.current-text{display:block}
.outgoing-text{position:absolute;inset:0;pointer-events:none}
.partial-note{display:block;font-size:12px;color:var(--warn);margin-top:6px}
.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}
.dialog .hint{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none;padding:4px 12px;font-size:12px;color:var(--text-2);min-height:26px}
.dialog .hint .btn-text{white-space:nowrap;color:var(--accent)}
.composer{position:relative;flex:none;width:100%;min-height:60px;border-radius:var(--radius-pill);pointer-events:auto;display:flex;align-items:center;gap:4px;padding:0 6px}
.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)}
@@ -335,8 +345,7 @@ body.v-listen .mic::after{animation:ring 1.4s var(--ease-out) infinite}
.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{position:relative;flex:none;min-height:48px;padding:0 6px 0 16px;border-radius:var(--radius-pill);display:flex;align-items:center;gap:12px;font-size:13.5px;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}
@@ -346,7 +355,7 @@ body.talk.voice .voicebar{opacity:1;transform:translate(-50%,0);pointer-events:a
.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)}
.log-panel{position:fixed;right:24px;top:calc(var(--view-top) + 72px);bottom:calc(var(--view-bottom) + 98px + var(--safe-bottom));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}
@@ -367,7 +376,7 @@ body.log .log-panel{opacity:1;transform:none;pointer-events:auto}
/* ============================================================
底部导航
============================================================ */
.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)}
.nav{position:fixed;left:50%;bottom:calc(var(--view-bottom) + 22px + var(--safe-bottom));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}
@@ -379,7 +388,7 @@ body.creating .nav{opacity:0;pointer-events:none;transform:translate(-50%,20px)}
/* ============================================================
区域面板:日子 / 设置(同一主题空间里升起的阅读面)
============================================================ */
.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{position:fixed;z-index:16;left:var(--pane-l);top:calc(var(--view-top) + var(--pane-t));bottom:calc(var(--view-bottom) + var(--pane-b) + var(--safe-bottom));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}
@@ -522,7 +531,7 @@ body.creating .nav{opacity:0;pointer-events:none;transform:translate(-50%,20px)}
/* ============================================================
首建
============================================================ */
.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)}
.create{position:fixed;inset:var(--view-top) 0 auto;height:var(--view-h);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}
@@ -530,7 +539,7 @@ body.creating .create{opacity:1;pointer-events:auto}
.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{border-radius:28px;display:flex;flex-direction:column;max-height:calc(var(--view-h) - 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)}
@@ -571,7 +580,7 @@ body.creating .create{opacity:1;pointer-events:auto}
============================================================ */
.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{position:fixed;left:50%;top:calc(var(--view-top) + var(--view-h) / 2);max-height:calc(var(--view-h) - 32px);overflow:auto;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)}
@@ -609,7 +618,7 @@ body.creating .create{opacity:1;pointer-events:auto}
============================================================ */
@media (max-width:720px){
:root{--fs-whisper:21px;--fs-line:17px}
.topbar{top:14px;left:18px;right:14px}
.topbar{top:calc(var(--view-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}
@@ -621,9 +630,11 @@ body.creating .create{opacity:1;pointer-events:auto}
.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{--home-pw:min(74vw,330px,max(120px,calc((var(--view-h) - 340px) / 1.22)))}
.portal{--pw:var(--home-pw); --ph:calc(var(--pw) * 1.22); --pl:calc(50vw - var(--pw) / 2); --pt:70px}
body.talk{--talk-stage-h:clamp(64px,calc((var(--view-h) - 360px) * .55),300px)}
body.talk .portal{--pw:calc(100vw - 24px); --ph:var(--talk-stage-h); --pl:12px; --pt:calc(var(--view-top) + 58px)}
body.talk.voice{--talk-stage-h:clamp(48px,calc((var(--view-h) - 412px) * .5),250px)}
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}
@@ -632,11 +643,11 @@ body.creating .create{opacity:1;pointer-events:auto}
.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}
.home{left:22px;right:22px;width:auto;top:calc(var(--view-top) + 70px + var(--home-pw) * 1.22 + 22px);max-height:calc(var(--view-h) - 70px - var(--home-pw) * 1.22 - 106px - var(--safe-bottom));overflow:auto;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}
.letter p{font-size:14px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.away{margin-top:8px;font-size:13px}
.cta{position:static;margin-top:16px}
.cta .btn-primary{flex:1;height:50px}
@@ -649,30 +660,29 @@ body.creating .create{opacity:1;pointer-events:auto}
.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}
.chat-layout{left:50vw;top:calc(var(--view-top) + 58px);height:calc(var(--view-h) - 142px - var(--safe-bottom));width:calc(100vw - 24px);gap:8px}
.dialog .her{font-size:17px}
.composer{min-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{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{bottom:calc(var(--view-bottom) + 16px + var(--safe-bottom));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}
.log-panel{left:0;right:0;top:calc(var(--view-top) + 54px);bottom:calc(var(--view-bottom) + 84px + var(--safe-bottom));width:auto;height:auto;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{left:8px;right:8px;width:auto;top:calc(var(--view-top) + 54px);bottom:calc(var(--view-bottom) + 84px + var(--safe-bottom));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}
@@ -696,17 +706,17 @@ body.creating .create{opacity:1;pointer-events:auto}
.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{display:flex;flex-direction:column;padding:56px 0 var(--safe-bottom);overflow:hidden}
.create .story{flex:none;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{overflow:auto;min-height:0}
.wiz{margin:0 8px;max-height:none;border-radius:24px 24px 0 0;min-height:0;flex:1;overflow:hidden}
.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{flex:none;padding:12px 18px 18px;background:var(--surface)}
.wiz .wfoot .grow{display:none}
.wiz .wfoot button{flex:1}
.create.at-story .story{display:block}
@@ -743,5 +753,13 @@ p,button,h1,h2,h3,dd{overflow-wrap:anywhere}
.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}}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition-duration:120ms!important;transition-delay:0ms!important}}
body:has(textarea:focus) .space *{animation-play-state:paused!important}
/* Birth explanations share the read surface and typography with existing forms. */
.calendar{margin:12px 0}
.color-sections section{border-top:1px solid var(--glass-line);padding-top:16px;margin-top:16px}
.color-sections h4{color:var(--text-1);margin-bottom:8px}
.birth-help, .color-sections details{font-size:13px;color:var(--text-2);margin-top:10px}
.birth-help summary, .color-sections summary{cursor:pointer;color:var(--accent)}
@media(max-height:480px){body.talk{--talk-stage-h:40px}body.talk .portal .mark,body.talk .portal .topleft{display:none}.create .story p,.create .story .single{display:none}.create .story{padding-bottom:4px}.create .story h1{font-size:18px}}
+5
View File
@@ -5,6 +5,9 @@
R2 新增:阅读底 --glass-read、状态色 --ok/--warn/--danger、--z-*
============================================================ */
:root{
--view-h:100vh; --view-top:0px; --view-bottom:0px;
--safe-bottom:env(safe-area-inset-bottom,0px);
--talk-stage-h:clamp(64px,calc(var(--view-h) - 420px),540px);
--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);
@@ -29,6 +32,7 @@ html[data-theme="moon"]{
--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);
--scroll-track:#0d1838; --scroll-thumb:#51678d; --scroll-hover:#a9dcff;
--ok:#9fe8cf; --warn:#ffd79c; --danger:#ff9fb3;
}
html[data-theme="fog"]{
@@ -39,5 +43,6 @@ html[data-theme="fog"]{
--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);
--scroll-track:#22174a; --scroll-thumb:#806298; --scroll-hover:#ebbcff;
--ok:#a8f0d8; --warn:#ffdcae; --danger:#ffa8c8;
}
+2 -2
View File
@@ -62,10 +62,10 @@ try:
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')
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')
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')
+4 -4
View File
@@ -17,7 +17,7 @@ from test_api import live_server, make_app # noqa: E402
from cybergril.api.app import create_app # noqa: E402
CONTROLLED = '--controlled' in sys.argv
OUT = ROOT/'docs/r1' / ('controlled' if CONTROLLED else '.')
OUT = ROOT/'docs/rework' / ('r1-regression' if CONTROLLED else 'live-gateway')
OUT.mkdir(parents=True, exist_ok=True)
results = {'checks': [], 'real_dialogue': [], 'console_errors': []}
@@ -41,7 +41,7 @@ def send(page, text, source='real gateway'):
click(page, '发送')
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible(timeout=65000)
assert not page.locator('.dialog .failed').count(), page.locator('.dialog').inner_text()
reply = page.locator('.dialog .her').inner_text()
reply = page.locator('.dialog .her .current-text').inner_text()
results['real_dialogue'].append({'input':text,'reply':reply,'source':'controlled provider' if CONTROLLED else source})
print('CONTROLLED_REPLY' if CONTROLLED else 'REAL_REPLY', reply, flush=True)
return reply
@@ -61,7 +61,7 @@ try:
page.goto(url)
expect(page.get_by_text('1 / 4 · 出生资料')).to_be_visible()
page.get_by_label('她的名字', exact=True).fill('星河')
page.get_by_label('出生日期', exact=True).fill('2000-10-18')
page.get_by_label('出生日期(公历)', exact=True).fill('2000-10-18')
click(page,'下一步')
expect(page.get_by_text('出生底色 · 真实排盘')).to_be_visible()
assert real_app.state.store.query_one('SELECT count(*) n FROM companion')['n']==0
@@ -153,7 +153,7 @@ try:
shot(mp,'mobile-relation-fog')
nav(mp,'此刻'); mp.reload()
expect(mp.locator('.meta')).to_contain_text('恋人')
nav(mp,'日子'); expect(mp.get_by_text('尚未接通',exact=True)).to_be_visible()
nav(mp,'日子'); expect(mp.locator('.pane[aria-label="日子"] .empty').first).to_contain_text('还没有可展示的生活记录')
assert mp.locator('.demo').count()==0
passed('mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console')
# Model timeout and abrupt SSE are separately exercised by deterministic backend/unit tests.
+262
View File
@@ -0,0 +1,262 @@
"""HEL-783 foreground Chromium checks. Only temporary SQLite + controlled LLM.
PYTHONPATH=src <python with playwright> web/tests/rework_browser_check.py
No deployed service or user data is read or written. Every server/context closes.
"""
import asyncio
import json
import sys
import tempfile
from pathlib import Path
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'tests'))
from test_api import ControlledLLM, live_server, make_app # noqa: E402
from cybergril.contracts import LLMDelta # noqa: E402
OUT = ROOT / 'docs/rework'
OUT.mkdir(parents=True, exist_ok=True)
LONG = '开头:每一段都应该完整留下。\n\n' + '\n\n'.join(
f'第{i}段。' + '窗外的灯慢慢亮起来,我们可以把话说完整,再决定下一步。' * 10
for i in range(1, 6)) + '\n\n最后:故事没有被截掉,前文和末尾都能读到。'
USER = '长句开头。' + '这是完整保留的用户长句。' * 15 + '用户长句末尾。'
class ReworkLLM(ControlledLLM):
async def stream(self, messages, **kwargs):
if self.mode != 'long':
async for delta in super().stream(messages, **kwargs):
yield delta
return
self.calls.append(messages)
try:
for index in range(0, len(LONG), 40):
yield LLMDelta(text=LONG[index:index + 40])
await asyncio.sleep(.025)
yield LLMDelta(done=True)
finally:
self.closed += 1
report = {'checks': [], 'viewports': [], 'motion': {}, 'console_errors': [],
'limits': ['Chromium only; physical phone keyboard, Safari and screen reader not tested.',
'LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway.']}
def passed(text):
report['checks'].append(text)
print('PASS', text, flush=True)
def click(page, label):
(page.locator('.pane-manage .back') if label == '设置' else page.get_by_role('button', name=label, exact=True)).click()
def nav(page, label):
page.get_by_role('navigation', name='主导航').get_by_role('button', name=label, exact=True).click()
if label == '设置' and page.viewport_size['width'] <= 720 and page.locator('.pane-manage.has-detail').is_visible(): page.locator('.pane-manage .back').click()
def shot(page, name):
page.evaluate("""async () => { await Promise.all([...document.querySelectorAll('.wstep,.pane,.pane .detail,.portal,.tabs button')].flatMap(el=>el.getAnimations()).filter(a=>a.effect.getTiming().iterations !== Infinity).map(a=>a.finished.catch(()=>{}))); }""")
page.screenshot(path=str(OUT / (name + '.png')))
def send(page, text):
page.get_by_role('textbox', name='想和她说的话').fill(text)
click(page, '发送')
try:
with tempfile.TemporaryDirectory(prefix='cg-rework-') as directory, sync_playwright() as pw:
provider = ReworkLLM()
app, _ = make_app(Path(directory) / 'isolated.db', provider)
with live_server(app) as url:
browser = pw.chromium.launch(headless=True, args=['--no-sandbox'])
context = browser.new_context(viewport={'width': 1440, 'height': 900})
page = context.new_page()
page.on('pageerror', lambda e: report['console_errors'].append(str(e)))
page.add_init_script("""
const get = Storage.prototype.getItem;
Storage.prototype.getItem = function(key) {
if(key === 'cybergril.demo.v1' && !location.search.includes('demo=1')) throw new Error('real read demo');
return get.call(this, key);
};
window.motionStarts = [];
const animate = Element.prototype.animate;
Element.prototype.animate = function(...args) {
if (this.matches('.her .current-text')) window.motionStarts.push({text:this.textContent, duration:args[1].duration});
return animate.apply(this, args);
};
"""
)
page.goto(url)
page.get_by_label('她的名字', exact=True).fill('星河')
page.get_by_label('出生日期(公历)', exact=True).fill('2001-05-23')
click(page, '下一步')
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
expect(page.locator('.color-sections section')).to_have_count(6)
assert app.state.store.query_one('SELECT count(*) n FROM companion')['n'] == 0
shot(page, 'birth-preview-moon')
page.locator('.birth-help summary').click()
shot(page, 'birth-calendar-help-moon')
click(page, '下一步'); click(page, '朋友'); click(page, '下一步')
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
page.get_by_role('checkbox').check(); click(page, '确认创建'); click(page, '去看看她')
passed('Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows')
nav(page, '交谈')
provider.mode = 'long'
before = page.evaluate('motionStarts.length')
send(page, USER)
expect(page.locator('.her .current-text')).to_contain_text('开头:')
page.evaluate("window.streamNode = document.querySelector('.her .current-text')")
# Pause the actual animation and inspect deterministic frames, then resume.
frames = []
for time in (0, 80, 200):
values = page.evaluate("""t => {const el=window.streamNode;const a=el.getAnimations()[0];if(a){a.pause();a.currentTime=t;}const s=getComputedStyle(el);return {time:t,opacity:s.opacity,transform:s.transform};}""", time)
frames.append(values)
shot(page, f'motion-enter-{time}')
page.evaluate('window.streamNode.getAnimations().forEach(a=>a.play())')
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
expect(page.locator('.her .current-text')).to_have_text(LONG)
assert page.evaluate("window.streamNode === document.querySelector('.her .current-text')")
assert page.evaluate('motionStarts.length') - before == 1
assert float(frames[0]['opacity']) < float(frames[1]['opacity']) < float(frames[2]['opacity'])
report['motion']['entry_frames'] = frames
expect(page.locator('.you .current-text')).to_have_text(USER)
passed('Long Chinese stream: entire user/reply retained; final keeps same DOM; one entrance across all chunks and final')
# Replace a completed sentence; retain its exit DOM until the animation ends.
send(page, '再讲一次')
outgoing = page.locator('.her .outgoing-text')
expect(outgoing).to_have_count(1)
assert outgoing.text_content() == LONG
exit_frames = page.evaluate("""() => {const el=document.querySelector('.her .outgoing-text');const a=el.getAnimations()[0];a.pause();return [0,80,159].map(t=>{a.currentTime=t;return {time:t,opacity:getComputedStyle(el).opacity,retained:el.isConnected}});} """)
assert float(exit_frames[0]['opacity']) > float(exit_frames[-1]['opacity'])
report['motion']['exit_frames'] = exit_frames
page.evaluate("document.querySelector('.her .outgoing-text').getAnimations().forEach(a=>a.play())")
expect(outgoing).to_have_count(0)
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
passed('Old sentence stays mounted during a visible 160ms fade-out, then leaves')
for width, height in [(1440, 900), (360, 640), (390, 700), (390, 844), (390, 420), (844, 390)]:
page.set_viewport_size({'width': width, 'height': height})
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
click(page, label)
expect(page.locator('html')).to_have_attribute('data-theme', theme)
page.get_by_role('textbox', name='想和她说的话').focus()
metrics = page.evaluate("""() => {
const box=s=>{const b=document.querySelector(s).getBoundingClientRect();return {top:b.top,bottom:b.bottom,height:b.height}};
const scroll=document.querySelector('.dialog-scroll');return {dialog:box('.dialog'),composer:box('.composer'),nav:box('.nav'),stage:box('.portal'),scrollHeight:scroll.scrollHeight,clientHeight:scroll.clientHeight,width:innerWidth,overflow:document.documentElement.scrollWidth>innerWidth};
}""")
assert not metrics['overflow'], metrics
assert metrics['stage']['bottom'] <= metrics['dialog']['top'] + 1, metrics
assert metrics['dialog']['bottom'] <= metrics['composer']['top'] + 1, metrics
assert metrics['composer']['bottom'] < metrics['nav']['top'], metrics
assert metrics['clientHeight'] >= 60, metrics
assert metrics['scrollHeight'] > metrics['clientHeight'], metrics
report['viewports'].append({'width': width, 'height': height, 'theme': theme, **metrics})
page.locator('.dialog-scroll').evaluate('el=>el.scrollTop=0')
expect(page.locator('.you .current-text')).to_have_text('再讲一次')
if width in (360, 390) and height in (640, 420, 844): shot(page, f'long-start-{width}-{height}-{theme}')
page.locator('.dialog-scroll').evaluate('el=>el.scrollTop=el.scrollHeight')
if width in (360, 390) and height in (640, 420, 844): shot(page, f'long-end-{width}-{height}-{theme}')
click(page, '完整记录')
expect(page.locator('.log-panel')).to_contain_text('长句开头')
expect(page.locator('.log-panel')).to_contain_text('故事没有被截掉')
if width == 390 and height == 844: shot(page, f'full-history-{theme}')
click(page, '关闭记录')
passed('12 viewport/theme combinations: long text scrolls, stage/dialog/composer/navigation never overlap; full history reachable')
page.set_viewport_size({'width': 390, 'height': 700})
provider.mode = 'slow'; send(page, '取消并重发')
expect(page.locator('.her .current-text')).to_have_text('答')
click(page, '取消本轮回复')
provider.mode = 'normal'; send(page, '新轮')
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
expect(page.locator('.her .current-text')).to_have_text('答复')
provider.mode = 'error'; send(page, '失败再试')
expect(page.locator('.dialog .failed')).to_be_visible()
expect(page.get_by_role('textbox', name='想和她说的话')).to_have_value('失败再试')
count = app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
provider.mode = 'normal'; page.locator('.dialog .retry').click()
expect(page.locator('.dialog .failed')).not_to_be_visible()
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] == count + 1
passed('Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages')
nav(page, '设置'); click(page, '她')
expect(page.locator('.color-sections section')).to_have_count(6)
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
shot(page, 'existing-birth-fog')
page.locator('.color-sections section').last.scroll_into_view_if_needed()
shot(page, 'existing-growth-fog')
click(page, '设置')
for label in ('城市', '外观素材', '备份', '外接服务'):
page.get_by_role('navigation', name='设置分区').get_by_role('button', name=label, exact=True).click()
shot(page, {'城市':'city','外观素材':'appearance','备份':'backup','外接服务':'services'}[label] + '-fog')
if label == '城市':
page.get_by_label('搜索城市').fill('不存在')
expect(page.get_by_text('当前目录没有匹配的城市。')).to_be_visible()
if label == '外接服务':
click(page, '重新检查连接')
expect(page.get_by_role('status')).to_contain_text('文字服务可达')
click(page, '设置')
click(page, '关系')
page.get_by_label('相处边界').fill('未保存的边界')
nav(page, '日子')
expect(page.get_by_role('dialog')).to_contain_text('有未保存的修改')
click(page, '取消')
expect(page.locator('.pane-manage')).to_be_visible()
nav(page, '日子'); click(page, '放弃修改并离开')
expect(page.get_by_text('还没有可展示的生活记录')).to_be_visible()
shot(page, 'life-empty-fog')
page.get_by_role('tab', name='我们的回忆').click()
expect(page.locator('.pane .list .grp')).to_have_count(4)
shot(page, 'memory-groups-empty-fog')
assert app.state.store.query_one('SELECT count(*) n FROM life_event')['n'] == 0
passed('Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard')
nav(page, '交谈'); page.keyboard.press('Escape')
expect(page.get_by_role('navigation', name='主导航').get_by_role('button', name='此刻', exact=True)).to_have_attribute('aria-current', 'page')
nav(page, '设置'); click(page, '显示')
page.get_by_role('checkbox').check(); click(page, '保存')
expect(page.get_by_role('status')).to_contain_text('此浏览器')
page.reload(); nav(page, '交谈')
assert page.locator('html').evaluate("el=>el.classList.contains('reduced')")
provider.mode = 'normal'; send(page, '减少动效检查')
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
assert page.evaluate('motionStarts.at(-1).duration') == 120
reduced = browser.new_context(viewport={'width': 390, 'height': 700}, reduced_motion='reduce')
rp = reduced.new_page(); rp.goto(url); nav(rp, '交谈'); send(rp, '系统减少动效')
expect(rp.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
reduced.close()
passed('Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity')
# Demonstration mode must never request APIs or borrow a real profile.
demo = browser.new_context(viewport={'width': 390, 'height': 844})
dp = demo.new_page(); requests = []
dp.on('request', lambda r: requests.append(r.url) if '/api/' in r.url else None)
dp.goto(url + '/?demo=1')
expect(dp.get_by_text('1 / 4 · 出生资料')).to_be_visible()
dp.get_by_label('她的名字', exact=True).fill('小满')
click(dp, '下一步'); click(dp, '下一步'); click(dp, '朋友'); click(dp, '下一步')
dp.get_by_role('checkbox').check(); click(dp, '确认创建'); click(dp, '去看看她')
nav(dp, '交谈'); click(dp, '开始模拟语音'); click(dp, '开始模拟')
expect(dp.get_by_role('button', name='结束语音')).to_be_visible()
assert dp.locator('.voicebar').bounding_box()['y'] > dp.locator('.dialog').bounding_box()['y']
shot(dp, 'demo-voice-390')
click(dp, '结束语音')
dp.locator('.demo > button').click(); click(dp, '载入三天记录')
nav(dp, '日子'); dp.locator('.ev').first.click(); click(dp, '看那段聊天')
expect(dp.locator('.log-panel')).to_be_visible(); click(dp, '关闭记录'); click(dp, '返回日子')
expect(dp.locator('.pane[aria-label="日子"]')).to_have_class('pane glass read open has-detail')
assert not requests
passed('Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests')
demo.close(); browser.close()
assert not report['console_errors'], report['console_errors']
finally:
(OUT / 'browser-results.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')