HEL-780 / HEL-783: 真实档案和文字对话接入,修复六项界面问题 #4

Open
leefer wants to merge 2 commits from agent/agent/de8874a3803b into main
81 changed files with 2764 additions and 130 deletions
+8 -11
View File
@@ -1,11 +1,8 @@
# 复制成 .env 后填写。不填也能跑(会回退到离线 provider)。
# ---- LLM ----
DEEPSEEK_API_KEY=
# ---- Embedding(可选,留空则用本地或跳过向量检索)----
EMBEDDING_API_KEY=
# ---- 运行时 ----
CYBERGRIL_DB=companion.db
CYBERGRIL_LOG_LEVEL=INFO
# R1 uses the approved no-key gateway from config/models.yaml.
# These variables are read from the process environment; this file is not auto-loaded.
# Keep data outside release directories. Use an independent directory for local testing.
CYBERGRIL_DB=/opt/cybergril-data/cybergril.db
CYBERGRIL_MODELS=config/models.yaml
CYBERGRIL_WEB=web/dist
CYBERGRIL_HOST=127.0.0.1
CYBERGRIL_PORT=8310
+2
View File
@@ -48,3 +48,5 @@ models/
*.swp
.DS_Store
Thumbs.db
.venv311/
+8 -8
View File
@@ -18,9 +18,9 @@ tiers:
# ---- 旗舰:人设浓度最高的时刻 ----
high:
provider: openai_compat
model: deepseek-v4.1-flash
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
model: halogen-qwen3.8-flash-next
base_url: http://192.168.200.11:8731/v1
api_key_env: null
timeout: 60.0
temperature: 0.85
@@ -28,9 +28,9 @@ tiers:
# 省钱的杠杆通常不是换更小的模型,而是少喂上下文。
standard:
provider: openai_compat
model: deepseek-v4.1-flash
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
model: halogen-qwen3.8-flash-next
base_url: http://192.168.200.11:8731/v1
api_key_env: null
timeout: 40.0
temperature: 0.7
@@ -38,8 +38,8 @@ tiers:
# 没有本地服务时,把它指向 openai_compat 的远端小模型即可。
low:
provider: openai_compat
model: qwen3.5-4b-instruct
base_url: http://127.0.0.1:8000/v1
model: halogen-qwen3.8-flash-next
base_url: http://192.168.200.11:8731/v1
api_key_env: null
timeout: 20.0
temperature: 0.4
+28
View File
@@ -0,0 +1,28 @@
# Review/install by 总工 only. Single worker; never share DB with demo or tests.
[Unit]
Description=CyberGril R1 persistent text API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cybergril
WorkingDirectory=/opt/cybergril-api/current
Environment=CYBERGRIL_DB=/opt/cybergril-data/cybergril.db
Environment=CYBERGRIL_MODELS=/opt/cybergril-api/current/config/models.yaml
Environment=CYBERGRIL_WEB=/opt/cybergril-api/current/web/dist
Environment=CYBERGRIL_HOST=0.0.0.0
Environment=CYBERGRIL_PORT=8310
ExecStart=/opt/cybergril-api/current/.venv/bin/python -m cybergril.api
Restart=on-failure
RestartSec=3
TimeoutStopSec=15
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/cybergril-data
[Install]
WantedBy=multi-user.target
+12
View File
@@ -0,0 +1,12 @@
{
"checks": [
"1440: ordinary entry, creation validation/failure/retry and reopen",
"1440: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"1440: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"1440: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"1440: privacy-filtered away summary and proactive message",
"No uncaught browser errors, API/external requests, microphone or camera requests"
],
"errors": [],
"requests": 31
}
+2 -2
View File
@@ -5,8 +5,8 @@
"390: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"390: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"390: privacy-filtered away summary and proactive message",
"No uncaught browser errors, external requests, microphone or camera requests"
"No uncaught browser errors, API/external requests, microphone or camera requests"
],
"errors": [],
"requests": 36
"requests": 23
}
+15
View File
@@ -0,0 +1,15 @@
HEL-780 · 8310独立部署/回滚材料(总工执行,尚未部署)
1. 审核确切任务分支和提交,复核8310空闲;保持8300和其他既有服务不动。建立 `/opt/cybergril-api/releases/<release>/`,构建生产前端和独立Python≥3.11虚拟环境,按 IMPLEMENTATION 安装。服务账号示例为 cybergril,由总工按主机现有规范准备,不默认root。
2. 准备代码目录外的 `/opt/cybergril-data/`,仅服务账号可写。首次上线保持数据库没有真实档案,不运行创建POST、不替用户创建。已有数据升级前,停止该API,用 SQLite backup API 做一致快照至独立回滚目录;禁止只复制正在写入中的主DB文件而漏WAL。
3. 将 `current` 指向已审核release,审查 `deploy/cybergril-api.service.example` 后再安装服务。模板限制单进程,端口8310,持久数据目录独立;模板不改防火墙、证书或反向代理。按现场systemd版本核对保护项和目录权限。uvicorn直连SSE无代理缓冲;若后续接代理,需另行配置关闭SSE缓冲。
4. 启动前确认 `web/dist/index.html`、models配置、虚拟环境可读和数据目录可写。启动时自动执行幂等增量建表、epoch加一和pending取消。使用 `GET /api/health`、`GET /api/companion`(首装预期404)与普通页面做无副作用检查;模型健康异常先记录,不改共享网关、不循环调参。不要以调用preview之外的写接口作为上线健康检查。
5. 审核通过后由总工记录服务URL、release、SHA、`journalctl -u cybergril-api` 日志位置,再交用户创建。普通入口 `/`,演示 `/?demo=1`。本轮不切换8300入口,不合并、不部署。
回滚:
- 停止 `cybergril-api`,记录当时release与数据快照;把 current 切回上一已审核release,重新启动。数据目录保持原样,不随release回退或删除。R1迁移仅增表,保留旧表;每次重新启动epoch递增。回滚到纯前端版本时真实API将不可用,应明确告知,服务器数据仍保留。
- 数据恢复与代码回滚分别处理。需恢复快照时先再次备份当前数据,由总管确认可接受的数据时间点,再由总工停服恢复;禁止把本地测试库覆盖用户库。
- 取消部署的清理只针对新建8310服务及明确release;不删除 `/opt/cybergril-data/`,不停止8300或其他项目服务。
限制:单用户内网、无新增鉴权体系;应沿既有内网访问边界使用。尚无产品级备份功能,本文的升级快照属于部署运维操作;真实页面不会声称已自动备份。SQLite单worker是当前约束,后续扩展需独立设计。
+64
View File
@@ -0,0 +1,64 @@
HEL-780 · R1 实现与安装运行说明
基准为 main `498429399812a0776dadd4b56662d31b455a9ea5`,保留原前端两主题与统一样式,未增加 CSS 或运行中的服务。代码审核后由总工决定部署;施工阶段未登录部署服务器、未占用8310,也未修改8300、模型服务或用户档案。
- `src/cybergril/api/app.py`:单进程 FastAPI 工厂;字段中文复验、无副作用真实排盘预览、唯一档案、只允许关系/称呼/边界修改、历史游标分页和 SSE。复用 `Store`、八字编译器、`ModelRouter` 与 `OpenAICompatLLM`。
- `memory/schema.sql`:只增 `companion`、`api_meta`、`chat_message` 及索引;既有八字/生活/记忆/关系表保留。WAL;启动自动加 epoch,并把上次意外退出遗留的 pending 标记 cancelled。
- `web/src/adapters/real.ts`、`app/RealApp.tsx`:普通入口只读服务器数据,404首建;复用创建、聊天、管理和人物静帧组件。真实入口不读/写 `cybergril.demo.v1`,草稿使用独立 `cg.real.draft`。`?demo=1` 仍由原 App 与 DemoAdapter 处理,零 API 请求。
- 创建第二步通过 `POST /api/companion/preview` 排盘,不写档案;仅最终确认 POST 才在一个事务内写唯一档案和八字。SQLite 单行主键与事务保证并发只成功一次。生辰按 Asia/Shanghai、公历、1900年起、满18岁复验;时辰映射为对应双小时内偶数点,不引入出生地点/真太阳时规则。
- SSE 使用同一 `turn_id` 和持久 `epoch`。前端每次发送先同步 epoch,并可处理发送前恰逢重启的409。轮次门槛继续拒绝旧事件;取消/退出直接 abort;服务端取消并等待上游结束后才开始新轮。响应缺少完成标志、空回答、异常或60秒超时均作为失败。部分回答丢弃,用户消息标 failed/cancelled,失败与取消的轮次不进下一轮上下文。成功回答可按同一 turn_id 回放,重试不会重复用户记录或重新生成已完成回答;并发相同轮次也只生成一次。
- 模型三档均使用批准网关与模型,保留原 temperature、provider timeout 与预算。dialogue_main 路由限制最近20轮,再按档位上下文预算保留完整近轮;不截断当前用户消息。每轮最多默认1024输出 token,usage 缺失时按字符数保守估算计账,沿用原预算降档策略。原 BudgetGuard 的预算是进程内运维量,重启重置;不是持久化费用账本。
API 契约:
| API | 成功/错误 |
|---|---|
| GET `/api/health` | 200;epoch、是否已创建、模型探测与能力声明。模型 `/models` 2xx且包含配置模型才为真;5秒上限、30秒缓存、并发探测合并;不返回地址或凭据 |
| GET `/api/companion` | 200档案(含只读四柱/白话),404未创建 |
| POST `/api/companion/preview` | `{name,birth,hour}`→只读底色,422中文字段错误 |
| POST `/api/companion` | `{name,birth,hour,relation,nickname?}`→201,409已有档案,422字段错误;拒绝额外字段 |
| PATCH `/api/relation` | `{relation,nickname?,boundary?}`→更新后档案;404/422;无任何生辰/底色修改入口 |
| GET `/api/messages?limit=200&before=<id>` | 升序 messages、next_before、epoch;每页1–200条;按id稳定排序;前端逐页恢复 |
| POST `/api/chat` | `{text,turn_id,epoch}`;UUID turn_id;1–4000字;SSE chat.delta/final/error;404/409/422。chat.error带中文phi和retryable |
已接通:真实唯一档案、排盘、文字流式对话、服务器聊天历史、关系修改。尚未接通:生活循环、长期记忆检索/整合、主动消息、语音、视觉、天气、素材包与服务器备份。真实页面明确不可用,不填示例经历或伪造执行结果。当前只有近轮对话上下文,不把它说成长期记忆。
安装(Python ≥3.11;隔离环境,Node/npm用于构建):
```bash
python3.11 -m venv .venv
.venv/bin/pip install -r requirements.lock
.venv/bin/pip install --no-deps .
npm ci --prefix web
npm run build --prefix web
```
`requirements.lock` 固定本轮已解析的运行时依赖;开发检查另装 `pip install -e '.[dev]'`。默认 Python 3.10 不满足项目要求。服务不自动读取 `.env`,变量需由启动环境或 systemd 提供:
```bash
export CYBERGRIL_DB=/absolute/separate/test-data/companion.db
export CYBERGRIL_HOST=127.0.0.1
export CYBERGRIL_PORT=18310
.venv/bin/python -m cybergril.api
```
从仓库根目录启动;默认模型配置 `config/models.yaml`、静态产物 `web/dist`,可分别通过 `CYBERGRIL_MODELS` / `CYBERGRIL_WEB` 指定绝对路径。本地默认数据库在用户 `.local/share/cybergril/`,生产必须显式指定独立数据目录。**单进程单worker,不启用reload或多worker。** 8310由总工部署时使用,本地测试脚本使用随机loopback端口、临时数据库并在结束时关闭服务。
自测:
```bash
.venv/bin/pytest -q
.venv/bin/ruff check src/cybergril/api src/cybergril/providers/llm/openai_compat.py tests/test_api.py
npm run typecheck --prefix web
npm run lint --prefix web
npm test --prefix web
npm run build --prefix web
# 下列为可选浏览器工具,只装在测试虚拟环境:
.venv/bin/pip install playwright
.venv/bin/python -m playwright install chromium
.venv/bin/python web/tests/real_browser_check.py
.venv/bin/python web/tests/browser_check.py 1440
.venv/bin/python web/tests/browser_check.py 390
```
浏览器脚本启动/关闭自己的HTTP服务。真实脚本只向批准网关发两轮完整对话和一轮中途取消;随后切同源隔离桩验证失败、重启和立即新轮,结果区分真实模型与桩。环境若需额外浏览器运行库,应使用现有用户级库,不改系统/GPU环境。本轮复用 `/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu` 设置 LD_LIBRARY_PATH;它不是部署依赖。
+7
View File
@@ -0,0 +1,7 @@
普通入口此前只有未接服务说明,无法创建真实档案或与模型聊天。本次接通唯一服务器档案、无副作用排盘预览、真实SSE文字对话、历史恢复与关系修改;演示入口继续独立使用本机资料,零API请求,保持原两主题和统一组件。
服务端复用Store/八字编译/模型路由与OpenAI兼容provider,增量建表,持久epoch随启动递增。取消会关闭上游且不把残句存成完整回答;失败、取消轮不进入后续上下文;同轮重试/并发回放去重。未接生活、长期记忆、语音、视觉、天气、素材和备份均明确不可用。
验证:Python92项通过(原78+新增14),前端14项通过;typecheck/lint/build及新增Pythonruff通过。真实网关两轮上下文与一次中途取消有浏览器证据;电脑与390×844独立上下文、重启epoch、隔离错误/超时/断流、演示双主题零API请求通过。手机为浏览器模拟,无真机。验证细节及版本边界见docs/r1/TEST-RECORD.md。
运行安装/数据语义见docs/r1/IMPLEMENTATION.md;8310部署回滚材料及单进程systemd示例已备。数据必须独立于代码release,回滚不删;首次上线留待用户自行创建。本次未部署、未合并、未触碰8300和共享模型服务,交总工独立审核。
+20
View File
@@ -0,0 +1,20 @@
HEL-780 · R1 自测记录 · 2026-10-22(北京时间)
环境:Python3.11.16独立虚拟环境、Node24/npm11、Playwright1.64无头Chromium;生产前端构建;服务均为本机随机loopback端口、临时SQLite。未部署8310、未连接部署服务器、未访问4080或改动8300。真实模型仅连接批准的 .11:8731/v1,不安装或修改模型。
| 批准方案2.3 / 本卡补充验收 | 实测 |
|---|---|
| 1. 全新首建、真实排盘、必选关系 | 电脑四步浏览器通过;第二步四柱为庚辰/丙戌/己酉/乙亥;preview后companion与bazi_core均零记录;最终确认才写。API绕过校验:非法名/日期/未成年/早于1900/时辰/关系/未知字段均422中文错误;5个并发创建仅1个201,其余409;底色字段不可PATCH |
| 2. 真实流式、取消和新轮隔离 | 网关两轮完成:设置“蓝色风铃”暗号,次轮直接答“蓝色风铃”。第三轮长故事在首个delta后取消;用户记录cancelled、无完整回答入库、上游请求已结束。隔离慢provider紧接新轮通过;另以真实OpenAICompatLLM连接隔离HTTP网关,验证浏览器侧断流后上游HTTP响应在1秒内关闭 |
| 3. 重开/另一设备恢复 | 实际reload及另一独立Chromium上下文(390×844)恢复同一“星河”和两轮历史。**没有物理手机或另一台物理设备,独立上下文与手机视口模拟不称真机** |
| 4. 重启epoch | 同origin停/启本地API,复用同一临时DB;epoch1→2,原页面直接续发通过。API测试同时验证档案/关系/历史保留,旧epoch409携当前epoch;前端单测覆盖过期epoch、其他turn拒绝 |
| 5. 模型异常、失败草稿与去重 | 隔离错误provider抛异常→中文chat.error,隐藏内部详情、草稿恢复;同轮重试只增加1条回答,无重复用户记录。后端覆盖超时和上游提前结束;SSE单测覆盖UTF-8逐字节/CRLF/多帧/坏JSON/无final;并发相同turn只调用模型一次。另验证较早失败轮在较新轮完成后重试,上下文仍按完成轮次正确成对 |
| 6. 演示隔离 | 演示电脑1440与手机390双主题全流程通过;每尺寸23个本机静态请求,**零API/外部请求**;无麦克风/摄像头申请。真实入口用存储读取陷阱断言cybergril.demo.v1零读取。正常页面无演示控制台;日子/长期记忆/语音/视觉/天气/素材/备份明确未接通 |
| 7. health与history | health模型要求/models成功且含配置模型;401/404/503及其他模型均false。5秒超时、30秒缓存测试通过;不含地址/敏感配置。历史limit1–200、确定id顺序与before分页;非法页大小422 |
| 8. 构建与测试 | Python **92 passed**(原78+新增14个参数化实例);前端 **14 passed**(原11+SSE3);typecheck/lint/build通过;新增Python与provider的ruff通过。CSS仍为原61.20KB,JS约207.70KB/gzip67.45KB;未加覆盖样式 |
浏览器截图在本目录:desktop-preview-moon、desktop-real-chat-moon、desktop-history-moon、desktop-model-error(该张为隔离故障)、mobile-history-moon、mobile-chat-fog、mobile-relation-fog。原始真实网关对话及逐项记录见 `browser-results.json`;末版隔离复测见 `controlled/browser-results.json`,来源明确区分,未拿桩回复冒充真实模型。
最终运行时锁定为requirements.lock(FastAPI0.159.1、Starlette1.7.0、uvicorn0.54.0、lunar-python1.4.8等)。初次真实网关浏览器证据采集时Starlette为1.9.0;锁定后在1.7.0完整重跑92项Python(含真实OpenAI兼容HTTP断流)、前端检查与相同浏览器关键路径隔离复测,均通过,避免为凑线重复调用共享模型。pytest有1条上游TestClient/httpx弃用提示,不影响结果,未另装不需要的httpx2。
没有覆盖:真机浏览器、正式8310/系统服务重启、生产数据迁移/权限与真实部署回滚、长期并发/长时间运行。上述部署验证由总工在审核后执行;模板与数据保留步骤见DEPLOY-ROLLBACK。当前单用户内网、单进程;近轮上下文与进程内预算不是长期记忆或持久费用账本。
+35
View File
@@ -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": []
}
+35
View File
@@ -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: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+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

