Compare commits

..
Author SHA1 Message Date
Chief Engineerandmultica-agent 71660155cb HEL-826: continuation review handoff (R2/R3 seams + R1 personality depth, read-only)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 08:41:38 +08:00
Chief Engineerandmultica-agent 2d97050c7e HEL-825: review handoff (verified build/tests/browser checks/requirements; deploying 8320)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 08:27:33 +08:00
Chief Engineerandmultica-agent e32a1ac0d2 HEL-825 review: link-card accessible-name selector fix (browser_check), evidence scripts, regenerated screenshots/recordings
Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 08:27:06 +08:00
Chief Engineerandmultica-agent 0bc4a9e68e HEL-823 A3: days/memories, creation wizard, settings pages on cef8ae5 (author delivery, 15 modified + 1 handoff per CHANGES-HEL-823.json)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 08:26:54 +08:00
白栖知andmultica-agent cef8ae5581 HEL-824: land verified fixes on 4321aeb (small-screen first-press, real-time reading clock)
- 6 modified + handoff JSON per CHANGES-HEL-824.json, all hashes verified against git 4321aeb and package bytes
- rework_browser_check: skip full-text sheet at micro height (<420px design hides the control); migrate stale 返回日子 selector to 结束交谈 · 回到日子
- fresh run evidence: web build 25/25 unit tests, backend 142/142 (py3.12), all four browser checks PASS incl. new compact first-press and 700ms slow-frame sections (slow_frames_new_turn_to_reply_s=2.32s), layout regression 320/390/1024/1600 both themes

Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 07:39:26 +08:00
白栖知andmultica-agent 4321aebfdc HEL-822: fix handoff read_commit format
Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 06:59:18 +08:00
白栖知andmultica-agent e5fe01bfd1 HEL-822: review handoff JSON
Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 06:59:07 +08:00
白栖知andmultica-agent 7db6677234 HEL-822: run all four browser checks; minimal mechanical check fixes + fresh run evidence
- browser_check.py: 视觉观察 → 视觉观察(模拟) (author renamed the aria-label, selector not migrated)
- import_check.py: handle the dirty-settings leave confirmation before opening the record (pre-existing gap, fails at base db571ba too)
- rework_browser_check.py: long-user-line reply budget 10s→60s (approved hel-799 dwell ≈2.4s/page), conditional 继续 resume (toLatest resumes flow in the approved prototype), 600ms settle before measuring compact typing nav transition
- regenerated docs/frontend + docs/rework evidence from this Linux run

Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 06:58:20 +08:00
白栖知andmultica-agent 003d8c2cae HEL-822: land HEL-821 A1+A2 source onto PR#4 base (db571ba)
- 13 modified / 8 added / 1 deleted per CHANGES.json (author package 76bc120b)
- docs/requirements v1.10 + prototypes/hel-799 + PROVENANCE from bundle, verified byte-identical to 63799f0
- AGENTS.md + scripts/check_requirements.py restored from governance commit 63799f0

Co-authored-by: multica-agent <github@multica.ai>
2026-10-24 06:10:53 +08:00
白栖知andmultica-agent db571ba204 HEL-783: repair readable dialogue, birth explanations and complete real page layouts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 17:55:05 +08:00
白栖知andmultica-agent f6614d4bf9 HEL-780: connect persistent companion and real streaming text chat
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 16:25:35 +08:00
122 changed files with 5266 additions and 407 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/
-2
View File
@@ -4,8 +4,6 @@
不是聊天机器人。区别在于:**它在你不说话的时候也在运行**。
设计、施工与审核先读 [完整需求入口](docs/requirements/README.md) 和 [项目必读规则](AGENTS.md)。后续逐项确认的需求优先于本仓库早期构想;演示通过不代表整个项目完成。
---
## 先看一眼
+9
View File
@@ -0,0 +1,9 @@
{
"kind": "mechanical source bundle, not a Git merge or release",
"product_base": "db571ba204c8372989f96e1ed9b5cd00d3a306c7",
"requirements_and_hel799_source": "63799f02474ed94fd793de9e2360d185c878165d",
"approval_comment": "01a169a8-5884-7c26-a00f-c23a5f5399a9",
"scope": "A1/A2 frontend continuation; do not replace 8300/8310 or production data",
"hel815_source_attachment": "01a16845-b50a-7335-a87e-61bf11c44703",
"unmodified_product_sources": true
}
+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
}
+2 -2
View File
@@ -15,8 +15,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": 88
"requests": 54
}
Binary file not shown.
+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

