HEL-780: connect persistent companion and real streaming text chat

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

After

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+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,
)
+500
View File
@@ -0,0 +1,500 @@
"""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,
compile_pillars,
element_scores,
pillars_from_lunar,
ten_god_distribution,
)
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 {"pillars": asdict(pillars), "traits": derived.traits_text()}, 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():
row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1")
if not row:
raise HTTPException(404, "还没有创建真实伴侣。")
return json.loads(row["identity_json"])
@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()
identity.update(data.model_dump())
with app.state.store.conn:
app.state.store.execute(
"UPDATE companion SET identity_json=? WHERE id=1",
(json.dumps(identity, ensure_ascii=False),),
)
return identity
@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']}。出生底色仅供措辞参考:{identity['color']['traits']}。"
"用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。"
"当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。"
"不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。"
"不窥探用户未分享的信息,不以缺席、孤独或终止要挟对方,不输出内部数值或思考过程。"
)
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
+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"
+80
View File
@@ -0,0 +1,80 @@
import type { ChatAdapter } from './demo';
import type { DemoState, Identity, Message, ServerEvent } from '../domain/model';
import { defaultSettings } from '../domain/model';
export interface Color { pillars: Record<string, string>; traits: string }
export interface 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();
return { version: 1, identity, settings: structuredClone(defaultSettings), 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); } });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, loadReal, type Health } from '../adapters/real';
import type { DemoState, Message, Theme } from '../domain/model';
import { Background } from '../stage/Background';
import { FallbackPresenter } from '../stage/Presenter';
import { CreationWizard } from '../manage/CreationWizard';
import { ManagePanel } from '../manage/ManagePanel';
import { ChatPanel } from '../chat/ChatPanel';
import { Button, Icon, Status } from '../shared/ui';
export function RealApp() {
const [state, setState] = useState<DemoState | null>(null), [ready, setReady] = useState(false), [creating, setCreating] = useState(false), [error, setError] = useState(''), [health, setHealth] = useState<Health>(), [area, setArea] = useState('now'), [log, setLog] = useState(false), [presence, setPresence] = useState('文字已接通');
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
const latest = useRef(state); latest.current = state;
const initialize = useCallback(async () => {
setError(''); setReady(false);
try { setHealth(await api<Health>('health')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); }
catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); }
}, []);
useEffect(() => { void initialize(); }, [initialize]);
useEffect(() => { document.documentElement.dataset.theme = theme; try { localStorage.setItem('cg.theme', theme); } catch { /* Preference stays in memory. */ } }, [theme]);
useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]);
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []);
const ignoreVoice = useCallback(() => {}, []);
function navigate(next: string) { setArea(next); setLog(false); }
return <><Background/><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{ready ? '真实档案 · 文字对话' : '连接真实服务'}</span></div><div className="env"><span className="chip glass weather">天气尚未接通</span><span className="theme-switch glass">{(['moon', 'fog'] as Theme[]).map(t => <button key={t} data-t={t} aria-label={t === 'moon' ? '靛蓝月光' : '蓝紫雾光'} aria-pressed={theme === t} onClick={() => setTheme(t)}><i/><span>{t === 'moon' ? '月光' : '雾光'}</span></button>)}</span></div></header>
{!ready ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><Status text={error || '正在读取服务器档案…'} error={!!error}/>{error && <><p>请检查真实服务是否可达。资料仍在服务器保存。</p><Button primary onClick={() => void initialize()}>重新连接</Button></>}</div></main> : <>
{creating && <CreationWizard real fail={false} onCreate={setState} onEnter={() => setCreating(false)}/>}
{!creating && state && <><FallbackPresenter real scene={{ mode: area === 'talk' ? 'talk' : 'daily', activity: '文字已接通 · 生活尚未接通', time_band: '', weather: '', outfit: '' }} presence={presence} observation="" onClose={() => navigate('now')} closeLabel="回到此刻"/>
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onClose={() => navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
{area === 'days' && <section className="pane glass read open" aria-label="日子"><header><h2>日子与回忆</h2><button className="btn-round sm" aria-label="关闭日子" onClick={() => navigate('now')}><Icon name="x"/></button></header><div className="card"><h3>尚未接通</h3><p>生活循环与长期记忆将在后续接入,目前没有可展示的生活经历。聊天记录可在交谈中查看。</p></div></section>}
<ManagePanel real state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/>
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className="nav-item" aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}</button>)}</nav></>}
</>}</>;
}
+20 -15
View File
@@ -1,10 +1,12 @@
import { newId } from '../domain/id';
import { useEffect, 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 }: {
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: {
real?: boolean;
state: DemoState;
onChange: (messages: Message[]) => void;
onPresence: (p: string) => void;
@@ -18,22 +20,22 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
focusLife: string;
}) {
const [draft, setDraft] = useState(() => { try {
return sessionStorage.getItem('cg.draft') || '';
return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || '';
}
catch {
return '';
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
const gate = useRef(new TurnGate()), adapter = useRef(real ? new RealChatAdapter(epoch => { gate.current.epoch = epoch; }) : new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
live.current = state.messages;
const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion');
const change = (messages: Message[]) => { live.current = messages; onChange(messages); };
function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); }
useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]);
useEffect(() => { const current = gate.current; return () => { current.cancel(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]);
useEffect(() => { try {
sessionStorage.setItem('cg.draft', draft);
sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft);
}
catch { /* Draft remains in memory. */ } }, [draft]);
catch { /* Draft remains in memory. */ } }, [draft, real]);
useEffect(() => { if (offline) {
gate.current.resync();
setBusy(false);
@@ -51,17 +53,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
else if (logRef.current)
logRef.current.scrollTop = logRef.current.scrollHeight;
} }, [log, focusLife, view.messages.length]);
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = retry?.turnId || newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
return; if (event.t === 'chat.delta') {
setReply(x => x + event.delta);
onPresence('在说话');
}
else {
else if (event.t === 'chat.final') {
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
setReply('');
if (real) setDraft(current => current === text ? '' : current);
setBusy(false);
onPresence(voice ? '在听你说(模拟)' : '在你面前');
} }, fail);
@@ -71,16 +74,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
setNotice((e as Error).message);
setReply('');
setDraft(text);
setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message);
onPresence('在你面前');
} }
const setVoiceMode = () => { if (voice) {
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) {
endVoice();
return;
} setPermission('mic'); };
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || '演示回复 · 没有连接模型、语音或记忆后台'}</div></div>
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || (real ? '真实文字对话 · 生活与长期记忆尚未接通' : '演示回复 · 没有连接模型、语音或记忆后台')}</div></div>
{voice && <div className="voicebar glass"><span className="live"><i /></span><span className="vstate">{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button className="btn-ghost" onClick={() => { stop(); setNotice(fail?'刚才没听清,你再说一遍?(模拟识别失败)':'她停下来了,在等你。'); }}>打断她</button><button className="end" onClick={endVoice}>结束语音</button></div>}
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (vision) {
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) {
setVision(false);
onObservation('窗户关上了');
}
@@ -88,8 +93,8 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
setPermission('vision'); }}><Icon name="eye"/></button><textarea aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault();
void send();
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">本机演示记录</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={real ? '语音尚未接通' : voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">{real ? '服务器聊天记录' : '本机演示记录'}</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
setDenied(false);
+2
View File
@@ -59,6 +59,8 @@ export interface SceneDesc {
outfit: string;
}
export type ServerEvent = {
t: 'chat.error'; epoch: number; turn_id: string; phi: string; retryable: boolean;
} | {
t: 'chat.delta';
epoch: number;
turn_id: string;
+2 -1
View File
@@ -1,5 +1,6 @@
import ReactDOM from 'react-dom/client';
import { RealApp } from './app/RealApp';
import { App } from './app/App';
import './styles/tokens.css';
import './styles/components.css';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
ReactDOM.createRoot(document.getElementById('root')!).render(new URLSearchParams(location.search).get('demo') === '1' ? <App /> : <RealApp />);
+15 -7
View File
@@ -1,3 +1,4 @@
import { api, type Color, type RealIdentity } from '../adapters/real';
import { newId } from '../domain/id';
import { useState } from 'react';
import { validateCreation, type Identity } from '../domain/model';
@@ -5,21 +6,28 @@ import { freshState } from '../demo/fixtures';
import { delay } from '../adapters/demo';
import { BaseColor, Button, Field, Status } from '../shared/ui';
import type { DemoState } from '../domain/model';
export function CreationWizard({ onCreate, onEnter, fail }: {
export function CreationWizard({ onCreate, onEnter, fail, real = false }: {
real?: boolean;
onCreate: (s: DemoState) => void;
onEnter: () => void;
fail: boolean;
}) {
const [step, setStep] = useState(0), [name, setName] = useState(''), [birth, setBirth] = useState('2000-10-18'), [hour, setHour] = useState('亥时'), [relation, setRelation] = useState(''), [nickname, setNickname] = useState(''), [agreed, setAgreed] = useState(false), [error, setError] = useState(''), [phase, setPhase] = useState(-1), [busy, setBusy] = useState(false);
const [color, setColor] = useState<Color>();
const labels = ['出生资料', '她的底色', '你们的关系', '最终确认'];
function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
async function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
setError(err);
return;
} setError(''); setStep(s => s + 1); }
} setError(''); if (real && step === 0) { setBusy(true); try { setColor(await api<Color>('companion/preview', { method: 'POST', body: JSON.stringify({ name, birth, hour }) })); } catch (e) { setError((e as Error).message); return; } finally { setBusy(false); } } setStep(s => s + 1); }
async function create() { const err = validateCreation(name, birth, relation); if (err || !agreed) {
setError(err || '请先确认你已了解。');
return;
} setBusy(true); setError(''); try {
if (real) {
setPhase(0);
const identity = await api<RealIdentity>('companion', { method: 'POST', body: JSON.stringify({ name, birth, hour, relation, nickname }) });
setPhase(2); onCreate({ ...freshState(identity), messages: [], events: [], memories: [] }); setStep(4); return;
}
const c = new AbortController();
for (let p = 0; p < 3; p++) {
setPhase(p);
@@ -37,11 +45,11 @@ export function CreationWizard({ onCreate, onEnter, fail }: {
finally {
setBusy(false);
} }
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>{real ? '资料与对话保存在服务器,重开或换设备都能恢复同一位她。最终确认前不会建档。' : '这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。'}</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
{step === 0 && <><h2>先认识她的开始</h2><p className="lead">名字由你起,生辰也可以在确认前修改。</p><Field label="她的名字"><input className="input" value={name} maxLength={20} onChange={e => setName(e.target.value)} autoComplete="off"/></Field><Field label="出生日期"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰"><select className="select" value={hour} onChange={e => setHour(e.target.value)}>{['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'].map(h => <option key={h}>{h}</option>)}</select></Field><Button onClick={() => { setBirth(`${1990 + Math.floor(Math.random() * 13)}-${String(1 + Math.floor(Math.random() * 12)).padStart(2, '0')}-18`); }}>自动生成一组生辰</Button></>}
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor /></>}
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor color={real ? color : undefined}/></>}
{step === 2 && <><h2>由你选择关系</h2><p className="lead">没有预选答案。以后可以在设置中确认修改,记忆会保留。</p><div className="seg">{['朋友', '恋人'].map(r => <button aria-pressed={relation === r} key={r} onClick={() => setRelation(r)}>{r}</button>)}</div><Field label="她怎么称呼你(可选)"><input className="input" maxLength={40} value={nickname} onChange={e => setNickname(e.target.value)}/></Field></>}
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>我明白:这是本机演示;创建后底色只读,只保留一位伴侣。</label>{phase >= 0 && <ul className="phases">{['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)'].map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>{real ? '我明白:这将创建服务器上的真实档案;创建后底色只读,只保留一位伴侣。' : '我明白:这是本机演示;创建后底色只读,只保留一位伴侣。'}</label>{phase >= 0 && <ul className="phases">{(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{step === 4 && <div className="born"><div className="halo"/><h2>{name.trim()}住进来了</h2><p className="lead">刚开始,还没有共同的日子。聊着聊着就会有了。</p></div>}
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary onClick={next}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary disabled={busy} onClick={() => void next()}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
}
+17 -10
View File
@@ -1,10 +1,12 @@
import { api, type RealIdentity } from '../adapters/real';
import { useEffect, useRef, useState } from 'react';
import type { DemoState, Settings, Theme } from '../domain/model';
import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme }: {
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme, real = false }: {
real?: boolean;
state: DemoState;
open: boolean;
onClose: () => void;
@@ -31,14 +33,18 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
async function save(confirmed = false) { if (section==='relation' && draft.identity.relation !== state.identity.relation && !confirmed) {
setConfirm('relation');
return;
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage('正在保存到本机…'); try {
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage(real ? '正在保存…' : '正在保存到本机…'); try {
if (real && section === 'relation') {
const identity = await api<RealIdentity>('relation', { method: 'PATCH', signal: ctrl.signal, body: JSON.stringify({ relation: draft.identity.relation, nickname: draft.identity.nickname, boundary: draft.identity.boundary }) });
if (!ctrl.signal.aborted) { onSave({ ...latestState.current, identity }); setMessage('已保存在服务器,聊天与底色保留。'); } return;
}
await delay(450, ctrl.signal);
if (fail)
throw new Error('这次没存好,你的修改还在,可以再试一次。');
const current=latestState.current;
const next = {...current, identity: section==='relation'?draft.identity:current.identity, settings:{...current.settings, ...(section==='city'?{city:draft.settings.city}:section==='services'?{services:draft.settings.services}:section==='display'?{reduced:draft.settings.reduced}:{})}};
onSave(next);
setMessage('已保存在本机演示中。');
setMessage(real ? '显示偏好已应用。' : '已保存在本机演示中。');
}
catch (e) {
if (ctrl.signal.aborted)
@@ -85,15 +91,16 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
setCheck('检查没通过:' + (e instanceof SyntaxError?'清单不是有效的 JSON,请重新选择。':(e as Error).message));
} }
const switchSection = (s: string) => { request.current?.abort(); generation.current++; setBusy(false); setSection(s); setDetail(true); setMessage(''); };
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">仅本机演示</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor /><div className="growth"><div><h4>最初就有的</h4>细腻、慢热(示例)</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">{real ? '服务器档案' : '仅本机演示'}</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{real && id === 'backup' ? '备份' : label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : real && section === 'backup' ? '备份' : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : real ? '资料保存在服务器。尚未接通的功能暂不可用。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor color={real ? (state.identity as RealIdentity).color : undefined}/><div className="growth"><div><h4>最初就有的</h4>{real ? (state.identity as RealIdentity).color.traits : '细腻、慢热(示例)'}</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : real ? '长期记忆尚未接通。聊天记录已独立保存。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
{section === 'relation' && <><Field label="你们的关系"><select className="select" value={draft.identity.relation} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, relation: e.target.value as '朋友' | '恋人' } }))}><option>朋友</option><option>恋人</option></select></Field><Field label="她怎么称呼你"><input className="input" value={draft.identity.nickname} maxLength={40} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, nickname: e.target.value } }))}/></Field><Field label="相处边界"><textarea className="textarea" maxLength={2000} value={draft.identity.boundary} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, boundary: e.target.value } }))}/></Field><p className="d-foot">改关系需要再次确认,已有记忆与信任都保留,底色不变。</p></>}
{section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
{section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{!real && section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
{!real && section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{!real && section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{!real && section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{section === 'display' && <><div className="opt-row">{(['moon', 'fog'] as Theme[]).map(t => <button className="opt" data-t={t} key={t} aria-pressed={theme === t} onClick={() => setTheme(t)}><div className="sw"/><b>{t === 'moon' ? '靛蓝月光' : '蓝紫雾光'}</b><span>立即切换,保留你的选择</span></button>)}</div><label className="switch-row">减少动效<input type="checkbox" checked={draft.settings.reduced} onChange={e => settings({ reduced: e.target.checked })}/></label><p className="d-foot">也会自动尊重系统的减少动效设置。切到其他网页时暂停背景动画。</p></>}
<div ref={feedback}><Status text={message} error={error}/></div>{['relation', 'city', 'services', 'display'].includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{real && ['city', 'appearance', 'backup', 'services'].includes(section) && <div className="card"><h4>尚未接通</h4><p>{section === 'services' ? '文字对话已接真实模型。语音、视觉与长期记忆尚未接通。' : '这项功能将在后续批次接入,目前不执行保存、导入或备份。'}</p></div>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'relation')
void save(true); if (which === 'appearance') {
try {
+3 -1
View File
@@ -52,4 +52,6 @@ export function Modal({ title, children, onClose, onConfirm, confirm = '确认',
} }; document.addEventListener('keydown', key, true); return () => { document.removeEventListener('keydown', key, true); prev?.focus(); }; }, []);
return <><div className="scrim open" onClick={onClose}/><div className={`modal glass open ${danger ? 'danger' : ''}`} ref={root} tabIndex={-1} role="dialog" aria-modal="true" aria-label={title}><div className="mi"><Icon name={danger ? 'warn' : 'sparkle'}/></div><h3>{title}</h3><div className="mb">{children}</div><div className="acts"><Button onClick={onClose}>取消</Button><Button primary disabled={disabled} onClick={onConfirm}>{confirm}</Button></div></div></>;
}
export function BaseColor() { return <><div className="card"><h4><Icon name="lock"/>出生底色 · 演示预览</h4><div className="pillars">{['年柱', '月柱', '日柱', '时柱'].map(p => <div key={p}><small>{p}</small><b>待接</b></div>)}</div><div className="lean"><span>细腻</span><span>慢热</span><span>有自己的节奏</span></div><p>这些是演示倾向,不是固定台词。真实排盘待接 Python 八字编译器,不以示例代替计算。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读,成长另存。</p></>; }
export function BaseColor({ color }: { color?: { pillars: Record<string, string>; traits: string } } = {}) {
if (color) return <><div className="card"><h4><Icon name="lock"/>出生底色 · 真实排盘</h4><div className="pillars">{[['year','年柱'], ['month','月柱'], ['day','日柱'], ['time','时柱']].map(([key, label]) => <div key={key}><small>{label}</small><b>{color.pillars[key]}</b></div>)}</div><p>{color.traits}。底色是倾向,保留诠释与成长的空间。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读。</p></>;
return <><div className="card"><h4><Icon name="lock"/>出生底色 · 演示预览</h4><div className="pillars">{['年柱', '月柱', '日柱', '时柱'].map(p => <div key={p}><small>{p}</small><b>待接</b></div>)}</div><div className="lean"><span>细腻</span><span>慢热</span><span>有自己的节奏</span></div><p>这些是演示倾向,不是固定台词。真实排盘待接 Python 八字编译器,不以示例代替计算。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读,成长另存。</p></>; }
+3 -2
View File
@@ -3,13 +3,14 @@ import type { SceneDesc } from '../domain/model';
import { Icon } from '../shared/ui';
export interface PresenterProps {
scene: SceneDesc;
real?: boolean;
onClose: () => void;
closeLabel: string;
presence: string;
observation: string;
}
// Only consumes scene. No life writes, no synthesis, no microphone/camera APIs.
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation }: PresenterProps) {
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation, real = false }: PresenterProps) {
const [failed, setFailed] = useState(false);
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">演示状态</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>来自演示生活状态<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">{real ? '生活尚未接通' : '演示状态'}</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>{real ? '生活尚未接通' : '来自演示生活状态'}<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
}
+4 -5
View File
@@ -56,10 +56,8 @@ try:
page.set_default_timeout(7000)
page.on('pageerror', lambda e: errors.append(str(e)))
page.on('request', lambda r: requests.append(r.url))
page.goto(url)
expect(page.get_by_text('真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。')).to_be_visible()
expect(page.get_by_role('button', name='演示场景', exact=False)).to_have_count(0)
page.get_by_role('link', name='进入本机演示').click()
page.goto(url + '/?demo=1')
expect(page.get_by_role('button', name='演示场景', exact=False)).to_be_visible()
shot(page, f'{width}-creation-moon')
click(page, '下一步')
expect(page.get_by_role('alert')).to_contain_text('名字')
@@ -198,7 +196,8 @@ try:
context.close()
assert not errors, errors
assert not [r for r in requests if not r.startswith(url)], requests
passed('No uncaught browser errors, external requests, microphone or camera requests')
assert not [r for r in requests if '/api/' in r], requests
passed('No uncaught browser errors, API/external requests, microphone or camera requests')
browser.close()
finally:
server.shutdown(); server.server_close(); thread.join()
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RealChatAdapter, readEvents } from '../src/adapters/real';
import { TurnGate } from '../src/adapters/demo';
import type { ServerEvent } from '../src/domain/model';
const event = (t: string, payload: object = {}) => ({ t, epoch: 2, turn_id: 'turn', ...payload });
const frame = (e: object) => `data: ${JSON.stringify(e)}\r\n\r\n`;
function body(text: string) {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({ start(controller) { for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); controller.close(); } });
}
afterEach(() => vi.unstubAllGlobals());
describe('real SSE transport', () => {
it('decodes fragmented Chinese, CRLF, comment and multiple events without losing bytes', async () => {
const output: ServerEvent[] = [];
await readEvents(body(': ping\r\n\r\n' + frame(event('chat.delta', { delta: '你好🌙' })) + frame(event('chat.final', { utterance: { text: '你好🌙', speech_tags: [] } }))), e => output.push(e));
expect(output).toHaveLength(2); expect(output[0]).toHaveProperty('delta', '你好🌙');
});
it('fails closed on missing final, invalid JSON and SSE errors', async () => {
await expect(readEvents(body(frame(event('chat.delta', { delta: '半句' }))), () => {})).rejects.toThrow('连接中断');
await expect(readEvents(body('data: {oops}\n\n'), () => {})).rejects.toThrow();
await expect(readEvents(body(frame(event('chat.error', { phi: '暂时没连上', retryable: true }))), e => { if (e.t === 'chat.error') throw new Error(e.phi); })).rejects.toThrow('暂时没连上');
});
it('synchronizes a restarted server epoch and rejects foreign/late turns', async () => {
const gate = new TurnGate(), signal = gate.begin('turn'), emit = vi.fn();
const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ epoch: 2, llm: { ok: true } }))).mockResolvedValueOnce(new Response(body(frame({ ...event('chat.delta', { delta: '旧' }), epoch: 1 }) + frame({ ...event('chat.delta', { delta: '他轮' }), turn_id: 'other' }) + frame(event('chat.final', { utterance: { text: '新', speech_tags: [] } })))));
vi.stubGlobal('fetch', fetch);
await new RealChatAdapter(e => { gate.epoch = e; }).send('你好', 'turn', 1, signal, e => { if (gate.accepts(e)) emit(e); });
expect(emit).toHaveBeenCalledTimes(1); expect(gate.epoch).toBe(2);
expect(JSON.parse(fetch.mock.calls[1][1].body).epoch).toBe(2);
expect(fetch.mock.calls[1][1].signal).toBe(signal);
});
});
+163
View File
@@ -0,0 +1,163 @@
"""Foreground real Chromium acceptance. Uses temporary DB + loopback, never deployed data.
Run: .venv311/bin/python web/tests/real_browser_check.py
Requires Chromium installed by Playwright; makes at most three real model turns.
"""
import json
import sys
import tempfile
import time
from pathlib import Path
from urllib.parse import urlsplit
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT/'tests'))
from test_api import live_server, make_app # noqa: E402
from cybergril.api.app import create_app # noqa: E402
CONTROLLED = '--controlled' in sys.argv
OUT = ROOT/'docs/r1' / ('controlled' if CONTROLLED else '.')
OUT.mkdir(parents=True, exist_ok=True)
results = {'checks': [], 'real_dialogue': [], 'console_errors': []}
def passed(text):
if CONTROLLED: text = text.replace('real gateway:', 'controlled provider:')
print('PASS', text, flush=True)
results['checks'].append(text)
def nav(page, text):
page.get_by_role('navigation', name='主导航').get_by_role('button', name=text, exact=True).click()
def click(page, text):
page.get_by_role('button', name=text, exact=True).click()
def shot(page, name):
if CONTROLLED: return
page.screenshot(path=str(OUT/(name+'.png')))
def send(page, text, source='real gateway'):
page.get_by_role('textbox', name='想和她说的话').fill(text)
click(page, '发送')
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible(timeout=65000)
assert not page.locator('.dialog .failed').count(), page.locator('.dialog').inner_text()
reply = page.locator('.dialog .her').inner_text()
results['real_dialogue'].append({'input':text,'reply':reply,'source':'controlled provider' if CONTROLLED else source})
print('CONTROLLED_REPLY' if CONTROLLED else 'REAL_REPLY', reply, flush=True)
return reply
try:
with tempfile.TemporaryDirectory(prefix='cybergril-r1-') as directory, sync_playwright() as pw:
db = Path(directory)/'acceptance.db'
browser = pw.chromium.launch(headless=True, args=['--no-sandbox'])
desktop = browser.new_context(viewport={'width':1440,'height':900}, reduced_motion='reduce')
page = desktop.new_page()
page.on('pageerror', lambda e: results['console_errors'].append(str(e)))
page.add_init_script("""const get=Storage.prototype.getItem;Storage.prototype.getItem=function(key){if(key==='cybergril.demo.v1'){window.demoReads=(window.demoReads||0)+1;throw new Error('REAL MODE READ DEMO');}return get.call(this,key)};""")
if CONTROLLED: real_app, initial_provider = make_app(db)
else: real_app = create_app(db_path=db)
with live_server(real_app) as url:
port = urlsplit(url).port
page.goto(url)
expect(page.get_by_text('1 / 4 · 出生资料')).to_be_visible()
page.get_by_label('她的名字', exact=True).fill('星河')
page.get_by_label('出生日期', exact=True).fill('2000-10-18')
click(page,'下一步')
expect(page.get_by_text('出生底色 · 真实排盘')).to_be_visible()
assert real_app.state.store.query_one('SELECT count(*) n FROM companion')['n']==0
shot(page,'desktop-preview-moon')
click(page,'下一步')
for relation in ('朋友','恋人'):
expect(page.get_by_role('button', name=relation, exact=True)).to_have_attribute('aria-pressed','false')
click(page,'下一步')
expect(page.get_by_role('alert')).to_contain_text('选择')
click(page,'朋友'); click(page,'下一步')
page.get_by_role('checkbox').check(); click(page,'确认创建'); click(page,'去看看她')
expect(page.locator('.who .name')).to_have_text('星河')
passed('desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship')
click(page,'和她说话')
composer=page.get_by_role('textbox',name='想和她说的话')
composer.fill('中文输入法')
composer.evaluate("el=>el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true,isComposing:true}))")
expect(composer).to_have_value('中文输入法')
composer.press('Shift+Enter'); expect(composer).to_have_value('中文输入法\n')
chunks=[]
page.on('response', lambda r: chunks.append(r.status) if '/api/chat' in r.url else None)
send(page,'请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。')
reply2=send(page,'刚才我们的暗号是什么?请直接回答。')
if not CONTROLLED: assert '蓝色风铃' in reply2
assert len(real_app.state.store.query('SELECT * FROM chat_message'))==4
shot(page,'desktop-real-chat-moon')
passed('controlled provider: two complete streamed turns' if CONTROLLED else 'real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context')
if CONTROLLED: initial_provider.mode='slow'
composer.fill('请用中文写一段较长的故事,约800字。'); click(page,'发送')
expect(page.get_by_role('button',name='取消本轮回复')).to_be_visible()
expect(page.locator('.dialog .her')).not_to_contain_text('她在想',timeout=35000)
click(page,'取消本轮回复')
# This finite local wait lets the HTTP disconnect reach the isolated service.
expect(page.get_by_role('button',name='取消本轮回复')).not_to_be_visible()
deadline=time.monotonic()+2
while real_app.state.active and time.monotonic()<deadline: time.sleep(.01)
assert real_app.state.active is None
rows=real_app.state.store.query('SELECT * FROM chat_message')
assert len(rows)==5 and rows[-1]['status']=='cancelled'
passed('real gateway: cancel after first delta, upstream request closed, no complete assistant row')
page.reload(); nav(page,'交谈'); click(page,'完整记录')
expect(page.locator('.log-panel')).to_contain_text('蓝色风铃')
shot(page,'desktop-history-moon')
mobile=browser.new_context(viewport={'width':390,'height':844},reduced_motion='reduce',is_mobile=True,has_touch=True)
mp=mobile.new_page(); mp.goto(url)
expect(mp.locator('.who .name')).to_have_text('星河')
nav(mp,'交谈'); click(mp,'完整记录')
expect(mp.locator('.log-panel')).to_contain_text('蓝色风铃')
shot(mp,'mobile-history-moon'); click(mp,'关闭记录')
click(mp,'蓝紫雾光'); shot(mp,'mobile-chat-fog')
assert mp.evaluate('document.documentElement.scrollWidth<=innerWidth')
assert page.evaluate('window.demoReads || 0')==0
passed('independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter')
# A real process lifespan restart on the same origin, with a controlled provider for fault paths.
app, provider = make_app(db)
with live_server(app, port) as restarted_url:
assert restarted_url==url
page.get_by_role('button',name='关闭记录').click()
send(page,'重启后继续','controlled provider')
assert app.state.epoch==2
passed('server restart on same origin: epoch 1→2, existing page sends automatically, data retained')
provider.mode='error'
composer=page.get_by_role('textbox',name='想和她说的话')
composer.fill('保留的失败草稿'); click(page,'发送')
expect(page.locator('.dialog .failed')).to_be_visible()
expect(composer).to_have_value('保留的失败草稿')
shot(page,'desktop-model-error')
count=app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
provider.mode='normal'; click(page,'重试')
expect(page.locator('.dialog .failed')).not_to_be_visible()
expect(page.get_by_role('button',name='取消本轮回复')).not_to_be_visible()
expect(composer).to_have_value('')
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']==count+1
passed('controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row')
provider.mode='slow'
composer.fill('取消并紧接新问题'); click(page,'发送')
expect(page.locator('.dialog .her')).to_contain_text('答')
click(page,'取消本轮回复')
provider.mode='normal'; send(page,'新轮不会串台','controlled provider')
passed('controlled blocked upstream: immediate cancellation/new question isolation')
nav(mp,'设置')
mp.get_by_role('navigation',name='设置分区').get_by_role('button',name='关系',exact=True).click()
mp.get_by_label('你们的关系',exact=True).select_option('恋人'); click(mp,'取消修改')
expect(mp.get_by_label('你们的关系',exact=True)).to_have_value('朋友')
mp.get_by_label('你们的关系',exact=True).select_option('恋人'); click(mp,'保存'); click(mp,'取消')
assert app.state.store.query_one('SELECT identity_json FROM companion')['identity_json'].find('恋人')<0
click(mp,'保存'); mp.get_by_role('dialog').get_by_role('button',name='确认',exact=True).click()
expect(mp.get_by_role('status')).to_contain_text('已保存在服务器')
shot(mp,'mobile-relation-fog')
nav(mp,'此刻'); mp.reload()
expect(mp.locator('.meta')).to_contain_text('恋人')
nav(mp,'日子'); expect(mp.get_by_text('尚未接通',exact=True)).to_be_visible()
assert mp.locator('.demo').count()==0
passed('mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console')
# Model timeout and abrupt SSE are separately exercised by deterministic backend/unit tests.
browser.close()
assert not results['console_errors'], results['console_errors']
finally:
(OUT/'browser-results.json').write_text(json.dumps(results,ensure_ascii=False,indent=2)+'\n')