+4
View File
@@ -16,6 +16,10 @@ dependencies = [
"httpx>=0.27",
"pyyaml>=6.0",
"pydantic>=2.7",
"fastapi>=0.115,<1",
"anyio>=4,<5",
"uvicorn>=0.30,<1",
"lunar-python>=1.4,<2",
]
[project.optional-dependencies]
+60
View File
@@ -0,0 +1,60 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --python .venv311/bin/python pyproject.toml -o requirements.lock
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.15.1
# via
# cybergril (pyproject.toml)
# httpx
# starlette
certifi==2026.10.19
# via
# httpcore
# httpx
click==8.5.0
# via uvicorn
fastapi==0.159.1
# via cybergril (pyproject.toml)
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via cybergril (pyproject.toml)
idna==3.20
# via
# anyio
# httpx
lunar-python==1.4.8
# via cybergril (pyproject.toml)
opentelemetry-api==1.45.1
# via fastapi
pydantic==2.14.1
# via
# cybergril (pyproject.toml)
# fastapi
pydantic-core==2.50.1
# via pydantic
pyyaml==6.0.3
# via cybergril (pyproject.toml)
starlette==1.7.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# fastapi
# opentelemetry-api
# pydantic
# pydantic-core
# starlette
# typing-inspection
typing-inspection==0.4.4
# via
# fastapi
# pydantic
uvicorn==0.54.0
# via cybergril (pyproject.toml)
+1
View File
@@ -0,0 +1 @@
"""R1 same-origin persistent text service."""
+13
View File
@@ -0,0 +1,13 @@
"""Run with python -m cybergril.api (one worker, no reload)."""
import os
import uvicorn
uvicorn.run(
"cybergril.api.app:create_app",
factory=True,
host=os.environ.get("CYBERGRIL_HOST", "127.0.0.1"),
port=int(os.environ.get("CYBERGRIL_PORT", "8310")),
workers=1,
)
+518
View File
@@ -0,0 +1,518 @@
"""Single-process R1 API. Store is authoritative; no demo state enters this module."""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
import time
from contextlib import asynccontextmanager, suppress
from dataclasses import asdict
from datetime import date, datetime
from pathlib import Path
from typing import Literal
from uuid import UUID
from zoneinfo import ZoneInfo
import yaml
from anyio import CancelScope
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, field_validator
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
HOURS = [
"子时",
"丑时",
"寅时",
"卯时",
"辰时",
"巳时",
"午时",
"未时",
"申时",
"酉时",
"戌时",
"亥时",
]
ERROR = "她这会儿没接上话,稍后再试试。你写的话还在。"
def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str:
value = value.strip()
if (
(required and not value)
or len(value) > maximum
or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value)
):
raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。")
return value
class StrictBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class BirthInput(StrictBody):
name: str
birth: str
hour: str
@field_validator("name")
@classmethod
def name_valid(cls, value: str) -> str:
return safe_text(value, 20, "名字", True)
@field_validator("birth")
@classmethod
def birth_valid(cls, value: str) -> str:
try:
born = date.fromisoformat(value)
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
if born.isoformat() != value or born.year < 1900 or age < 18:
raise ValueError
except ValueError as exc:
raise ValueError("请填写有效的出生日期,且已满18岁(不早于1900年)。") from exc
return value
@field_validator("hour")
@classmethod
def hour_valid(cls, value: str) -> str:
if value not in HOURS:
raise ValueError("请选择有效的出生时辰。")
return value
class RelationInput(StrictBody):
relation: Literal["朋友", "恋人"]
nickname: str = ""
boundary: str = "尊重彼此的节奏"
@field_validator("nickname")
@classmethod
def nickname_valid(cls, value: str) -> str:
return safe_text(value, 40, "称呼")
@field_validator("boundary")
@classmethod
def boundary_valid(cls, value: str) -> str:
return safe_text(value, 2000, "相处边界")
class CreationInput(BirthInput, RelationInput):
pass
class ChatInput(StrictBody):
text: str
turn_id: str
epoch: int
@field_validator("text")
@classmethod
def text_valid(cls, value: str) -> str:
if not value.strip() or len(value) > 4000:
raise ValueError("请输入1–4000字的消息。")
return value.strip()
@field_validator("turn_id")
@classmethod
def turn_valid(cls, value: str) -> str:
try:
UUID(value)
except ValueError as exc:
raise ValueError("轮次标识无效,请刷新后重试。") from exc
return value
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 color_view(data.birth, data.hour, pillars, derived), pillars, derived
def create_app(
*,
db_path: Path | str | None = None,
models: ModelsConfig | None = None,
router=None,
web_dir: Path | None = None,
stream_timeout: float = 60,
) -> FastAPI:
"""Dependencies can be injected for isolated fault/cancellation tests, never via browser flags."""
db_path = db_path or os.environ.get(
"CYBERGRIL_DB", str(Path.home() / ".local/share/cybergril/companion.db")
)
if models is None:
config = Path(os.environ.get("CYBERGRIL_MODELS", "config/models.yaml"))
models = ModelsConfig(**yaml.safe_load(config.read_text()))
router = router or build_router_from_config(models)
@asynccontextmanager
async def lifespan(app):
store = Store(db_path)
app.state.store = store
with store.conn:
store.execute(
"INSERT INTO api_meta(key,value) VALUES('epoch',1) ON CONFLICT(key) DO UPDATE SET value=value+1"
)
store.execute("UPDATE chat_message SET status='cancelled' WHERE status='pending'")
app.state.epoch = store.query_one("SELECT value FROM api_meta WHERE key='epoch'")["value"]
app.state.active = None
app.state.chat_lock = asyncio.Lock()
app.state.health_cache = (0, False)
app.state.health_lock = asyncio.Lock()
try:
yield
finally:
if app.state.active:
app.state.active["cancel"].set()
await app.state.active["finished"].wait()
for provider in set(router.provider_for(t) for t in router.available_tiers()):
await provider.aclose()
store.close()
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
@app.exception_handler(RequestValidationError)
async def invalid(_request, exc):
fields = {}
for e in exc.errors():
key = str(e["loc"][-1])
msg = e["msg"].removeprefix("Value error, ")
if e["type"] == "missing":
msg = "请填写此项。" if key != "relation" else "请亲自选择你们的关系。"
elif e["type"] == "literal_error":
msg = "请亲自选择朋友或恋人。"
elif e["type"] == "extra_forbidden":
msg = "此字段不允许修改。"
elif not any("\u4e00" <= c <= "\u9fff" for c in msg):
msg = "字段格式无效,请检查后重试。"
fields[key] = msg
return JSONResponse({"detail": "请检查填写内容。", "fields": fields}, status_code=422)
def companion(enrich=True):
row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1")
if not row:
raise HTTPException(404, "还没有创建真实伴侣。")
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():
async with app.state.health_lock:
checked, ok = app.state.health_cache
if time.monotonic() - checked > 30:
provider = router.route(Scene.DIALOGUE_MAIN).provider
try:
async with asyncio.timeout(5):
ok = bool(provider and (await provider.health()).ok)
except Exception:
ok = False
app.state.health_cache = (time.monotonic(), ok)
return {
"ok": True,
"version": "R1",
"epoch": app.state.epoch,
"companion_created": bool(app.state.store.query_one("SELECT id FROM companion")),
"llm": {"ok": ok, "model": models.tiers["high"].model},
"capabilities": {
"text": True,
**dict.fromkeys(
["life", "memory", "voice", "vision", "weather", "materials", "backup"], False
),
},
}
@app.get("/api/companion")
async def get_companion():
return companion()
@app.post("/api/companion/preview")
async def preview(data: BirthInput):
return compile_birth(data)[0]
@app.post("/api/companion", status_code=201)
async def create(data: CreationInput):
store = app.state.store
if store.query_one("SELECT id FROM companion"):
raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。")
color, pillars, derived = compile_birth(data)
identity = {"id": "1", **data.model_dump(), "color": color}
try:
with store.conn:
store.execute(
"INSERT INTO companion VALUES(1,?)", (json.dumps(identity, ensure_ascii=False),)
)
store.execute(
"""INSERT INTO bazi_core
(id,birth_input,pillars,day_master,day_master_elem,ten_gods,element_scores,strength,derived,created_at)
VALUES(1,?,?,?,?,?,?,?,?,?)""",
(
json.dumps({"birth": data.birth, "hour": data.hour, "tz": "Asia/Shanghai"}),
json.dumps(asdict(pillars), ensure_ascii=False),
pillars.day[0],
GAN_ELEM[pillars.day[0]],
json.dumps(ten_god_distribution(pillars)),
json.dumps(element_scores(pillars)),
derived.strength,
json.dumps(derived.as_dict()),
int(time.time() * 1000),
),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") from exc
return identity
@app.patch("/api/relation")
async def relation(data: RelationInput):
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 companion()
@app.get("/api/messages")
async def messages(
limit: int = Query(200, ge=1, le=200), before: int | None = Query(None, ge=1)
):
companion()
rows = app.state.store.query(
"SELECT * FROM chat_message WHERE id<? ORDER BY id DESC LIMIT ?",
(before or 9223372036854775807, limit + 1),
)
more = len(rows) > limit
rows = rows[:limit]
return {
"messages": [dict(r) for r in reversed(rows)],
"next_before": rows[-1]["id"] if more else None,
"epoch": app.state.epoch,
}
def event(data, kind, **payload):
return (
"data: "
+ json.dumps(
{"t": kind, "epoch": app.state.epoch, "turn_id": data.turn_id, **payload},
ensure_ascii=False,
)
+ "\n\n"
)
@app.post("/api/chat")
async def chat(data: ChatInput, request: Request):
identity = companion()
if data.epoch != app.state.epoch:
return JSONResponse(
{"detail": "服务已重新启动,请同步后重试。", "epoch": app.state.epoch},
status_code=409,
)
store = app.state.store
# Serialize reservation, including replays. Concurrent retries of a completed
# turn must never call the model twice or race the unique message constraint.
async with app.state.chat_lock:
previous = store.query_one(
"SELECT * FROM chat_message WHERE turn_id=? AND role='user'", (data.turn_id,)
)
if previous and previous["text"] != data.text:
raise HTTPException(409, "相同轮次不能改写消息。")
if app.state.active:
active = app.state.active
if active["turn_id"] != data.turn_id:
active["cancel"].set()
await active["finished"].wait()
saved = store.query_one(
"SELECT text FROM chat_message WHERE turn_id=? AND role='companion' AND status='sent'",
(data.turn_id,),
)
if saved:
async def replay():
yield event(
data, "chat.final", utterance={"text": saved["text"], "speech_tags": []}
)
return StreamingResponse(replay(), media_type="text/event-stream")
active = {
"cancel": asyncio.Event(),
"finished": asyncio.Event(),
"turn_id": data.turn_id,
}
app.state.active = active
with store.conn:
store.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'user',?,?,'pending') ON CONFLICT(turn_id,role) DO UPDATE SET status='pending'",
(int(time.time() * 1000), data.text, data.turn_id),
)
turns = store.query("""SELECT u.text AS user_text, a.text AS assistant_text
FROM chat_message a JOIN chat_message u ON u.turn_id=a.turn_id AND u.role='user'
WHERE a.role='companion' AND a.status='sent' AND u.status='sent'
ORDER BY a.id DESC LIMIT 20""")
system = (
f"你叫{identity['name']},与对方是{identity['relation']}。称呼对方:{identity['nickname'] or '你'}。"
f"相处边界:{identity['boundary']}。"
"以下是与档案页一致的底色倾向,只约束表达与相处方式,不得当作当前情绪、命定结局或既有经历:"
+ "\n".join(f"{s['title']}:{s['text']}" for s in identity['color'].get('sections', []))
+
"用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。"
"当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。"
"不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。"
"不窥探用户未分享的信息,不以缺席、孤独或终止要挟对方,不输出内部数值或思考过程。"
)
context = (
[LLMMessage(role="system", content=system)]
+ [
message
for turn in reversed(turns)
for message in (
LLMMessage(role="user", content=turn["user_text"]),
LLMMessage(role="assistant", content=turn["assistant_text"]),
)
]
+ [LLMMessage(role="user", content=data.text)]
)
async def generate():
completed = False
status, text, upstream, pending, cancelling = "cancelled", "", None, None, None
usage = {}
try:
decision = router.route(
Scene.DIALOGUE_MAIN,
estimated_tokens=sum(len(m.content) for m in context) + 1024,
)
if not decision.provider:
raise RuntimeError("No dialogue provider")
history_budget = max(0, decision.context_budget - len(system) - len(data.text))
recent = []
# Order by completed turns, not adjacent message ids: a retried user
# may have an older id than messages sent while that turn had failed.
for turn in turns:
size = len(turn["user_text"]) + len(turn["assistant_text"])
if size > history_budget:
break
recent = [
LLMMessage(role="user", content=turn["user_text"]),
LLMMessage(role="assistant", content=turn["assistant_text"]),
] + recent
history_budget -= size
context[:] = [context[0]] + recent + [context[-1]]
cfg = models.tiers[decision.effective.value]
upstream = decision.provider.stream(
context, temperature=cfg.temperature, max_tokens=cfg.max_tokens or 1024
)
cancelling = asyncio.create_task(active["cancel"].wait())
async with asyncio.timeout(stream_timeout):
while True:
pending = asyncio.create_task(anext(upstream))
done, _ = await asyncio.wait(
[pending, cancelling], return_when=asyncio.FIRST_COMPLETED
)
if cancelling in done or await request.is_disconnected():
return
try:
delta = pending.result()
except StopAsyncIteration:
break
if delta.usage:
usage = delta.usage
if delta.text:
text += delta.text
yield event(data, "chat.delta", delta=delta.text)
if delta.done or delta.finish_reason:
completed = True
if delta.done:
break
if active["cancel"].is_set() or await request.is_disconnected():
return
if not text.strip() or not completed:
raise RuntimeError("Empty upstream response")
with store.conn:
store.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'companion',?,?,'sent')",
(int(time.time() * 1000), text, data.turn_id),
)
store.execute(
"UPDATE chat_message SET status='sent' WHERE turn_id=?", (data.turn_id,)
)
status = "sent"
yield event(data, "chat.final", utterance={"text": text, "speech_tags": []})
except asyncio.CancelledError:
raise
except Exception:
status = "failed"
yield event(data, "chat.error", phi=ERROR, retryable=True)
finally:
with CancelScope(shield=True):
# Cancel the in-flight __anext__ before closing the async generator.
for task in (pending, cancelling):
if task is not None:
task.cancel()
with suppress(asyncio.CancelledError, StopAsyncIteration, Exception):
await task
if upstream is not None:
with suppress(Exception):
await upstream.aclose()
try:
if status != "sent":
with store.conn:
store.execute(
"UPDATE chat_message SET status=? WHERE turn_id=? AND role='user'",
(status, data.turn_id),
)
if usage:
router.guard.record_usage(usage, Scene.DIALOGUE_MAIN.value)
else:
router.guard.record(
sum(len(m.content) for m in context) + len(text),
Scene.DIALOGUE_MAIN.value,
)
finally:
if app.state.active is active:
app.state.active = None
active["finished"].set()
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
static = web_dir or Path(os.environ.get("CYBERGRIL_WEB", "web/dist"))
if static.is_dir():
app.mount("/", StaticFiles(directory=static, html=True), name="web")
return app
+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": "这是以四柱、五行、十神和已存底色为依据的角色塑造说明,不是对真人性格的测定,也不是已经发生的经历。倾向不等于固定台词或此刻情绪。",
}
+16
View File
@@ -184,3 +184,19 @@ CREATE VIRTUAL TABLE IF NOT EXISTS mem_fts USING fts5(
-- epi_id INTEGER PRIMARY KEY,
-- embedding FLOAT[1024]
-- );
-- R1 additive migration: no existing memory/state table is replaced.
CREATE TABLE IF NOT EXISTS api_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS companion (
id INTEGER PRIMARY KEY CHECK(id=1), identity_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user','companion')),
text TEXT NOT NULL,
turn_id TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending','sent','failed','cancelled')),
UNIQUE(turn_id, role)
);
CREATE INDEX IF NOT EXISTS idx_chat_order ON chat_message(id);
+2 -2
View File
@@ -71,8 +71,8 @@ class OpenAICompatLLM(LLMProvider):
async def health(self) -> HealthStatus:
try:
r = await self._client.get("/models", timeout=5.0)
return HealthStatus(ok=r.status_code < 500, detail=str(r.status_code))
except httpx.HTTPError as exc:
return HealthStatus(ok=r.is_success and any(m.get("id") == self.model for m in r.json().get("data", [])), detail=str(r.status_code))
except (httpx.HTTPError, ValueError, TypeError, AttributeError) as exc:
return HealthStatus(ok=False, detail=f"{type(exc).__name__}: {exc}")
async def stream(
+381
View File
@@ -0,0 +1,381 @@
"""R1 invariants through HTTP and real loopback streaming, with isolated databases."""
import asyncio
import concurrent.futures
import json
import socket
import threading
import time
from contextlib import contextmanager
from uuid import uuid4
import httpx
import pytest
import uvicorn
from fastapi.testclient import TestClient
from cybergril.api.app import create_app
from cybergril.contracts import HealthStatus, LLMDelta
from cybergril.providers.llm.echo import EchoLLM
from cybergril.think.router import build_router
from cybergril.think.scenes import Tier
CREATE = {"name": "星河", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
class ControlledLLM(EchoLLM):
def __init__(self):
super().__init__()
self.calls = []
self.closed = 0
self.mode = "normal"
self.health_calls = 0
async def health(self):
self.health_calls += 1
if self.mode == "health_timeout":
await asyncio.sleep(30)
return HealthStatus(ok=self.mode != "unreachable")
async def stream(self, messages, **kwargs):
self.calls.append(messages)
try:
if self.mode == "error":
raise OSError("secret upstream configuration must never be exposed")
yield LLMDelta(text="答")
if self.mode == "slow":
await asyncio.sleep(30)
if self.mode == "truncated":
return
yield LLMDelta(text="复")
yield LLMDelta(done=True)
finally:
self.closed += 1
def make_app(path, provider=None, **kwargs):
provider = provider or ControlledLLM()
router = build_router(
providers={Tier.HIGH: provider, Tier.STANDARD: provider, Tier.LOW: provider}
)
return create_app(db_path=path, router=router, **kwargs), provider
def turn(client, text="你好", turn_id=None, epoch=None):
return client.post(
"/api/chat",
json={
"text": text,
"turn_id": turn_id or str(uuid4()),
"epoch": epoch or client.get("/api/health").json()["epoch"],
},
)
def events(response):
return [
json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")
]
def test_preview_validation_singleton_and_readonly(tmp_path):
app, _ = make_app(tmp_path / "test.db")
with TestClient(app) as client:
assert client.get("/api/companion").status_code == 404
preview = client.post(
"/api/companion/preview", json={k: CREATE[k] for k in ("name", "birth", "hour")}
)
assert preview.status_code == 200
assert len(preview.json()["pillars"]["day"]) == 2
assert client.get("/api/companion").status_code == 404
assert app.state.store.query_one("SELECT count(*) n FROM bazi_core")["n"] == 0
for bad in [
dict(CREATE, name="<x>"),
dict(CREATE, birth="2000-02-30"),
dict(CREATE, birth="2020-01-01"),
dict(CREATE, birth="1899-01-01"),
dict(CREATE, hour="未知"),
dict(CREATE, relation=""),
dict(CREATE, nickname="x\x01"),
{k: v for k, v in CREATE.items() if k != "relation"},
dict(CREATE, derived={}),
]:
response = client.post("/api/companion", json=bad)
assert response.status_code == 422, response.text
assert response.json()["fields"]
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
codes = list(
pool.map(lambda _: client.post("/api/companion", json=CREATE).status_code, range(5))
)
assert sorted(codes) == [201, 409, 409, 409, 409]
identity = client.get("/api/companion").json()
assert identity["color"] == preview.json()
assert "derived" not in identity and "strength" not in identity
assert (
client.patch(
"/api/relation", json={"relation": "恋人", "birth": "1990-01-01"}
).status_code
== 422
)
assert app.state.store.query_one("PRAGMA journal_mode")[0] == "wal"
def test_history_context_relation_retry_and_restart(tmp_path):
path = tmp_path / "test.db"
app, provider = make_app(path)
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
epoch = client.get("/api/health").json()["epoch"]
tid = str(uuid4())
result = events(turn(client, "我的暗号是蓝星", tid))
assert result[-1]["t"] == "chat.final"
assert all(e["turn_id"] == tid and e["epoch"] == epoch for e in result)
assert result[0]["t"] == "chat.delta"
turn(client, "记得暗号吗")
assert any(m.content == "我的暗号是蓝星" for m in provider.calls[-1])
assert (
"星河" in provider.calls[-1][0].content
and "尚未接通" not in provider.calls[-1][-1].content
)
assert len(client.get("/api/messages").json()["messages"]) == 4
assert events(turn(client, "我的暗号是蓝星", tid))[-1]["t"] == "chat.final"
assert len(provider.calls) == 2 # A lost final can be replayed without regenerating.
assert turn(client, "tampered", tid).status_code == 409
first = client.get("/api/messages?limit=2").json()
second = client.get(f"/api/messages?limit=2&before={first['next_before']}").json()
assert second["messages"][-1]["id"] < first["messages"][0]["id"]
assert client.get("/api/messages?limit=201").status_code == 422
core = dict(app.state.store.query_one("SELECT * FROM bazi_core"))
updated = client.patch(
"/api/relation", json={"relation": "恋人", "nickname": "小星", "boundary": "慢慢来"}
).json()
assert updated["relation"] == "恋人"
assert dict(app.state.store.query_one("SELECT * FROM bazi_core")) == core
assert len(client.get("/api/messages").json()["messages"]) == 4
app2, _ = make_app(path)
with TestClient(app2) as client:
assert client.get("/api/health").json()["epoch"] == epoch + 1
assert client.get("/api/companion").json() == updated
assert len(client.get("/api/messages").json()["messages"]) == 4
assert turn(client, epoch=epoch).json()["epoch"] == epoch + 1
assert events(turn(client))[-1]["t"] == "chat.final"
def test_errors_retry_context_excludes_failed_and_health_cache(tmp_path):
app, provider = make_app(tmp_path / "test.db", stream_timeout=0.03)
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
provider.mode = "unreachable"
health = client.get("/api/health").json()
assert not health["llm"]["ok"]
assert not health["capabilities"]["life"]
client.get("/api/health")
assert provider.health_calls == 1
for mode in ("error", "slow", "truncated"):
provider.mode = mode
tid = str(uuid4())
response = turn(client, text="失败的消息" + mode, turn_id=tid)
assert events(response)[-1]["t"] == "chat.error"
assert "secret" not in response.text
assert client.get("/api/messages").json()["messages"][-1]["status"] == "failed"
provider.mode = "normal"
response = turn(client, text="重试成功")
assert events(response)[-1]["t"] == "chat.final"
assert len(provider.calls[-1]) == 2 # system + current; no failed turns
provider.mode = "error"
tid = str(uuid4())
turn(client, text="同一轮", turn_id=tid)
count = len(client.get("/api/messages").json()["messages"])
provider.mode = "normal"
turn(client, text="同一轮", turn_id=tid)
assert len(client.get("/api/messages").json()["messages"]) == count + 1
assert provider.closed == len(provider.calls)
@contextmanager
def live_server(app, port=0):
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", port))
sock.listen()
server = uvicorn.Server(uvicorn.Config(app, log_level="error", lifespan="on"))
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]})
thread.start()
try:
deadline = time.monotonic() + 10
while not server.started:
if not thread.is_alive() or time.monotonic() > deadline:
raise RuntimeError("local test server failed to start")
time.sleep(0.01)
yield f"http://127.0.0.1:{sock.getsockname()[1]}"
finally:
server.should_exit = True
thread.join(timeout=10)
sock.close()
assert not thread.is_alive()
@pytest.mark.asyncio
async def test_real_disconnect_and_immediate_new_turn(tmp_path):
app, provider = make_app(tmp_path / "test.db")
with live_server(app) as url:
async with httpx.AsyncClient(base_url=url) as client:
await client.post("/api/companion", json=CREATE)
epoch = (await client.get("/api/health")).json()["epoch"]
provider.mode = "slow"
tid = str(uuid4())
async with client.stream(
"POST", "/api/chat", json={"text": "取消我", "turn_id": tid, "epoch": epoch}
) as response:
async for line in response.aiter_lines():
if line.startswith("data:"):
break
# Closing the browser response must cancel upstream while its next token is blocked.
deadline = time.monotonic() + 1
while provider.closed < 1 and time.monotonic() < deadline:
await asyncio.sleep(0.01)
assert provider.closed == 1
provider.mode = "normal"
new_tid = str(uuid4())
response = await client.post(
"/api/chat", json={"text": "新问题", "turn_id": new_tid, "epoch": epoch}
)
assert events(response)[-1]["t"] == "chat.final"
assert all(e["turn_id"] == new_tid for e in events(response))
messages = (await client.get("/api/messages")).json()["messages"]
assert messages[0]["status"] == "cancelled"
assert len(messages) == 3 # discarded partial reply is never a complete answer
assert len(provider.calls[-1]) == 2 # cancelled turn excluded from context
def test_concurrent_identical_retry_generates_once(tmp_path):
app, provider = make_app(tmp_path / "test.db")
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
epoch = client.get("/api/health").json()["epoch"]
tid = str(uuid4())
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
responses = list(pool.map(lambda _: turn(client, "同一轮", tid, epoch), range(4)))
assert all(events(response)[-1]["t"] == "chat.final" for response in responses)
assert len(provider.calls) == 1
assert len(client.get("/api/messages").json()["messages"]) == 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status,data,expected",
[
(401, {}, False),
(404, {}, False),
(503, {}, False),
(200, {"data": [{"id": "other"}]}, False),
(200, {"data": [{"id": "test-model"}]}, True),
],
)
async def test_model_health_requires_success_and_requested_model(status, data, expected):
from cybergril.providers.llm.openai_compat import OpenAICompatLLM
provider = OpenAICompatLLM(base_url="http://isolated/v1", model="test-model")
await provider._client.aclose()
provider._client = httpx.AsyncClient(
base_url="http://isolated/v1",
transport=httpx.MockTransport(lambda request: httpx.Response(status, json=data)),
)
try:
assert (await provider.health()).ok is expected
finally:
await provider.aclose()
def test_health_timeout_is_bounded_and_cached(tmp_path):
app, provider = make_app(tmp_path / "test.db")
provider.mode = "health_timeout"
with TestClient(app) as client:
start = time.monotonic()
assert client.get("/api/health").json()["llm"]["ok"] is False
assert time.monotonic() - start < 6
assert client.get("/api/health").json()["llm"]["ok"] is False
assert provider.health_calls == 1
@pytest.mark.asyncio
async def test_disconnect_closes_real_openai_http_stream(tmp_path):
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from cybergril.providers.llm.openai_compat import OpenAICompatLLM
closed = threading.Event()
gateway = FastAPI()
@gateway.post("/v1/chat/completions")
async def completion():
async def chunks():
try:
yield 'data: {"choices":[{"delta":{"content":"部分"}}]}\n\n'
await asyncio.sleep(30)
finally:
closed.set()
return StreamingResponse(chunks(), media_type="text/event-stream")
with live_server(gateway) as gateway_url:
provider = OpenAICompatLLM(base_url=gateway_url + "/v1", model="controlled")
app, _ = make_app(tmp_path / "test.db", provider)
with live_server(app) as url:
async with httpx.AsyncClient(base_url=url) as client:
await client.post("/api/companion", json=CREATE)
async with client.stream(
"POST",
"/api/chat",
json={"text": "断开上游", "turn_id": str(uuid4()), "epoch": 1},
) as response:
async for line in response.aiter_lines():
if line.startswith("data:"):
break
deadline = time.monotonic() + 1
while not closed.is_set() and time.monotonic() < deadline:
await asyncio.sleep(0.01)
assert closed.is_set() # Real OpenAICompatLLM's HTTP response was actually closed.
def test_retry_old_turn_preserves_user_assistant_context_pairs(tmp_path):
app, provider = make_app(tmp_path / "test.db")
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
tid = str(uuid4())
provider.mode = "error"
turn(client, "旧轮失败", tid)
provider.mode = "normal"
turn(client, "中间的新轮")
turn(client, "旧轮失败", tid)
turn(client, "最后一轮")
context = provider.calls[-1]
assert [m.role for m in context] == [
"system",
"user",
"assistant",
"user",
"assistant",
"user",
]
assert [m.content for m in context[1::2]] == ["中间的新轮", "旧轮失败", "最后一轮"]
def test_upstream_http_503_maps_to_chinese_sse_error(tmp_path):
from cybergril.providers.llm.openai_compat import OpenAICompatLLM
provider = OpenAICompatLLM(base_url="http://isolated/v1", model="controlled")
provider._client = httpx.AsyncClient(
base_url="http://isolated/v1",
transport=httpx.MockTransport(
lambda request: httpx.Response(503, json={"error": "secret gateway path"})
),
)
app, _ = make_app(tmp_path / "test.db", provider)
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
response = turn(client, epoch=1)
assert events(response)[-1]["t"] == "chat.error"
assert "稍后再试" in response.text and "secret" not in response.text
assert client.get("/api/messages").json()["messages"][0]["status"] == "failed"
+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
+87
View File
@@ -0,0 +1,87 @@
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;
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> {
let response: Response;
try { response = await fetch(`/api/${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers }, signal: init?.signal ?? AbortSignal.timeout(10000) }); }
catch (e) { if (init?.signal?.aborted) throw e; throw new Error('暂时连不上真实服务,请检查连接后重试。'); }
const data = await response.json().catch(() => { throw new Error('真实服务暂时没有正确响应,请稍后重试。'); });
if (!response.ok) throw new Error(data.fields ? Object.values(data.fields).join(' ') : data.detail || '这次没有完成,请稍后重试。');
return data as T;
}
export async function loadReal(): Promise<DemoState | null> {
let response: Response;
try { response = await fetch('/api/companion', { signal: AbortSignal.timeout(10000) }); } catch { throw new Error('真实档案暂时连不上,请检查连接后重试。'); }
if (response.status === 404) return null;
if (!response.ok) throw new Error('真实档案暂时读不出来,请稍后重试。');
const identity: RealIdentity = await response.json();
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.
const messages: Message[] = [];
let before: number | null = null;
do {
const page: { messages: { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status'] }[]; next_before: number | null } = await api(`messages?limit=200${before ? `&before=${before}` : ''}`);
messages.unshift(...page.messages.map(m => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: [], turnId: m.turn_id, status: m.status })));
before = page.next_before;
} while (before);
return messages;
}
// Streaming decoder retains incomplete UTF-8 bytes, CRLF and multi-line SSE frames.
export async function readEvents(body: ReadableStream<Uint8Array>, onEvent: (e: ServerEvent) => void) {
const reader = body.getReader(), decoder = new TextDecoder();
let buffer = '', final = false;
const consume = () => {
let boundary: RegExpExecArray | null;
while ((boundary = /\r?\n\r?\n/u.exec(buffer))) {
const frame = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary[0].length);
const data = frame.split(/\r?\n/u).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
if (!data) continue;
let e: ServerEvent;
try { e = JSON.parse(data) as ServerEvent; } catch { throw new Error('收到的对话数据不完整,请重试这一轮。'); }
if (e.t === 'chat.final') final = true;
onEvent(e);
}
};
try {
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
consume();
if (done) break;
}
if (!final) throw new Error('连接中断了,你写的话还在,可以重试。');
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
}
export class RealChatAdapter implements ChatAdapter {
constructor(private syncEpoch: (epoch: number) => void) {}
async send(text: string, turnId: string, _epoch: number, signal: AbortSignal, emit: (e: ServerEvent) => void) {
let health = await api<Health>('health', { signal });
this.syncEpoch(health.epoch);
let response: Response | undefined;
for (let attempt = 0; attempt < 2; attempt++) {
response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, turn_id: turnId, epoch: health.epoch }), signal });
if (response.status !== 409) break;
const error = await response.json();
if (!error.epoch) throw new Error(error.detail);
health = { ...health, epoch: error.epoch }; this.syncEpoch(health.epoch);
}
if (!response?.ok || !response.body) throw new Error('她这会儿没接上话,稍后再试试。你写的话还在。');
await readEvents(response.body, e => { if (e.turn_id === turnId && e.epoch === health.epoch && !signal.aborted) { if (e.t === 'chat.error') throw new Error(e.phi); emit(e); } });
}
}
+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]);
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, loadReal, type Health } from '../adapters/real';
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';
export function RealApp() {
const [state, setState] = useState<DemoState | null>(null), [ready, setReady] = useState(false), [creating, setCreating] = useState(false), [error, setError] = useState(''), [health, setHealth] = useState<Health>(), [area, setArea] = useState('now'), [log, setLog] = useState(false), [presence, setPresence] = useState('文字已接通');
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
const latest = useRef(state); latest.current = state;
const initialize = useCallback(async () => {
setError(''); setReady(false);
try { setHealth(await api<Health>('health')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); }
catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); }
}, []);
useEffect(() => { void initialize(); }, [initialize]);
useEffect(() => { document.documentElement.dataset.theme = theme; try { localStorage.setItem('cg.theme', theme); } catch { /* Preference stays in memory. */ } }, [theme]);
useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]);
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(() => {}, []);
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><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=""/>}
<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></>}
</>}</>;
}
+35 -22
View File
@@ -1,10 +1,13 @@
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';
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: {
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;
onChange: (messages: Message[]) => void;
onPresence: (p: string) => void;
@@ -18,22 +21,24 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
focusLife: string;
}) {
const [draft, setDraft] = useState(() => { try {
return sessionStorage.getItem('cg.draft') || '';
return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || '';
}
catch {
return '';
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
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(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]);
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 {
sessionStorage.setItem('cg.draft', draft);
sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft);
}
catch { /* Draft remains in memory. */ } }, [draft]);
catch { /* Draft remains in memory. */ } }, [draft, real]);
useEffect(() => { if (offline) {
gate.current.resync();
setBusy(false);
@@ -51,17 +56,19 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
else if (logRef.current)
logRef.current.scrollTop = logRef.current.scrollHeight;
} }, [log, focusLife, view.messages.length]);
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
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(''); 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 {
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 ? '在听你说(模拟)' : '在你面前');
} }, fail);
@@ -71,16 +78,23 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
setNotice((e as Error).message);
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('在你面前');
} }
const setVoiceMode = () => { if (voice) {
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) {
endVoice();
return;
} setPermission('mic'); };
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || '演示回复 · 没有连接模型、语音或记忆后台'}</div></div>
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 (vision) {
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) {
setVision(false);
onObservation('窗户关上了');
}
@@ -88,9 +102,8 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
setPermission('vision'); }}><Icon name="eye"/></button><textarea aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault();
void send();
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">本机演示记录</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
} }}/><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>
</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);
+2
View File
@@ -59,6 +59,8 @@ export interface SceneDesc {
outfit: string;
}
export type ServerEvent = {
t: 'chat.error'; epoch: number; turn_id: string; phi: string; retryable: boolean;
} | {
t: 'chat.delta';
epoch: number;
turn_id: string;
+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>;
}
+3 -1
View File
@@ -1,5 +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(<App />);
ReactDOM.createRoot(document.getElementById('root')!).render(<Viewport>{new URLSearchParams(location.search).get('demo') === '1' ? <App /> : <RealApp />}</Viewport>);
+16 -8
View File
@@ -1,3 +1,4 @@
import { api, type Color, type RealIdentity } from '../adapters/real';
import { newId } from '../domain/id';
import { useState } from 'react';
import { validateCreation, type Identity } from '../domain/model';
@@ -5,21 +6,28 @@ import { freshState } from '../demo/fixtures';
import { delay } from '../adapters/demo';
import { BaseColor, Button, Field, Status } from '../shared/ui';
import type { DemoState } from '../domain/model';
export function CreationWizard({ onCreate, onEnter, fail }: {
export function CreationWizard({ onCreate, onEnter, fail, real = false }: {
real?: boolean;
onCreate: (s: DemoState) => void;
onEnter: () => void;
fail: boolean;
}) {
const [step, setStep] = useState(0), [name, setName] = useState(''), [birth, setBirth] = useState('2000-10-18'), [hour, setHour] = useState('亥时'), [relation, setRelation] = useState(''), [nickname, setNickname] = useState(''), [agreed, setAgreed] = useState(false), [error, setError] = useState(''), [phase, setPhase] = useState(-1), [busy, setBusy] = useState(false);
const [color, setColor] = useState<Color>();
const labels = ['出生资料', '她的底色', '你们的关系', '最终确认'];
function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
async function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
setError(err);
return;
} setError(''); setStep(s => s + 1); }
} setError(''); if (real && step === 0) { setBusy(true); try { setColor(await api<Color>('companion/preview', { method: 'POST', body: JSON.stringify({ name, birth, hour }) })); } catch (e) { setError((e as Error).message); return; } finally { setBusy(false); } } setStep(s => s + 1); }
async function create() { const err = validateCreation(name, birth, relation); if (err || !agreed) {
setError(err || '请先确认你已了解。');
return;
} setBusy(true); setError(''); try {
if (real) {
setPhase(0);
const identity = await api<RealIdentity>('companion', { method: 'POST', body: JSON.stringify({ name, birth, hour, relation, nickname }) });
setPhase(2); onCreate({ ...freshState(identity), messages: [], events: [], memories: [] }); setStep(4); return;
}
const c = new AbortController();
for (let p = 0; p < 3; p++) {
setPhase(p);
@@ -37,11 +45,11 @@ export function CreationWizard({ onCreate, onEnter, fail }: {
finally {
setBusy(false);
} }
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
{step === 0 && <><h2>先认识她的开始</h2><p className="lead">名字由你起,生辰也可以在确认前修改。</p><Field label="她的名字"><input className="input" value={name} maxLength={20} onChange={e => setName(e.target.value)} autoComplete="off"/></Field><Field label="出生日期"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰"><select className="select" value={hour} onChange={e => setHour(e.target.value)}>{['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'].map(h => <option key={h}>{h}</option>)}</select></Field><Button onClick={() => { setBirth(`${1990 + Math.floor(Math.random() * 13)}-${String(1 + Math.floor(Math.random() * 12)).padStart(2, '0')}-18`); }}>自动生成一组生辰</Button></>}
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor /></>}
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="出生日期(公历)" 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)}/>我明白:这是本机演示;创建后底色只读,只保留一位伴侣。</label>{phase >= 0 && <ul className="phases">{['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)'].map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{step === 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 onClick={next}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
<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>;
}
+40 -13
View File
@@ -1,10 +1,12 @@
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';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme }: {
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme, real = false }: {
real?: boolean;
state: DemoState;
open: boolean;
onClose: () => void;
@@ -14,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();
@@ -26,19 +31,37 @@ 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) {
setConfirm('relation');
return;
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage('正在保存到本机…'); try {
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage(real ? '正在保存…' : '正在保存到本机…'); try {
if (real && section === 'relation') {
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('这次没存好,你的修改还在,可以再试一次。');
const current=latestState.current;
const next = {...current, identity: section==='relation'?draft.identity:current.identity, settings:{...current.settings, ...(section==='city'?{city:draft.settings.city}:section==='services'?{services:draft.settings.services}:section==='display'?{reduced:draft.settings.reduced}:{})}};
onSave(next);
setMessage('已保存在本机演示中。');
setMessage(real ? '显示偏好已应用。' : '已保存在本机演示中。');
}
catch (e) {
if (ctrl.signal.aborted)
@@ -85,16 +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">仅本机演示</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor /><div className="growth"><div><h4>最初就有的</h4>细腻、慢热(示例)</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
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></>}
{section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
{section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{!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></>}
<div ref={feedback}><Status text={message} error={error}/></div>{['relation', 'city', 'services', 'display'].includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'relation')
{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 === '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)
@@ -124,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 -1
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,4 +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() { 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></>;
}
+3 -2
View File
@@ -3,13 +3,14 @@ import type { SceneDesc } from '../domain/model';
import { Icon } from '../shared/ui';
export interface PresenterProps {
scene: SceneDesc;
real?: boolean;
onClose: () => void;
closeLabel: string;
presence: string;
observation: string;
}
// Only consumes scene. No life writes, no synthesis, no microphone/camera APIs.
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation }: PresenterProps) {
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation, real = false }: PresenterProps) {
const [failed, setFailed] = useState(false);
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">演示状态</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>来自演示生活状态<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">{real ? '生活尚未接通' : '演示状态'}</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>{real ? '生活尚未接通' : '来自演示生活状态'}<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
}
+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;
}
+6 -7
View File
@@ -56,18 +56,16 @@ try:
page.set_default_timeout(7000)
page.on('pageerror', lambda e: errors.append(str(e)))
page.on('request', lambda r: requests.append(r.url))
page.goto(url)
expect(page.get_by_text('真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。')).to_be_visible()
expect(page.get_by_role('button', name='演示场景', exact=False)).to_have_count(0)
page.get_by_role('link', name='进入本机演示').click()
page.goto(url + '/?demo=1')
expect(page.get_by_role('button', name='演示场景', exact=False)).to_be_visible()
shot(page, f'{width}-creation-moon')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('名字')
page.get_by_label('她的名字', exact=True).fill('小满')
page.get_by_label('出生日期', exact=True).fill('2020-01-01')
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')
@@ -198,7 +196,8 @@ try:
context.close()
assert not errors, errors
assert not [r for r in requests if not r.startswith(url)], requests
passed('No uncaught browser errors, external requests, microphone or camera requests')
assert not [r for r in requests if '/api/' in r], requests
passed('No uncaught browser errors, API/external requests, microphone or camera requests')
browser.close()
finally:
server.shutdown(); server.server_close(); thread.join()
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RealChatAdapter, readEvents } from '../src/adapters/real';
import { TurnGate } from '../src/adapters/demo';
import type { ServerEvent } from '../src/domain/model';
const event = (t: string, payload: object = {}) => ({ t, epoch: 2, turn_id: 'turn', ...payload });
const frame = (e: object) => `data: ${JSON.stringify(e)}\r\n\r\n`;
function body(text: string) {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({ start(controller) { for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); controller.close(); } });
}
afterEach(() => vi.unstubAllGlobals());
describe('real SSE transport', () => {
it('decodes fragmented Chinese, CRLF, comment and multiple events without losing bytes', async () => {
const output: ServerEvent[] = [];
await readEvents(body(': ping\r\n\r\n' + frame(event('chat.delta', { delta: '你好🌙' })) + frame(event('chat.final', { utterance: { text: '你好🌙', speech_tags: [] } }))), e => output.push(e));
expect(output).toHaveLength(2); expect(output[0]).toHaveProperty('delta', '你好🌙');
});
it('fails closed on missing final, invalid JSON and SSE errors', async () => {
await expect(readEvents(body(frame(event('chat.delta', { delta: '半句' }))), () => {})).rejects.toThrow('连接中断');
await expect(readEvents(body('data: {oops}\n\n'), () => {})).rejects.toThrow();
await expect(readEvents(body(frame(event('chat.error', { phi: '暂时没连上', retryable: true }))), e => { if (e.t === 'chat.error') throw new Error(e.phi); })).rejects.toThrow('暂时没连上');
});
it('synchronizes a restarted server epoch and rejects foreign/late turns', async () => {
const gate = new TurnGate(), signal = gate.begin('turn'), emit = vi.fn();
const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ epoch: 2, llm: { ok: true } }))).mockResolvedValueOnce(new Response(body(frame({ ...event('chat.delta', { delta: '旧' }), epoch: 1 }) + frame({ ...event('chat.delta', { delta: '他轮' }), turn_id: 'other' }) + frame(event('chat.final', { utterance: { text: '新', speech_tags: [] } })))));
vi.stubGlobal('fetch', fetch);
await new RealChatAdapter(e => { gate.epoch = e; }).send('你好', 'turn', 1, signal, e => { if (gate.accepts(e)) emit(e); });
expect(emit).toHaveBeenCalledTimes(1); expect(gate.epoch).toBe(2);
expect(JSON.parse(fetch.mock.calls[1][1].body).epoch).toBe(2);
expect(fetch.mock.calls[1][1].signal).toBe(signal);
});
});
+163
View File
@@ -0,0 +1,163 @@
"""Foreground real Chromium acceptance. Uses temporary DB + loopback, never deployed data.
Run: .venv311/bin/python web/tests/real_browser_check.py
Requires Chromium installed by Playwright; makes at most three real model turns.
"""
import json
import sys
import tempfile
import time
from pathlib import Path
from urllib.parse import urlsplit
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 live_server, make_app # noqa: E402
from cybergril.api.app import create_app # noqa: E402
CONTROLLED = '--controlled' in sys.argv
OUT = ROOT/'docs/rework' / ('r1-regression' if CONTROLLED else 'live-gateway')
OUT.mkdir(parents=True, exist_ok=True)
results = {'checks': [], 'real_dialogue': [], 'console_errors': []}
def passed(text):
if CONTROLLED: text = text.replace('real gateway:', 'controlled provider:')
print('PASS', text, flush=True)
results['checks'].append(text)
def nav(page, text):
page.get_by_role('navigation', name='主导航').get_by_role('button', name=text, exact=True).click()
def click(page, text):
page.get_by_role('button', name=text, exact=True).click()
def shot(page, name):
if CONTROLLED: return
page.screenshot(path=str(OUT/(name+'.png')))
def send(page, text, source='real gateway'):
page.get_by_role('textbox', name='想和她说的话').fill(text)
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 .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
try:
with tempfile.TemporaryDirectory(prefix='cybergril-r1-') as directory, sync_playwright() as pw:
db = Path(directory)/'acceptance.db'
browser = pw.chromium.launch(headless=True, args=['--no-sandbox'])
desktop = browser.new_context(viewport={'width':1440,'height':900}, reduced_motion='reduce')
page = desktop.new_page()
page.on('pageerror', lambda e: results['console_errors'].append(str(e)))
page.add_init_script("""const get=Storage.prototype.getItem;Storage.prototype.getItem=function(key){if(key==='cybergril.demo.v1'){window.demoReads=(window.demoReads||0)+1;throw new Error('REAL MODE READ DEMO');}return get.call(this,key)};""")
if CONTROLLED: real_app, initial_provider = make_app(db)
else: real_app = create_app(db_path=db)
with live_server(real_app) as url:
port = urlsplit(url).port
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')
click(page,'下一步')
expect(page.get_by_text('出生底色 · 真实排盘')).to_be_visible()
assert real_app.state.store.query_one('SELECT count(*) n FROM companion')['n']==0
shot(page,'desktop-preview-moon')
click(page,'下一步')
for relation in ('朋友','恋人'):
expect(page.get_by_role('button', name=relation, exact=True)).to_have_attribute('aria-pressed','false')
click(page,'下一步')
expect(page.get_by_role('alert')).to_contain_text('选择')
click(page,'朋友'); click(page,'下一步')
page.get_by_role('checkbox').check(); click(page,'确认创建'); click(page,'去看看她')
expect(page.locator('.who .name')).to_have_text('星河')
passed('desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship')
click(page,'和她说话')
composer=page.get_by_role('textbox',name='想和她说的话')
composer.fill('中文输入法')
composer.evaluate("el=>el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true,isComposing:true}))")
expect(composer).to_have_value('中文输入法')
composer.press('Shift+Enter'); expect(composer).to_have_value('中文输入法\n')
chunks=[]
page.on('response', lambda r: chunks.append(r.status) if '/api/chat' in r.url else None)
send(page,'请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。')
reply2=send(page,'刚才我们的暗号是什么?请直接回答。')
if not CONTROLLED: assert '蓝色风铃' in reply2
assert len(real_app.state.store.query('SELECT * FROM chat_message'))==4
shot(page,'desktop-real-chat-moon')
passed('controlled provider: two complete streamed turns' if CONTROLLED else 'real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context')
if CONTROLLED: initial_provider.mode='slow'
composer.fill('请用中文写一段较长的故事,约800字。'); click(page,'发送')
expect(page.get_by_role('button',name='取消本轮回复')).to_be_visible()
expect(page.locator('.dialog .her')).not_to_contain_text('她在想',timeout=35000)
click(page,'取消本轮回复')
# This finite local wait lets the HTTP disconnect reach the isolated service.
expect(page.get_by_role('button',name='取消本轮回复')).not_to_be_visible()
deadline=time.monotonic()+2
while real_app.state.active and time.monotonic()<deadline: time.sleep(.01)
assert real_app.state.active is None
rows=real_app.state.store.query('SELECT * FROM chat_message')
assert len(rows)==5 and rows[-1]['status']=='cancelled'
passed('real gateway: cancel after first delta, upstream request closed, no complete assistant row')
page.reload(); nav(page,'交谈'); click(page,'完整记录')
expect(page.locator('.log-panel')).to_contain_text('蓝色风铃')
shot(page,'desktop-history-moon')
mobile=browser.new_context(viewport={'width':390,'height':844},reduced_motion='reduce',is_mobile=True,has_touch=True)
mp=mobile.new_page(); mp.goto(url)
expect(mp.locator('.who .name')).to_have_text('星河')
nav(mp,'交谈'); click(mp,'完整记录')
expect(mp.locator('.log-panel')).to_contain_text('蓝色风铃')
shot(mp,'mobile-history-moon'); click(mp,'关闭记录')
click(mp,'蓝紫雾光'); shot(mp,'mobile-chat-fog')
assert mp.evaluate('document.documentElement.scrollWidth<=innerWidth')
assert page.evaluate('window.demoReads || 0')==0
passed('independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter')
# A real process lifespan restart on the same origin, with a controlled provider for fault paths.
app, provider = make_app(db)
with live_server(app, port) as restarted_url:
assert restarted_url==url
page.get_by_role('button',name='关闭记录').click()
send(page,'重启后继续','controlled provider')
assert app.state.epoch==2
passed('server restart on same origin: epoch 1→2, existing page sends automatically, data retained')
provider.mode='error'
composer=page.get_by_role('textbox',name='想和她说的话')
composer.fill('保留的失败草稿'); click(page,'发送')
expect(page.locator('.dialog .failed')).to_be_visible()
expect(composer).to_have_value('保留的失败草稿')
shot(page,'desktop-model-error')
count=app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
provider.mode='normal'; click(page,'重试')
expect(page.locator('.dialog .failed')).not_to_be_visible()
expect(page.get_by_role('button',name='取消本轮回复')).not_to_be_visible()
expect(composer).to_have_value('')
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']==count+1
passed('controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row')
provider.mode='slow'
composer.fill('取消并紧接新问题'); click(page,'发送')
expect(page.locator('.dialog .her')).to_contain_text('答')
click(page,'取消本轮回复')
provider.mode='normal'; send(page,'新轮不会串台','controlled provider')
passed('controlled blocked upstream: immediate cancellation/new question isolation')
nav(mp,'设置')
mp.get_by_role('navigation',name='设置分区').get_by_role('button',name='关系',exact=True).click()
mp.get_by_label('你们的关系',exact=True).select_option('恋人'); click(mp,'取消修改')
expect(mp.get_by_label('你们的关系',exact=True)).to_have_value('朋友')
mp.get_by_label('你们的关系',exact=True).select_option('恋人'); click(mp,'保存'); click(mp,'取消')
assert app.state.store.query_one('SELECT identity_json FROM companion')['identity_json'].find('恋人')<0
click(mp,'保存'); mp.get_by_role('dialog').get_by_role('button',name='确认',exact=True).click()
expect(mp.get_by_role('status')).to_contain_text('已保存在服务器')
shot(mp,'mobile-relation-fog')
nav(mp,'此刻'); mp.reload()
expect(mp.locator('.meta')).to_contain_text('恋人')
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.
browser.close()
assert not results['console_errors'], results['console_errors']
finally:
(OUT/'browser-results.json').write_text(json.dumps(results,ensure_ascii=False,indent=2)+'\n')
+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')