+34
View File
@@ -0,0 +1,34 @@
{
"task": "HEL-821 视觉总监:A1+A2 把对坐场景(hel-799 scene/ambient/room)和完整对话呈现接入正式 React 前端;本文件为视觉总监交接(额度中断后恢复续交)",
"baseline_version": "1.10",
"read_commit": "63799f02474ed94fd793de9e2360d185c878165d",
"read_notes": [
"无 git 环境:需求与 prototypes/hel-799 读自总管机械包《CyberGril-A批施工基底-PR4与需求v1.10.zip》(SHA256 0c5b1665048960b3102216a834e26791dee2f665b467d49e62bfb50319f3c138),其 SOURCE-BUNDLE-PROVENANCE.json 记 requirements_and_hel799_source=63799f0、product_base=db571ba(PR#4 head)。非独立 checkout;origin/main 仍 3a986726(v1.8)。",
"需求文件随包置于 docs/requirements(v1.10,59项)。原运行开工阅读范围以其执行记录为准;本次恢复复读 README.md、task-handoff-template.md、handoff-HEL-816.json、sources/shared-space-static-acceptance-20261023.md,并对照 issue 原文逐条核对本卡七项施工要求。current/coverage/changes 全文须审核时对照原话复核;附件 emil 动效参考(animate/MAIN、RECIPES、review-animations/MAIN、STANDARDS)仅作参考,未安装或改动运行时技能。包内无仓库根 AGENTS.md 与 scripts/,未能读取/运行,交总工在仓库内补。",
"施工授权:主卡 HEL-797 白栖知评论 01a169a8-5884-7c26-a00f-c23a5f5399a9『继续吧,现在没什么主要的内容需要我确认的』,回应总管提案 01a16851-302d-75ef-9e8f-cf1719998076。这是本轮前端施工授权,不是真实能力验收或旧产品规则修改。"
],
"requirement_ids": ["CG-002", "CG-004", "CG-006", "CG-007", "CG-010", "CG-012", "CG-013", "CG-014", "CG-015", "CG-018", "CG-024", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "正式 React 源码(基于 PR#4 db571ba,未合并、未改 main):A1 新增 web/src/stage/camera.ts(同脸对齐的单相机、对数缩放、可打断临界阻尼弹簧)、roomScene.ts(逐帧只写 transform/opacity,推近/退回、反向保留速度、减少动效改为静置交叉淡入、自动降级档、页面隐藏暂停)、ambient.ts(窗内雨、城市灯、近景桌面,两主题材质光线不同),Presenter.tsx 改为共享空间三层;shared/space.ts 把此刻/交谈/记录/面板映射到空间并管理焦点;shared/motion.ts 键盘快照(即时落位);tokens.css/components.css 落 hel-799 room/talk/nav/pane 样式。A2 新增 web/src/chat/reading.ts(按轮分组、一两句分页、字素安全、无标点长句也断、流式最新一句先压住、阅读位置用文本锚点)、lineStage.ts(已显示的句子不重渲染不重播,墨入每句只播一次);ChatPanel.tsx 在原 RealChatAdapter/TurnGate/epoch/AbortController 数据流上接自动续读、暂停/上一句/下一句/回到读到的地方、全文、完整记录、身份标签、取消保留未完片段并标『未完成 · 已取消』。App.tsx/RealApp.tsx 接空间、Esc 逐层、导航直接切换;删除不再使用的 shared/SoftText.tsx;浏览器检查脚本迁到新选择器。",
"acceptance": [
"相处↔交流同一空间:点她或『和她说话』推近,『结束交谈』/Esc 退回;Esc 即时落位,鼠标/点击走约0.85s弹簧,中途反向不跳(camera-in-reverse-esc-1600-moon.webm)。",
"两主题同结构:月光/雾光切换只换材质、光线、雨色与玻璃,不改布局(desk-1600-moon/fog-*)。",
"交流:一次一两句,长回复自动完整续读;暂停后 ←/→/按钮即时回看无动画,『回到读到的地方』回到进度;全文与完整记录可读全部内容;『你』与实际名字区分;停止回复保留已生成部分并标未完成,未存入记录。",
"流式增长不重置阅读位置、不改已显示句子(reading.test.ts 流式只追加用例);历史恢复按 sessionStorage 轮次锚点;快速取消后新轮与旧轮迟到事件由 TurnGate/epoch 过滤。",
"演示 ?demo=1 独立 localStorage、零网络;真实模式不读演示数据(rework_browser_check.py 断言);未改 API 协议、SQLite、后端。",
"浮层打开时导航可见可直接切换(.nav z25 高于 .record z20/21);Esc 顺序:模态→全文→记录→退回空间;全文关闭焦点回『全文』,记录关闭焦点回记录按钮;普通面板 240ms 进入,不沿用空间长转场。",
"布局:1600×1000、1024、390×844、320×568、390×430 键盘等效下长中文与无标点长句不溢出,舞台/阅读条/输入/导航不重叠(check/shots/*.json 几何报告)。"
],
"deferred": [
"构建与测试未运行:本机无 Node/npm/git/Python。须总工在既有 Linux 环境执行 npm ci、npm run typecheck、npm run build、npm run lint、npm test(含新 reading.test.ts)、Playwright 三份浏览器检查、python3 scripts/check_requirements.py --base-ref origin/main 及 --handoff、git diff --check。",
"check/ 证据为静态 harness:产品 CSS + 剥类型后的控制器 + 静态 DOM,用 Edge CDP 截图/录屏;不是正式 React 运行,也未跑真实 SSE;录屏帧率不代表产品 GPU 性能。",
"rework_browser_check.py 本次恢复后才迁移(原运行未迁),未执行;lineStage 墨入每句只播一次为本次恢复时新增修正,未进 harness 录屏。",
"A3 其余页面(日子/回忆/创建/设置按 HEL-815 稿)未改,保持原功能与入口。",
"人物仍为旧参考静帧,推近是镜头不是人物动作;新日常视频、首尾衔接、真实口型、语音、天气、生活、记忆、备份、视觉观察均未接入(R1 人格深度、R2–R7 继续承接);手机 4:5 候选默认关闭;真机/Safari/Firefox/读屏未测。"
],
"requirement_changes": [],
"verification": [
"未执行:tsc/vite build/vitest/eslint/Playwright/check_requirements/git diff --check(缺工具,如实交总工)。",
"人工静态复核(本次恢复):base 对 orig 逐文件 SHA256 比对得 13 改、8 增(另加本交接 JSON)、1 删,逐文件哈希见包内 CHANGES.json;全部相对 import 可解析,无残留 SoftText 引用;tsconfig ES2022 覆盖 Array.at 与 Intl.Segmenter。",
"原运行 harness 证据:check/shots 桌面两主题与 390/320/390×430 截图及几何 JSON;check/rec 三段 webm(进出、快速反向+Esc、手机进出)。"
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"task": "HEL-822 总工:HEL-821 A1+A2 源码机械落库、Linux 构建、四份浏览器检查、真实SSE独立审核;因发现必须修复项未部署",
"baseline_version": "1.10",
"read_commit": "63799f02474ed94fd793de9e2360d185c878165d",
"read_notes": "需求/治理文件与 63799f0 字节核对一致;产品基底 db571ba(PR#4 head,refs/pull/4/head 复核仍指向 db571ba);origin/main 3a986726(v1.8)。",
"requirement_ids": ["CG-002", "CG-004", "CG-018", "CG-024", "CG-031", "VIS-003", "VIS-004", "VIS-007", "VIS-011", "VIS-013", "UX-001", "UX-002", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "候选分支 hel-822-candidate(自 db571ba):核验作者包 SHA256 后按 CHANGES.json 落库 13改/8增(另交接JSON)/1删,全部哈希一致;docs/requirements v1.10 与 prototypes/hel-799 字节等于治理提交 63799f0;补齐根 AGENTS.md 与 scripts/check_requirements.py。审核侧最小机械检查修正 3 处(browser_check 视觉观察(模拟)选择器、import_check 脏表单离开确认处理、rework_browser_check 长用户句预算60s/条件续读/紧凑打字沉降600ms)并附本机全部运行证据。",
"acceptance": [
"机械落库核验:ZIP 250条反斜杠路径规范化解包零越界;CHANGES 23条前后哈希全部复算一致;git status 恰为23条变更+治理增补。",
"构建:npm ci/typecheck/lint/test/build 全过(lint 1条既有模式警告);vitest 22/22 含新 reading.test.ts 8条。",
"后端:Python 3.12 下 142/142 通过;本机默认 3.10 缺 asyncio.timeout(pyproject 要求≥3.11)致 8 条流式相关测试失败,基底 db571ba 同样失败,属环境非回归。",
"浏览器检查:browser_check 16/16、import_check 7/7、real_browser_check 全过(真实网关3轮真实SSE、取消、重启epoch 1→2、移动端、受控错误重试,隔离临时库);rework_browser_check 前3节过后被下方问题1阻塞(本地绕过后12视口节通过)。",
"check_requirements 对 origin/main 与实际候选目标 origin/agent/agent/de8874a3803b 均通过(59项);git diff --check 干净。",
"代码卫生:无硬编码密钥/console残留/debugger;reduced-motion 全局处理;hover 动效均以 (hover:hover) and (pointer:fine) 门控。"
],
"deferred": [
"未部署:审核发现2项必须修复问题(见评论),按卡交总管退回原作者返工后复审一次。",
"候选 PR 未开:Gitea ssh 远端无 CLI/API 凭据,分支 hel-822-candidate 已推送,开 PR 需总管经 Web/API 以 base=agent/agent/de8874a3803b 操作。",
"真机/Safari/Firefox/读屏未测;录屏fps不代表GPU流畅度;人物仍为参考静帧;R1人格深度与R2–R7、A3页面(HEL-815稿)继续承接。"
],
"requirement_changes": [],
"verification": [
"真实SSE证据:real_browser_check 全部PASS输出(含 REAL_REPLY 蓝色风铃 两轮上下文召回、取消后无完整assistant行、epoch重启、390×844移动端)。",
"问题1证据:390×420 紧凑高度下聚焦输入后点击发送,click 事件落点 SECTION.conversation(trusted click 捕获),草稿未发出;先blur再点击则命中。发送失败连锁导致 rework 检查 390×420 节全文本按钮永不出现。",
"问题2证据:受控慢流取消→立即新轮后,阅读停在用户句约10–15秒(dwell 多次被重置,进度条读数 0.06→0.93 证据),随后自行恢复推进到『答复』。",
"本审核机环境:uv 安装 Python 3.12.14 于 .venv312(项目依赖+playwright);系统 Chromium 经 LD_LIBRARY_PATH=~/pwlibs 与 FONTCONFIG_FILE 启动。"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"task": "HEL-823 视觉总监:把 HEL-815 的日子、已分享生活详情、共同回忆、首次创建、设置管理完整页面接到正式 React 产品(A3)",
"baseline_version": "1.10",
"read_commit": "cef8ae5581f68ae81991533698ef05abcb82e659",
"read_notes": "唯一施工基底为远端 hel-822-candidate 提交 cef8ae5(总管 git archive 附件 HEL-823-A3-base-cef8ae5.zip,SHA256 245dab3f124c0c987a322c4dd788dbb7e7f5e24c4fb96f9eb50f4ec6c61a3894,本机复算一致)。本机实际阅读该包内根 AGENTS.md、docs/requirements/README.md、current.md(v1.10,59项)、coverage.md、changes.md、task-handoff-template.md、handoff-HEL-824.json,并按编号核对 CG-006/007/012–016/018/024/029–031、VIS-001–013、AM-001–003、UX-001–006、GOV-001–005、DEL-001 原文与边界。页面设计依据为本人 HEL-815 原稿附件 01a16845-b50a-7335-a87e-61bf11c44703(README、src/pages.js、boards/01)。授权原话:主卡评论 01a169a8-5884-7c26-a00f-c23a5f5399a9「继续吧,现在没什么主要的内容需要我确认的」。模板里的旧 1.7 字样不覆盖当前入口 v1.10。",
"requirement_ids": ["CG-006", "CG-007", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-024", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "AM-001", "AM-002", "AM-003", "UX-001", "UX-002", "UX-003", "UX-004", "UX-005", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "在 cef8ae5 上改 web 下 15 个文件(10 个源码、5 个测试)、新增本交接,无删除(清单与前后哈希见 CHANGES-HEL-823.json)。(1) web/src/life/LifePanel.tsx 重写为 HEL-815 日子页:标题区「她的日子 / 我们的回忆」+ 宽屏「日子 · 演示数据」/ ≤1100「她此刻 · …」;页签行附说明;列表按「她说过的打算」置顶、其余按日分组,行=时间·状态点(实心已发生/呼吸点正在/虚线打算)·标题·一句摘要·少量标签;分页「看更早的日子」→ 加载中 → 到底「再往前,就是她刚住进来的那天了」;演示失败开关下的读取失败卡与「再试一次」(加载骨架);空状态(首晚 / 真实后台未接)。详情:类别·时间·地点、何时告诉你、带身份的她的原话、关联回忆卡、那时候的聊天(只取已通过 publicView 的消息)、「在交谈里看这段」、演示说明。回忆四组带计数,详情「她是这样记得的」、成长注明不改写底色并可跳到设置·她、来自这些日子的反链或说明。任何解析不到的选择(过期、删除或从未讲过)统一显示「这一条现在打不开」,不暗示记录存在。每个页签保留自己的滚动位置,面板隐藏再打开(交谈返回日子)恢复;手机 Esc 先关推入详情。(2) web/src/manage/CreationWizard.tsx:左侧叙事随步骤与创建中/失败/完成变化;名字为空并有占位,错误时焦点落到出问题的字段(名字/生日/关系);生日「随机生成 / 我来指定」(默认随机一个满 19–35 岁的生日,可再随机;手动改日期或时辰即为指定);关系改为两张说明卡且不预选;新增可选相处边界(后端 CreationInput 已接受 boundary,未改 API);汇总含边界;创建中提示、失败「返回修改 / 再试一次」资料保留;真实 409「已经有一位她」时禁用再次创建并提供「重新打开,恢复已有的她」(只 reload,不覆盖);完成页「第一晚」。(3) web/src/manage/ManagePanel.tsx:分区列表加能力说明与手机推入箭头、底注「首版只有一位她 · 没有重建或清空」;分区标题旁能力标签(只读/可以调整/演示数据/未接入/文字已接通/本机生效);「她」改为名字只读+生辰只读、底色(沿用 BaseColor 真实排盘)、「她怎么理解自己」诚实未接卡(不放固定段落)、最初就有的/后来慢慢有的分栏(成长只取已分享来源的回忆);关系改为分段按钮,确认框写明「把关系从「X」改成「Y」?」并保留记忆与信任说明;真实城市/外观/备份/语音视觉开头加「还没有接上」说明卡(接上后做什么、现在什么都不会发生),原有待接入细节保留;减少动效改为开关样式。(4) web/src/stage/Presenter.tsx 新增「她此刻」浮签:面板打开且宽度 >1100 时贴在她下方,随相机落定与窗口变化跟随命中区;≤1100 改写在面板标题上方。(5) App.tsx / RealApp.tsx 传入此刻文字、演示失败开关与「在设置里看底色与成长」跳转;domain/model.ts LifeEvent 增可选 told;demo/fixtures.ts 演示条目补 told;adapters/storage.ts 恢复时只为已分享条目保留 told。(6) styles/components.css 追加 A3 区块(两主题共用 token,状态色只用于点、细线与短字;≤1100、≤720、≤720×620、≤360 断点)。(7) tests:domain.test.ts 新增 told 只保留在已分享记录的单测;browser_check / real_browser_check / import_check / rework_browser_check 只按本次有意的界面变化改选择器(关系下拉→分段按钮,「看那段聊天」→「在交谈里看这段」,私有深链断言改为中性「这一条现在打不开」),其余断言不动。",
"acceptance": [
"日子(演示 ?demo=1,载入三天记录):打算置顶并标「计划可能会变」,正在为呼吸点;列表与详情、回忆、搜索不出现未分享条目;#event=secret 深链只显示「这一条现在打不开。」,页面不含「秘密散步/私密地点」(browser_check 已改断言)。",
"长记录:首屏 18 条,看更早的日子后 30 条;选第 11 条 → 在交谈里看这段 → 结束交谈 · 回到日子,列表滚动位置与选中项不变(import_check 原断言)。",
"我们的回忆:真实入口四组各显示「暂无可核实的回忆」(.pane .list .grp = 4,rework 原断言);演示入口四组带计数,成长条目可跳到设置·她。",
"首建:名字为空、关系不预选(两张卡 aria-pressed=false)、未满 18 岁与未选关系被拦并聚焦对应字段;勾选确认前「确认创建」禁用;演示失败后资料保留、再试一次成功;真实入口 409 不覆盖已有档案。",
"设置:名字与生辰只读,无改名/新建/切换/清空/重建入口;改关系类型须确认,取消恢复,保存失败修改保留;离开有未保存修改须确认(原守卫未改);未接入能力全部标「未接入」且按钮不可用,演示能力标「演示数据」。",
"两主题结构一致(同一 DOM,只换 token);1600×1000、1024、390×844、320×568、390×430 键盘等效下无关键遮挡——需总工以浏览器检查与截图实测,本机未能运行。",
"未触碰 A1/A2:ChatPanel、reading.ts、lineStage、Viewport、compact/typing 规则、真实适配器/TurnGate/epoch 均未修改。"
],
"deferred": [
"本机(Windows 运行时)没有 node/npm、python、git,也找不到可用的 TypeScript:未执行 npm ci/typecheck/lint/test/build、四份浏览器检查、check_requirements.py、git diff --check,也没有正式 React 页面截图或录屏;全部交总工机械执行,未运行即未通过,不以静态阅读或 HEL-815 静态样图代替。",
"未提交、未开 PR:按 CHANGES-HEL-823.json 落到 hel-822-candidate(cef8ae5)之上的独立分支,产品目标仍为 agent/agent/de8874a3803b,不指 main;不合并、不部署 8320,8300/8310 未触碰。",
"HEL-815 中「看接入后的设计稿」只作施工参照,按原稿施工须知未放进正式产品;城市、外观、备份、语音、视觉观察、生活、长期记忆、主动消息的真实能力仍未接通(R2–R7)。",
"「她怎么理解自己」与最初小习惯在真实入口显示未接说明,由 R1 人格深度承接(CG-013/014/015、UX-004);不以固定模板填充。",
"真机软键盘、Safari/Firefox、读屏未测;等效视口不冒充真机(UX-001)。人物仍为参考静帧,真实日常视频/首尾衔接/语音口型由主卡 HEL-797 与 HEL-802/803 承接(DEL-001)。"
],
"requirement_changes": [],
"verification": [
"附件 SHA256 复算 245dab3f124c0c987a322c4dd788dbb7e7f5e24c4fb96f9eb50f4ec6c61a3894,与卡片一致;解包为只读 before 与可写 work 两份,修改只在 work,before 全部 250 个文件哈希另存。",
"修改前后文件哈希与改/增/删清单见交付包 CHANGES-HEL-823.json。",
"静态自查:逐文件回读 JSX 结构与类型(新增 props 均为可选、noUnusedLocals 下无多余变量),核对测试所依赖的可访问名称(她的名字、出生日期(公历)、1 / 4 · 出生资料、朋友/恋人 aria-pressed、确认创建、再试一次、去看看她、看更早的日子、结束交谈 · 回到日子、设置分区各按钮名、她怎么称呼你、相处边界、取消修改/保存/重试保存、.kv/.ev/.list/.grp/.city-list/.svc、关闭日子/关闭设置、has-detail 类串)保持不变。静态自查不等于构建或测试通过。",
"待总工执行:cd web && npm ci && npm run typecheck && npm run lint && npm test && npm run build;PYTHONPATH=src python3.12 web/tests/{browser_check,import_check,real_browser_check,rework_browser_check}.py;python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-823.json 及实际目标引用;git diff --check;并补 1600×1000、1024、390×844、320×568、390×430 两主题截图。"
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"task": "HEL-824 视觉总监:HEL-822 审核两项必须修复缺陷返工(紧凑高度聚焦后首击失灵;停止回复后立即新轮续读迟滞),并修正 rework 检查错误断言、核对 Viewport 无效逻辑",
"baseline_version": "1.10",
"read_commit": "4321aebfdc491993e0648f68a0375047c3eb6359",
"read_notes": "唯一返工基底为远端 hel-822-candidate 提交 4321aeb(总管 git archive 附件 HEL-822-candidate-4321aeb.zip,SHA256 d18d54891f365f4b7999b4defb8cc4f92dace3e296b96707c617ac08f3b3e870,本机复算一致)。已读根 AGENTS.md、docs/requirements/README.md、current.md(59项)、coverage.md、changes.md、task-handoff-template.md、sources/dynamic-sample-effect-acceptance-20261023.md,并按编号核对 CG-018、VIS-003/004/007/011/013、UX-001/002/005、GOV-001–005、DEL-001 原文与边界;模板中的旧1.7字样不覆盖当前入口 v1.10。审核原文:HEL-822 评论 01a16afe-0cb1-7146-b561-c348c9fc8ed8。",
"requirement_ids": ["CG-018", "VIS-003", "VIS-004", "VIS-007", "VIS-011", "VIS-013", "UX-001", "UX-002", "UX-005", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "在 4321aeb 上修改 6 个文件、新增本交接:(1) web/src/styles/components.css 删除 html.compact.typing{--nav-space:6px}:聚焦输入只让导航淡出让位,对话区几何不随焦点变化,按下与松开落在同一按钮;交流视觉、两主题与导航淡出保留。(2) web/src/chat/reading.ts 新增 ReadingClock,阅读停留按真实时间计(每帧最多计 STALL=1000ms,页面隐藏后的间隔不计),同一句 key 不变即不重置;web/src/chat/ChatPanel.tsx 改用它(goTo→forget、setFlow→restart、visibilitychange→pause),自动续读、暂停、回看、回到进度、墨入只播一次的逻辑不变。(3) web/src/shared/Viewport.tsx 删除没有任何 CSS 对应的 viewport-resizing 类及其定时器,注释改为实际行为。(4) web/tests/reading.test.ts 新增 3 项时钟回归;web/tests/rework_browser_check.py 三处整行 to_have_text('答复') 改为只对正文 <p> 精确断言,新增 390×420/390×430 双主题聚焦后首击回归与 700ms 慢帧下停止→立即新轮回归。总工在 browser_check / import_check 的机械修正未触碰。",
"root_causes": [
"缺陷1:compact 下输入聚焦时 --nav-space 由 54px 变 6px;按下按钮使输入失焦,对话区在按下与松开之间下移 48px,click 落到两者共同祖先 SECTION.conversation。修法:对话区几何不随焦点改变(只保留导航淡出),未用先 blur、强制点击、脚本调 handler 或 pointerdown 绕过。",
"缺陷2:阅读时钟每帧把 dt 截到 100ms(ChatPanel.tsx 原 113 行 Math.min(100, now - last)),停留时长随帧率缩放。停止→立即新轮时交叉淡化、模糊、墨入与房间画面同时进行,审核环境帧间隔约 1 秒,『新轮』1400ms 的停留被拉长为 14 帧≈14 秒;审核读到的 0.06…0.93 正是 1/14…13/14 的逐帧步进,满格后进入下一句再次从 0 起,看起来像反复重置。逐行核对:同一轮内 key 只由轮次与位置决定,流式增长、落库替换、旧轮迟到事件(TurnGate 拒收)都不会改变当前句 key,也没有其他自动调用 forget/restart 的路径。修法:按真实时间计时,不缩短任何设计停留、不取消自动阅读、不放宽测试等待(8s 超时保持)。"
],
"acceptance": [
"正式 React 月光/雾光,390×420 与 390×430:输入框聚焦后直接点发送、上一句、暂停/继续、全文,均首击生效;草稿真正发出并落库(用户+她各1条)、全文浮层真正打开;按下与松开时对话区 top 相同、click 送达该按钮(rework_browser_check 新节,真实指针点击,脚本不 blur 不派发)。",
"停止回复→立即新轮:取消即时、旧轮迟到不串入(当前句状态为空),新轮先显示你的一句(设计停留1.4s)后她的回复上台;在 rAF 每 700ms 一帧的慢帧下同样在原 8s 断言内完成(旧实现需约 9.8s 必然失败)。",
"正常 60fps 下各句停留与修改前一致(每帧 dt≈16ms 远小于原 100ms 上限,行为不变);暂停/恢复、前后回看、回到读到的地方、全文、完整记录、同句墨入只播一次、历史恢复锚点逻辑未改动。",
"reading.test.ts:任意帧间隔(16/100/250/700/1000ms)下你的一句在 1400ms 至 1400ms+一帧之间到期;流式增长与落库不回退进度;新句、回看、恢复从零开始,暂停/悬停与隐藏期间不计,单帧停顿最多计 1 秒。",
"320×568、390×844、1024、1600×1000 布局:本次只删除 compact+typing 时的位移规则,非 compact 尺寸 CSS 无变化;仍需总工以 browser_check / rework 12 组视口复核。"
],
"deferred": [
"本机(Windows 运行时)没有 node/npm、python、git,未能执行 typecheck/lint/test/build、四份浏览器检查、check_requirements.py 与 git diff --check;全部交总工机械执行,未运行即未通过,不以静态阅读代替。",
"修前修后实测计时未在本机取得:修前数据沿用总工 HEL-822 观测(约10–15秒、0.06→0.93),修后应由总工以 rework 慢帧节的 slow_frames_new_turn_to_reply_s 及受控慢流原复现记录。",
"未开 PR、未提交:本机无 git;按 changes 清单落到 hel-822-candidate 之上,PR 目标仍为 PR#4 产品线 agent/agent/de8874a3803b(原产品基底 db571ba),不指 main;远端引用名称须由总工用 git 实查。",
"真机键盘、Safari/Firefox、读屏未测;等效视口不冒充真机(UX-001)。人物仍为参考静帧,真实视频/语音/口型/生活/记忆未接入(DEL-001)。A3 日子/回忆/创建/设置由 HEL-823 承接;R1 人格深度与 R2–R7 在主卡 HEL-797 追踪。"
],
"requirement_changes": [],
"verification": [
"附件 SHA256 复算 d18d54891f365f4b7999b4defb8cc4f92dace3e296b96707c617ac08f3b3e870,与卡片一致;解包为只读 before 与可写 work 两份,修改只在 work。",
"修改前后文件哈希与新增/删除清单见交付包 CHANGES-HEL-824.json(6 改、1 增、0 删)。",
"未执行任何构建、测试或浏览器检查(工具缺失);待总工执行:cd web && npm ci && npm run typecheck && npm run lint && npm test && npm run build;PYTHONPATH=src python3.12 web/tests/{browser_check,import_check,real_browser_check,rework_browser_check}.py;python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-824.json 及实际 PR 目标引用;git diff --check。",
"真实网关 SSE 证据仍为 HEL-822 已有记录(本次未改后端、协议、SQLite、RealChatAdapter/TurnGate/epoch/AbortController);rework 新节使用隔离临时库与受控模型,二者分别标明。"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"task": "HEL-825 总工:HEL-823(日子/回忆/首建/设置 A3)功能+界面独立审核,通过后更新 8320 隔离测试版",
"baseline_version": "1.10",
"read_commit": "cef8ae5581f68ae81991533698ef05abcb82e659",
"read_notes": "独立审核分支 hel-825-review 自 cef8ae5(hel-822-candidate 远端头,本机核验一致)。实际阅读根 AGENTS.md、docs/requirements/README.md、current.md(v1.10,59项)、coverage.md、changes.md、task-handoff-template.md,及 sources/design-acceptance-v1.md、visual-confirmation-v2.md、integration-plan-v1.md、dynamic-sample-effect-acceptance-20261023.md;核对 CG-006/007/012–016/018/024/029–031、VIS-001–013、AM-001–003、UX-001–006、GOV-001–005、DEL-001 原文与边界。交付包 SHA256 3a8e1720f347f7b5449c2ac5af116553a41a28982046d4d19eadbb1f4e2f2b27 本机独立复算一致;15改1增的 before 对 git cef8ae5、after 对源码字节逐项独立核验一致,无未列明改动、无删除、无路径越界。",
"requirement_ids": ["CG-006", "CG-007", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-024", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "AM-001", "AM-002", "AM-003", "UX-001", "UX-002", "UX-003", "UX-004", "UX-005", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "审核通过并部署。分支 hel-825-review(cef8ae5 之上三提交:0bc4a9e 作者交付原字节、e32a1ac 审核接线修正与证据、本 handoff 提交)。审核接线修正仅一处:web/tests/browser_check.py 关联回忆卡点击改 exact=False——新版 link-card 可访问名称由「标题+来源」拼接,作者改版时漏改该选择器,断言强度不变(heading 断言保留),不放宽隐私或回归断言。另新增 web/tests/hel825_shot.py、hel825_tag_probe.py 两个审核证据脚本。8320 更新为新 release 并存切 current,数据目录与档案不动。",
"acceptance": [
"机械构建:npm ci/typecheck/lint/test/build 全通过;lint 0 errors 2 warnings(ChatPanel:156 为基线已有,ManagePanel:123 unused-directive 为本次引入的非阻塞建议项)。前端 vitest 26/26;后端回归 pytest 142/142(与上批基线一致,本轮实际数字)。",
"四份浏览器脚本全部通过:browser_check 16 PASS(1440/1280/390 三视口:创建校验/失败重试/重开、两主题日子/交谈/设置往返、私密深链中性文案、取消隔离、IME/XSS、发送失败重试、离开摘要过滤);import_check 7 PASS(分页18→12滚动保持、损坏持久化不覆盖首建等);real_browser_check 8 PASS(真实网关流式/取消/epoch 重启/SSE 错误/手机关系恢复);rework_browser_check 9 PASS(12 视口主题组合无遮挡、390×430 首击命中、reading 真实时钟、演示零 API)。",
"需求检查:check_requirements.py --base-ref origin/main 与 origin/agent/agent/de8874a3803b(均带 --handoff handoff-HEL-823.json)通过,59项;git diff --check 干净。",
"隐私与深链:#event=secret 只显示「这一条现在打不开。」,页面无「秘密散步/私密地点」;列表/详情/回忆/摘要无未分享条目(脚本断言+人工核对 LifePanel publicView 路径)。",
"首建:名字空拦、未成年拦、关系不预选(朋友/恋人 aria-pressed=false)、未勾选确认创建禁用、失败资料保留、409 只提供恢复已有她(源码 74-75 行核对+脚本验证)。",
"设置:名字/生辰只读;关系确认框含「记忆与信任都保留」;未保存离开确认守卫(beforeunload+cg:leave-settings 双路径,ManagePanel:43-49 核对);未接入能力如实标注。",
"她此刻浮签几何实测:1600/1440/1101 now-chip 显示于角色下方;1100/1024/390 面板标题上方 .k-near「她此刻 · 在窗边待着」(hel825_tag_probe.py 六档全过)。",
"截图证据:两主题 × 1600×1000/1024/390×844/320×568/390×430 键盘等效共 46 张(docs/frontend/screenshots/hel825/)+ 宽屏/手机两段录屏(docs/frontend/recordings/)。"
],
"deferred": [
"真机软键盘、Safari、Firefox、读屏未测(等效视口不冒充真机,UX-001);本机模型不支持图像目检,截图以 DOM 几何断言+脚本覆盖代替人工目检,全部截图已附卡供人工复核。",
"ManagePanel.tsx:123 unused eslint-disable directive(本次引入,非阻塞)与 ChatPanel.tsx:156 ref cleanup warning(基线已有)留作后续小优化,不阻塞本轮。",
"生活、长期记忆、主动消息、语音口型、天气素材备份、视觉观察真实能力仍未接通(R1–R7 主卡承接);人物仍为参考静帧;本卡不宣称完整产品完成。",
"阶段工程审核通过不等于白栖知体验验收;小样与正式界面替换边界不变。"
],
"requirement_changes": [],
"verification": [
"独立复算:交付 zip SHA256、逐文件 before/after 哈希、清单外无差异、无删除;commit 0bc4a9e 逐字节等于作者交付(16 文件哈希复验全 OK)。",
"机械执行记录:npm ci 176 包、typecheck 0 错、lint 0/2、vitest 26/26、build 514ms 产物 263.64 kB js + 81.28 kB css、pytest 142 passed 6.75s、四浏览器脚本 exit=0、双 base-ref 需求检查通过、git diff --check 通过。",
"审核环境:uv 管理 Python 3.12.14 独立 venv(.venv-hel825,项目 requirements.lock + pytest/playwright),Chromium 156.0.8078.4,系统库复用既有 .browser-libs 用户态副本;未改全局环境、未动队员运行时/模型/认证。",
"部署验证见本卡成果评论:8300/8310 前后可用、8320 服务健康、?demo=1 与普通真实入口实测、epoch 历史保留、回滚方案(切回旧 release current 符号链接)。"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"task": "HEL-826 总工:核清 R2/R3 数据接缝与 R1 人格缺口,交可直接派施工的小范围接续说明(只读核对,不改产品源码)",
"baseline_version": "1.10",
"read_commit": "2d97050c7e86f909590ffdec3e8bc1aa5627cade",
"read_notes": "读取提交为 origin/hel-825-review 头 2d97050(59项 v1.10)。实际阅读根 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、changes.md、task-handoff-template.md,及 sources/integration-plan-v1.md、design-acceptance-v1.md、visual-confirmation-v2.md、dynamic-sample-effect-acceptance-20261023.md、comment-evidence.json 中 CG-013/014/015 原话(01a15763-1fc5、01a15763-e729、01a15765-2fa8、01a15771-3a93、01a15772-3101、01a15774-ddc6)与 UX-004 来源(01a162eb-1eab);另读本卡附件 HEL-816-接续方案.md 第4–6节。源码核对 src/cybergril/memory/schema.sql、src/cybergril/api/app.py、src/cybergril/core/bazi.py、src/cybergril/core/birth_view.py、src/cybergril/think/scenes.py、config/{character,models}.yaml、web/src/{adapters/real.ts,app/RealApp.tsx,life/LifePanel.tsx,manage/ManagePanel.tsx,domain/privacy.ts}。服务核查仅 8310/8320 GET /api/health(两者 capabilities 如实仅 text=true;8310 epoch 3、8320 epoch 4),未读档案与聊天正文,未做任何变更。",
"requirement_ids": ["CG-007", "CG-008", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-021", "CG-023", "CG-031", "AM-001", "AM-002", "AM-003", "UX-004", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "一份接续说明(本卡附件 HEL-826-接续说明.md):R2 最小生活接入的可审核批次设计(服务端循环、事件来源/时区/幂等键、重启不补造、故障预算停止条件、列表/详情/摘要/搜索/关联回忆/聊天引用的响应前裁剪、told 提交时机与可执行反例),R1 人格深度在 R2/R3 的落点(八字原始事实只读每轮独立、模型诠释/初始习惯/后天成长三层分离、已有档案诚实补建、失败幂等、关系变更不重建人格),含数据表/迁移草案、端点与事件契约、前端绑定点、测试与隔离部署建议、下一张施工卡草稿及 R3/R4 依赖;另加本 handoff。产品源码零改动。",
"acceptance": [
"接续说明标明实际读取版本 v1.10 / 提交 2d97050、关联要求原意与原话来源、已有与缺失的实证(逐条 file:line)、下一批可观察验收项、仍待用户决定的问题。",
"已交付事实与缺失事实分清:R1 真实档案/流式/近20轮在 8310(epoch 3)运行、A1/A2/A3 已过工程审核并部署 8320(epoch 4);生活循环零代码(life_event 表无生产者)、记忆仅测试路径、近20轮上下文不冒充 R3 长期记忆、六段固定模板即 chat 系统提示的底色来源(app.py:385-395、birth_view.py:41-80)、capabilities 前端未消费(real.ts 仅读 epoch/llm.ok)。",
"told 机制定义完整:生成即 told=0;仅在 chat.final 完整送达并经引用判定后才置 1,取消/失败/部分流不置;provenance 记录(telling_log UNIQUE message);方案附可执行反例清单。",
"施工卡草稿可独立执行:覆盖服务重启幂等、流式取消不误标、伪造ID/搜索/摘要/关联引用零泄漏、底色指纹不变、关系变更不重建人格、演示零API、真实聊天回归;压缩时钟测试与真实三天运行分开表述。",
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref origin/agent/agent/de8874a3803b(均带本 handoff)通过;git diff --check 干净。"
],
"deferred": [
"本卡不写产品代码、不部署、不生成真实生活事件、不调用模型生成人格、不做迁移;一切实现进下一张施工卡。",
"R3 长期记忆、R4 主动联系、R5 语音、R6 天气/素材/备份、R7 视觉观察按 DEL-001 与 integration-plan-v1 依赖顺序由主卡承接;真实视频首尾衔接、口型由 HEL-802/803 承接。",
"仍待用户决定(经总管):R2 真实三天运行的启用位置(推荐 8310,R2 已获批,带回滚);生活时区默认 Asia/Shanghai 至 R6 城市选择(工程默认,可后续随城市调整)。",
"需求变更:无。"
],
"requirement_changes": [],
"verification": [
"只读源码核对(file:line 证据见接续说明第一节);8310/8320 health 实测 2026-10-24(本机 curl,capabilities 全部如实 false 除 text)。",
"git 状态干净(本分支仅新增本 handoff 与接续说明不落仓库、以附件交付);未动 8300/8310/8320 任何数据与服务;未访问 4080;未合并任何 PR。",
"结构检查不能代替原话核对:CG-013/014/015 与 UX-004 原话已逐条对照 comment-evidence.json;本卡不声称任何产品功能已通过用户验收。"
]
}
+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: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+760
View File
@@ -0,0 +1,760 @@
{
"checks": [
"Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows",
"Long Chinese stream: shown lines never change or re-key, ink plays once per line; pause/keyboard review is instant; record keeps everything",
"12 viewport/theme combinations: lines stay inside the stage, stage/reader/composer/navigation never overlap; Esc closes full text with focus back; full history reachable",
"390x420 and 390x430, both themes: with the input focused, the first press on send, review, pause/continue and full text acts; the dialogue does not move between press and release",
"Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages",
"Stop then new turn with busy (700 ms) frames: your line keeps its own time and her reply follows; the reading clock counts real time",
"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",
"stage": {
"top": 605.609375,
"bottom": 733.609375,
"left": 250,
"right": 1190,
"height": 128
},
"reader": {
"top": 762,
"bottom": 792,
"left": 596.484375,
"right": 843.515625,
"height": 30
},
"composer": {
"top": 798,
"bottom": 848,
"left": 400,
"right": 1040,
"height": 50
},
"nav": {
"top": 860,
"bottom": 894,
"left": 582,
"right": 858,
"height": 34
},
"lines": [
{
"top": 643.21875,
"bottom": 665.71875,
"left": 596.625,
"right": 887.328125,
"height": 22.5
},
{
"top": 699.109375,
"bottom": 733.609375,
"left": 555.765625,
"right": 884.21875,
"height": 34.5
}
],
"overflow": false
},
{
"width": 1440,
"height": 900,
"theme": "fog",
"stage": {
"top": 605.609375,
"bottom": 733.609375,
"left": 250,
"right": 1190,
"height": 128
},
"reader": {
"top": 762,
"bottom": 792,
"left": 596.484375,
"right": 843.515625,
"height": 30
},
"composer": {
"top": 798,
"bottom": 848,
"left": 400,
"right": 1040,
"height": 50
},
"nav": {
"top": 860,
"bottom": 894,
"left": 582,
"right": 858,
"height": 34
},
"lines": [
{
"top": 643.21875,
"bottom": 665.71875,
"left": 596.625,
"right": 887.328125,
"height": 22.5
},
{
"top": 699.109375,
"bottom": 733.609375,
"left": 555.765625,
"right": 884.21875,
"height": 34.5
}
],
"overflow": false
},
{
"width": 360,
"height": 640,
"theme": "moon",
"stage": {
"top": 362,
"bottom": 494,
"left": 10,
"right": 350,
"height": 132
},
"reader": {
"top": 496,
"bottom": 526,
"left": 72.5,
"right": 287.5,
"height": 30
},
"composer": {
"top": 532,
"bottom": 580,
"left": 10,
"right": 350,
"height": 48
},
"nav": {
"top": 586,
"bottom": 634,
"left": 30,
"right": 330,
"height": 48
},
"lines": [
{
"top": 412.453125,
"bottom": 432.59375,
"left": 155.5625,
"right": 248.390625,
"height": 20.140625
},
{
"top": 466.109375,
"bottom": 494,
"left": 51.46875,
"right": 308.515625,
"height": 27.890625
}
],
"overflow": false
},
{
"width": 360,
"height": 640,
"theme": "fog",
"stage": {
"top": 362,
"bottom": 494,
"left": 10,
"right": 350,
"height": 132
},
"reader": {
"top": 496,
"bottom": 526,
"left": 72.5,
"right": 287.5,
"height": 30
},
"composer": {
"top": 532,
"bottom": 580,
"left": 10,
"right": 350,
"height": 48
},
"nav": {
"top": 586,
"bottom": 634,
"left": 30,
"right": 330,
"height": 48
},
"lines": [
{
"top": 412.453125,
"bottom": 432.59375,
"left": 155.5625,
"right": 248.390625,
"height": 20.140625
},
{
"top": 466.109375,
"bottom": 494,
"left": 51.46875,
"right": 308.515625,
"height": 27.890625
}
],
"overflow": false
},
{
"width": 390,
"height": 700,
"theme": "moon",
"stage": {
"top": 422,
"bottom": 554,
"left": 10,
"right": 380,
"height": 132
},
"reader": {
"top": 556,
"bottom": 586,
"left": 87.5,
"right": 302.5,
"height": 30
},
"composer": {
"top": 592,
"bottom": 640,
"left": 10,
"right": 380,
"height": 48
},
"nav": {
"top": 646,
"bottom": 694,
"left": 45,
"right": 345,
"height": 48
},
"lines": [
{
"top": 472.453125,
"bottom": 492.59375,
"left": 91,
"right": 342.953125,
"height": 20.140625
},
{
"top": 526.109375,
"bottom": 554,
"left": 66.46875,
"right": 323.515625,
"height": 27.890625
}
],
"overflow": false
},
{
"width": 390,
"height": 700,
"theme": "fog",
"stage": {
"top": 422,
"bottom": 554,
"left": 10,
"right": 380,
"height": 132
},
"reader": {
"top": 556,
"bottom": 586,
"left": 87.5,
"right": 302.5,
"height": 30
},
"composer": {
"top": 592,
"bottom": 640,
"left": 10,
"right": 380,
"height": 48
},
"nav": {
"top": 646,
"bottom": 694,
"left": 45,
"right": 345,
"height": 48
},
"lines": [
{
"top": 472.453125,
"bottom": 492.59375,
"left": 91,
"right": 342.953125,
"height": 20.140625
},
{
"top": 526.109375,
"bottom": 554,
"left": 66.46875,
"right": 323.515625,
"height": 27.890625
}
],
"overflow": false
},
{
"width": 390,
"height": 844,
"theme": "moon",
"stage": {
"top": 566,
"bottom": 698,
"left": 10,
"right": 380,
"height": 132
},
"reader": {
"top": 700,
"bottom": 730,
"left": 87.5,
"right": 302.5,
"height": 30
},
"composer": {
"top": 736,
"bottom": 784,
"left": 10,
"right": 380,
"height": 48
},
"nav": {
"top": 790,
"bottom": 838,
"left": 45,
"right": 345,
"height": 48
},
"lines": [
{
"top": 616.453125,
"bottom": 636.59375,
"left": 91,
"right": 342.953125,
"height": 20.140625
},
{
"top": 670.109375,
"bottom": 698,
"left": 66.46875,
"right": 323.515625,
"height": 27.890625
}
],
"overflow": false
},
{
"width": 390,
"height": 844,
"theme": "fog",
"stage": {
"top": 566,
"bottom": 698,
"left": 10,
"right": 380,
"height": 132
},
"reader": {
"top": 700,
"bottom": 730,
"left": 87.5,
"right": 302.5,
"height": 30
},
"composer": {
"top": 736,
"bottom": 784,
"left": 10,
"right": 380,
"height": 48
},
"nav": {
"top": 790,
"bottom": 838,
"left": 45,
"right": 345,
"height": 48
},
"lines": [
{
"top": 616.453125,
"bottom": 636.59375,
"left": 91,
"right": 342.953125,
"height": 20.140625
},
{
"top": 670.109375,
"bottom": 698,
"left": 66.46875,
"right": 323.515625,
"height": 27.890625
}
],
"overflow": false
},
{
"width": 390,
"height": 420,
"theme": "moon",
"stage": {
"top": 206,
"bottom": 284,
"left": 10,
"right": 380,
"height": 78
},
"reader": {
"top": 284,
"bottom": 314,
"left": 87.5,
"right": 302.5,
"height": 30
},
"composer": {
"top": 318,
"bottom": 366,
"left": 10,
"right": 380,
"height": 48
},
"nav": {
"top": 436,
"bottom": 480,
"left": 45,
"right": 345,
"height": 44
},
"lines": [
{
"top": 259.203125,
"bottom": 284,
"left": 80.75,
"right": 309.234375,
"height": 24.796875
}
],
"overflow": false
},
{
"width": 390,
"height": 420,
"theme": "fog",
"stage": {
"top": 206,
"bottom": 284,
"left": 10,
"right": 380,
"height": 78
},
"reader": {
"top": 284,
"bottom": 314,
"left": 87.5,
"right": 302.5,
"height": 30
},
"composer": {
"top": 318,
"bottom": 366,
"left": 10,
"right": 380,
"height": 48
},
"nav": {
"top": 436,
"bottom": 480,
"left": 45,
"right": 345,
"height": 44
},
"lines": [
{
"top": 259.203125,
"bottom": 284,
"left": 80.75,
"right": 309.234375,
"height": 24.796875
}
],
"overflow": false
},
{
"width": 844,
"height": 390,
"theme": "moon",
"stage": {
"top": 174,
"bottom": 252,
"left": 50.640625,
"right": 793.359375,
"height": 78
},
"reader": {
"top": 252,
"bottom": 282,
"left": 359.484375,
"right": 484.5,
"height": 30
},
"composer": {
"top": 286,
"bottom": 336,
"left": 102,
"right": 742,
"height": 50
},
"nav": {
"top": 406,
"bottom": 450,
"left": 284,
"right": 560,
"height": 44
},
"lines": [
{
"top": 227.203125,
"bottom": 252,
"left": 307.75,
"right": 536.234375,
"height": 24.796875
}
],
"overflow": false
},
{
"width": 844,
"height": 390,
"theme": "fog",
"stage": {
"top": 174,
"bottom": 252,
"left": 50.640625,
"right": 793.359375,
"height": 78
},
"reader": {
"top": 252,
"bottom": 282,
"left": 359.484375,
"right": 484.5,
"height": 30
},
"composer": {
"top": 286,
"bottom": 336,
"left": 102,
"right": 742,
"height": 50
},
"nav": {
"top": 406,
"bottom": 450,
"left": 284,
"right": 560,
"height": 44
},
"lines": [
{
"top": 227.203125,
"bottom": 252,
"left": 307.75,
"right": 536.234375,
"height": 24.796875
}
],
"overflow": false
}
],
"motion": {
"inked_lines": {
"6fc4f8ff-218a-4c0a-933c-7982fef596b6:1:0": 1
},
"slow_frames_new_turn_to_reply_s": 2.31
},
"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."
],
"compact_first_press": [
{
"size": "390x420",
"theme": "moon",
"down": 182,
"up": 182,
"typing": false,
"target": "发送"
},
{
"size": "390x420",
"theme": "moon",
"down": 182,
"up": 182,
"typing": false,
"target": "上一句"
},
{
"size": "390x420",
"theme": "moon",
"down": 182,
"up": 182,
"typing": false,
"target": "暂停"
},
{
"size": "390x420",
"theme": "moon",
"down": 182,
"up": 182,
"typing": false,
"target": "继续"
},
{
"size": "390x420",
"theme": "moon",
"down": 182,
"up": 182,
"typing": false,
"target": "全文"
},
{
"size": "390x420",
"theme": "fog",
"down": 182,
"up": 182,
"typing": false,
"target": "发送"
},
{
"size": "390x420",
"theme": "fog",
"down": 182,
"up": 182,
"typing": false,
"target": "上一句"
},
{
"size": "390x420",
"theme": "fog",
"down": 182,
"up": 182,
"typing": false,
"target": "暂停"
},
{
"size": "390x420",
"theme": "fog",
"down": 182,
"up": 182,
"typing": false,
"target": "继续"
},
{
"size": "390x420",
"theme": "fog",
"down": 182,
"up": 182,
"typing": false,
"target": "全文"
},
{
"size": "390x430",
"theme": "moon",
"down": 192,
"up": 192,
"typing": false,
"target": "发送"
},
{
"size": "390x430",
"theme": "moon",
"down": 192,
"up": 192,
"typing": false,
"target": "上一句"
},
{
"size": "390x430",
"theme": "moon",
"down": 192,
"up": 192,
"typing": false,
"target": "暂停"
},
{
"size": "390x430",
"theme": "moon",
"down": 192,
"up": 192,
"typing": false,
"target": "继续"
},
{
"size": "390x430",
"theme": "moon",
"down": 192,
"up": 192,
"typing": false,
"target": "全文"
},
{
"size": "390x430",
"theme": "fog",
"down": 192,
"up": 192,
"typing": false,
"target": "发送"
},
{
"size": "390x430",
"theme": "fog",
"down": 192,
"up": 192,
"typing": false,
"target": "上一句"
},
{
"size": "390x430",
"theme": "fog",
"down": 192,
"up": 192,
"typing": false,
"target": "暂停"
},
{
"size": "390x430",
"theme": "fog",
"down": 192,
"up": 192,
"typing": false,
"target": "继续"
},
{
"size": "390x430",
"theme": "fog",
"down": 192,
"up": 192,
"typing": false,
"target": "全文"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 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: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

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: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 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: 283 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: 136 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
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="zh-CN" data-theme="moon"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark"><title>CyberGril · 一扇亮着的窗</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
<!doctype html><html lang="zh-CN" data-theme="moon"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content"><meta name="color-scheme" content="dark"><title>CyberGril · 一扇亮着的窗</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+3
View File
@@ -25,4 +25,7 @@
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 15a4.5 4.5 0 0 1 .6-9 5.5 5.5 0 0 1 10.3 2.1A3.5 3.5 0 0 1 17.5 15z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M9 18l-1 2.5M13 18l-1 2.5M17 18l-1 2.5" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="i-send" viewBox="0 0 24 24"><path d="M4 12l16-8-6 16-2.5-6.5z" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M9 6v12M15 6v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.5v13l10-6.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M4 7h16M4 12h10M4 17h13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

+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); } });
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function parseState(text: string, sameIdentity?: Identity): DemoState {
if (!messages.every(m => object(m) && str(m.id, 100) && ['user', 'companion'].includes(String(m.role)) && str(m.text) && strings(m.lifeIds) && (!m.status || ['pending', 'failed', 'sent', 'cancelled'].includes(String(m.status)))))
throw new Error('聊天记录不完整。');
// Reconstruct the schema: do not preserve arbitrary imported object keys/prototypes.
return { version: 1, identity: { id: i.id, name: i.name, birth: i.birth, hour: i.hour, relation: i.relation as Identity['relation'], nickname: i.nickname, boundary: i.boundary }, settings: { city: s.city, appearance: s.appearance, reduced: s.reduced, services: Object.fromEntries(Object.keys(defaultSettings.services).map(k => [k, String((s.services as Record<string, unknown>)[k] || '')])) }, events: events.map(e => { const v = e as DemoState['events'][number]; return { id: v.id, title: v.title, text: v.text, time: v.time, place: v.place, kind: v.kind, shared: v.shared === true, memoryIds: [...v.memoryIds] }; }), memories: memories.map(m => { const v = m as DemoState['memories'][number]; return { id: v.id, title: v.title, text: v.text, group: v.group, source: v.source, lifeIds: [...v.lifeIds] }; }), messages: messages.map(m => { const v = m as DemoState['messages'][number]; return { id: v.id, role: v.role, text: v.text, lifeIds: [...v.lifeIds], status: v.status === 'pending' ? 'cancelled' : v.status, turnId: typeof v.turnId === 'string' ? v.turnId : undefined, proactive: v.proactive === true }; }), lastVisit: typeof x.lastVisit === 'number' ? x.lastVisit : Date.now() };
return { version: 1, identity: { id: i.id, name: i.name, birth: i.birth, hour: i.hour, relation: i.relation as Identity['relation'], nickname: i.nickname, boundary: i.boundary }, settings: { city: s.city, appearance: s.appearance, reduced: s.reduced, services: Object.fromEntries(Object.keys(defaultSettings.services).map(k => [k, String((s.services as Record<string, unknown>)[k] || '')])) }, events: events.map(e => { const v = e as DemoState['events'][number]; return { id: v.id, title: v.title, text: v.text, time: v.time, place: v.place, kind: v.kind, shared: v.shared === true, ...(v.shared === true && str(v.told, 100) ? { told: v.told } : {}), memoryIds: [...v.memoryIds] }; }), memories: memories.map(m => { const v = m as DemoState['memories'][number]; return { id: v.id, title: v.title, text: v.text, group: v.group, source: v.source, lifeIds: [...v.lifeIds] }; }), messages: messages.map(m => { const v = m as DemoState['messages'][number]; return { id: v.id, role: v.role, text: v.text, lifeIds: [...v.lifeIds], status: v.status === 'pending' ? 'cancelled' : v.status, turnId: typeof v.turnId === 'string' ? v.turnId : undefined, proactive: v.proactive === true }; }), lastVisit: typeof x.lastVisit === 'number' ? x.lastVisit : Date.now() };
}
export function loadState(): {
state: DemoState | null;
+28 -14
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';
@@ -7,9 +8,10 @@ import { CreationWizard } from '../manage/CreationWizard';
import { ManagePanel } from '../manage/ManagePanel';
import { LifePanel } from '../life/LifePanel';
import { ChatPanel } from '../chat/ChatPanel';
import { Background } from '../stage/Background';
import { FallbackPresenter } from '../stage/Presenter';
import { Presenter } from '../stage/Presenter';
import { Button, Icon, Status } from '../shared/ui';
import { snap } from '../shared/motion';
import { spaceFor, useSpace } from '../shared/space';
const demo = new URLSearchParams(location.search).get('demo') === '1';
export function App() {
const [initial] = useState(loadState), [state, setState] = useState<DemoState | null>(initial.state), [creating, setCreating] = useState(!initial.state), [area, setArea] = useState('now'), [theme, setThemeValue] = useState<Theme>(() => { try {
@@ -17,7 +19,8 @@ export function App() {
}
catch {
return 'moon';
} }), [toast, setToast] = useState(''), [controls, setControls] = useState(false), [fail, setFail] = useState(false), [offline, setOffline] = useState(false), [reconnecting, setReconnecting] = useState(false), [welcome, setWelcome] = useState(!!initial.state && Date.now() - initial.state.lastVisit > 86400000), [reach, setReach] = useState(false), [unread, setUnread] = useState(false), [log, setLog] = useState(false), [focusLife, setFocusLife] = useState(''), [returnTo, setReturnTo] = useState('now'), [presence, setPresence] = useState('在你面前'), [voice, setVoice] = useState(false), [observation, setObservation] = useState('');
} }), [toast, setToast] = useState(''), [controls, setControls] = useState(false), [fail, setFail] = useState(false), [offline, setOffline] = useState(false), [reconnecting, setReconnecting] = useState(false), [welcome, setWelcome] = useState(!!initial.state && Date.now() - initial.state.lastVisit > 86400000), [reach, setReach] = useState(false), [unread, setUnread] = useState(false), [log, setLog] = useState(false), [focusLife, setFocusLife] = useState(''), [returnTo, setReturnTo] = useState('now'), [presence, setPresence] = useState('在你面前'), [voice, setVoice] = useState(false), [observation, setObservation] = useState(''), [record, setRecord] = useState(false), [instant, setInstant] = useState(0);
const [herFocus, setHerFocus] = useState(0);
const latest = useRef(state);
latest.current = state;
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null), reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -38,18 +41,20 @@ 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);
} }, []);
} }); }, []);
const space = spaceFor(area, record || log, creating);
useSpace(area, space);
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
useEffect(() => { document.body.className = [creating && demo ? 'creating' : '', area === 'talk' ? 'talk' : '', !creating && ['days', 'settings'].includes(area) ? 'paned' : '', voice ? 'voice' : '', log ? 'log' : '', offline ? 'offline' : '', welcome ? 'welcoming' : '', state?.events.length === 0 ? 'data-empty' : ''].join(' '); }, [area, creating, voice, log, offline, welcome, state]);
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
useEffect(() => { const visibility = () => { document.documentElement.classList.toggle('paused', document.hidden); if (document.hidden && latest.current) {
useEffect(() => { const visibility = () => { if (document.hidden && latest.current) {
try {
persistState({ ...latest.current, lastVisit: Date.now() });
}
@@ -57,6 +62,9 @@ export function App() {
} }; document.addEventListener('visibilitychange', visibility); return () => { document.removeEventListener('visibilitychange', visibility); if (toastTimer.current)
clearTimeout(toastTimer.current); if (reconnectTimer.current)
clearTimeout(reconnectTimer.current); }; }, []);
function openEventChat(id: string) { setFocusLife(id); setReturnTo('days'); setLog(true); setArea('talk'); }
const closeTalk = useCallback(() => { setArea(returnTo); setLog(false); setVoice(false); }, [returnTo]);
// Esc peels one layer at a time: demo menu, welcome, record; then you are back across the room at once.
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating)
return; if (controls) {
setControls(false);
@@ -67,9 +75,12 @@ export function App() {
} if (log) {
setLog(false);
return;
} navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [controls, welcome, log, creating, navigate]);
function openEventChat(id: string) { setFocusLife(id); setReturnTo('days'); setLog(true); setArea('talk'); }
function closeTalk() { setArea(returnTo); setLog(false); setVoice(false); }
} if (area === 'talk') {
snap();
setInstant(i => i + 1);
closeTalk();
return;
} if (area !== 'now') navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [controls, welcome, log, creating, navigate, area, closeTalk]);
function loadSamples(long = false) { if (!state)
return; try {
save(sampleDays(state, long));
@@ -94,15 +105,18 @@ export function App() {
notify('暂时还没连上,再试一次。');
return;
} setOffline(false); notify('模拟恢复完成,已重新同步。草稿仍在,可自行发送。'); }, 700); }
const scene = { mode: area === 'talk' ? 'talk' as const : 'daily' as const, activity: view?.events.find(e => e.kind === 'now') ? '在窗边读书' : '刚搬进来,安静待着', time_band: 'evening', weather: 'demo', outfit: 'home' };
return <><Background /><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{demo ? '本机演示 · 无真实后台' : '服务尚未接通'}</span></div><div className="env"><span className="chip glass place"><Icon name="pin"/>{state?.settings.city || '杭州'}</span><span className="chip glass weather"><Icon name="cloud"/>雨后 · 模拟</span><span className="theme-switch glass">{(['moon', 'fog'] as Theme[]).map(t => <button key={t} data-t={t} aria-label={t === 'moon' ? '靛蓝月光' : '蓝紫雾光'} aria-pressed={theme === t} onClick={() => setTheme(t)}><i /><span>{t === 'moon' ? '月光' : '雾光'}</span></button>)}</span></div></header>
// The reference still is not her new daily life (reading is no longer the default, 01a166aa): say only where she is.
const scene = { mode: area === 'talk' ? 'talk' as const : 'daily' as const, activity: view?.events.length ? '在窗边待着' : '刚搬进来,安静待着', time_band: 'evening', weather: 'demo', outfit: 'home' };
const talking = area === 'talk';
return <><Presenter scene={scene} space={space} theme={theme} instant={instant} observation={observation} now={state && !creating ? scene.activity : ''} onApproach={() => navigate('talk')}/>
<header className="topbar"><div className="who"><i className="dot"/><span className="name" title={state?.identity.name}>{state?.identity.name || 'CyberGril'}</span>{state && !creating && <span className="presence">{talking ? presence : scene.activity}</span>}<span className="state">{demo ? '本机演示 · 无真实后台' : '服务尚未接通'}</span></div><div className="env"><span className="chip glass place"><Icon name="pin"/>{state?.settings.city || '杭州'}</span><span className="chip glass weather"><Icon name="cloud"/>雨后 · 模拟</span><span className="theme-switch glass">{(['moon', 'fog'] as Theme[]).map(t => <button key={t} data-t={t} aria-label={t === 'moon' ? '靛蓝月光' : '蓝紫雾光'} aria-pressed={theme === t} onClick={() => setTheme(t)}><i /><span>{t === 'moon' ? '月光' : '雾光'}</span></button>)}</span>{talking && state && <button className="end-talk" onClick={closeTalk}><Icon name="x"/><span>{returnTo === 'days' ? '结束交谈 · 回到日子' : '结束交谈'}</span></button>}</div></header>
{!demo ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><p>真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。</p><a className="btn-primary" href="?demo=1">进入本机演示</a></div></main> : initial.error ? <main className="entry"><div className="card"><h1>资料暂时读不出来</h1><Status text={initial.error} error/><p>请先检查浏览器存储权限,或备份浏览器中的原始资料后再处理。不会自动清空或覆盖。</p></div></main> : <>
{creating && <CreationWizard fail={fail} onCreate={save} onEnter={() => setCreating(false)}/>}
{!creating && state && view && <><FallbackPresenter scene={scene} presence={presence} observation={observation} onClose={area === 'talk' ? closeTalk : () => navigate('now')} closeLabel={returnTo === 'days' ? '返回日子' : '回到此刻'}/>
{!creating && state && view && <>
{area === 'now' && !welcome && <main className="home"><div className="eyebrow">夜色里 · 一扇亮着的窗</div><h1 className="whisper">{state.events.length ? '夜深了,慢慢说。' : '刚住进来,一切都慢慢来。'}</h1><div className="meta"><span><Icon name="moon"/>心情平静 · 演示</span><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div>{view.messages.filter(m => m.role === 'companion').length > 0 && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>她留给你的</b><span>演示留言</span></span><p>{[...view.messages].reverse().find(m => m.role === 'companion')?.text}</p></button>}<button className="away" onClick={() => navigate('days')}>你不在时 · 看她已经分享的日子 <Icon name="next"/></button><div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button><button className="btn-round glass" aria-label="进入模拟语音" onClick={() => { navigate('talk'); notify('点输入框右侧麦克风,可开启无收音的语音模拟。'); }}><Icon name="mic"/></button></div><p className="first-note">{state.identity.name}今晚刚住进来。这里还没有共同的日子。</p><p className="demo-label">本机演示 · 没有真实后台生活</p></main>}
{welcome && area === 'now' && <section className="welcome glass"><div className="eyebrow">回来坐一会儿</div><h2>你不在的这两天</h2><p className="sub">固定演示摘要 · 只展示已分享记录</p><ul>{view.events.filter(e => e.kind !== 'plan').slice(0, 3).map(e => <li key={e.id}><time>{e.time}</time><span>{e.title}</span></li>)}</ul><p className="note">演示中网页关闭不会产生新的生活,服务器后台尚未接通。</p><div className="acts"><Button primary onClick={() => { navigate('talk'); setLog(true); }}>看她留的话</Button><Button onClick={() => navigate('days')}>看她的日子</Button><button className="btn-text" onClick={() => setWelcome(false)}>先待一会儿</button></div></section>}
<LifePanel state={state} open={area === 'days'} onClose={() => navigate('now')} onChat={openEventChat}/><ManagePanel state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={save} fail={fail} theme={theme} setTheme={setTheme}/>
{area === 'talk' && <ChatPanel state={state} onChange={changeMessages} onPresence={setPresence} onVoice={setVoice} onObservation={setObservation} onClose={closeTalk} offline={offline} fail={fail} log={log} setLog={setLog} focusLife={focusLife}/>}
<LifePanel state={state} open={area === 'days'} now={scene.activity} fail={fail} onClose={() => navigate('now')} onChat={openEventChat} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/><ManagePanel state={state} open={area === 'settings'} now={scene.activity} focus={herFocus} onClose={() => navigate('now')} onSave={save} fail={fail} theme={theme} setTheme={setTheme}/>
{talking && <ChatPanel state={state} onChange={changeMessages} onPresence={setPresence} onVoice={setVoice} onObservation={setObservation} onRecord={setRecord} offline={offline} fail={fail} log={log} setLog={setLog} focusLife={focusLife}/>}
{offline && <div className={`netbar show glass ${reconnecting ? '' : 'failed'}`} role="status"><span>{reconnecting ? '信号不太好,正在重新连上…' : '模拟断线,草稿还在'}</span><button className="btn-ghost" onClick={reconnect}>再试一次</button></div>}
{reach && <aside className="reach glass show"><div className="rh"><b>{state.identity.name}</b>主动说的 · 模拟</div><p>窗外的雨停了,想跟你说一声。</p><div className="basis">来自已分享的「听了一下午的雨」</div><div className="acts"><Button primary onClick={() => { setReach(false); setUnread(false); setReturnTo(area); setArea('talk'); setFocusLife('rain'); setLog(true); }}>回她</Button><Button onClick={() => setReach(false)}>稍后再看</Button></div></aside>}
<nav className="nav glass" aria-label="主导航">{[['now', 'moon', '此刻'], ['talk', 'chat', '交谈'], ['days', 'book', '日子'], ['settings', 'gear', '设置']].map(([id, icon, label]) => <button className={`nav-item ${id === 'talk' && unread ? 'unread' : ''}`} key={id} aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}<i className="badge"/></button>)}</nav></>}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, loadReal, type Health } from '../adapters/real';
import type { DemoState, Message, Theme } from '../domain/model';
import { Presenter } 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';
import { snap } from '../shared/motion';
import { spaceFor, useSpace } from '../shared/space';
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('文字已接通'), [record, setRecord] = useState(false), [instant, setInstant] = useState(0);
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
const [herFocus, setHerFocus] = useState(0);
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]);
const space = spaceFor(area, record || log, creating || !ready);
useSpace(area, space);
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); }); }, []);
// Esc peels one layer at a time: the record first, then you are back across the room at once.
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) { setLog(false); return; } if (area === 'talk') { snap(); setInstant(i => i + 1); setArea('now'); return; } if (area !== 'now') navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate, area]);
const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined;
const talking = area === 'talk' && !!state && !creating;
return <><Presenter real scene={{ mode: area === 'talk' ? 'talk' : 'daily', activity: '文字已接通', time_band: '', weather: '', outfit: '' }} space={space} theme={theme} instant={instant} observation="" now={state && !creating ? '生活尚未接通' : ''} onApproach={() => navigate('talk')}/>
<header className="topbar"><div className="who"><i className="dot"/><span className="name" title={state?.identity.name}>{state?.identity.name || 'CyberGril'}</span>{talking && <span className="presence">{presence}</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>{talking && <button className="end-talk" onClick={() => navigate('now')}><Icon name="x"/><span>结束交谈</span></button>}</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 && <>
{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} onRecord={setRecord} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
<LifePanel real state={state} open={area === 'days'} now="生活尚未接通" onClose={() => navigate('now')} onChat={() => { navigate('talk'); setLog(true); }} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/>
<ManagePanel real state={state} open={area === 'settings'} now="生活尚未接通" focus={herFocus} 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></>}
</>}</>;
}
+219 -89
View File
@@ -1,111 +1,241 @@
import { newId } from '../domain/id';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, 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';
import { snap } from '../shared/motion';
import { motionOff } from '../stage/roomScene';
import { LineStage, type PresentHow } from './lineStage';
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
const START: Anchor = { mi: 0, offset: 0 };
const metricsFor = (width: number): Metrics => { const compact = document.documentElement.classList.contains('compact'); return { width, font: compact ? 16 : innerWidth <= 720 ? 18 : 23, compact }; };
const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]'));
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, real = false }: {
real?: boolean;
state: DemoState;
onChange: (messages: Message[]) => void;
onPresence: (p: string) => void;
onVoice: (v: boolean) => void;
onObservation: (s: string) => void;
onClose: () => void;
// A reading sheet (full text or the whole record) is open: she stays across the table, the room dims a little.
onRecord: (open: boolean) => void;
offline: boolean;
fail: boolean;
log: boolean;
setLog: (b: boolean) => void;
focusLife: string;
}) {
const [draft, setDraft] = useState(() => { try {
return sessionStorage.getItem('cg.draft') || '';
}
catch {
return '';
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
const draftKey = real ? 'cg.real.draft' : 'cg.draft', readKey = real ? 'cg.real.reading' : 'cg.reading';
const [draft, setDraft] = useState(() => { try { return sessionStorage.getItem(draftKey) || ''; } catch { return ''; } }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [flash, setFlash] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
const gate = useRef(new TurnGate()), adapter = useRef(real ? new RealChatAdapter(epoch => { gate.current.epoch = epoch; }) : new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
const [activeTurn, setActiveTurn] = useState(''), [interrupted, setInterrupted] = useState<{ text: string; turn: string } | null>(null);
const replyLive = useRef(''), turnLive = useRef(''), sentTurn = useRef('');
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 name = state.identity.name;
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)); }
// ── What the stage shows: the newest turn of the (already privacy-filtered) record, plus the live reply. ──
const view = useMemo(() => publicView(state), [state]);
// The cut-off part of a stopped reply stays with its own turn (it is shown, never stored).
const shown = useMemo<ShownMessage[]>(() => {
const list: ShownMessage[] = [...view.messages];
if (reply) list.push({ id: 'live-reply', role: 'companion', text: reply, lifeIds: [], turnId: activeTurn, live: 'generating' });
else if (interrupted) {
const at = list.map(m => m.turnId).lastIndexOf(interrupted.turn);
list.splice(at < 0 ? list.length : at + 1, 0, { id: 'cut-reply', role: 'companion', text: interrupted.text, lifeIds: [], turnId: interrupted.turn, live: 'cancelled' });
}
return list;
}, [view.messages, reply, activeTurn, interrupted]);
const turn = useMemo(() => groupTurns(shown).at(-1), [shown]);
const stageRef = useRef<HTMLDivElement>(null), lines = useRef<LineStage | null>(null), meter = useRef<HTMLElement>(null);
const [metrics, setMetrics] = useState<Metrics>(() => metricsFor(Math.min(900, innerWidth * .88)));
const pages = useMemo(() => pagesOf(turn, metrics), [turn, metrics]);
// Reading position is kept as a place in the text (not a line number), so re-chunking never loses it.
const restored = useRef<{ turn: string; anchor: Anchor; flow: boolean } | null>((() => { try { return JSON.parse(sessionStorage.getItem(readKey) || 'null'); } catch { return null; } })());
const resume = restored.current && turn && restored.current.turn === turn.key ? restored.current : null;
const [anchor, setAnchor] = useState<Anchor>(resume?.anchor || START), [flow, setFlowState] = useState(resume ? resume.flow : true), [full, setFull] = useState(false);
const head = useRef<Anchor>(resume?.anchor || START), how = useRef<PresentHow>('jump'), turnKey = useRef(turn?.key || '');
const cursor = cursorAt(pages, anchor), page = pages[cursor], atEnd = cursor >= pages.length - 1;
const headIndex = cursorAt(pages, head.current);
if (cursor > headIndex) head.current = anchor;
const held = useRef(new Set<string>()), clock = useRef(new ReadingClock());
const reading = useRef({ pages, cursor, flow }); reading.current = { pages, cursor, flow };
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(() => { try {
sessionStorage.setItem('cg.draft', draft);
}
catch { /* Draft remains in memory. */ } }, [draft]);
useEffect(() => { if (offline) {
gate.current.resync();
setBusy(false);
setReply('');
setVoice(false);
onVoice(false);
voiceController.current?.abort();
} }, [offline, onVoice]);
useEffect(() => { if (!vision)
return; onObservation('模拟 · 刚刚看到,未采集影像'); const t = setTimeout(() => onObservation('模拟 · 已过期,不再当作眼前'), 30000); return () => clearTimeout(t); }, [vision, onObservation]);
useEffect(() => { if (log) {
const el = focusLife ? logRef.current?.querySelector(`[data-life="${CSS.escape(focusLife)}"]`) : null;
if (el)
el.scrollIntoView({ block: 'center' });
else if (logRef.current)
logRef.current.scrollTop = logRef.current.scrollHeight;
} }, [log, focusLife, view.messages.length]);
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
return; if (event.t === 'chat.delta') {
setReply(x => x + event.delta);
onPresence('在说话');
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(draftKey, draft); } catch { /* Draft remains in memory. */ } }, [draft, draftKey]);
useEffect(() => { try { if (turn) sessionStorage.setItem(readKey, JSON.stringify({ turn: turn.key, anchor, flow })); } catch { /* Position remains in memory. */ } }, [turn, anchor, flow, readKey]);
useEffect(() => { if (offline) { gate.current.resync(); setBusy(false); setReply(''); setVoice(false); onVoice(false); voiceController.current?.abort(); } }, [offline, onVoice]);
useEffect(() => { if (!vision) return; onObservation('模拟 · 刚刚看到,未采集影像'); const t = setTimeout(() => onObservation('模拟 · 已过期,不再当作眼前'), 30000); return () => clearTimeout(t); }, [vision, onObservation]);
useEffect(() => { if (!flash) return; const t = setTimeout(() => setFlash(''), 2600); return () => clearTimeout(t); }, [flash]);
useEffect(() => { onRecord(log || full); }, [log, full, onRecord]);
useEffect(() => () => onRecord(false), [onRecord]);
useEffect(() => { if (log) { const el = focusLife ? logRef.current?.querySelector(`[data-life="${CSS.escape(focusLife)}"]`) : null; if (el) el.scrollIntoView({ block: 'center' }); else if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log, focusLife, view.messages.length]);
// ── Stage ──
useLayoutEffect(() => { if (stageRef.current) lines.current = new LineStage(stageRef.current); return () => { lines.current = null; }; }, []);
useLayoutEffect(() => {
const el = stageRef.current; if (!el) return;
const measure = () => setMetrics(m => { const next = metricsFor(el.clientWidth || m.width); if (next.width === m.width && next.font === m.font && next.compact === m.compact) return m; how.current = 'jump'; return next; });
const ro = new ResizeObserver(measure), vv = window.visualViewport; ro.observe(el); addEventListener('resize', measure); vv?.addEventListener('resize', measure); measure();
return () => { ro.disconnect(); removeEventListener('resize', measure); vv?.removeEventListener('resize', measure); };
}, []);
// A new turn (you sent something, or she spoke on her own) starts at its first line.
useLayoutEffect(() => {
const key = turn?.key || '';
if (key === turnKey.current) return;
turnKey.current = key; how.current = 'jump'; head.current = START; setAnchor(START);
if (key && key === sentTurn.current) setFlowState(true);
}, [turn?.key]);
const signature = pages.map(p => p.key).join('|');
useLayoutEffect(() => {
lines.current?.present(pages, cursor, how.current, name, !motionOff());
how.current = 'advance';
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-present only when the visible lines change
}, [signature, cursor]);
useLayoutEffect(() => {
if (!page) return;
const mine = page.message, cancelled = mine.live === 'cancelled' || (mine.status === 'cancelled' && mine.role === 'companion');
const text = cancelled && page.last ? '未完成 · 已取消' : mine.role === 'user' && mine.status === 'failed' ? '没发出去' : mine.role === 'user' && mine.status === 'cancelled' && atEnd ? '本轮已取消' : atEnd && busy && mine.role === 'companion' ? '还在说' : '';
lines.current?.mark({ text, waiting: atEnd && busy && mine.role === 'companion' });
});
useEffect(() => { lines.current?.rename(name); }, [name]);
// ── Reading clock. Holds (hover, selection, open sheets, hidden page) freeze it without changing your choice. ──
useEffect(() => {
let raf = 0;
const c = clock.current, away = () => c.pause();
const tick = (now: number) => {
const { pages: list, cursor: at, flow: on } = reading.current;
if (c.tick(now, list[at], on && !held.current.size && !document.hidden) && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); }
if (meter.current) meter.current.style.transform = `scaleX(${c.progress})`;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
document.addEventListener('visibilitychange', away);
return () => { cancelAnimationFrame(raf); document.removeEventListener('visibilitychange', away); };
}, []);
const [holding, setHolding] = useState(false);
const hold = useCallback((why: string, on: boolean) => { if (on) held.current.add(why); else held.current.delete(why); setHolding(held.current.size > 0); }, []);
useEffect(() => { hold('sheet', log || full); }, [log, full, hold]);
useEffect(() => {
const selection = () => { const s = getSelection(); hold('select', !!(s && String(s).length && stageRef.current?.contains(s.anchorNode))); };
document.addEventListener('selectionchange', selection);
return () => document.removeEventListener('selectionchange', selection);
}, [hold]);
const goTo = (index: number, mode: PresentHow) => { const to = Math.max(0, Math.min(index, pages.length - 1)); if (to === cursor) return; how.current = mode; clock.current.forget(); setAnchor(anchorOf(pages, to) || START); };
const setFlow = (on: boolean, quiet = false) => { setFlowState(on); clock.current.restart(); if (!quiet) setFlash(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看'); };
const back = (instant = false) => { if (flow) setFlow(false, true); goTo(cursor - 1, instant ? 'instant' : 'jump'); };
const forward = (instant = false) => goTo(cursor + 1, instant ? 'instant' : 'advance');
const toLatest = () => { how.current = 'jump'; setAnchor(head.current); setFlow(true, true); };
// Keyboard review lands at once (no slide, no replayed ink). Sheets and dialogs own their keys.
const keys = useRef({ back, forward, toggle: () => setFlow(!flow) }); keys.current = { back, forward, toggle: () => setFlow(!flow) };
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || typingTarget(document.activeElement) || document.querySelector('.modal.open') || document.documentElement.dataset.mode !== 'talk') return;
if (e.key === 'ArrowLeft') { snap(); keys.current.back(true); }
else if (e.key === 'ArrowRight') { snap(); keys.current.forward(true); }
else if (e.key === ' ' && (document.activeElement === document.body || !document.activeElement)) { e.preventDefault(); keys.current.toggle(); }
};
addEventListener('keydown', key);
return () => removeEventListener('keydown', key);
}, []);
// Esc closes the full-text sheet first (one layer at a time) and returns focus to the button that opened it.
const fullButton = useRef<HTMLButtonElement>(null), historyButton = useRef<HTMLButtonElement>(null), fullClose = useRef<HTMLButtonElement>(null), logClose = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!full) return;
fullClose.current?.focus({ preventScroll: true });
const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || document.querySelector('.modal.open')) return; e.stopImmediatePropagation(); e.preventDefault(); setFull(false); };
document.addEventListener('keydown', key, true);
return () => { document.removeEventListener('keydown', key, true); const back = fullButton.current; if (back && !back.closest('[inert]')) back.focus({ preventScroll: true }); };
}, [full]);
const logWasOpen = useRef(log);
useEffect(() => {
if (log) logClose.current?.focus({ preventScroll: true });
else if (logWasOpen.current && document.activeElement && (document.activeElement === document.body || document.activeElement.closest('.record'))) historyButton.current?.focus({ preventScroll: true });
logWasOpen.current = log;
}, [log]);
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' };
sentTurn.current = turnId;
change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]);
if (!retry) setDraft('');
setActiveTurn(turnId); turnLive.current = turnId; setBusy(true); onPresence('在想'); setNotice('');
if (!flow) setFlash('新的一轮会自动呈现,随时可以暂停');
try {
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => {
if (!gate.current.accepts(event)) return;
if (event.t === 'chat.delta') { replyLive.current += event.delta; setInterrupted(null); setReply(replyLive.current); onPresence('在说话'); }
else if (event.t === 'chat.final') {
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
replyLive.current = ''; setInterrupted(null); setReply('');
if (real) setDraft(current => current === text ? '' : current);
setBusy(false);
onPresence(voice ? '在听你说(模拟)' : '在你面前');
}
}, fail);
}
else {
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
setReply('');
catch (e) {
if (signal.aborted) return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
onPresence(voice ? '在听你说(模拟)' : '在你面前');
} }, fail);
}
catch (e) {
if (signal.aborted)
return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
setNotice((e as Error).message);
onPresence('在你面前');
} }
const setVoiceMode = () => { if (voice) {
endVoice();
return;
} setPermission('mic'); };
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || '演示回复 · 没有连接模型、语音或记忆后台'}</div></div>
{voice && <div className="voicebar glass"><span className="live"><i /></span><span className="vstate">{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button className="btn-ghost" onClick={() => { stop(); setNotice(fail?'刚才没听清,你再说一遍?(模拟识别失败)':'她停下来了,在等你。'); }}>打断她</button><button className="end" onClick={endVoice}>结束语音</button></div>}
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (vision) {
setVision(false);
onObservation('窗户关上了');
}
else
setPermission('vision'); }}><Icon name="eye"/></button><textarea aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault();
void send();
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">本机演示记录</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
setDenied(false);
setVoice(true);
onVoice(true);
voiceController.current = new AbortController();
onPresence('在听你说(模拟)');
setNotice('模拟语音没有收音,可继续用文字输入。');
}
else {
if (fail) {
onObservation('模拟 · 看不清,视觉服务未接通');
setNotice('模拟观察失败,文字仍可使用。');
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('在你面前');
}
else
setVision(true);
} setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。</Modal>}
<button hidden className="sr" onClick={onClose}>退出交流</button></>;
}
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) { endVoice(); return; } setPermission('mic'); };
const lastUser = [...view.messages].reverse().find(m => m.role === 'user');
const status = notice || flash || (busy ? (reply ? `${name}正在回应…` : `${name}在想…`) : !pages.length ? '你们还没有说过话。' : real ? '' : '演示回复 · 未连接真实模型');
const unread = pages.length - 1 - cursor, single = pages.length <= 1 && !busy, fullMessage = page?.message;
const textarea = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => { const t = textarea.current; if (!t) return; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 88) + 'px'; }, [draft]);
const typing = (on: boolean) => document.documentElement.classList.toggle('typing', on);
useEffect(() => () => { document.documentElement.classList.remove('typing'); }, []);
const record = reply ? shown.filter(m => !m.live) : shown;
// While a reading sheet is open the table words are out of reach (the sheet holds them all); layout effect so the
// focus hand-back after closing never lands on an inert button.
const conversation = useRef<HTMLElement>(null);
useLayoutEffect(() => { conversation.current?.toggleAttribute('inert', log || full); }, [log, full]);
return <><section ref={conversation} className={`conversation ${single ? 'single-page' : ''} ${flow ? '' : 'reading-paused'} ${holding && flow ? 'reading-held' : ''}`} aria-label="当前对话">
{voice && <div className="voice-row"><span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span><span>{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button onClick={() => { stop(); setNotice(fail ? '刚才没听清,你再说一遍?(模拟识别失败)' : '她停下来了,在等你。'); }}>打断她</button><button onClick={endVoice}>结束语音</button></div>}
<div className="stage" ref={stageRef} aria-hidden="true" onPointerEnter={e => { if (e.pointerType === 'mouse') hold('hover', true); }} onPointerLeave={() => hold('hover', false)}/>
<p className="sr" aria-live="polite" aria-atomic="true">{page ? `${page.message.role === 'user' ? '你' : name}:${page.text}` : ''}</p>
{status && <div className="talk-status" role="status"><span>{status}</span>{lastUser?.status === 'failed' && !busy && <button className="retry" onClick={() => void send(lastUser)}>重试</button>}</div>}
<div className="reader-bar">
<button className="flow" aria-pressed={flow} onClick={() => setFlow(!flow)}><Icon name={flow ? 'pause' : 'play'}/><span>{flow ? '暂停' : '继续'}</span></button>
<button className="icon" aria-label="上一句" disabled={cursor <= 0} onClick={() => back()}><Icon name="back"/></button>
<span className="count" aria-label={pages.length ? `第 ${cursor + 1} 句,共 ${pages.length} 句${busy ? ',还在说' : ''}` : '还没有对话'}><span>{pages.length ? `${cursor + 1} / ${pages.length}${busy ? '…' : ''}` : '0 / 0'}</span><i ref={meter}/></span>
<button className="icon" aria-label="下一句" disabled={atEnd} onClick={() => forward()}><Icon name="next"/></button>
<button className="read-all" ref={fullButton} disabled={!page} onClick={() => setFull(true)}>全文</button>
{busy && <button className="stop" onClick={stop}>停止回复</button>}
{!flow && cursor < cursorAt(pages, head.current) && <button className="latest" onClick={toLatest}><Icon name="down"/><span>{unread > 0 ? `回到读到的地方 · 后面还有 ${unread} 句` : '回到读到的地方'}</span></button>}
</div>
<div className="composer"><button className={`round log-btn ${log ? 'on' : ''}`} ref={historyButton} aria-label="完整记录" aria-expanded={log} onClick={() => setLog(!log)}><Icon name="lines"/></button>
<textarea ref={textarea} aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onFocus={() => typing(true)} onBlur={() => typing(false)} 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={`round cam ${vision ? 'on' : ''}`} aria-label={real ? '视觉观察尚未接通' : '视觉观察(模拟)'} onClick={() => { if (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) { setVision(false); onObservation('窗户关上了'); } else setPermission('vision'); }}><Icon name="eye"/></button>
<button className="round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button>
<button className={`voice-button ${denied ? 'denied' : ''}`} aria-label={real ? '语音尚未接通' : voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button>
</div>
</section>
<aside className="record log-panel glass read" hidden={!log} role="dialog" aria-modal="false" aria-labelledby="record-title"><header><div><p className="eyebrow">{real ? '服务器聊天记录' : '完整记录 · 本机演示'}</p><h2 id="record-title">你们说过的话</h2></div><button className="btn-round sm" ref={logClose} aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header>
<div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds[0]} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}</div></aside>
{full && fullMessage && <aside className="record full glass read" role="dialog" aria-modal="false" aria-labelledby="full-title"><header><div><p className="eyebrow">{fullMessage.role === 'user' ? '你' : name} · 全文</p><h2 id="full-title">这一段,慢慢看</h2></div><button className="btn-round sm" ref={fullClose} aria-label="关闭全文" onClick={() => setFull(false)}><Icon name="x"/></button></header>
<div className="scroll"><p className="guide">整段完整可读。关闭后回到刚才那一句。</p><article className="message" data-role={fullMessage.role}><div className="author"><i/>{fullMessage.role === 'user' ? '你' : name}</div><p>{fullMessage.text}</p>{fullMessage.live === 'generating' && <span className="incomplete">还在说…</span>}{fullMessage.live === 'cancelled' && <span className="incomplete">这段回复已停止,内容未完成,未存入聊天记录。</span>}</article></div></aside>}
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') { setDenied(false); setVoice(true); onVoice(true); voiceController.current = new AbortController(); onPresence('在听你说(模拟)'); setNotice('模拟语音没有收音,可继续用文字输入。'); } else { if (fail) { onObservation('模拟 · 看不清,视觉服务未接通'); setNotice('模拟观察失败,文字仍可使用。'); } else setVision(true); } setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。</Modal>}
</>;
}
+92
View File
@@ -0,0 +1,92 @@
import { graphemes, type Page } from './reading';
// The lines on your side of the table (HEL-799 talk.js present/pin/leave). React owns the element; this class owns
// its children so finished lines are never re-rendered, re-typeset or re-animated. Text is always textContent.
const EASE = 'cubic-bezier(.22,1,.36,1)';
export type PresentHow = 'advance' | 'jump' | 'instant';
export interface LineState { text: string; waiting: boolean }
export class LineStage {
constructor(private stage: HTMLElement) {}
// A line inks in only the first time it is read; review, returns and resizes show it as it is.
private inked = new Set<string>();
private line(page: Page, current: boolean, name: string, ink: boolean) {
if (ink) this.inked.add(page.key);
const el = document.createElement('div');
el.className = 'line' + (current ? ' is-current' : ' is-prev');
el.dataset.role = page.message.role; el.dataset.key = page.key;
const who = document.createElement('span'); who.className = 'by';
const dot = document.createElement('i'), label = document.createElement('b'), state = document.createElement('em');
label.textContent = page.message.role === 'user' ? '你' : name; label.title = label.textContent; state.className = 'state';
who.append(dot, label, state);
const p = document.createElement('p');
if (ink) {
// Her current line inks in once, character by character (capped so long lines don't drag).
p.className = 'ink';
graphemes(page.text).forEach((c, i) => { const s = document.createElement('span'); s.style.setProperty('--i', String(Math.min(i, 48))); s.textContent = c; p.append(s); });
} else p.textContent = page.text;
el.append(who, p);
return el;
}
// Take a line out of the flow at its current place, so the remaining lines lay out once at their final size.
private pin(node: HTMLElement, rect: DOMRect, stageRect: DOMRect) {
node.classList.add('leaving');
Object.assign(node.style, { position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px', transformOrigin: '50% 0' });
}
private leave(node: HTMLElement, rect: DOMRect, stageRect: DOMRect, quick = false, lift = quick ? -4 : -14, shrink = 1) {
this.pin(node, rect, stageRect);
node.animate([{ opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)' }, { opacity: 0, transform: `translateY(${lift}px) scale(${shrink})`, filter: 'blur(5px)' }],
{ duration: quick ? 240 : 520, easing: EASE, fill: 'forwards' }).finished.then(() => node.remove()).catch(() => node.remove());
}
clear() { this.stage.replaceChildren(); }
// Shows the current page with the one before it. Returns early when the same lines are already on screen, so
// a growing stream, a status change or a re-render never replays anything.
present(pages: Page[], cursor: number, how: PresentHow, name: string, animate: boolean) {
const stage = this.stage, page = pages[cursor];
if (!page) { stage.replaceChildren(); return; }
const prev = pages[cursor - 1], want = [prev, page].filter((p): p is Page => !!p);
const have = [...stage.querySelectorAll<HTMLElement>('.line:not(.leaving)')], keys = have.map(n => n.dataset.key);
const inkFor = (p: Page) => p === page && animate && p.message.role === 'companion' && !this.inked.has(p.key);
if (how === 'instant' || !animate) {
// Keyboard review and reduced motion: show the target lines as they are — no slide, no re-played ink.
if (keys.join() === want.map(p => p.key).join() && how !== 'instant') return;
for (const n of stage.querySelectorAll('.line.leaving')) n.remove();
stage.replaceChildren(...want.map(p => this.line(p, p === page, name, false)));
return;
}
if (keys.join() === want.map(p => p.key).join()) return;
const stageRect = stage.getBoundingClientRect();
// Forward by one: the finished line lifts toward the "previous" slot while a small copy settles there; the
// oldest drifts away. Both copies keep a fixed font size; only transform/opacity/filter move.
if (how === 'advance' && prev && keys[keys.length - 1] === prev.key) {
const first = new Map(have.map(n => [n, n.getBoundingClientRect()]));
for (const n of have) if (n.dataset.key !== prev.key) this.leave(n, first.get(n)!, stageRect);
const keep = have.find(n => n.dataset.key === prev.key)!, was = first.get(keep)!;
const settled = this.line(prev, false, name, false), fresh = this.line(page, true, name, inkFor(page));
this.pin(keep, was, stageRect);
stage.append(settled, fresh);
const to = settled.getBoundingClientRect();
if (to.height) {
const shrink = parseFloat(getComputedStyle(settled.querySelector('p')!).fontSize) / parseFloat(getComputedStyle(keep.querySelector('p')!).fontSize);
this.leave(keep, was, stageRect, false, to.top - was.top, shrink);
settled.animate([{ opacity: 0, transform: `translateY(${Math.round((was.top - to.top) * .35)}px)` }, { opacity: getComputedStyle(settled).opacity, transform: 'none' }], { duration: 620, easing: EASE });
} else this.leave(keep, was, stageRect); // compact screens hide the previous line
fresh.animate([{ opacity: 0, transform: 'translateY(16px)', filter: 'blur(8px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 680, easing: EASE });
return;
}
// Jumps (review, new turn, resize) crossfade instead of sliding, so direction never lies.
for (const n of have) this.leave(n, n.getBoundingClientRect(), stageRect, true);
for (const p of want) {
const el = this.line(p, p === page, name, inkFor(p)); stage.append(el);
el.animate([{ opacity: 0, filter: 'blur(6px)' }, { opacity: 1, filter: 'blur(0)' }], { duration: 520, delay: 90, easing: EASE, fill: 'backwards' });
}
}
// Status words beside the current speaker ("还在说", "未完成 · 已取消"); changing them never touches the text.
mark(state: LineState) {
const cur = this.stage.querySelector('.line.is-current:not(.leaving)'); if (!cur) return;
const s = cur.querySelector('.state'); if (s && s.textContent !== state.text) s.textContent = state.text;
cur.classList.toggle('waiting', state.waiting);
}
rename(name: string) {
for (const n of this.stage.querySelectorAll<HTMLElement>('.line[data-role="companion"] .by b')) { n.textContent = name; n.title = name; }
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { Message } from '../domain/model';
// Reading logic carried over from the verified HEL-793/795 prototype (PR#8) and HEL-799 (approved 01a16810), moved
// onto the real message flow: replies are shown one or two lines at a time and flow on by themselves.
// A message as the stage sees it: stored messages plus, at most, the reply still being written or cut off.
export interface ShownMessage extends Message { live?: 'generating' | 'cancelled' }
export interface Turn { key: string; items: ShownMessage[] }
export interface Page { key: string; text: string; index: number; last: boolean; mi: number; message: ShownMessage }
export interface Metrics { width: number; font: number; compact: boolean }
export const graphemes = (text: string): string[] => typeof Intl.Segmenter === 'function' ? [...new Intl.Segmenter('zh', { granularity: 'grapheme' }).segment(text)].map(s => s.segment) : Array.from(text);
// A turn starts with your message (or with something she says on her own). Server turn ids keep a reply with the
// message it answers; older local records without ids fall back to their order.
export function groupTurns(messages: ShownMessage[]): Turn[] {
const turns: Turn[] = [];
for (const m of messages) {
const cur = turns[turns.length - 1], head = cur?.items[0];
const fresh = !cur || m.role === 'user' || !!m.proactive || (!!m.turnId && !!head?.turnId && m.turnId !== head.turnId);
if (fresh || !cur) turns.push({ key: m.turnId || m.id, items: [m] }); else cur.items.push(m);
}
return turns;
}
export function chunks(text: string, { width, font, compact }: Metrics): string[] {
const perLine = Math.floor(Math.max(220, Math.min(width, 900) - 24) / font), max = Math.max(14, Math.min(60, Math.floor(perLine * (compact ? 1.6 : 2))));
const out: string[] = []; let line = '', n = 0;
for (const ch of graphemes(text)) {
if (ch === '\n') { if (line.trim()) { out.push(line); line = ''; n = 0; } continue; }
// Keep closing punctuation with its sentence; never produce an empty line.
if (!line && out.length && /[。!?!?,、;:,;:」』))”]/u.test(ch)) { out[out.length - 1] += ch; continue; }
line += ch; n++;
// Short sentences travel together (as in the approved still); a line ends at a sentence once it has some weight.
if (/[。!?!?]/u.test(ch) && n >= Math.min(22, max * .45)) { out.push(line); line = ''; n = 0; }
else if (n >= max) {
// Long text without punctuation still breaks, so nothing ever overflows the stage.
const cut = Math.max(line.lastIndexOf(','), line.lastIndexOf('、'), line.lastIndexOf(';'));
if (cut > max * .55) { out.push(line.slice(0, cut + 1)); line = line.slice(cut + 1); n = graphemes(line).length; } else { out.push(line); line = ''; n = 0; }
}
}
if (line.trim()) out.push(line);
return out;
}
export function pagesOf(turn: Turn | undefined, metrics: Metrics): Page[] {
const list: Page[] = [];
if (!turn) return list;
turn.items.forEach((message, mi) => {
const parts = chunks(message.text, metrics);
// The newest chunk of a reply still being written is held back (it may still grow or merge with the next
// sentence), so a line never changes or re-animates after it has appeared.
const held = message.live === 'generating' && parts.length ? 1 : 0;
// Keys follow the turn and position, not the message id, so a streamed reply keeps its lines when it is stored.
for (let index = 0; index < parts.length - held; index++) list.push({ key: `${turn.key}:${mi}:${index}`, text: parts[index], index, last: index === parts.length - 1, mi, message });
});
return list;
}
// Each line stays on screen in proportion to its length (about 2.4–8 s for her, shorter for yours).
export function dwellFor(page: Page) {
const n = graphemes(page.text).length;
return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150));
}
// How long the current line has stayed. It counts real time, not frames: busy frames (a new turn's crossfade,
// ink, the room) never stretch a stay. One frame counts at most STALL, so a stall (sleep, debugger) is not
// reading; a hidden page restarts the count from its return.
export const STALL = 1000;
export class ReadingClock {
key = ''; dwell = 0; total = 0;
private last = 0;
// True once the line on screen has stayed its time while reading runs. Same key = same line: a growing stream
// or a re-render never restarts it.
tick(now: number, page: Page | undefined, running: boolean): boolean {
const dt = this.last ? Math.min(STALL, Math.max(0, now - this.last)) : 0; this.last = now;
if (!page) return false;
if (page.key !== this.key) { this.key = page.key; this.dwell = 0; this.total = dwellFor(page); }
if (running && this.dwell < this.total) this.dwell = Math.min(this.total, this.dwell + dt);
return running && this.dwell >= this.total;
}
get progress() { return this.total ? this.dwell / this.total : 0; }
// You moved or resumed: the line you land on gets its full time.
restart() { this.dwell = 0; }
forget() { this.key = ''; }
// Page hidden: the gap until it is seen again is not reading time.
pause() { this.last = 0; }
}
// Reading position as a character offset inside one message, so a re-chunk (resize, keyboard) keeps your place.
export interface Anchor { mi: number; offset: number }
export function anchorOf(pages: Page[], cursor: number): Anchor | null {
const page = pages[cursor];
if (!page) return null;
let offset = 0;
for (const p of pages) { if (p === page) break; if (p.mi === page.mi) offset += p.text.length; }
return { mi: page.mi, offset };
}
export function cursorAt(pages: Page[], anchor: Anchor | null): number {
if (!anchor) return 0;
let used = 0, best = -1;
for (let i = 0; i < pages.length; i++) {
if (pages[i].mi < anchor.mi) { best = i; continue; }
if (pages[i].mi > anchor.mi) break;
best = i;
if (used + pages[i].text.length > anchor.offset) return i;
used += pages[i].text.length;
}
return Math.max(0, best);
}
+4 -4
View File
@@ -5,11 +5,11 @@ export function freshState(identity: Identity): DemoState {
// Loaded only through an explicit demo action; never a fabricated background life loop.
export function sampleDays(state: DemoState, long = false): DemoState {
const events: DemoState['events'] = [
{ id: 'book', title: '读到了书里的那片海', text: '翻到讲海的那一页,忽然想起你说过喜欢安静的地方。', time: '10月21日 19:40', place: '卧室 · 窗边', kind: 'now', shared: true, memoryIds: ['habit'] },
{ id: 'tea', title: '给自己泡了一杯热茶', text: '杯子捧在手里,夜晚就慢了下来。', time: '10月21日 16:20', place: '卧室', kind: 'done', shared: true, memoryIds: [] },
{ id: 'rain', title: '听了一下午的雨', text: '雨滴在玻璃上跑得一快一慢,很像一段没有歌词的曲子。', time: '10月20日 15:30', place: '卧室 · 窗边', kind: 'done', shared: true, memoryIds: ['rain-memory'] },
{ id: 'book', title: '读到了书里的那片海', text: '翻到讲海的那一页,忽然想起你说过喜欢安静的地方。', time: '10月21日 19:40', place: '卧室 · 窗边', kind: 'now', shared: true, told: '19:46 告诉你', memoryIds: ['habit'] },
{ id: 'tea', title: '给自己泡了一杯热茶', text: '杯子捧在手里,夜晚就慢了下来。', time: '10月21日 16:20', place: '卧室', kind: 'done', shared: true, told: '16:40 告诉你', memoryIds: [] },
{ id: 'rain', title: '听了一下午的雨', text: '雨滴在玻璃上跑得一快一慢,很像一段没有歌词的曲子。', time: '10月20日 15:30', place: '卧室 · 窗边', kind: 'done', shared: true, told: '17:05 留言给你', memoryIds: ['rain-memory'] },
{ id: 'secret', title: '未分享的秘密散步', text: '这段未分享的生活不能出现在任何视图里。', time: '10月21日 13:00', place: '私密地点', kind: 'done', shared: false, memoryIds: ['secret-memory'] },
{ id: 'plan', title: '明天想读完这一章', text: '如果精神好,就再读一会儿。计划也可能会变。', time: '10月22日 20:00', place: '卧室', kind: 'plan', shared: true, memoryIds: [] }
{ id: 'plan', title: '明天想读完这一章', text: '如果精神好,就再读一会儿。计划也可能会变。', time: '10月22日 20:00', place: '卧室', kind: 'plan', shared: true, told: '10月21日 21:10 告诉你', memoryIds: [] }
];
if (long)
for (let i = 0; i < 40; i++)
+4
View File
@@ -23,6 +23,8 @@ export interface LifeEvent {
place: string;
kind: 'done' | 'now' | 'plan';
shared: boolean;
// When and how she told you ("19:34 告诉你", "你们一起经历的"); only ever present on records she has shared.
told?: string;
memoryIds: string[];
}
export interface Memory {
@@ -59,6 +61,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;
+85 -9
View File
@@ -1,15 +1,27 @@
import { useEffect, useRef, useState } from 'react';
import type { DemoState } from '../domain/model';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { DemoState, LifeEvent, Memory } from '../domain/model';
import { publicView } from '../domain/privacy';
import { Button, Icon } from '../shared/ui';
export function LifePanel({ state, open, onClose, onChat }: {
import { delay } from '../adapters/demo';
// HEL-815 page design: a page lying on your side of the table while the room steps back. Only what she has already
// told you is ever rendered (AM-001), so there is no "not told yet" label, filter or switch anywhere; any link the
// client cannot resolve opens one neutral "can't open" state that reveals nothing about whether the record exists.
const KIND = { plan: '打算', now: '正在', done: '已经发生' } as const;
const MEMORY_GROUPS = ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'];
const phone = () => matchMedia('(max-width:720px)').matches;
type Load = 'ok' | 'loading' | 'error';
export function LifePanel({ state, open, onClose, onChat, onSettings, now = '', fail = false, real = false }: {
real?: boolean;
state: DemoState;
open: boolean;
now?: string;
fail?: boolean;
onClose: () => void;
onChat: (id: string) => void;
onSettings?: () => void;
}) {
const view = publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18);
const list = useRef<HTMLDivElement>(null);
const view = publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18), [load, setLoad] = useState<Load>('ok'), [more, setMore] = useState(false);
const list = useRef<HTMLDivElement>(null), detail = useRef<HTMLElement>(null), scroll = useRef<Record<string, number>>({}), wasOpen = useRef(false), request = useRef<AbortController | null>(null);
useEffect(() => { if (open) {
const params = new URLSearchParams(location.hash.slice(1));
const id = params.get('event');
@@ -18,9 +30,73 @@ export function LifePanel({ state, open, onClose, onChat }: {
setSelection(id);
}
} }, [open]);
// Demo only: the failure switch shows how a list that could not be read looks; nothing real is requested.
useEffect(() => { if (open && !wasOpen.current && !real) setLoad(fail ? 'error' : 'ok'); wasOpen.current = open; if (!open) { request.current?.abort(); setMore(false); } }, [open, fail, real]);
useEffect(() => () => request.current?.abort(), []);
async function retry() { request.current?.abort(); const c = new AbortController(); request.current = c; setLoad('loading'); try { await delay(600, c.signal); setLoad(fail ? 'error' : 'ok'); } catch { /* Closed or replaced. */ } }
async function earlier() { request.current?.abort(); const c = new AbortController(); request.current = c; setMore(true); try { await delay(real ? 0 : 450, c.signal); setLimit(x => x + 12); } catch { /* Closed. */ } finally { if (!c.signal.aborted) setMore(false); } }
// Each tab keeps its own reading position; switching back lands where you left it.
// A hidden panel is display:none, which drops its scroll offset; reopening (e.g. "结束交谈 · 回到日子") puts it back.
useLayoutEffect(() => { if (list.current && open) list.current.scrollTop = scroll.current[tab] || 0; }, [tab, open]);
useLayoutEffect(() => { if (detail.current) detail.current.scrollTop = 0; }, [selection, tab]);
const switchTab = (t: string) => { if (list.current) scroll.current[tab] = list.current.scrollTop; setTab(t); setSelection(''); };
const select = (id: string, t = tab) => { if (t !== tab) switchTab(t); setSelection(id); };
// Esc on a phone closes the pushed detail first; the app then closes the panel on the next press.
useEffect(() => { if (!open || !selection) return; const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || !phone() || document.querySelector('.modal.open')) return; e.stopImmediatePropagation(); setSelection(''); }; window.addEventListener('keydown', key, true); return () => window.removeEventListener('keydown', key, true); }, [open, selection]);
const ordered = [...view.events.filter(e => e.kind === 'plan'), ...view.events.filter(e => e.kind !== 'plan')], shown = ordered.slice(0, limit);
const day = (e: LifeEvent) => e.kind === 'plan' ? '她说过的打算' : e.time.split(' ')[0];
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>;
const name = view.identity.name, demoTag = !real && <span className="tag-demo">演示数据</span>;
const lines = (e: LifeEvent) => view.messages.filter(m => m.lifeIds.includes(e.id));
const said = (who: string, text: string) => <blockquote className="quote"><span className="who"><i/>{who}</span>{text}</blockquote>;
const linkCard = (key: string, title: string, sub: string, go: () => void) => <button className="link-card" key={key} aria-describedby={`lc-${key}`} onClick={go}><span><b>{title}</b><small id={`lc-${key}`}>{sub}</small></span><Icon name="next"/></button>;
function eventView(e: LifeEvent) {
const mems = e.memoryIds.map(id => view.memories.find(m => m.id === id)).filter((m): m is Memory => !!m), chat = lines(e);
return <>
<div className="d-kicker"><i className={`k ${e.kind}`}/>{KIND[e.kind]} · {e.time} · {e.place}</div>
<h3 className="d-title">{e.title}</h3>
<p className="d-told">{e.told ? `她在 ${e.told}` : '她讲给你听过的事'}{e.kind === 'plan' ? ' · 计划可能会变' : ''}</p>
{said(name, e.text)}
{mems.length > 0 && <><div className="d-sec">和这件事有关的回忆</div><div className="link-list">{mems.map(m => linkCard(m.id, m.title, m.source, () => select(m.id, 'memory')))}</div></>}
<div className="d-sec">那时候的聊天</div>
{chat.length ? <div className="said-then">{chat.map(m => <p key={m.id} data-role={m.role}><span className="who"><i/>{m.role === 'user' ? '你' : name}</span>{m.text}</p>)}</div> : <p className="d-dim">那时候没有留下聊天记录。</p>}
<Button onClick={() => onChat(e.id)}>在交谈里看这段<Icon name="next"/></Button>
<div className="d-foot">{demoTag}这是模拟生活里的一条记录,不是真实发生的事。画面只按记录选片,不会反过来改写她的生活。</div>
</>;
}
function memoryView(m: Memory) {
const from = m.lifeIds.map(id => view.events.find(e => e.id === id)).filter((e): e is LifeEvent => !!e);
return <>
<div className="d-kicker"><i className={`k ${m.group === '她的成长' ? 'grow' : 'mem'}`}/>{m.group} · {m.source}</div>
<h3 className="d-title">{m.title}</h3>
<p className="d-told">她是这样记得的</p>
{said(name, m.text)}
{m.group === '她的成长' && <p className="d-note">这是后来慢慢有的。成长单独记录,不改写她最初的底色。{onSettings && <button className="btn-text" onClick={onSettings}>在设置里看底色与成长<Icon name="next"/></button>}</p>}
<div className="d-sec">来自这些日子</div>
{from.length ? <div className="link-list">{from.map(e => linkCard(e.id, e.title, e.time, () => select(e.id, 'life')))}</div> : <p className="d-dim">这条回忆来自你们的日常聊天,没有对应的某一天。</p>}
<div className="d-foot">{demoTag}回忆由她在相处中整理,只引用她讲过的日子。</div>
</>;
}
const failed = <div className="state-card" role="alert"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这几天的事一时没翻出来。</p><p>不是你的问题,记录也没有丢。</p><Button onClick={() => void retry()}>再试一次</Button></div>;
const skeleton = (n: number) => <div className="skel" aria-hidden="true">{Array.from({ length: n }, (_, i) => <i key={i} style={{ width: `${[78, 62, 70, 54, 66][i % 5]}%` }}/>)}</div>;
function lifeList() {
if (load === 'loading') return <><p className="grp" role="status">正在翻她的日子…</p>{skeleton(6)}</>;
if (load === 'error') return failed;
if (!view.events.length) return <div className="empty">{real ? '还没有可展示的生活记录' : '她今晚刚住进来,还没有发生什么。'}<small>{real ? '生活后台尚未接通,不用示例经历填充。你们说过的话可在交谈的完整记录中查看。' : '等她跟你讲起,日子会一条条留在这里。演示资料可以从「演示场景」载入。'}</small></div>;
return <>{shown.map((e, i) => <div key={e.id}>{(i === 0 || day(shown[i - 1]) !== day(e)) && <div className="grp">{day(e)}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.kind === 'plan' ? e.time : e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sn">{e.text}</span>{(e.kind !== 'done' || e.memoryIds.length > 0) && <span className="sub">{e.kind === 'plan' && <span className="tag">计划可能会变</span>}{e.kind === 'now' && <span className="tag accent">正在</span>}{e.memoryIds.length > 0 && <span className="tag">和你有关</span>}</span>}</span></button></div>)}
{more ? <p className="more-busy" role="status"><i className="spin"/>正在翻更早的日子…</p> : limit < ordered.length ? <div className="more"><Button onClick={() => void earlier()}>看更早的日子</Button></div> : <p className="list-end">再往前,就是她刚住进来的那天了。</p>}</>;
}
function memoryList() {
if (load === 'loading') return skeleton(5);
if (load === 'error') return failed;
if (!real && !view.memories.length) return <div className="empty">还没有共同的回忆。<small>聊着聊着就会有了。</small></div>;
return MEMORY_GROUPS.map(group => { const items = view.memories.filter(m => m.group === group); return <div key={group}><div className="grp">{group}{!real && <span>{items.length}</span>}</div>{real && <div className="empty">暂无可核实的回忆<small>长期记忆尚未接通;文字聊天记录独立保留。</small></div>}{items.map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}><i className={`k ${group === '她的成长' ? 'grow' : 'mem'}`}/><span><small className="src">{m.source}</small><b>{m.title}</b><span className="sn">{m.text}</span></span></button>)}</div>; });
}
const blank = load !== 'ok' || (tab === 'life' ? !view.events.length : !view.memories.length);
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><div className="ttl"><small className="kicker"><span className="k-wide">日子 · {real ? '后台待接入' : '演示数据'}</span><span className="k-near"><i/>她此刻 · {now || '安静待着'}</span></small><h2>{tab === 'life' ? '她的日子' : '我们的回忆'}</h2></div><span className="grow"/>{real ? <span className="tag-demo">后台待接入</span> : demoTag}<button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header>
<div className="pane-tabs"><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => switchTab(t)}>{l}</button>)}</div><span className="tabnote">{tab === 'life' ? '只有她讲给你听过的事,才会出现在这里' : '她记得的你们,和她后来慢慢有的'}</span></div>
<div className="cols"><div className="list" ref={list} onScroll={e => { scroll.current[tab] = e.currentTarget.scrollTop; }}>{tab === 'life' ? lifeList() : memoryList()}</div>
<article className="detail" ref={detail}><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>
{event ? <div className="d-in" key={`e-${event.id}`}>{eventView(event)}</div> : memory ? <div className="d-in" key={`m-${memory.id}`}>{memoryView(memory)}</div> : selection ? <div className="state-card"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这一条现在打不开。</p><p>可能是链接已经过期。</p><Button onClick={() => setSelection('')}>回到{tab === 'life' ? '她的日子' : '我们的回忆'}</Button></div> : load === 'loading' ? skeleton(5) : <div className="d-empty"><div><Icon name="book"/>{real ? '后台接通后,她讲给你听过的事会出现在左侧;这里保留日子详情、回忆与聊天的关联。' : blank ? (tab === 'life' ? '选一天,看她那天跟你讲过的事。' : '选一条,看她怎么记得。') : tab === 'life' ? '选一件小事,慢慢读。' : '选一条,看她怎么记得。'}</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>);

Some files were not shown because too many files have changed in this diff Show More