diff --git a/docs/rework/REVIEW.md b/docs/rework/REVIEW.md new file mode 100644 index 0000000..5dc6586 --- /dev/null +++ b/docs/rework/REVIEW.md @@ -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. 本批用户尚未验收,不合并、不归档、不推进到收尾。 diff --git a/docs/rework/appearance-fog.png b/docs/rework/appearance-fog.png new file mode 100644 index 0000000..efc6cff Binary files /dev/null and b/docs/rework/appearance-fog.png differ diff --git a/docs/rework/backup-fog.png b/docs/rework/backup-fog.png new file mode 100644 index 0000000..37cfbe9 Binary files /dev/null and b/docs/rework/backup-fog.png differ diff --git a/docs/rework/birth-calendar-help-moon.png b/docs/rework/birth-calendar-help-moon.png new file mode 100644 index 0000000..b53ebce Binary files /dev/null and b/docs/rework/birth-calendar-help-moon.png differ diff --git a/docs/rework/birth-preview-moon.png b/docs/rework/birth-preview-moon.png new file mode 100644 index 0000000..47c3406 Binary files /dev/null and b/docs/rework/birth-preview-moon.png differ diff --git a/docs/rework/browser-results.json b/docs/rework/browser-results.json new file mode 100644 index 0000000..42773ba --- /dev/null +++ b/docs/rework/browser-results.json @@ -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." + ] +} diff --git a/docs/rework/city-fog.png b/docs/rework/city-fog.png new file mode 100644 index 0000000..785c24d Binary files /dev/null and b/docs/rework/city-fog.png differ diff --git a/docs/rework/demo-voice-390.png b/docs/rework/demo-voice-390.png new file mode 100644 index 0000000..75ef125 Binary files /dev/null and b/docs/rework/demo-voice-390.png differ diff --git a/docs/rework/existing-birth-fog.png b/docs/rework/existing-birth-fog.png new file mode 100644 index 0000000..e237294 Binary files /dev/null and b/docs/rework/existing-birth-fog.png differ diff --git a/docs/rework/existing-growth-fog.png b/docs/rework/existing-growth-fog.png new file mode 100644 index 0000000..8bab7b5 Binary files /dev/null and b/docs/rework/existing-growth-fog.png differ diff --git a/docs/rework/full-history-fog.png b/docs/rework/full-history-fog.png new file mode 100644 index 0000000..1b6613a Binary files /dev/null and b/docs/rework/full-history-fog.png differ diff --git a/docs/rework/full-history-moon.png b/docs/rework/full-history-moon.png new file mode 100644 index 0000000..f02f274 Binary files /dev/null and b/docs/rework/full-history-moon.png differ diff --git a/docs/rework/life-empty-fog.png b/docs/rework/life-empty-fog.png new file mode 100644 index 0000000..883e54e Binary files /dev/null and b/docs/rework/life-empty-fog.png differ diff --git a/docs/rework/live-gateway/browser-results.json b/docs/rework/live-gateway/browser-results.json new file mode 100644 index 0000000..daa12f5 --- /dev/null +++ b/docs/rework/live-gateway/browser-results.json @@ -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": [] +} diff --git a/docs/rework/long-end-360-640-fog.png b/docs/rework/long-end-360-640-fog.png new file mode 100644 index 0000000..f70a2b5 Binary files /dev/null and b/docs/rework/long-end-360-640-fog.png differ diff --git a/docs/rework/long-end-360-640-moon.png b/docs/rework/long-end-360-640-moon.png new file mode 100644 index 0000000..283d105 Binary files /dev/null and b/docs/rework/long-end-360-640-moon.png differ diff --git a/docs/rework/long-end-390-420-fog.png b/docs/rework/long-end-390-420-fog.png new file mode 100644 index 0000000..92a8a81 Binary files /dev/null and b/docs/rework/long-end-390-420-fog.png differ diff --git a/docs/rework/long-end-390-420-moon.png b/docs/rework/long-end-390-420-moon.png new file mode 100644 index 0000000..b0c8cf9 Binary files /dev/null and b/docs/rework/long-end-390-420-moon.png differ diff --git a/docs/rework/long-end-390-844-fog.png b/docs/rework/long-end-390-844-fog.png new file mode 100644 index 0000000..f45efb5 Binary files /dev/null and b/docs/rework/long-end-390-844-fog.png differ diff --git a/docs/rework/long-end-390-844-moon.png b/docs/rework/long-end-390-844-moon.png new file mode 100644 index 0000000..9b0b720 Binary files /dev/null and b/docs/rework/long-end-390-844-moon.png differ diff --git a/docs/rework/long-start-360-640-fog.png b/docs/rework/long-start-360-640-fog.png new file mode 100644 index 0000000..b733bd1 Binary files /dev/null and b/docs/rework/long-start-360-640-fog.png differ diff --git a/docs/rework/long-start-360-640-moon.png b/docs/rework/long-start-360-640-moon.png new file mode 100644 index 0000000..b8c0c2f Binary files /dev/null and b/docs/rework/long-start-360-640-moon.png differ diff --git a/docs/rework/long-start-390-420-fog.png b/docs/rework/long-start-390-420-fog.png new file mode 100644 index 0000000..f949178 Binary files /dev/null and b/docs/rework/long-start-390-420-fog.png differ diff --git a/docs/rework/long-start-390-420-moon.png b/docs/rework/long-start-390-420-moon.png new file mode 100644 index 0000000..7753543 Binary files /dev/null and b/docs/rework/long-start-390-420-moon.png differ diff --git a/docs/rework/long-start-390-844-fog.png b/docs/rework/long-start-390-844-fog.png new file mode 100644 index 0000000..9976c0b Binary files /dev/null and b/docs/rework/long-start-390-844-fog.png differ diff --git a/docs/rework/long-start-390-844-moon.png b/docs/rework/long-start-390-844-moon.png new file mode 100644 index 0000000..280fc4e Binary files /dev/null and b/docs/rework/long-start-390-844-moon.png differ diff --git a/docs/rework/memory-groups-empty-fog.png b/docs/rework/memory-groups-empty-fog.png new file mode 100644 index 0000000..a86c7d0 Binary files /dev/null and b/docs/rework/memory-groups-empty-fog.png differ diff --git a/docs/rework/motion-enter-0.png b/docs/rework/motion-enter-0.png new file mode 100644 index 0000000..bea583d Binary files /dev/null and b/docs/rework/motion-enter-0.png differ diff --git a/docs/rework/motion-enter-200.png b/docs/rework/motion-enter-200.png new file mode 100644 index 0000000..3ecb424 Binary files /dev/null and b/docs/rework/motion-enter-200.png differ diff --git a/docs/rework/motion-enter-80.png b/docs/rework/motion-enter-80.png new file mode 100644 index 0000000..0ed158c Binary files /dev/null and b/docs/rework/motion-enter-80.png differ diff --git a/docs/rework/r1-regression/browser-results.json b/docs/rework/r1-regression/browser-results.json new file mode 100644 index 0000000..e04ccca --- /dev/null +++ b/docs/rework/r1-regression/browser-results.json @@ -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": [] +} diff --git a/docs/rework/services-fog.png b/docs/rework/services-fog.png new file mode 100644 index 0000000..0b4832f Binary files /dev/null and b/docs/rework/services-fog.png differ diff --git a/src/cybergril/api/app.py b/src/cybergril/api/app.py index a55e59a..e88c596 100644 --- a/src/cybergril/api/app.py +++ b/src/cybergril/api/app.py @@ -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', [])) + + "用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。" "当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。" "不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。" diff --git a/src/cybergril/core/birth_view.py b/src/cybergril/core/birth_view.py new file mode 100644 index 0000000..62f2560 --- /dev/null +++ b/src/cybergril/core/birth_view.py @@ -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": "这是以四柱、五行、十神和已存底色为依据的角色塑造说明,不是对真人性格的测定,也不是已经发生的经历。倾向不等于固定台词或此刻情绪。", + } diff --git a/tests/test_birth_view.py b/tests/test_birth_view.py new file mode 100644 index 0000000..0fdb427 --- /dev/null +++ b/tests/test_birth_view.py @@ -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 diff --git a/web/src/adapters/real.ts b/web/src/adapters/real.ts index 0260309..b83025c 100644 --- a/web/src/adapters/real.ts +++ b/web/src/adapters/real.ts @@ -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; traits: string } +export interface Color { + pillars: Record; 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(path: string, init?: RequestInit): Promise { @@ -19,7 +24,9 @@ export async function loadReal(): Promise { 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 { // Cursor pages are bounded by the server. All pages restore in deterministic id order. diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 60ddf14..c11e2b6 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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]); diff --git a/web/src/app/RealApp.tsx b/web/src/app/RealApp.tsx index e64916c..182d2d4 100644 --- a/web/src/app/RealApp.tsx +++ b/web/src/app/RealApp.tsx @@ -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 <>
{state?.identity.name || 'CyberGril'}{ready ? '真实档案 · 文字对话' : '连接真实服务'}
天气尚未接通{(['moon', 'fog'] as Theme[]).map(t => )}
{!ready ?

一扇亮着的窗。

{error && <>

请检查真实服务是否可达。资料仍在服务器保存。

}
: <> {creating && setCreating(false)}/>} {!creating && state && <> navigate('now')} closeLabel="回到此刻"/> - {area === 'now' &&
一扇亮着的窗

慢慢说,我在这里。

{state.identity.relation} · 慢慢熟悉

资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。

{health && !health.llm.ok && }
} + {area === 'now' &&
一扇亮着的窗

慢慢说,我在这里。

{state.identity.relation} · 慢慢熟悉
{lastReply && }

资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。

{health && !health.llm.ok && }
} {area === 'talk' && navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>} - {area === 'days' &&

日子与回忆

尚未接通

生活循环与长期记忆将在后续接入,目前没有可展示的生活经历。聊天记录可在交谈中查看。

} + navigate('now')} onChat={() => { navigate('talk'); setLog(true); }}/> navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/> } }; diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx index dbbe214..a4714e0 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -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(null), voiceController = useRef(null); + const [activeTurn, setActiveTurn] = useState(''), [interrupted, setInterrupted] = useState<{ text: string; turn: string } | null>(null); + const replyLive = useRef(''), turnLive = useRef(''), dialogue = useRef(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 <>
{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <>没发出去}}
{state.identity.name}{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && }
{notice || (real ? '真实文字对话 · 生活与长期记忆尚未接通' : '演示回复 · 没有连接模型、语音或记忆后台')}
+ const herText = reply || interrupted?.text || lastHer?.text || '我在。你想说什么?'; + const herTurn = reply ? activeTurn : interrupted ? interrupted.turn : lastHer?.turnId || lastHer?.id || 'greeting'; + return <>
{permission && { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') { setDenied(false); setVoice(true); diff --git a/web/src/life/LifePanel.tsx b/web/src/life/LifePanel.tsx index c50f22e..c3d045c 100644 --- a/web/src/life/LifePanel.tsx +++ b/web/src/life/LifePanel.tsx @@ -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 ; + return ; } diff --git a/web/src/main.tsx b/web/src/main.tsx index f49cb85..7df3a2a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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' ? : ); +ReactDOM.createRoot(document.getElementById('root')!).render({new URLSearchParams(location.search).get('demo') === '1' ? : }); diff --git a/web/src/manage/CreationWizard.tsx b/web/src/manage/CreationWizard.tsx index 6b04d0c..7c9c634 100644 --- a/web/src/manage/CreationWizard.tsx +++ b/web/src/manage/CreationWizard.tsx @@ -46,10 +46,10 @@ export function CreationWizard({ onCreate, onEnter, fail, real = false }: { setBusy(false); } } return
一扇窗 · 一个开始

{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}

{real ? '资料与对话保存在服务器,重开或换设备都能恢复同一位她。最终确认前不会建档。' : '这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。'}

首版只有一位。创建后不提供切换或清空重建。
{labels.map((l, i) => )}
{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}
- {step === 0 && <>

先认识她的开始

名字由你起,生辰也可以在确认前修改。

setName(e.target.value)} autoComplete="off"/> setBirth(e.target.value)}/>} + {step === 0 && <>

先认识她的开始

名字由你起,生辰也可以在确认前修改。

setName(e.target.value)} autoComplete="off"/> setBirth(e.target.value)}/>} {step === 1 && <>

{name.trim()}的底色

底色是倾向,会留下诠释和成长的空间。

} {step === 2 && <>

由你选择关系

没有预选答案。以后可以在设置中确认修改,记忆会保留。

{['朋友', '恋人'].map(r => )}
setNickname(e.target.value)}/>} - {step === 3 && <>

让这次相识,有一个开始

{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) =>
{k}{v}
)}
{phase >= 0 &&
    {(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).map((p, i) =>
  • {i < phase ? '✓' : ''}{p}
  • )}
}} + {step === 3 && <>

让这次相识,有一个开始

{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) =>
{k}{v}
)}
{phase >= 0 &&
    {(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).map((p, i) =>
  • {i < phase ? '✓' : ''}{p}
  • )}
}} {step === 4 &&

{name.trim()}住进来了

刚开始,还没有共同的日子。聊着聊着就会有了。

}
{step > 0 && step < 4 && }{step < 3 ? : step === 3 ? : }
; } diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx index 8b3c6ce..4686bde 100644 --- a/web/src/manage/ManagePanel.tsx +++ b/web/src/manage/ManagePanel.tsx @@ -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(null), [material, setMaterial] = useState(''), [check, setCheck] = useState(''); + const approvedNavigation = useRef(false); + const pendingNavigation = useRef<(() => void) | null>(null); + const [serviceHealth, setServiceHealth] = useState(); const request = useRef(null), generation = useRef(0), feedback = useRef(null); useEffect(()=>{if(message) feedback.current?.scrollIntoView({block:'center'});},[message]); const latestState = useRef(state); latestState.current = state; - const dirtySections: Record = {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 = {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'); 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) => 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('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