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/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..a55e59a --- /dev/null +++ b/src/cybergril/api/app.py @@ -0,0 +1,500 @@ +"""Single-process R1 API. Store is authoritative; no demo state enters this module.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sqlite3 +import time +from contextlib import asynccontextmanager, suppress +from dataclasses import asdict +from datetime import date, datetime +from pathlib import Path +from typing import Literal +from uuid import UUID +from zoneinfo import ZoneInfo + +import yaml +from anyio import CancelScope +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict, field_validator + +from ..config import ModelsConfig +from ..contracts import LLMMessage +from ..core.bazi import ( + GAN_ELEM, + compile_pillars, + element_scores, + pillars_from_lunar, + ten_god_distribution, +) +from ..memory.store import Store +from ..think.scenes import Scene +from ..think.wiring import build_router_from_config + +HOURS = [ + "子时", + "丑时", + "寅时", + "卯时", + "辰时", + "巳时", + "午时", + "未时", + "申时", + "酉时", + "戌时", + "亥时", +] +ERROR = "她这会儿没接上话,稍后再试试。你写的话还在。" + + +def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str: + value = value.strip() + if ( + (required and not value) + or len(value) > maximum + or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value) + ): + raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。") + return value + + +class StrictBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + +class BirthInput(StrictBody): + name: str + birth: str + hour: str + + @field_validator("name") + @classmethod + def name_valid(cls, value: str) -> str: + return safe_text(value, 20, "名字", True) + + @field_validator("birth") + @classmethod + def birth_valid(cls, value: str) -> str: + try: + born = date.fromisoformat(value) + today = datetime.now(ZoneInfo("Asia/Shanghai")).date() + age = today.year - born.year - ((today.month, today.day) < (born.month, born.day)) + if born.isoformat() != value or born.year < 1900 or age < 18: + raise ValueError + except ValueError as exc: + raise ValueError("请填写有效的出生日期,且已满18岁(不早于1900年)。") from exc + return value + + @field_validator("hour") + @classmethod + def hour_valid(cls, value: str) -> str: + if value not in HOURS: + raise ValueError("请选择有效的出生时辰。") + return value + + +class RelationInput(StrictBody): + relation: Literal["朋友", "恋人"] + nickname: str = "" + boundary: str = "尊重彼此的节奏" + + @field_validator("nickname") + @classmethod + def nickname_valid(cls, value: str) -> str: + return safe_text(value, 40, "称呼") + + @field_validator("boundary") + @classmethod + def boundary_valid(cls, value: str) -> str: + return safe_text(value, 2000, "相处边界") + + +class CreationInput(BirthInput, RelationInput): + pass + + +class ChatInput(StrictBody): + text: str + turn_id: str + epoch: int + + @field_validator("text") + @classmethod + def text_valid(cls, value: str) -> str: + if not value.strip() or len(value) > 4000: + raise ValueError("请输入1–4000字的消息。") + return value.strip() + + @field_validator("turn_id") + @classmethod + def turn_valid(cls, value: str) -> str: + try: + UUID(value) + except ValueError as exc: + raise ValueError("轮次标识无效,请刷新后重试。") from exc + return value + + +def compile_birth(data: BirthInput) -> tuple[dict, object, object]: + born = date.fromisoformat(data.birth) + pillars = pillars_from_lunar(born.year, born.month, born.day, HOURS.index(data.hour) * 2) + derived = compile_pillars(pillars) + return {"pillars": asdict(pillars), "traits": derived.traits_text()}, pillars, derived + + +def create_app( + *, + db_path: Path | str | None = None, + models: ModelsConfig | None = None, + router=None, + web_dir: Path | None = None, + stream_timeout: float = 60, +) -> FastAPI: + """Dependencies can be injected for isolated fault/cancellation tests, never via browser flags.""" + db_path = db_path or os.environ.get( + "CYBERGRIL_DB", str(Path.home() / ".local/share/cybergril/companion.db") + ) + if models is None: + config = Path(os.environ.get("CYBERGRIL_MODELS", "config/models.yaml")) + models = ModelsConfig(**yaml.safe_load(config.read_text())) + router = router or build_router_from_config(models) + + @asynccontextmanager + async def lifespan(app): + store = Store(db_path) + app.state.store = store + with store.conn: + store.execute( + "INSERT INTO api_meta(key,value) VALUES('epoch',1) ON CONFLICT(key) DO UPDATE SET value=value+1" + ) + store.execute("UPDATE chat_message SET status='cancelled' WHERE status='pending'") + app.state.epoch = store.query_one("SELECT value FROM api_meta WHERE key='epoch'")["value"] + app.state.active = None + app.state.chat_lock = asyncio.Lock() + app.state.health_cache = (0, False) + app.state.health_lock = asyncio.Lock() + try: + yield + finally: + if app.state.active: + app.state.active["cancel"].set() + await app.state.active["finished"].wait() + for provider in set(router.provider_for(t) for t in router.available_tiers()): + await provider.aclose() + store.close() + + app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None) + + @app.exception_handler(RequestValidationError) + async def invalid(_request, exc): + fields = {} + for e in exc.errors(): + key = str(e["loc"][-1]) + msg = e["msg"].removeprefix("Value error, ") + if e["type"] == "missing": + msg = "请填写此项。" if key != "relation" else "请亲自选择你们的关系。" + elif e["type"] == "literal_error": + msg = "请亲自选择朋友或恋人。" + elif e["type"] == "extra_forbidden": + msg = "此字段不允许修改。" + elif not any("\u4e00" <= c <= "\u9fff" for c in msg): + msg = "字段格式无效,请检查后重试。" + fields[key] = msg + return JSONResponse({"detail": "请检查填写内容。", "fields": fields}, status_code=422) + + def companion(): + row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1") + if not row: + raise HTTPException(404, "还没有创建真实伴侣。") + return json.loads(row["identity_json"]) + + @app.get("/api/health") + async def health(): + async with app.state.health_lock: + checked, ok = app.state.health_cache + if time.monotonic() - checked > 30: + provider = router.route(Scene.DIALOGUE_MAIN).provider + try: + async with asyncio.timeout(5): + ok = bool(provider and (await provider.health()).ok) + except Exception: + ok = False + app.state.health_cache = (time.monotonic(), ok) + return { + "ok": True, + "version": "R1", + "epoch": app.state.epoch, + "companion_created": bool(app.state.store.query_one("SELECT id FROM companion")), + "llm": {"ok": ok, "model": models.tiers["high"].model}, + "capabilities": { + "text": True, + **dict.fromkeys( + ["life", "memory", "voice", "vision", "weather", "materials", "backup"], False + ), + }, + } + + @app.get("/api/companion") + async def get_companion(): + return companion() + + @app.post("/api/companion/preview") + async def preview(data: BirthInput): + return compile_birth(data)[0] + + @app.post("/api/companion", status_code=201) + async def create(data: CreationInput): + store = app.state.store + if store.query_one("SELECT id FROM companion"): + raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") + color, pillars, derived = compile_birth(data) + identity = {"id": "1", **data.model_dump(), "color": color} + try: + with store.conn: + store.execute( + "INSERT INTO companion VALUES(1,?)", (json.dumps(identity, ensure_ascii=False),) + ) + store.execute( + """INSERT INTO bazi_core + (id,birth_input,pillars,day_master,day_master_elem,ten_gods,element_scores,strength,derived,created_at) + VALUES(1,?,?,?,?,?,?,?,?,?)""", + ( + json.dumps({"birth": data.birth, "hour": data.hour, "tz": "Asia/Shanghai"}), + json.dumps(asdict(pillars), ensure_ascii=False), + pillars.day[0], + GAN_ELEM[pillars.day[0]], + json.dumps(ten_god_distribution(pillars)), + json.dumps(element_scores(pillars)), + derived.strength, + json.dumps(derived.as_dict()), + int(time.time() * 1000), + ), + ) + except sqlite3.IntegrityError as exc: + raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") from exc + return identity + + @app.patch("/api/relation") + async def relation(data: RelationInput): + identity = companion() + identity.update(data.model_dump()) + with app.state.store.conn: + app.state.store.execute( + "UPDATE companion SET identity_json=? WHERE id=1", + (json.dumps(identity, ensure_ascii=False),), + ) + return identity + + @app.get("/api/messages") + async def messages( + limit: int = Query(200, ge=1, le=200), before: int | None = Query(None, ge=1) + ): + companion() + rows = app.state.store.query( + "SELECT * FROM chat_message WHERE id limit + rows = rows[:limit] + return { + "messages": [dict(r) for r in reversed(rows)], + "next_before": rows[-1]["id"] if more else None, + "epoch": app.state.epoch, + } + + def event(data, kind, **payload): + return ( + "data: " + + json.dumps( + {"t": kind, "epoch": app.state.epoch, "turn_id": data.turn_id, **payload}, + ensure_ascii=False, + ) + + "\n\n" + ) + + @app.post("/api/chat") + async def chat(data: ChatInput, request: Request): + identity = companion() + if data.epoch != app.state.epoch: + return JSONResponse( + {"detail": "服务已重新启动,请同步后重试。", "epoch": app.state.epoch}, + status_code=409, + ) + store = app.state.store + # Serialize reservation, including replays. Concurrent retries of a completed + # turn must never call the model twice or race the unique message constraint. + async with app.state.chat_lock: + previous = store.query_one( + "SELECT * FROM chat_message WHERE turn_id=? AND role='user'", (data.turn_id,) + ) + if previous and previous["text"] != data.text: + raise HTTPException(409, "相同轮次不能改写消息。") + if app.state.active: + active = app.state.active + if active["turn_id"] != data.turn_id: + active["cancel"].set() + await active["finished"].wait() + saved = store.query_one( + "SELECT text FROM chat_message WHERE turn_id=? AND role='companion' AND status='sent'", + (data.turn_id,), + ) + if saved: + + async def replay(): + yield event( + data, "chat.final", utterance={"text": saved["text"], "speech_tags": []} + ) + + return StreamingResponse(replay(), media_type="text/event-stream") + active = { + "cancel": asyncio.Event(), + "finished": asyncio.Event(), + "turn_id": data.turn_id, + } + app.state.active = active + with store.conn: + store.execute( + "INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'user',?,?,'pending') ON CONFLICT(turn_id,role) DO UPDATE SET status='pending'", + (int(time.time() * 1000), data.text, data.turn_id), + ) + turns = store.query("""SELECT u.text AS user_text, a.text AS assistant_text + FROM chat_message a JOIN chat_message u ON u.turn_id=a.turn_id AND u.role='user' + WHERE a.role='companion' AND a.status='sent' AND u.status='sent' + ORDER BY a.id DESC LIMIT 20""") + system = ( + f"你叫{identity['name']},与对方是{identity['relation']}。称呼对方:{identity['nickname'] or '你'}。" + f"相处边界:{identity['boundary']}。出生底色仅供措辞参考:{identity['color']['traits']}。" + "用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。" + "当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。" + "不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。" + "不窥探用户未分享的信息,不以缺席、孤独或终止要挟对方,不输出内部数值或思考过程。" + ) + context = ( + [LLMMessage(role="system", content=system)] + + [ + message + for turn in reversed(turns) + for message in ( + LLMMessage(role="user", content=turn["user_text"]), + LLMMessage(role="assistant", content=turn["assistant_text"]), + ) + ] + + [LLMMessage(role="user", content=data.text)] + ) + + async def generate(): + completed = False + status, text, upstream, pending, cancelling = "cancelled", "", None, None, None + usage = {} + try: + decision = router.route( + Scene.DIALOGUE_MAIN, + estimated_tokens=sum(len(m.content) for m in context) + 1024, + ) + if not decision.provider: + raise RuntimeError("No dialogue provider") + history_budget = max(0, decision.context_budget - len(system) - len(data.text)) + recent = [] + # Order by completed turns, not adjacent message ids: a retried user + # may have an older id than messages sent while that turn had failed. + for turn in turns: + size = len(turn["user_text"]) + len(turn["assistant_text"]) + if size > history_budget: + break + recent = [ + LLMMessage(role="user", content=turn["user_text"]), + LLMMessage(role="assistant", content=turn["assistant_text"]), + ] + recent + history_budget -= size + context[:] = [context[0]] + recent + [context[-1]] + cfg = models.tiers[decision.effective.value] + upstream = decision.provider.stream( + context, temperature=cfg.temperature, max_tokens=cfg.max_tokens or 1024 + ) + cancelling = asyncio.create_task(active["cancel"].wait()) + async with asyncio.timeout(stream_timeout): + while True: + pending = asyncio.create_task(anext(upstream)) + done, _ = await asyncio.wait( + [pending, cancelling], return_when=asyncio.FIRST_COMPLETED + ) + if cancelling in done or await request.is_disconnected(): + return + try: + delta = pending.result() + except StopAsyncIteration: + break + if delta.usage: + usage = delta.usage + if delta.text: + text += delta.text + yield event(data, "chat.delta", delta=delta.text) + if delta.done or delta.finish_reason: + completed = True + if delta.done: + break + if active["cancel"].is_set() or await request.is_disconnected(): + return + if not text.strip() or not completed: + raise RuntimeError("Empty upstream response") + with store.conn: + store.execute( + "INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'companion',?,?,'sent')", + (int(time.time() * 1000), text, data.turn_id), + ) + store.execute( + "UPDATE chat_message SET status='sent' WHERE turn_id=?", (data.turn_id,) + ) + status = "sent" + yield event(data, "chat.final", utterance={"text": text, "speech_tags": []}) + except asyncio.CancelledError: + raise + except Exception: + status = "failed" + yield event(data, "chat.error", phi=ERROR, retryable=True) + finally: + with CancelScope(shield=True): + # Cancel the in-flight __anext__ before closing the async generator. + for task in (pending, cancelling): + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError, StopAsyncIteration, Exception): + await task + if upstream is not None: + with suppress(Exception): + await upstream.aclose() + try: + if status != "sent": + with store.conn: + store.execute( + "UPDATE chat_message SET status=? WHERE turn_id=? AND role='user'", + (status, data.turn_id), + ) + if usage: + router.guard.record_usage(usage, Scene.DIALOGUE_MAIN.value) + else: + router.guard.record( + sum(len(m.content) for m in context) + len(text), + Scene.DIALOGUE_MAIN.value, + ) + finally: + if app.state.active is active: + app.state.active = None + active["finished"].set() + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + static = web_dir or Path(os.environ.get("CYBERGRIL_WEB", "web/dist")) + if static.is_dir(): + app.mount("/", StaticFiles(directory=static, html=True), name="web") + return app 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/web/src/adapters/real.ts b/web/src/adapters/real.ts new file mode 100644 index 0000000..0260309 --- /dev/null +++ b/web/src/adapters/real.ts @@ -0,0 +1,80 @@ +import type { ChatAdapter } from './demo'; +import type { DemoState, Identity, Message, ServerEvent } from '../domain/model'; +import { defaultSettings } from '../domain/model'; + +export interface Color { pillars: Record; traits: 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(); + return { version: 1, identity, settings: structuredClone(defaultSettings), 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/RealApp.tsx b/web/src/app/RealApp.tsx new file mode 100644 index 0000000..e64916c --- /dev/null +++ b/web/src/app/RealApp.tsx @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, loadReal, type Health } from '../adapters/real'; +import type { DemoState, Message, Theme } from '../domain/model'; +import { Background } from '../stage/Background'; +import { FallbackPresenter } from '../stage/Presenter'; +import { CreationWizard } from '../manage/CreationWizard'; +import { ManagePanel } from '../manage/ManagePanel'; +import { ChatPanel } from '../chat/ChatPanel'; +import { Button, Icon, Status } from '../shared/ui'; + +export function RealApp() { + const [state, setState] = useState(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(() => {}, []); + function navigate(next: string) { setArea(next); setLog(false); } + return <>
{state?.identity.name || 'CyberGril'}{ready ? '真实档案 · 文字对话' : '连接真实服务'}
天气尚未接通{(['moon', 'fog'] as Theme[]).map(t => )}
+ {!ready ?

一扇亮着的窗。

{error && <>

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

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

慢慢说,我在这里。

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

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

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

日子与回忆

尚未接通

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

} + 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..dbbe214 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -1,10 +1,12 @@ import { newId } from '../domain/id'; import { useEffect, useRef, useState } from 'react'; import type { DemoState, Message } from '../domain/model'; +import { RealChatAdapter } from '../adapters/real'; import { DemoAdapter, TurnGate } from '../adapters/demo'; import { publicView } from '../domain/privacy'; import { Icon, Button, Modal } from '../shared/ui'; -export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: { +export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: { + real?: boolean; state: DemoState; onChange: (messages: Message[]) => void; onPresence: (p: string) => void; @@ -18,22 +20,22 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, focusLife: string; }) { const [draft, setDraft] = useState(() => { try { - return sessionStorage.getItem('cg.draft') || ''; + return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || ''; } catch { return ''; } }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false); - const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef(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); live.current = state.messages; const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion'); const change = (messages: Message[]) => { live.current = messages; onChange(messages); }; function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); } function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); } - useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]); + useEffect(() => { const current = gate.current; return () => { current.cancel(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]); useEffect(() => { try { - sessionStorage.setItem('cg.draft', draft); + sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft); } - catch { /* Draft remains in memory. */ } }, [draft]); + catch { /* Draft remains in memory. */ } }, [draft, real]); useEffect(() => { if (offline) { gate.current.resync(); setBusy(false); @@ -51,17 +53,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, else if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log, focusLife, view.messages.length]); - async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline) - return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry) + async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline) + return; stop(); const turnId = retry?.turnId || newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry) setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try { await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event)) return; if (event.t === 'chat.delta') { setReply(x => x + event.delta); onPresence('在说话'); } - else { + else if (event.t === 'chat.final') { change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]); setReply(''); + if (real) setDraft(current => current === text ? '' : current); setBusy(false); onPresence(voice ? '在听你说(模拟)' : '在你面前'); } }, fail); @@ -71,16 +74,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, return; change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m)); setBusy(false); - setNotice((e as Error).message); + setReply(''); + setDraft(text); + setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message); onPresence('在你面前'); } } - const setVoiceMode = () => { if (voice) { + const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) { endVoice(); return; } setPermission('mic'); }; - return <>
{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <>没发出去}}
{state.identity.name}{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && }
{notice || '演示回复 · 没有连接模型、语音或记忆后台'}
+ return <>
{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <>没发出去}}
{state.identity.name}{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && }
{notice || (real ? '真实文字对话 · 生活与长期记忆尚未接通' : '演示回复 · 没有连接模型、语音或记忆后台')}
{voice &&
{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}
} -