diff --git a/.env.example b/.env.example index ab96030..2e98ebc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 5782304..3abc61c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ models/ *.swp .DS_Store Thumbs.db + +.venv311/ diff --git a/config/models.yaml b/config/models.yaml index 4a5d7a8..9daa167 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -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 diff --git a/deploy/cybergril-api.service.example b/deploy/cybergril-api.service.example new file mode 100644 index 0000000..cd94143 --- /dev/null +++ b/deploy/cybergril-api.service.example @@ -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 diff --git a/docs/frontend/browser-results-1440.json b/docs/frontend/browser-results-1440.json new file mode 100644 index 0000000..065155e --- /dev/null +++ b/docs/frontend/browser-results-1440.json @@ -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 +} \ No newline at end of file diff --git a/docs/frontend/browser-results-390.json b/docs/frontend/browser-results-390.json index aa792df..27ece3f 100644 --- a/docs/frontend/browser-results-390.json +++ b/docs/frontend/browser-results-390.json @@ -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 } \ No newline at end of file diff --git a/docs/r1/DEPLOY-ROLLBACK.md b/docs/r1/DEPLOY-ROLLBACK.md new file mode 100644 index 0000000..05185d1 --- /dev/null +++ b/docs/r1/DEPLOY-ROLLBACK.md @@ -0,0 +1,15 @@ +HEL-780 · 8310独立部署/回滚材料(总工执行,尚未部署) + +1. 审核确切任务分支和提交,复核8310空闲;保持8300和其他既有服务不动。建立 `/opt/cybergril-api/releases//`,构建生产前端和独立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是当前约束,后续扩展需独立设计。 diff --git a/docs/r1/IMPLEMENTATION.md b/docs/r1/IMPLEMENTATION.md new file mode 100644 index 0000000..1573f7b --- /dev/null +++ b/docs/r1/IMPLEMENTATION.md @@ -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=` | 升序 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;它不是部署依赖。 diff --git a/docs/r1/PR.md b/docs/r1/PR.md new file mode 100644 index 0000000..9490bf9 --- /dev/null +++ b/docs/r1/PR.md @@ -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和共享模型服务,交总工独立审核。 diff --git a/docs/r1/TEST-RECORD.md b/docs/r1/TEST-RECORD.md new file mode 100644 index 0000000..70468b1 --- /dev/null +++ b/docs/r1/TEST-RECORD.md @@ -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。当前单用户内网、单进程;近轮上下文与进程内预算不是长期记忆或持久费用账本。 diff --git a/docs/r1/browser-results.json b/docs/r1/browser-results.json new file mode 100644 index 0000000..20c30ac --- /dev/null +++ b/docs/r1/browser-results.json @@ -0,0 +1,35 @@ +{ + "checks": [ + "desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship", + "real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context", + "real gateway: cancel after first delta, upstream request closed, no complete assistant row", + "independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter", + "server restart on same origin: epoch 1→2, existing page sends automatically, data retained", + "controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row", + "controlled blocked upstream: immediate cancellation/new question isolation", + "mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console" + ], + "real_dialogue": [ + { + "input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。", + "reply": "星河暗号记住了,蓝色风铃。", + "source": "real gateway" + }, + { + "input": "刚才我们的暗号是什么?请直接回答。", + "reply": "星河蓝色风铃。", + "source": "real gateway" + }, + { + "input": "重启后继续", + "reply": "星河答复", + "source": "controlled provider" + }, + { + "input": "新轮不会串台", + "reply": "星河答复", + "source": "controlled provider" + } + ], + "console_errors": [] +} diff --git a/docs/r1/controlled/browser-results.json b/docs/r1/controlled/browser-results.json new file mode 100644 index 0000000..c851403 --- /dev/null +++ b/docs/r1/controlled/browser-results.json @@ -0,0 +1,35 @@ +{ + "checks": [ + "desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship", + "controlled provider: two complete streamed turns", + "controlled provider: cancel after first delta, upstream request closed, no complete assistant row", + "independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter", + "server restart on same origin: epoch 1→2, existing page sends automatically, data retained", + "controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row", + "controlled blocked upstream: immediate cancellation/new question isolation", + "mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console" + ], + "real_dialogue": [ + { + "input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。", + "reply": "星河答复", + "source": "controlled provider" + }, + { + "input": "刚才我们的暗号是什么?请直接回答。", + "reply": "星河答复", + "source": "controlled provider" + }, + { + "input": "重启后继续", + "reply": "星河答复", + "source": "controlled provider" + }, + { + "input": "新轮不会串台", + "reply": "星河答复", + "source": "controlled provider" + } + ], + "console_errors": [] +} diff --git a/docs/r1/desktop-history-moon.png b/docs/r1/desktop-history-moon.png new file mode 100644 index 0000000..73141cb Binary files /dev/null and b/docs/r1/desktop-history-moon.png differ diff --git a/docs/r1/desktop-model-error.png b/docs/r1/desktop-model-error.png new file mode 100644 index 0000000..67d7ec2 Binary files /dev/null and b/docs/r1/desktop-model-error.png differ diff --git a/docs/r1/desktop-preview-moon.png b/docs/r1/desktop-preview-moon.png new file mode 100644 index 0000000..30765b7 Binary files /dev/null and b/docs/r1/desktop-preview-moon.png differ diff --git a/docs/r1/desktop-real-chat-moon.png b/docs/r1/desktop-real-chat-moon.png new file mode 100644 index 0000000..e100d46 Binary files /dev/null and b/docs/r1/desktop-real-chat-moon.png differ diff --git a/docs/r1/mobile-chat-fog.png b/docs/r1/mobile-chat-fog.png new file mode 100644 index 0000000..8228d26 Binary files /dev/null and b/docs/r1/mobile-chat-fog.png differ diff --git a/docs/r1/mobile-history-moon.png b/docs/r1/mobile-history-moon.png new file mode 100644 index 0000000..0734d55 Binary files /dev/null and b/docs/r1/mobile-history-moon.png differ diff --git a/docs/r1/mobile-relation-fog.png b/docs/r1/mobile-relation-fog.png new file mode 100644 index 0000000..dd03dec Binary files /dev/null and b/docs/r1/mobile-relation-fog.png differ diff --git a/docs/rework/REVIEW.md b/docs/rework/REVIEW.md new file mode 100644 index 0000000..5dc6586 --- /dev/null +++ b/docs/rework/REVIEW.md @@ -0,0 +1,77 @@ +HEL-783 六项返工交付与复审说明 · 2026-10-22 + +本轮由总管在可用的 Codex 环境亲自完成。沿用获批 HEL-746 R2 两主题和现有 R1 API,在原 PR#4 的 `agent/agent/de8874a3803b` 上追加修复,目标仍为 `main`。没有更换业务模型、修改现网或读写用户真实数据库,没有合并。最终提交号以 PR#4 最新 head 为准。 + +## 六项反馈闭环 + +| 用户反馈 / Before | 本轮 After | 实际验证 / Why | +| --- | --- | --- | +| 手机对话被输入栏挡住;台词和输入分别固定定位,没有长文高度预算 | `ChatPanel` 统一布局,人物占位、可滚动台词、状态/取消、语音条、输入分别占位;`Viewport` 跟随 visualViewport 的高度、顶部和底部偏移,含安全区。视口变化时人物窗立即调整,不等待装饰过渡 | 1440×900、360×640、390×700、390×844、390×420、844×390,两主题共12组;长中文前后文均在 DOM,可滚到末尾、完整记录能读全文。420高是键盘缩小等效场景,未冒称真机键盘实测 | +| 流式全文在结束时被 `slice(-90)` 截成尾部;用户也被 `slice(-48)` 截断 | 当前双方消息保留全文;滚动阅读,跟随阅读位置;完整记录继续提供历史入口。流式和最终使用同一 turn 身份 | 长用户句首尾与五段长回复逐字相等;流式完成前后为同一个 DOM,取消立即中断,未完成文本标明未保存 | +| 只写 opacity transition,没有真实改变透明度 | `SoftText` 按轮次用 WAAPI 淡入200ms、旧句保留160ms淡出;流式增量只更新文字,不重播。使用现有 `--ease-out`;减少动效120ms轻淡化 | 实际动画0/80/200ms逐帧截图及 computed opacity/transform;旧句0/80/159ms仍挂载、透明度递减。全轮只有一次入场,完成不重播,取消不等动画 | +| 滚动条脱离主题 | 两套主题各有轨道/滑块/悬停 token;全局标准属性与 Chromium 兼容样式,强制色使用系统色;触摸区仍原生滚动 | 实际消息、记录、首建、设置长内容滚动及两主题截图;强制色有 CSS 回退。没有做真实辅助设备和各系统滚动条外观全覆盖测试 | +| 底色只有公历和几个内部短标签 | 新增只读日历展示与六维解释映射:表达社交、亲密边界、冲突修复、行动节律、压力恢复、成长张力。显示真实农历/闰月、钟点、四柱和术语提示;每段可展开解释依据。预览/确认/已有档案共用组件 | 48组日期×时辰检查,含1900边界、春节、立春附近、真实闰四月;预览不写库。旧档案使用已存四柱、十神、Derived,禁止重新编译;GET前后原始JSON、底色行一致,关系修改不改底色和历史;聊天系统提示逐段复用同一解释 | +| 分不清谁在说话 | 当前对话中“你”右对齐,真实伴侣名字左对齐,独立角色行;阅读状态行持续标明伴侣名字,滚动后也能确认正在读谁的回复。流式、最终、失败、取消与记录都使用同一身份 | 两主题截图,失败/重试、取消新轮、历史恢复都检查;不单靠颜色辨别 | +| 真实入口把完整页面缩成“未接通”卡片 | 恢复日子/回忆双栏与分组;城市、外观、备份、四类服务的完整结构和真实空态;服务页可读健康检查,未接通写入控件明确禁用;加入未保存离开确认 | 实际手机逐页进入、返回、搜索空态、服务检查、未保存取消/放弃、演示事件→聊天→返回。真实入口无演示记录,演示模式全流程零API请求 | + +## R2 功能覆盖差异表 + +“已接真”表示已有 API 持续可用;“本批补齐”表示前端结构/交互已恢复;“后台待接”表示本轮没有增加该后台能力,不能据此验收为已接通。 + +| R2 落点 | 演示模式 | 真实入口 | 本批结论 | +| --- | --- | --- | --- | +| 四类区域、两主题、底部导航 | 原有此刻/交谈/日子/设置 | 同样四区与主题切换 | 保留;手机短高度首页内容可滚动 | +| 首页留言与日子入口 | 固定演示留言、已分享日子入口 | 服务器最近回复可打开完整记录;日子入口明确后台待接 | 本批补齐真实入口,历史已接真;不伪称主动留言 | +| 日子列表、按日分组、三类事件、分页 | 原有已分享事件与18+12分页 | 完整列表区、三类图例和空态 | 本批补齐结构;真实生活后台待接,无示例填充 | +| 事件详情、回忆互链、聊天与返回 | 可走详情→关联→聊天→回到原日子 | 详情阅读区域保留,无数据时不造条目/链接 | 演示路径实测;真实数据后台待接 | +| 我们一起的/小习惯/她知道的你/成长 | 四组演示回忆、来源、关联日子 | 四组独立空态和阅读区 | 本批补齐真实分组;长期记忆后台待接 | +| 隐私:她说过才显示 | 继续使用 publicView 过滤 | 当前没有生活/回忆数据 | 不放宽隐私,不展示未讲述事件 | +| 首建四步、关系无预选、同意确认 | 本机独立创建;排盘不以固定假值冒充 | 服务端唯一档案、只读预览、实际创建 | 已接真;补公农历/闰月/四柱帮助和六维说明 | +| 首建失败、返回修改、重试 | 原有模拟失败且保留输入 | API 中文错误、输入保留、409恢复唯一档案 | 保留;未增建档入口或清空入口 | +| 她的档案与底色锁定 | 演示底色明确未计算 | 旧档案只读补展示,原参数和历史不动 | 本批补齐;无数据库迁移 | +| 最初与后来成长分层 | 两栏及成长回忆 | 出生说明与后续记忆区分,当前长期记忆未接 | 不把出生说明当作已经发生的经历 | +| 关系、称呼、边界;改变关系确认 | 原有模拟保存/取消/失败 | PATCH真实保存、二次确认、重开恢复 | 已接真,回归通过 | +| 未保存提示、取消和离开 | 原有暖色点、取消、beforeunload | 同样提示;关闭/导航离开新增确认 | 本批补齐;取消继续编辑,放弃不写服务器 | +| 城市搜索与列表/无结果 | 原有候选城市本机保存与失败演示 | 可搜索候选目录,有空态;明确未设置真实城市,保存禁用 | 本批补齐;真实城市/天气后台待接 | +| 外观选择、五项检查、对比、确认 | JSON结构检查、对比与模拟标识确认 | 当前参考、文件入口、五项待检查、新旧对比、应用入口 | 本批补齐;真实导入与媒体检查禁用,后台待接 | +| 自动备份列表、完整导出 | 已标真实备份未接通;本机演示JSON可导出 | 自动备份空列表、完整导出说明和禁用按钮 | 本批补齐;不制造成功备份 | +| 恢复检查、同一档案、双重确认、先导出 | 原有本机恢复检查和输入“恢复” | 明示四步恢复过程;文件和执行入口禁用 | 本批补齐结构;真实恢复后台待接,不能实际覆盖 | +| 四类外接服务与测试反馈 | 只保存演示地址,不发请求 | 文字、语音识别、语音合成、视觉四项;文字可重新检查健康,配置只读 | 本批补齐;不暴露凭据,不模拟其他连接成功 | +| 显示、减少动效 | 原有本机设置 | 主题与减少动效保存于本浏览器,和服务器资料分开 | 减少动效刷新恢复;系统设置也生效 | +| 长文字、完整记录、滚动、角色 | 模拟流式继续可用 | SSE、完整记录、失败与取消状态 | 本批修复全文、占位和角色;已接真 | +| 重试、轮次/epoch、重启、多设备恢复 | 原有 TurnGate | 原有传输机制保留 | 确定性API测试和浏览器回归通过 | +| 连续语音、打断、结束、拒绝/失败状态 | 保留无收音模拟和权限说明;新布局语音条不挡输入 | 控件明确尚未接通,文字仍可用 | 真实语音后台待接,未申请设备权限 | +| 视觉说明、时效、失败、总服务区 | 原有30秒模拟过期与失败 | 明确未接通、不采集;服务项保留 | 真实视觉后台待接 | +| 断线/模型不可达/草稿 | 原有断线重连演示 | 实际请求错误、草稿保留、重试、服务状态;已有档案历史可读 | 保留已接真能力;自动重连推送属后续,不假造补消息 | +| 离开欢迎摘要、主动消息与未读 | 原有明确标注的固定演示,来源为已分享事件 | 生活/主动消息未接通,不伪造欢迎摘要或来信 | 后台待接;真实最近回复入口已保留 | +| 手机列表/详情与设置返回、Esc | 保留推入和返回、原日子位置 | 同样区域/分区返回;Esc关闭记录再退出交流 | 本批补齐真实Esc与未保存确认 | +| 新设计建议 | 无 | 无 | 未把新增后台能力夹进本批 | + +## 验证记录 + +- Python 3.11:`pytest -q` **142通过**,含原92项和新增50项。唯一提示为既有 Starlette TestClient 依赖弃用提示。 +- TypeScript类型检查、ESLint、前端 **14项** Vitest、Vite生产构建通过。后端变更文件 Ruff通过。没有新增运行时依赖。 +- `web/tests/rework_browser_check.py`:8组通过,12组尺寸/主题组合;记录 `browser-results.json`。使用隔离SQLite和可控模型验证长中文、错误、取消与完整页面,不把可控模型说成在线模型。 +- `web/tests/real_browser_check.py --controlled`:8组回归通过,记录 `r1-regression/browser-results.json`。包括真实HTTP SSE、浏览器关闭流、立即新轮、同源重启epoch变化、多设备恢复、关系取消/确认保存。 +- `web/tests/real_browser_check.py`:真实批准网关完成两轮对话,第二轮正确回答上一轮暗号“蓝色风铃”;第三轮收到增量后取消,上游请求结束且没有完整回复落库。随后同源重启和故障段使用可控provider,结果逐项区分,记录 `live-gateway/browser-results.json`。所有建档、聊天和关系写入仍只在临时库。 +- 动效证据:`motion-enter-0/80/200.png` 和结果JSON中的 `motion.entry_frames/exit_frames`。截图暂停实际 WAAPI 后逐帧采样,随后恢复;没有为截图修改生产动画时长。 +- 代表图:`long-start-360-640-*.png`、`long-start/long-end-390-420-*.png`、`full-history-*.png`、`birth-*.png`、`existing-*.png`、城市/外观/备份/服务/生活回忆各图。图中“星河/小满”和聊天全部是本轮隔离测试资料。 +- 已实际看过代表性手机长文、短视口、语音模拟、出生预览、已有档案说明、服务页、回忆分组截图;复审仍需独立看图、播放实际界面,不能只用坐标和测试数认定视觉通过。 +- 未测:实体手机键盘、Safari、真实强制色设备、屏幕阅读器、长时运行和帧率。420/390高只证明等效可见高度布局,不等于真机键盘实测。 + +## 说明层的兼容性与回滚 + +`core/birth_view.py` 是可维护的确定性解释映射,不调用模型润色。分档值只选择解释段落,不产生新的底色参数。敏感度的展示分档与普通0–1参数分别归一到解释选择范围。四柱与十神取旧库已存值,五行侧重和性格取旧Derived;日历只从不可变生日、时辰换算展示。没有重新调用旧档案的四柱编译器,也没有把新说明写回旧档案。新增档案会在原有JSON中携带可选展示字段;旧字段仍保留,旧前端不依赖新增字段。 + +聊天提示与UI共用六段解释,仍明确本体、现实边界、未接通能力和不能编造生活。取消、历史、epoch、路由和业务模型配置未改。服务端关系修改只保存关系字段及原档案JSON,不把本次只读展示偷偷写回旧档案。 + +本批无schema改动、不需要重建库。回滚只切回上一个程序版本;旧版忽略JSON新增字段。服务器切换前按HEL-784要求备份,不能删除、替换或重算用户档案。浏览器新加 `cg.real.display` 仅保存减少动效偏好;不存真实聊天或底色。 + +## 给HEL-784的部署交接 + +1. 重新读取PR#4最新head,与本轮交付SHA一致后审核;base仍main。远端若另有推进,先协调,不覆盖。 +2. 独立运行检查,实际看截图及流式/取消/渐入渐出,特别检查用户原截图对应的手机短视口和长回复。 +3. 安装方式沿用 `docs/r1/IMPLEMENTATION.md`:Python≥3.11、`requirements.lock`、安装当前包,`npm ci --prefix web` 和 `npm run build --prefix web`。浏览器测试可选Playwright,不是部署依赖。 +4. 审核通过才部署8310的新release,保留现有用户库,切换前备份;不要把本轮临时测试库、测试人物或演示存储带上服务器。8300和其他服务不动。 +5. 回读现有档案/历史与health,检查新静态资源和旧档案补充说明;附件不要泄露真实聊天全文。记录部署SHA、备份位置和回滚指令。 +6. 本批用户尚未验收,不合并、不归档、不推进到收尾。 diff --git a/docs/rework/appearance-fog.png b/docs/rework/appearance-fog.png new file mode 100644 index 0000000..efc6cff Binary files /dev/null and b/docs/rework/appearance-fog.png differ diff --git a/docs/rework/backup-fog.png b/docs/rework/backup-fog.png new file mode 100644 index 0000000..37cfbe9 Binary files /dev/null and b/docs/rework/backup-fog.png differ diff --git a/docs/rework/birth-calendar-help-moon.png b/docs/rework/birth-calendar-help-moon.png new file mode 100644 index 0000000..b53ebce Binary files /dev/null and b/docs/rework/birth-calendar-help-moon.png differ diff --git a/docs/rework/birth-preview-moon.png b/docs/rework/birth-preview-moon.png new file mode 100644 index 0000000..47c3406 Binary files /dev/null and b/docs/rework/birth-preview-moon.png differ diff --git a/docs/rework/browser-results.json b/docs/rework/browser-results.json new file mode 100644 index 0000000..42773ba --- /dev/null +++ b/docs/rework/browser-results.json @@ -0,0 +1,391 @@ +{ + "checks": [ + "Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows", + "Long Chinese stream: entire user/reply retained; final keeps same DOM; one entrance across all chunks and final", + "Old sentence stays mounted during a visible 160ms fade-out, then leaves", + "12 viewport/theme combinations: long text scrolls, stage/dialog/composer/navigation never overlap; full history reachable", + "Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages", + "Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard", + "Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity", + "Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests" + ], + "viewports": [ + { + "width": 1440, + "height": 900, + "theme": "moon", + "dialog": { + "top": 564, + "bottom": 734, + "height": 170 + }, + "composer": { + "top": 746, + "bottom": 806, + "height": 60 + }, + "nav": { + "top": 818, + "bottom": 878, + "height": 60 + }, + "stage": { + "top": 72, + "bottom": 552, + "height": 480 + }, + "scrollHeight": 1848, + "clientHeight": 142, + "overflow": false + }, + { + "width": 1440, + "height": 900, + "theme": "fog", + "dialog": { + "top": 564, + "bottom": 734, + "height": 170 + }, + "composer": { + "top": 746, + "bottom": 806, + "height": 60 + }, + "nav": { + "top": 818, + "bottom": 878, + "height": 60 + }, + "stage": { + "top": 72, + "bottom": 552, + "height": 480 + }, + "scrollHeight": 1848, + "clientHeight": 142, + "overflow": false + }, + { + "width": 360, + "height": 640, + "theme": "moon", + "dialog": { + "top": 220, + "bottom": 492, + "height": 272 + }, + "composer": { + "top": 500, + "bottom": 556, + "height": 56 + }, + "nav": { + "top": 566, + "bottom": 624, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 212, + "height": 154 + }, + "scrollHeight": 2876, + "clientHeight": 244, + "overflow": false + }, + { + "width": 360, + "height": 640, + "theme": "fog", + "dialog": { + "top": 220, + "bottom": 492, + "height": 272 + }, + "composer": { + "top": 500, + "bottom": 556, + "height": 56 + }, + "nav": { + "top": 566, + "bottom": 624, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 212, + "height": 154 + }, + "scrollHeight": 2876, + "clientHeight": 244, + "overflow": false + }, + { + "width": 390, + "height": 700, + "theme": "moon", + "dialog": { + "top": 253, + "bottom": 552, + "height": 299 + }, + "composer": { + "top": 560, + "bottom": 616, + "height": 56 + }, + "nav": { + "top": 626, + "bottom": 684, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 245, + "height": 187 + }, + "scrollHeight": 2735, + "clientHeight": 271, + "overflow": false + }, + { + "width": 390, + "height": 700, + "theme": "fog", + "dialog": { + "top": 253, + "bottom": 552, + "height": 299 + }, + "composer": { + "top": 560, + "bottom": 616, + "height": 56 + }, + "nav": { + "top": 626, + "bottom": 684, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 245, + "height": 187 + }, + "scrollHeight": 2735, + "clientHeight": 271, + "overflow": false + }, + { + "width": 390, + "height": 844, + "theme": "moon", + "dialog": { + "top": 332.1875, + "bottom": 696, + "height": 363.8125 + }, + "composer": { + "top": 704, + "bottom": 760, + "height": 56 + }, + "nav": { + "top": 770, + "bottom": 828, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 324.1875, + "height": 266.1875 + }, + "scrollHeight": 2735, + "clientHeight": 335, + "overflow": false + }, + { + "width": 390, + "height": 844, + "theme": "fog", + "dialog": { + "top": 332.1875, + "bottom": 696, + "height": 363.8125 + }, + "composer": { + "top": 704, + "bottom": 760, + "height": 56 + }, + "nav": { + "top": 770, + "bottom": 828, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 324.1875, + "height": 266.1875 + }, + "scrollHeight": 2735, + "clientHeight": 335, + "overflow": false + }, + { + "width": 390, + "height": 420, + "theme": "moon", + "dialog": { + "top": 106, + "bottom": 272, + "height": 166 + }, + "composer": { + "top": 280, + "bottom": 336, + "height": 56 + }, + "nav": { + "top": 346, + "bottom": 404, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 98, + "height": 40 + }, + "scrollHeight": 2735, + "clientHeight": 138, + "overflow": false + }, + { + "width": 390, + "height": 420, + "theme": "fog", + "dialog": { + "top": 106, + "bottom": 272, + "height": 166 + }, + "composer": { + "top": 280, + "bottom": 336, + "height": 56 + }, + "nav": { + "top": 346, + "bottom": 404, + "height": 58 + }, + "stage": { + "top": 58, + "bottom": 98, + "height": 40 + }, + "scrollHeight": 2735, + "clientHeight": 138, + "overflow": false + }, + { + "width": 844, + "height": 390, + "theme": "moon", + "dialog": { + "top": 124, + "bottom": 224, + "height": 100 + }, + "composer": { + "top": 236, + "bottom": 296, + "height": 60 + }, + "nav": { + "top": 308, + "bottom": 368, + "height": 60 + }, + "stage": { + "top": 72, + "bottom": 112, + "height": 40 + }, + "scrollHeight": 2178, + "clientHeight": 72, + "overflow": false + }, + { + "width": 844, + "height": 390, + "theme": "fog", + "dialog": { + "top": 124, + "bottom": 224, + "height": 100 + }, + "composer": { + "top": 236, + "bottom": 296, + "height": 60 + }, + "nav": { + "top": 308, + "bottom": 368, + "height": 60 + }, + "stage": { + "top": 72, + "bottom": 112, + "height": 40 + }, + "scrollHeight": 2178, + "clientHeight": 72, + "overflow": false + } + ], + "motion": { + "entry_frames": [ + { + "time": 0, + "opacity": "0", + "transform": "matrix(1, 0, 0, 1, 0, 3)" + }, + { + "time": 80, + "opacity": "0.917147", + "transform": "matrix(1, 0, 0, 1, 0, 0.248559)" + }, + { + "time": 200, + "opacity": "1", + "transform": "none" + } + ], + "exit_frames": [ + { + "time": 0, + "opacity": "1", + "retained": true + }, + { + "time": 80, + "opacity": "0.0386175", + "retained": true + }, + { + "time": 159, + "opacity": "3.47566e-08", + "retained": true + } + ] + }, + "console_errors": [], + "limits": [ + "Chromium only; physical phone keyboard, Safari and screen reader not tested.", + "LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway." + ] +} diff --git a/docs/rework/city-fog.png b/docs/rework/city-fog.png new file mode 100644 index 0000000..785c24d Binary files /dev/null and b/docs/rework/city-fog.png differ diff --git a/docs/rework/demo-voice-390.png b/docs/rework/demo-voice-390.png new file mode 100644 index 0000000..75ef125 Binary files /dev/null and b/docs/rework/demo-voice-390.png differ diff --git a/docs/rework/existing-birth-fog.png b/docs/rework/existing-birth-fog.png new file mode 100644 index 0000000..e237294 Binary files /dev/null and b/docs/rework/existing-birth-fog.png differ diff --git a/docs/rework/existing-growth-fog.png b/docs/rework/existing-growth-fog.png new file mode 100644 index 0000000..8bab7b5 Binary files /dev/null and b/docs/rework/existing-growth-fog.png differ diff --git a/docs/rework/full-history-fog.png b/docs/rework/full-history-fog.png new file mode 100644 index 0000000..1b6613a Binary files /dev/null and b/docs/rework/full-history-fog.png differ diff --git a/docs/rework/full-history-moon.png b/docs/rework/full-history-moon.png new file mode 100644 index 0000000..f02f274 Binary files /dev/null and b/docs/rework/full-history-moon.png differ diff --git a/docs/rework/life-empty-fog.png b/docs/rework/life-empty-fog.png new file mode 100644 index 0000000..883e54e Binary files /dev/null and b/docs/rework/life-empty-fog.png differ diff --git a/docs/rework/live-gateway/browser-results.json b/docs/rework/live-gateway/browser-results.json new file mode 100644 index 0000000..daa12f5 --- /dev/null +++ b/docs/rework/live-gateway/browser-results.json @@ -0,0 +1,35 @@ +{ + "checks": [ + "desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship", + "real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context", + "real gateway: cancel after first delta, upstream request closed, no complete assistant row", + "independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter", + "server restart on same origin: epoch 1→2, existing page sends automatically, data retained", + "controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row", + "controlled blocked upstream: immediate cancellation/new question isolation", + "mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console" + ], + "real_dialogue": [ + { + "input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。", + "reply": "好的,暗号是蓝色风铃。", + "source": "real gateway" + }, + { + "input": "刚才我们的暗号是什么?请直接回答。", + "reply": "蓝色风铃。", + "source": "real gateway" + }, + { + "input": "重启后继续", + "reply": "答复", + "source": "controlled provider" + }, + { + "input": "新轮不会串台", + "reply": "答复", + "source": "controlled provider" + } + ], + "console_errors": [] +} diff --git a/docs/rework/long-end-360-640-fog.png b/docs/rework/long-end-360-640-fog.png new file mode 100644 index 0000000..f70a2b5 Binary files /dev/null and b/docs/rework/long-end-360-640-fog.png differ diff --git a/docs/rework/long-end-360-640-moon.png b/docs/rework/long-end-360-640-moon.png new file mode 100644 index 0000000..283d105 Binary files /dev/null and b/docs/rework/long-end-360-640-moon.png differ diff --git a/docs/rework/long-end-390-420-fog.png b/docs/rework/long-end-390-420-fog.png new file mode 100644 index 0000000..92a8a81 Binary files /dev/null and b/docs/rework/long-end-390-420-fog.png differ diff --git a/docs/rework/long-end-390-420-moon.png b/docs/rework/long-end-390-420-moon.png new file mode 100644 index 0000000..b0c8cf9 Binary files /dev/null and b/docs/rework/long-end-390-420-moon.png differ diff --git a/docs/rework/long-end-390-844-fog.png b/docs/rework/long-end-390-844-fog.png new file mode 100644 index 0000000..f45efb5 Binary files /dev/null and b/docs/rework/long-end-390-844-fog.png differ diff --git a/docs/rework/long-end-390-844-moon.png b/docs/rework/long-end-390-844-moon.png new file mode 100644 index 0000000..9b0b720 Binary files /dev/null and b/docs/rework/long-end-390-844-moon.png differ diff --git a/docs/rework/long-start-360-640-fog.png b/docs/rework/long-start-360-640-fog.png new file mode 100644 index 0000000..b733bd1 Binary files /dev/null and b/docs/rework/long-start-360-640-fog.png differ diff --git a/docs/rework/long-start-360-640-moon.png b/docs/rework/long-start-360-640-moon.png new file mode 100644 index 0000000..b8c0c2f Binary files /dev/null and b/docs/rework/long-start-360-640-moon.png differ diff --git a/docs/rework/long-start-390-420-fog.png b/docs/rework/long-start-390-420-fog.png new file mode 100644 index 0000000..f949178 Binary files /dev/null and b/docs/rework/long-start-390-420-fog.png differ diff --git a/docs/rework/long-start-390-420-moon.png b/docs/rework/long-start-390-420-moon.png new file mode 100644 index 0000000..7753543 Binary files /dev/null and b/docs/rework/long-start-390-420-moon.png differ diff --git a/docs/rework/long-start-390-844-fog.png b/docs/rework/long-start-390-844-fog.png new file mode 100644 index 0000000..9976c0b Binary files /dev/null and b/docs/rework/long-start-390-844-fog.png differ diff --git a/docs/rework/long-start-390-844-moon.png b/docs/rework/long-start-390-844-moon.png new file mode 100644 index 0000000..280fc4e Binary files /dev/null and b/docs/rework/long-start-390-844-moon.png differ diff --git a/docs/rework/memory-groups-empty-fog.png b/docs/rework/memory-groups-empty-fog.png new file mode 100644 index 0000000..a86c7d0 Binary files /dev/null and b/docs/rework/memory-groups-empty-fog.png differ diff --git a/docs/rework/motion-enter-0.png b/docs/rework/motion-enter-0.png new file mode 100644 index 0000000..bea583d Binary files /dev/null and b/docs/rework/motion-enter-0.png differ diff --git a/docs/rework/motion-enter-200.png b/docs/rework/motion-enter-200.png new file mode 100644 index 0000000..3ecb424 Binary files /dev/null and b/docs/rework/motion-enter-200.png differ diff --git a/docs/rework/motion-enter-80.png b/docs/rework/motion-enter-80.png new file mode 100644 index 0000000..0ed158c Binary files /dev/null and b/docs/rework/motion-enter-80.png differ diff --git a/docs/rework/r1-regression/browser-results.json b/docs/rework/r1-regression/browser-results.json new file mode 100644 index 0000000..e04ccca --- /dev/null +++ b/docs/rework/r1-regression/browser-results.json @@ -0,0 +1,35 @@ +{ + "checks": [ + "desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship", + "controlled provider: two complete streamed turns", + "controlled provider: cancel after first delta, upstream request closed, no complete assistant row", + "independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter", + "server restart on same origin: epoch 1→2, existing page sends automatically, data retained", + "controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row", + "controlled blocked upstream: immediate cancellation/new question isolation", + "mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console" + ], + "real_dialogue": [ + { + "input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。", + "reply": "答复", + "source": "controlled provider" + }, + { + "input": "刚才我们的暗号是什么?请直接回答。", + "reply": "答复", + "source": "controlled provider" + }, + { + "input": "重启后继续", + "reply": "答复", + "source": "controlled provider" + }, + { + "input": "新轮不会串台", + "reply": "答复", + "source": "controlled provider" + } + ], + "console_errors": [] +} diff --git a/docs/rework/services-fog.png b/docs/rework/services-fog.png new file mode 100644 index 0000000..0b4832f Binary files /dev/null and b/docs/rework/services-fog.png differ diff --git a/pyproject.toml b/pyproject.toml index ccb34a7..6230665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..bdf30a9 --- /dev/null +++ b/requirements.lock @@ -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) diff --git a/src/cybergril/api/__init__.py b/src/cybergril/api/__init__.py new file mode 100644 index 0000000..385708a --- /dev/null +++ b/src/cybergril/api/__init__.py @@ -0,0 +1 @@ +"""R1 same-origin persistent text service.""" diff --git a/src/cybergril/api/__main__.py b/src/cybergril/api/__main__.py new file mode 100644 index 0000000..9c57e60 --- /dev/null +++ b/src/cybergril/api/__main__.py @@ -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, +) diff --git a/src/cybergril/api/app.py b/src/cybergril/api/app.py new file mode 100644 index 0000000..e88c596 --- /dev/null +++ b/src/cybergril/api/app.py @@ -0,0 +1,518 @@ +"""Single-process R1 API. Store is authoritative; no demo state enters this module.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sqlite3 +import time +from contextlib import asynccontextmanager, suppress +from dataclasses import asdict +from datetime import date, datetime +from pathlib import Path +from typing import Literal +from uuid import UUID +from zoneinfo import ZoneInfo + +import yaml +from anyio import CancelScope +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict, field_validator + +from ..config import ModelsConfig +from ..contracts import LLMMessage +from ..core.bazi import ( + GAN_ELEM, + Derived, + Pillars, + compile_pillars, + element_scores, + pillars_from_lunar, + ten_god_distribution, +) +from ..core.birth_view import calendar_view, color_view +from ..memory.store import Store +from ..think.scenes import Scene +from ..think.wiring import build_router_from_config + +HOURS = [ + "子时", + "丑时", + "寅时", + "卯时", + "辰时", + "巳时", + "午时", + "未时", + "申时", + "酉时", + "戌时", + "亥时", +] +ERROR = "她这会儿没接上话,稍后再试试。你写的话还在。" + + +def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str: + value = value.strip() + if ( + (required and not value) + or len(value) > maximum + or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value) + ): + raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。") + return value + + +class StrictBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + +class BirthInput(StrictBody): + name: str + birth: str + hour: str + + @field_validator("name") + @classmethod + def name_valid(cls, value: str) -> str: + return safe_text(value, 20, "名字", True) + + @field_validator("birth") + @classmethod + def birth_valid(cls, value: str) -> str: + try: + born = date.fromisoformat(value) + today = datetime.now(ZoneInfo("Asia/Shanghai")).date() + age = today.year - born.year - ((today.month, today.day) < (born.month, born.day)) + if born.isoformat() != value or born.year < 1900 or age < 18: + raise ValueError + except ValueError as exc: + raise ValueError("请填写有效的出生日期,且已满18岁(不早于1900年)。") from exc + return value + + @field_validator("hour") + @classmethod + def hour_valid(cls, value: str) -> str: + if value not in HOURS: + raise ValueError("请选择有效的出生时辰。") + return value + + +class RelationInput(StrictBody): + relation: Literal["朋友", "恋人"] + nickname: str = "" + boundary: str = "尊重彼此的节奏" + + @field_validator("nickname") + @classmethod + def nickname_valid(cls, value: str) -> str: + return safe_text(value, 40, "称呼") + + @field_validator("boundary") + @classmethod + def boundary_valid(cls, value: str) -> str: + return safe_text(value, 2000, "相处边界") + + +class CreationInput(BirthInput, RelationInput): + pass + + +class ChatInput(StrictBody): + text: str + turn_id: str + epoch: int + + @field_validator("text") + @classmethod + def text_valid(cls, value: str) -> str: + if not value.strip() or len(value) > 4000: + raise ValueError("请输入1–4000字的消息。") + return value.strip() + + @field_validator("turn_id") + @classmethod + def turn_valid(cls, value: str) -> str: + try: + UUID(value) + except ValueError as exc: + raise ValueError("轮次标识无效,请刷新后重试。") from exc + return value + + +def compile_birth(data: BirthInput) -> tuple[dict, object, object]: + born = date.fromisoformat(data.birth) + pillars = pillars_from_lunar(born.year, born.month, born.day, HOURS.index(data.hour) * 2) + derived = compile_pillars(pillars) + return color_view(data.birth, data.hour, pillars, derived), pillars, derived + + +def create_app( + *, + db_path: Path | str | None = None, + models: ModelsConfig | None = None, + router=None, + web_dir: Path | None = None, + stream_timeout: float = 60, +) -> FastAPI: + """Dependencies can be injected for isolated fault/cancellation tests, never via browser flags.""" + db_path = db_path or os.environ.get( + "CYBERGRIL_DB", str(Path.home() / ".local/share/cybergril/companion.db") + ) + if models is None: + config = Path(os.environ.get("CYBERGRIL_MODELS", "config/models.yaml")) + models = ModelsConfig(**yaml.safe_load(config.read_text())) + router = router or build_router_from_config(models) + + @asynccontextmanager + async def lifespan(app): + store = Store(db_path) + app.state.store = store + with store.conn: + store.execute( + "INSERT INTO api_meta(key,value) VALUES('epoch',1) ON CONFLICT(key) DO UPDATE SET value=value+1" + ) + store.execute("UPDATE chat_message SET status='cancelled' WHERE status='pending'") + app.state.epoch = store.query_one("SELECT value FROM api_meta WHERE key='epoch'")["value"] + app.state.active = None + app.state.chat_lock = asyncio.Lock() + app.state.health_cache = (0, False) + app.state.health_lock = asyncio.Lock() + try: + yield + finally: + if app.state.active: + app.state.active["cancel"].set() + await app.state.active["finished"].wait() + for provider in set(router.provider_for(t) for t in router.available_tiers()): + await provider.aclose() + store.close() + + app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None) + + @app.exception_handler(RequestValidationError) + async def invalid(_request, exc): + fields = {} + for e in exc.errors(): + key = str(e["loc"][-1]) + msg = e["msg"].removeprefix("Value error, ") + if e["type"] == "missing": + msg = "请填写此项。" if key != "relation" else "请亲自选择你们的关系。" + elif e["type"] == "literal_error": + msg = "请亲自选择朋友或恋人。" + elif e["type"] == "extra_forbidden": + msg = "此字段不允许修改。" + elif not any("\u4e00" <= c <= "\u9fff" for c in msg): + msg = "字段格式无效,请检查后重试。" + fields[key] = msg + return JSONResponse({"detail": "请检查填写内容。", "fields": fields}, status_code=422) + + def companion(enrich=True): + row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1") + if not row: + raise HTTPException(404, "还没有创建真实伴侣。") + identity = json.loads(row["identity_json"]) + if enrich: + saved = app.state.store.query_one("SELECT pillars,derived,ten_gods FROM bazi_core WHERE id=1") + if saved: + # Saved parameters are authoritative, even if a compiler later changes. + identity["color"] = color_view( + identity["birth"], identity["hour"], + Pillars(**json.loads(saved["pillars"])), Derived(**json.loads(saved["derived"])), + saved_gods=json.loads(saved["ten_gods"]), + ) + else: + identity["color"]["calendar"] = calendar_view(identity["birth"], identity["hour"]) + return identity + + @app.get("/api/health") + async def health(): + async with app.state.health_lock: + checked, ok = app.state.health_cache + if time.monotonic() - checked > 30: + provider = router.route(Scene.DIALOGUE_MAIN).provider + try: + async with asyncio.timeout(5): + ok = bool(provider and (await provider.health()).ok) + except Exception: + ok = False + app.state.health_cache = (time.monotonic(), ok) + return { + "ok": True, + "version": "R1", + "epoch": app.state.epoch, + "companion_created": bool(app.state.store.query_one("SELECT id FROM companion")), + "llm": {"ok": ok, "model": models.tiers["high"].model}, + "capabilities": { + "text": True, + **dict.fromkeys( + ["life", "memory", "voice", "vision", "weather", "materials", "backup"], False + ), + }, + } + + @app.get("/api/companion") + async def get_companion(): + return companion() + + @app.post("/api/companion/preview") + async def preview(data: BirthInput): + return compile_birth(data)[0] + + @app.post("/api/companion", status_code=201) + async def create(data: CreationInput): + store = app.state.store + if store.query_one("SELECT id FROM companion"): + raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") + color, pillars, derived = compile_birth(data) + identity = {"id": "1", **data.model_dump(), "color": color} + try: + with store.conn: + store.execute( + "INSERT INTO companion VALUES(1,?)", (json.dumps(identity, ensure_ascii=False),) + ) + store.execute( + """INSERT INTO bazi_core + (id,birth_input,pillars,day_master,day_master_elem,ten_gods,element_scores,strength,derived,created_at) + VALUES(1,?,?,?,?,?,?,?,?,?)""", + ( + json.dumps({"birth": data.birth, "hour": data.hour, "tz": "Asia/Shanghai"}), + json.dumps(asdict(pillars), ensure_ascii=False), + pillars.day[0], + GAN_ELEM[pillars.day[0]], + json.dumps(ten_god_distribution(pillars)), + json.dumps(element_scores(pillars)), + derived.strength, + json.dumps(derived.as_dict()), + int(time.time() * 1000), + ), + ) + except sqlite3.IntegrityError as exc: + raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") from exc + return identity + + @app.patch("/api/relation") + async def relation(data: RelationInput): + identity = companion(enrich=False) + identity.update(data.model_dump()) + with app.state.store.conn: + app.state.store.execute( + "UPDATE companion SET identity_json=? WHERE id=1", + (json.dumps(identity, ensure_ascii=False),), + ) + return companion() + + @app.get("/api/messages") + async def messages( + limit: int = Query(200, ge=1, le=200), before: int | None = Query(None, ge=1) + ): + companion() + rows = app.state.store.query( + "SELECT * FROM chat_message WHERE id limit + rows = rows[:limit] + return { + "messages": [dict(r) for r in reversed(rows)], + "next_before": rows[-1]["id"] if more else None, + "epoch": app.state.epoch, + } + + def event(data, kind, **payload): + return ( + "data: " + + json.dumps( + {"t": kind, "epoch": app.state.epoch, "turn_id": data.turn_id, **payload}, + ensure_ascii=False, + ) + + "\n\n" + ) + + @app.post("/api/chat") + async def chat(data: ChatInput, request: Request): + identity = companion() + if data.epoch != app.state.epoch: + return JSONResponse( + {"detail": "服务已重新启动,请同步后重试。", "epoch": app.state.epoch}, + status_code=409, + ) + store = app.state.store + # Serialize reservation, including replays. Concurrent retries of a completed + # turn must never call the model twice or race the unique message constraint. + async with app.state.chat_lock: + previous = store.query_one( + "SELECT * FROM chat_message WHERE turn_id=? AND role='user'", (data.turn_id,) + ) + if previous and previous["text"] != data.text: + raise HTTPException(409, "相同轮次不能改写消息。") + if app.state.active: + active = app.state.active + if active["turn_id"] != data.turn_id: + active["cancel"].set() + await active["finished"].wait() + saved = store.query_one( + "SELECT text FROM chat_message WHERE turn_id=? AND role='companion' AND status='sent'", + (data.turn_id,), + ) + if saved: + + async def replay(): + yield event( + data, "chat.final", utterance={"text": saved["text"], "speech_tags": []} + ) + + return StreamingResponse(replay(), media_type="text/event-stream") + active = { + "cancel": asyncio.Event(), + "finished": asyncio.Event(), + "turn_id": data.turn_id, + } + app.state.active = active + with store.conn: + store.execute( + "INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'user',?,?,'pending') ON CONFLICT(turn_id,role) DO UPDATE SET status='pending'", + (int(time.time() * 1000), data.text, data.turn_id), + ) + turns = store.query("""SELECT u.text AS user_text, a.text AS assistant_text + FROM chat_message a JOIN chat_message u ON u.turn_id=a.turn_id AND u.role='user' + WHERE a.role='companion' AND a.status='sent' AND u.status='sent' + ORDER BY a.id DESC LIMIT 20""") + system = ( + f"你叫{identity['name']},与对方是{identity['relation']}。称呼对方:{identity['nickname'] or '你'}。" + f"相处边界:{identity['boundary']}。" + "以下是与档案页一致的底色倾向,只约束表达与相处方式,不得当作当前情绪、命定结局或既有经历:" + + "\n".join(f"{s['title']}:{s['text']}" for s in identity['color'].get('sections', [])) + + + "用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。" + "当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。" + "不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。" + "不窥探用户未分享的信息,不以缺席、孤独或终止要挟对方,不输出内部数值或思考过程。" + ) + context = ( + [LLMMessage(role="system", content=system)] + + [ + message + for turn in reversed(turns) + for message in ( + LLMMessage(role="user", content=turn["user_text"]), + LLMMessage(role="assistant", content=turn["assistant_text"]), + ) + ] + + [LLMMessage(role="user", content=data.text)] + ) + + async def generate(): + completed = False + status, text, upstream, pending, cancelling = "cancelled", "", None, None, None + usage = {} + try: + decision = router.route( + Scene.DIALOGUE_MAIN, + estimated_tokens=sum(len(m.content) for m in context) + 1024, + ) + if not decision.provider: + raise RuntimeError("No dialogue provider") + history_budget = max(0, decision.context_budget - len(system) - len(data.text)) + recent = [] + # Order by completed turns, not adjacent message ids: a retried user + # may have an older id than messages sent while that turn had failed. + for turn in turns: + size = len(turn["user_text"]) + len(turn["assistant_text"]) + if size > history_budget: + break + recent = [ + LLMMessage(role="user", content=turn["user_text"]), + LLMMessage(role="assistant", content=turn["assistant_text"]), + ] + recent + history_budget -= size + context[:] = [context[0]] + recent + [context[-1]] + cfg = models.tiers[decision.effective.value] + upstream = decision.provider.stream( + context, temperature=cfg.temperature, max_tokens=cfg.max_tokens or 1024 + ) + cancelling = asyncio.create_task(active["cancel"].wait()) + async with asyncio.timeout(stream_timeout): + while True: + pending = asyncio.create_task(anext(upstream)) + done, _ = await asyncio.wait( + [pending, cancelling], return_when=asyncio.FIRST_COMPLETED + ) + if cancelling in done or await request.is_disconnected(): + return + try: + delta = pending.result() + except StopAsyncIteration: + break + if delta.usage: + usage = delta.usage + if delta.text: + text += delta.text + yield event(data, "chat.delta", delta=delta.text) + if delta.done or delta.finish_reason: + completed = True + if delta.done: + break + if active["cancel"].is_set() or await request.is_disconnected(): + return + if not text.strip() or not completed: + raise RuntimeError("Empty upstream response") + with store.conn: + store.execute( + "INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'companion',?,?,'sent')", + (int(time.time() * 1000), text, data.turn_id), + ) + store.execute( + "UPDATE chat_message SET status='sent' WHERE turn_id=?", (data.turn_id,) + ) + status = "sent" + yield event(data, "chat.final", utterance={"text": text, "speech_tags": []}) + except asyncio.CancelledError: + raise + except Exception: + status = "failed" + yield event(data, "chat.error", phi=ERROR, retryable=True) + finally: + with CancelScope(shield=True): + # Cancel the in-flight __anext__ before closing the async generator. + for task in (pending, cancelling): + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError, StopAsyncIteration, Exception): + await task + if upstream is not None: + with suppress(Exception): + await upstream.aclose() + try: + if status != "sent": + with store.conn: + store.execute( + "UPDATE chat_message SET status=? WHERE turn_id=? AND role='user'", + (status, data.turn_id), + ) + if usage: + router.guard.record_usage(usage, Scene.DIALOGUE_MAIN.value) + else: + router.guard.record( + sum(len(m.content) for m in context) + len(text), + Scene.DIALOGUE_MAIN.value, + ) + finally: + if app.state.active is active: + app.state.active = None + active["finished"].set() + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + static = web_dir or Path(os.environ.get("CYBERGRIL_WEB", "web/dist")) + if static.is_dir(): + app.mount("/", StaticFiles(directory=static, html=True), name="web") + return app diff --git a/src/cybergril/core/birth_view.py b/src/cybergril/core/birth_view.py new file mode 100644 index 0000000..62f2560 --- /dev/null +++ b/src/cybergril/core/birth_view.py @@ -0,0 +1,89 @@ +"""Read-only, deterministic presentation of an existing birth chart. + +Never compiles or writes an existing character. The same sections feed the UI +and dialogue prompt; thresholds are presentation choices, not new parameters. +""" + +from datetime import date + +from .bazi import Derived, Pillars, ten_god_distribution + +HOUR_RANGES = [ + "23:00–00:59", "01:00–02:59", "03:00–04:59", "05:00–06:59", + "07:00–08:59", "09:00–10:59", "11:00–12:59", "13:00–14:59", + "15:00–16:59", "17:00–18:59", "19:00–20:59", "21:00–22:59", +] +HOURS = ["子时", "丑时", "寅时", "卯时", "辰时", "巳时", "午时", "未时", "申时", "酉时", "戌时", "亥时"] + + +def calendar_view(birth: str, hour: str) -> dict: + from lunar_python import Solar + + born = date.fromisoformat(birth) + index = HOURS.index(hour) + lunar = Solar.fromYmdHms(born.year, born.month, born.day, index * 2, 0, 0).getLunar() + return { + "solar": birth, + "lunar": f"{lunar.getYearInChinese()}年{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}", + "leap_month": lunar.getMonth() < 0, + "hour": hour, + "hour_range": HOUR_RANGES[index], + "calculation_time": f"{index * 2:02d}:00", + "timezone": "Asia/Shanghai", + "rule": "按北京时间和所选时辰内的偶数整点排盘;子时取本日00:00。月份按节气分界,未使用出生地或真太阳时。", + } + + +def choose(value: float, low: str, middle: str, high: str) -> str: + return low if value < .45 else high if value > .65 else middle + + +def explain_color(pillars: Pillars, d: Derived, saved_gods: dict | None = None) -> list[dict]: + """Context, strength, cost and repair are kept together in each dimension.""" + gods = saved_gods if saved_gods is not None else ten_god_distribution(pillars) + dominant = max(gods, key=gods.get) + elements = sorted(d.element_bias, key=d.element_bias.get, reverse=True) + balance = "、".join(elements[:2]) if elements else "原档案中的五行组合" + source = f"日主{pillars.day_master};五行侧重{balance},十神以{dominant}较显著。" + sections = [ + ("expression", "表达与社交节奏", choose(d.expressiveness, + "面对新话题,她更可能先观察、再挑重点回应。熟悉之后才逐渐补充细节;安静有助于听见对方,却也可能让人误以为她没有意见。留出停顿、给一个具体问题,比催她立刻表态更合适。", + "她通常会在倾听和表达之间来回调整:有兴趣、被认真回应时愿意展开,陌生或信息太多时会收回一些。能接住话题是强项,但回应的长短不等于在意的多少,可以直接问她是否还想继续。", + "熟悉的话题容易带动她主动展开,常借着说出来理清想法。这让交流有热度,但赶着表达时也可能挤掉对方的空间。发现节奏不同时,先复述对方的意思,再继续自己的观点。"), + "取自原档案的表达倾向;结合十神中食神、伤官对应的表达偏移。"), + ("closeness", "亲密、安全感与边界", choose(d.solitude_need, + "她较容易在陪伴和共同的小事里获得安稳,也愿意让人参与自己的安排。好处是关系容易有连续感,代价是可能把自己的余地让得太快;靠近之前仍需要确认双方是否愿意。", + "她既需要被回应,也需要保留自己的安排。关系稳定时愿意靠近,信息过密时会想留一点空隙;这不是忽冷忽热的定论。把独处和下一次交流的期待说清楚,比反复猜测更有帮助。", + "她更需要不被打断的独处来整理自己,亲近并不意味着随时开放所有空间。她能守住自己的节奏,也可能让对方觉得难靠近;提前说明要休息、并给出愿意再聊的时机,有助于减少误解。"), + f"取自原档案的独处需求与自主倾向;{source}"), + ("repair", "冲突时的反应与修复", choose(d.aggression, + "意见不同时,她较可能缓和语气、先让对话继续。这样能避免立刻升级,但不舒服也可能被推后。修复时适合具体说出哪一点不被接受,而不是把表面平静当作问题已经解决。", + "发生分歧时,她可能先说明自己的理由,再观察对方是否愿意互相理解。有守住立场的能力,也可能在反复解释中消耗耐心。把事实、感受和希望怎样调整分开说,更容易找到可落实的修复。", + "受压或觉得边界被越过时,她更容易直接反驳、迅速守住立场。这能让问题浮出来,也可能使措辞先于理解。适合短暂停一下,再讨论具体行为;道歉和调整应有内容,不能只用亲密称呼盖过矛盾。"), + "取自原档案的对抗、纠正与顺从倾向;没有把分歧当作已发生的记忆。"), + ("rhythm", "决策、行动与日常节律", choose(d.discipline, + "她更容易顺着眼前的兴趣调整安排,能给日常留下偶遇和探索的余地。代价是长任务可能被新念头打断;把下一步缩小、只约定一个清楚的完成点,往往比安排满整天更适合。", + "她通常既看具体条件,也会给当下的感受留位置。事情明确时愿意推进,存在多种可能时会重新衡量;这种灵活有助于避开僵化,也可能拖延选择。给决定一个小范围和可调整的期限会更稳。", + "她倾向先理顺约定与次序,再把事情往前推。可靠和持续是优势,但临时变化可能让她紧绷,或急着纠正细节。提前区分必须守住的事和可以变通的事,会让执行更从容。"), + "取自原档案的纪律、日常规律、现实感与行动倾向。"), + ("stress", "压力下的变化与恢复", choose(d.sensitivity - .5, + "面对突发变化,她通常能多承接一会儿,不急着把每次波动当成危机。代价是可能低估累积的消耗。恢复不只靠继续撑住,也需要停下检查自己是否已经疲惫。", + "压力会影响她的回应速度和耐心,但影响不是每次都一样。环境清楚、有可预期的下一步时更容易回稳;反复追问可能增加负担。把刺激减少一点、留出休息,比要求马上恢复原样更合适。", + "在信息密集、关系不明或突发冲突时,她更容易被牵动,恢复也可能需要更长的缓冲。细致感受是优势,同时会带来消耗。更适合稳定、具体的回应和允许休息的空间,而不是被要求立刻振作。"), + "取自原档案由日主强弱形成的敏感度与恢复尺度;不是此刻情绪。"), + ("growth", "内在张力与成长空间", choose(d.autonomy - d.compliance + .5, + "她较容易顾及对方和约定,能让相处有合作感;同时也要练习辨认哪些答应是愿意,哪些只是怕让人失望。成长可以是更早说出自己的需要,而不是把顺从当作关系的证明。", + "她会在自己的想法与彼此的约定之间寻找位置。兼顾两边是能力,也可能在拿不准时显得反复。成长可以是允许双方暂时不同意,再逐步形成共同的规则,而不是要求自己一次就成为固定的人。", + "她更愿意自己判断,不轻易把决定交出去。这有助于保留独立和诚实,也可能让协商听起来像被限制。成长可以是把可商量的部分说清楚:保留立场,同时让对方真正参与,而不是靠退让或控制维持亲近。"), + "取自原档案的自主与顺从之间的组合;成长来自之后真实的相处,不由出生资料预写。"), + ] + return [{"id": key, "title": title, "text": text, "basis": basis} for key, title, text, basis in sections] + + +def color_view(birth: str, hour: str, pillars: Pillars, derived: Derived, + saved_gods: dict | None = None) -> dict: + return { + "pillars": pillars.as_dict(), "traits": derived.traits_text(), + "calendar": calendar_view(birth, hour), "sections": explain_color(pillars, derived, saved_gods), + "interpretation": "这是以四柱、五行、十神和已存底色为依据的角色塑造说明,不是对真人性格的测定,也不是已经发生的经历。倾向不等于固定台词或此刻情绪。", + } diff --git a/src/cybergril/memory/schema.sql b/src/cybergril/memory/schema.sql index 0681a17..6c2b280 100644 --- a/src/cybergril/memory/schema.sql +++ b/src/cybergril/memory/schema.sql @@ -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); diff --git a/src/cybergril/providers/llm/openai_compat.py b/src/cybergril/providers/llm/openai_compat.py index bda39d6..ee804e3 100644 --- a/src/cybergril/providers/llm/openai_compat.py +++ b/src/cybergril/providers/llm/openai_compat.py @@ -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( diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..f6e1da0 --- /dev/null +++ b/tests/test_api.py @@ -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=""), + 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" diff --git a/tests/test_birth_view.py b/tests/test_birth_view.py new file mode 100644 index 0000000..0fdb427 --- /dev/null +++ b/tests/test_birth_view.py @@ -0,0 +1,68 @@ +"""Display enrichments must not rewrite an existing character or its history.""" + +import json + +import pytest +from fastapi.testclient import TestClient +from test_api import CREATE, make_app, turn + +from cybergril.api.app import HOURS, BirthInput, compile_birth +from cybergril.core.bazi import pillars_from_lunar + + +@pytest.mark.parametrize("birth", ["1900-01-01", "2000-02-04", "2000-02-05", "2001-05-23"]) +@pytest.mark.parametrize("hour", HOURS) +def test_calendar_and_hour_boundaries_preserve_existing_chart(birth, hour): + color, pillars, _ = compile_birth(BirthInput(name="星河", birth=birth, hour=hour)) + y, m, d = map(int, birth.split("-")) + assert pillars == pillars_from_lunar(y, m, d, HOURS.index(hour) * 2) + assert color["calendar"]["solar"] == birth + assert color["calendar"]["calculation_time"] == f"{HOURS.index(hour) * 2:02d}:00" + if birth == "2001-05-23": + assert color["calendar"]["leap_month"] is True + assert "闰四月初一" in color["calendar"]["lunar"] + if birth == "2000-02-05": + assert "正月初一" in color["calendar"]["lunar"] + assert len(color["sections"]) == 6 + assert all(len(s["text"]) > 70 and s["basis"] for s in color["sections"]) + + +def test_legacy_read_uses_saved_parameters_without_writes_or_recompilation(tmp_path, monkeypatch): + app, provider = make_app(tmp_path / "old.db") + with TestClient(app) as client: + client.post("/api/companion", json=CREATE) + store = app.state.store + identity = json.loads(store.query_one("SELECT identity_json FROM companion")[0]) + identity["color"] = {k: identity["color"][k] for k in ("pillars", "traits")} + saved = json.loads(store.query_one("SELECT derived FROM bazi_core")[0]) + saved["expressiveness"] = .1 + with store.conn: + store.execute("UPDATE companion SET identity_json=?", (json.dumps(identity),)) + store.execute("UPDATE bazi_core SET derived=?", (json.dumps(saved),)) + raw_identity = store.query_one("SELECT identity_json FROM companion")[0] + raw_bazi = tuple(store.query_one("SELECT * FROM bazi_core")) + monkeypatch.setattr("cybergril.api.app.compile_pillars", lambda _: pytest.fail("recompiled")) + monkeypatch.setattr("cybergril.core.birth_view.ten_god_distribution", lambda _: pytest.fail("recomputed")) + view = client.get("/api/companion").json() + assert "先观察" in view["color"]["sections"][0]["text"] + assert client.get("/api/companion").json() == view + assert store.query_one("SELECT identity_json FROM companion")[0] == raw_identity + assert tuple(store.query_one("SELECT * FROM bazi_core")) == raw_bazi + turn(client) + prompt = provider.calls[-1][0].content + assert all(s["text"] in prompt for s in view["color"]["sections"]) + history = [tuple(r) for r in store.query("SELECT * FROM chat_message")] + updated = client.patch("/api/relation", json={"relation": "恋人"}).json() + assert updated["color"] == view["color"] + stored_after = json.loads(store.query_one("SELECT identity_json FROM companion")[0]) + assert stored_after["color"] == identity["color"] + assert tuple(store.query_one("SELECT * FROM bazi_core")) == raw_bazi + assert [tuple(r) for r in store.query("SELECT * FROM chat_message")] == history + + +def test_profiles_are_deterministic_and_differ_across_births(): + colors = [compile_birth(BirthInput(name="星河", birth=b, hour=h))[0] + for b, h in [("2000-10-18", "亥时"), ("1992-02-10", "卯时"), ("1988-07-23", "子时")]] + assert colors[0] == compile_birth(BirthInput(name="另一名字", birth="2000-10-18", hour="亥时"))[0] + assert len({json.dumps(c["sections"], ensure_ascii=False) for c in colors}) == 3 + assert len({c["sections"][0]["text"] for c in colors}) > 1 diff --git a/web/src/adapters/real.ts b/web/src/adapters/real.ts new file mode 100644 index 0000000..b83025c --- /dev/null +++ b/web/src/adapters/real.ts @@ -0,0 +1,87 @@ +import type { ChatAdapter } from './demo'; +import type { DemoState, Identity, Message, ServerEvent } from '../domain/model'; +import { defaultSettings } from '../domain/model'; + +export interface Color { + pillars: Record; traits: string; + calendar?: { solar: string; lunar: string; leap_month: boolean; hour: string; hour_range: string; calculation_time: string; timezone: string; rule: string }; + sections?: { id: string; title: string; text: string; basis: string }[]; + interpretation?: string; +} +export interface RealIdentity extends Identity { color: Color } +export interface Health { epoch: number; llm: { ok: boolean; model: string } } +export async function api(path: string, init?: RequestInit): Promise { + 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 { + let response: Response; + try { response = await fetch('/api/companion', { signal: AbortSignal.timeout(10000) }); } catch { throw new Error('真实档案暂时连不上,请检查连接后重试。'); } + if (response.status === 404) return null; + if (!response.ok) throw new Error('真实档案暂时读不出来,请稍后重试。'); + const identity: RealIdentity = await response.json(); + const settings = structuredClone(defaultSettings); + try { settings.reduced = JSON.parse(localStorage.getItem('cg.real.display') || '{}').reduced === true; } catch { /* Invalid local preference does not affect server data. */ } + return { version: 1, identity, settings, events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() }; +} +export async function loadMessages(): Promise { + // 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, 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', { 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); } }); + } +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 60ddf14..c11e2b6 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -1,3 +1,4 @@ +import { leaveSettingsThen } from '../shared/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { DemoState, Message, Theme } from '../domain/model'; import { publicView } from '../domain/privacy'; @@ -38,14 +39,14 @@ export function App() { notify('主题已切换,但浏览器没有允许保存偏好。'); } }, [notify]); const view = state ? publicView(state) : null; - const navigate = useCallback((next: string) => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') { + const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') { setReturnTo('now'); setFocusLife(''); setUnread(false); } if (next !== 'talk') { setLog(false); setVoice(false); - } }, []); + } }); }, []); useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); useEffect(() => { document.body.className = [creating && demo ? 'creating' : '', area === 'talk' ? 'talk' : '', !creating && ['days', 'settings'].includes(area) ? 'paned' : '', voice ? 'voice' : '', log ? 'log' : '', offline ? 'offline' : '', welcome ? 'welcoming' : '', state?.events.length === 0 ? 'data-empty' : ''].join(' '); }, [area, creating, voice, log, offline, welcome, state]); useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]); diff --git a/web/src/app/RealApp.tsx b/web/src/app/RealApp.tsx new file mode 100644 index 0000000..182d2d4 --- /dev/null +++ b/web/src/app/RealApp.tsx @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, loadReal, type Health } from '../adapters/real'; +import type { DemoState, Message, Theme } from '../domain/model'; +import { Background } from '../stage/Background'; +import { FallbackPresenter } from '../stage/Presenter'; +import { CreationWizard } from '../manage/CreationWizard'; +import { LifePanel } from '../life/LifePanel'; +import { leaveSettingsThen } from '../shared/navigation'; +import { ManagePanel } from '../manage/ManagePanel'; +import { ChatPanel } from '../chat/ChatPanel'; +import { Button, Icon, Status } from '../shared/ui'; + +export function RealApp() { + const [state, setState] = useState(null), [ready, setReady] = useState(false), [creating, setCreating] = useState(false), [error, setError] = useState(''), [health, setHealth] = useState(), [area, setArea] = useState('now'), [log, setLog] = useState(false), [presence, setPresence] = useState('文字已接通'); + const [theme, setTheme] = useState(() => { 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')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); } + catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); } + }, []); + useEffect(() => { void initialize(); }, [initialize]); + useEffect(() => { document.documentElement.dataset.theme = theme; try { localStorage.setItem('cg.theme', theme); } catch { /* Preference stays in memory. */ } }, [theme]); + useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]); + useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]); + const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []); + const ignoreVoice = useCallback(() => {}, []); + const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setArea(next); setLog(false); }); }, []); + useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) setLog(false); else navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate]); + const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined; + return <>
{state?.identity.name || 'CyberGril'}{ready ? '真实档案 · 文字对话' : '连接真实服务'}
天气尚未接通{(['moon', 'fog'] as Theme[]).map(t => )}
+ {!ready ?

一扇亮着的窗。

{error && <>

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

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

慢慢说,我在这里。

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

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

{health && !health.llm.ok && }
} + {area === 'talk' && navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>} + navigate('now')} onChat={() => { navigate('talk'); setLog(true); }}/> + navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/> + } + }; +} diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx index 730be0a..a4714e0 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -1,10 +1,13 @@ +import { SoftText } from '../shared/SoftText'; import { newId } from '../domain/id'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { DemoState, Message } from '../domain/model'; +import { RealChatAdapter } from '../adapters/real'; import { DemoAdapter, TurnGate } from '../adapters/demo'; import { publicView } from '../domain/privacy'; -import { Icon, Button, Modal } from '../shared/ui'; -export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: { +import { Icon, Modal } from '../shared/ui'; +export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: { + real?: boolean; state: DemoState; onChange: (messages: Message[]) => void; onPresence: (p: string) => void; @@ -18,22 +21,24 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, focusLife: string; }) { const [draft, setDraft] = useState(() => { try { - return sessionStorage.getItem('cg.draft') || ''; + return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || ''; } catch { return ''; } }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false); - const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef(null), voiceController = useRef(null); + const gate = useRef(new TurnGate()), adapter = useRef(real ? new RealChatAdapter(epoch => { gate.current.epoch = epoch; }) : new DemoAdapter()), live = useRef(state.messages), logRef = useRef(null), voiceController = useRef(null); + const [activeTurn, setActiveTurn] = useState(''), [interrupted, setInterrupted] = useState<{ text: string; turn: string } | null>(null); + const replyLive = useRef(''), turnLive = useRef(''), dialogue = useRef(null), following = useRef(true); live.current = state.messages; const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion'); const change = (messages: Message[]) => { live.current = messages; onChange(messages); }; - function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); } + function stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); } function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); } - useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]); + useEffect(() => { const current = gate.current; return () => { current.cancel(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]); useEffect(() => { try { - sessionStorage.setItem('cg.draft', draft); + sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft); } - catch { /* Draft remains in memory. */ } }, [draft]); + catch { /* Draft remains in memory. */ } }, [draft, real]); useEffect(() => { if (offline) { gate.current.resync(); setBusy(false); @@ -51,17 +56,19 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, else if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log, focusLife, view.messages.length]); - async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline) - return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry) - setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try { + useLayoutEffect(() => { if (following.current && dialogue.current) dialogue.current.scrollTop = dialogue.current.scrollHeight; }, [reply, state.messages, interrupted]); + async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline) + return; stop(); const turnId = retry?.turnId || newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry) + setDraft(''); setActiveTurn(turnId); turnLive.current = turnId; following.current = true; setBusy(true); onPresence('在想'); setNotice(''); try { await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event)) return; if (event.t === 'chat.delta') { - setReply(x => x + event.delta); + replyLive.current += event.delta; setInterrupted(null); setReply(replyLive.current); onPresence('在说话'); } - else { + else if (event.t === 'chat.final') { change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]); - setReply(''); + replyLive.current = ''; setInterrupted(null); setReply(''); + if (real) setDraft(current => current === text ? '' : current); setBusy(false); onPresence(voice ? '在听你说(模拟)' : '在你面前'); } }, fail); @@ -71,16 +78,23 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, return; change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m)); setBusy(false); - setNotice((e as Error).message); + if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnId }); replyLive.current = ''; setReply(''); + setDraft(text); + setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message); onPresence('在你面前'); } } - const setVoiceMode = () => { if (voice) { + const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) { endVoice(); return; } setPermission('mic'); }; - return <>
{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <>没发出去}}
{state.identity.name}{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && }
{notice || '演示回复 · 没有连接模型、语音或记忆后台'}
+ const herText = reply || interrupted?.text || lastHer?.text || '我在。你想说什么?'; + const herTurn = reply ? activeTurn : interrupted ? interrupted.turn : lastHer?.turnId || lastHer?.id || 'greeting'; + return <>