HEL-780: connect persistent companion and real streaming text chat
Co-authored-by: multica-agent <github@multica.ai>
@@ -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
|
||||
|
||||
@@ -48,3 +48,5 @@ models/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.venv311/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
HEL-780 · 8310独立部署/回滚材料(总工执行,尚未部署)
|
||||
|
||||
1. 审核确切任务分支和提交,复核8310空闲;保持8300和其他既有服务不动。建立 `/opt/cybergril-api/releases/<release>/`,构建生产前端和独立Python≥3.11虚拟环境,按 IMPLEMENTATION 安装。服务账号示例为 cybergril,由总工按主机现有规范准备,不默认root。
|
||||
2. 准备代码目录外的 `/opt/cybergril-data/`,仅服务账号可写。首次上线保持数据库没有真实档案,不运行创建POST、不替用户创建。已有数据升级前,停止该API,用 SQLite backup API 做一致快照至独立回滚目录;禁止只复制正在写入中的主DB文件而漏WAL。
|
||||
3. 将 `current` 指向已审核release,审查 `deploy/cybergril-api.service.example` 后再安装服务。模板限制单进程,端口8310,持久数据目录独立;模板不改防火墙、证书或反向代理。按现场systemd版本核对保护项和目录权限。uvicorn直连SSE无代理缓冲;若后续接代理,需另行配置关闭SSE缓冲。
|
||||
4. 启动前确认 `web/dist/index.html`、models配置、虚拟环境可读和数据目录可写。启动时自动执行幂等增量建表、epoch加一和pending取消。使用 `GET /api/health`、`GET /api/companion`(首装预期404)与普通页面做无副作用检查;模型健康异常先记录,不改共享网关、不循环调参。不要以调用preview之外的写接口作为上线健康检查。
|
||||
5. 审核通过后由总工记录服务URL、release、SHA、`journalctl -u cybergril-api` 日志位置,再交用户创建。普通入口 `/`,演示 `/?demo=1`。本轮不切换8300入口,不合并、不部署。
|
||||
|
||||
回滚:
|
||||
|
||||
- 停止 `cybergril-api`,记录当时release与数据快照;把 current 切回上一已审核release,重新启动。数据目录保持原样,不随release回退或删除。R1迁移仅增表,保留旧表;每次重新启动epoch递增。回滚到纯前端版本时真实API将不可用,应明确告知,服务器数据仍保留。
|
||||
- 数据恢复与代码回滚分别处理。需恢复快照时先再次备份当前数据,由总管确认可接受的数据时间点,再由总工停服恢复;禁止把本地测试库覆盖用户库。
|
||||
- 取消部署的清理只针对新建8310服务及明确release;不删除 `/opt/cybergril-data/`,不停止8300或其他项目服务。
|
||||
|
||||
限制:单用户内网、无新增鉴权体系;应沿既有内网访问边界使用。尚无产品级备份功能,本文的升级快照属于部署运维操作;真实页面不会声称已自动备份。SQLite单worker是当前约束,后续扩展需独立设计。
|
||||
@@ -0,0 +1,64 @@
|
||||
HEL-780 · R1 实现与安装运行说明
|
||||
|
||||
基准为 main `498429399812a0776dadd4b56662d31b455a9ea5`,保留原前端两主题与统一样式,未增加 CSS 或运行中的服务。代码审核后由总工决定部署;施工阶段未登录部署服务器、未占用8310,也未修改8300、模型服务或用户档案。
|
||||
|
||||
- `src/cybergril/api/app.py`:单进程 FastAPI 工厂;字段中文复验、无副作用真实排盘预览、唯一档案、只允许关系/称呼/边界修改、历史游标分页和 SSE。复用 `Store`、八字编译器、`ModelRouter` 与 `OpenAICompatLLM`。
|
||||
- `memory/schema.sql`:只增 `companion`、`api_meta`、`chat_message` 及索引;既有八字/生活/记忆/关系表保留。WAL;启动自动加 epoch,并把上次意外退出遗留的 pending 标记 cancelled。
|
||||
- `web/src/adapters/real.ts`、`app/RealApp.tsx`:普通入口只读服务器数据,404首建;复用创建、聊天、管理和人物静帧组件。真实入口不读/写 `cybergril.demo.v1`,草稿使用独立 `cg.real.draft`。`?demo=1` 仍由原 App 与 DemoAdapter 处理,零 API 请求。
|
||||
- 创建第二步通过 `POST /api/companion/preview` 排盘,不写档案;仅最终确认 POST 才在一个事务内写唯一档案和八字。SQLite 单行主键与事务保证并发只成功一次。生辰按 Asia/Shanghai、公历、1900年起、满18岁复验;时辰映射为对应双小时内偶数点,不引入出生地点/真太阳时规则。
|
||||
- SSE 使用同一 `turn_id` 和持久 `epoch`。前端每次发送先同步 epoch,并可处理发送前恰逢重启的409。轮次门槛继续拒绝旧事件;取消/退出直接 abort;服务端取消并等待上游结束后才开始新轮。响应缺少完成标志、空回答、异常或60秒超时均作为失败。部分回答丢弃,用户消息标 failed/cancelled,失败与取消的轮次不进下一轮上下文。成功回答可按同一 turn_id 回放,重试不会重复用户记录或重新生成已完成回答;并发相同轮次也只生成一次。
|
||||
- 模型三档均使用批准网关与模型,保留原 temperature、provider timeout 与预算。dialogue_main 路由限制最近20轮,再按档位上下文预算保留完整近轮;不截断当前用户消息。每轮最多默认1024输出 token,usage 缺失时按字符数保守估算计账,沿用原预算降档策略。原 BudgetGuard 的预算是进程内运维量,重启重置;不是持久化费用账本。
|
||||
|
||||
API 契约:
|
||||
|
||||
| API | 成功/错误 |
|
||||
|---|---|
|
||||
| GET `/api/health` | 200;epoch、是否已创建、模型探测与能力声明。模型 `/models` 2xx且包含配置模型才为真;5秒上限、30秒缓存、并发探测合并;不返回地址或凭据 |
|
||||
| GET `/api/companion` | 200档案(含只读四柱/白话),404未创建 |
|
||||
| POST `/api/companion/preview` | `{name,birth,hour}`→只读底色,422中文字段错误 |
|
||||
| POST `/api/companion` | `{name,birth,hour,relation,nickname?}`→201,409已有档案,422字段错误;拒绝额外字段 |
|
||||
| PATCH `/api/relation` | `{relation,nickname?,boundary?}`→更新后档案;404/422;无任何生辰/底色修改入口 |
|
||||
| GET `/api/messages?limit=200&before=<id>` | 升序 messages、next_before、epoch;每页1–200条;按id稳定排序;前端逐页恢复 |
|
||||
| POST `/api/chat` | `{text,turn_id,epoch}`;UUID turn_id;1–4000字;SSE chat.delta/final/error;404/409/422。chat.error带中文phi和retryable |
|
||||
|
||||
已接通:真实唯一档案、排盘、文字流式对话、服务器聊天历史、关系修改。尚未接通:生活循环、长期记忆检索/整合、主动消息、语音、视觉、天气、素材包与服务器备份。真实页面明确不可用,不填示例经历或伪造执行结果。当前只有近轮对话上下文,不把它说成长期记忆。
|
||||
|
||||
安装(Python ≥3.11;隔离环境,Node/npm用于构建):
|
||||
|
||||
```bash
|
||||
python3.11 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.lock
|
||||
.venv/bin/pip install --no-deps .
|
||||
npm ci --prefix web
|
||||
npm run build --prefix web
|
||||
```
|
||||
|
||||
`requirements.lock` 固定本轮已解析的运行时依赖;开发检查另装 `pip install -e '.[dev]'`。默认 Python 3.10 不满足项目要求。服务不自动读取 `.env`,变量需由启动环境或 systemd 提供:
|
||||
|
||||
```bash
|
||||
export CYBERGRIL_DB=/absolute/separate/test-data/companion.db
|
||||
export CYBERGRIL_HOST=127.0.0.1
|
||||
export CYBERGRIL_PORT=18310
|
||||
.venv/bin/python -m cybergril.api
|
||||
```
|
||||
|
||||
从仓库根目录启动;默认模型配置 `config/models.yaml`、静态产物 `web/dist`,可分别通过 `CYBERGRIL_MODELS` / `CYBERGRIL_WEB` 指定绝对路径。本地默认数据库在用户 `.local/share/cybergril/`,生产必须显式指定独立数据目录。**单进程单worker,不启用reload或多worker。** 8310由总工部署时使用,本地测试脚本使用随机loopback端口、临时数据库并在结束时关闭服务。
|
||||
|
||||
自测:
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest -q
|
||||
.venv/bin/ruff check src/cybergril/api src/cybergril/providers/llm/openai_compat.py tests/test_api.py
|
||||
npm run typecheck --prefix web
|
||||
npm run lint --prefix web
|
||||
npm test --prefix web
|
||||
npm run build --prefix web
|
||||
# 下列为可选浏览器工具,只装在测试虚拟环境:
|
||||
.venv/bin/pip install playwright
|
||||
.venv/bin/python -m playwright install chromium
|
||||
.venv/bin/python web/tests/real_browser_check.py
|
||||
.venv/bin/python web/tests/browser_check.py 1440
|
||||
.venv/bin/python web/tests/browser_check.py 390
|
||||
```
|
||||
|
||||
浏览器脚本启动/关闭自己的HTTP服务。真实脚本只向批准网关发两轮完整对话和一轮中途取消;随后切同源隔离桩验证失败、重启和立即新轮,结果区分真实模型与桩。环境若需额外浏览器运行库,应使用现有用户级库,不改系统/GPU环境。本轮复用 `/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu` 设置 LD_LIBRARY_PATH;它不是部署依赖。
|
||||
@@ -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和共享模型服务,交总工独立审核。
|
||||
@@ -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。当前单用户内网、单进程;近轮上下文与进程内预算不是长期记忆或持久费用账本。
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 713 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""R1 same-origin persistent text service."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Single-process R1 API. Store is authoritative; no demo state enters this module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import asdict
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
from anyio import CancelScope
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from ..config import ModelsConfig
|
||||
from ..contracts import LLMMessage
|
||||
from ..core.bazi import (
|
||||
GAN_ELEM,
|
||||
compile_pillars,
|
||||
element_scores,
|
||||
pillars_from_lunar,
|
||||
ten_god_distribution,
|
||||
)
|
||||
from ..memory.store import Store
|
||||
from ..think.scenes import Scene
|
||||
from ..think.wiring import build_router_from_config
|
||||
|
||||
HOURS = [
|
||||
"子时",
|
||||
"丑时",
|
||||
"寅时",
|
||||
"卯时",
|
||||
"辰时",
|
||||
"巳时",
|
||||
"午时",
|
||||
"未时",
|
||||
"申时",
|
||||
"酉时",
|
||||
"戌时",
|
||||
"亥时",
|
||||
]
|
||||
ERROR = "她这会儿没接上话,稍后再试试。你写的话还在。"
|
||||
|
||||
|
||||
def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str:
|
||||
value = value.strip()
|
||||
if (
|
||||
(required and not value)
|
||||
or len(value) > maximum
|
||||
or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value)
|
||||
):
|
||||
raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。")
|
||||
return value
|
||||
|
||||
|
||||
class StrictBody(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
|
||||
class BirthInput(StrictBody):
|
||||
name: str
|
||||
birth: str
|
||||
hour: str
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_valid(cls, value: str) -> str:
|
||||
return safe_text(value, 20, "名字", True)
|
||||
|
||||
@field_validator("birth")
|
||||
@classmethod
|
||||
def birth_valid(cls, value: str) -> str:
|
||||
try:
|
||||
born = date.fromisoformat(value)
|
||||
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
if born.isoformat() != value or born.year < 1900 or age < 18:
|
||||
raise ValueError
|
||||
except ValueError as exc:
|
||||
raise ValueError("请填写有效的出生日期,且已满18岁(不早于1900年)。") from exc
|
||||
return value
|
||||
|
||||
@field_validator("hour")
|
||||
@classmethod
|
||||
def hour_valid(cls, value: str) -> str:
|
||||
if value not in HOURS:
|
||||
raise ValueError("请选择有效的出生时辰。")
|
||||
return value
|
||||
|
||||
|
||||
class RelationInput(StrictBody):
|
||||
relation: Literal["朋友", "恋人"]
|
||||
nickname: str = ""
|
||||
boundary: str = "尊重彼此的节奏"
|
||||
|
||||
@field_validator("nickname")
|
||||
@classmethod
|
||||
def nickname_valid(cls, value: str) -> str:
|
||||
return safe_text(value, 40, "称呼")
|
||||
|
||||
@field_validator("boundary")
|
||||
@classmethod
|
||||
def boundary_valid(cls, value: str) -> str:
|
||||
return safe_text(value, 2000, "相处边界")
|
||||
|
||||
|
||||
class CreationInput(BirthInput, RelationInput):
|
||||
pass
|
||||
|
||||
|
||||
class ChatInput(StrictBody):
|
||||
text: str
|
||||
turn_id: str
|
||||
epoch: int
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def text_valid(cls, value: str) -> str:
|
||||
if not value.strip() or len(value) > 4000:
|
||||
raise ValueError("请输入1–4000字的消息。")
|
||||
return value.strip()
|
||||
|
||||
@field_validator("turn_id")
|
||||
@classmethod
|
||||
def turn_valid(cls, value: str) -> str:
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("轮次标识无效,请刷新后重试。") from exc
|
||||
return value
|
||||
|
||||
|
||||
def compile_birth(data: BirthInput) -> tuple[dict, object, object]:
|
||||
born = date.fromisoformat(data.birth)
|
||||
pillars = pillars_from_lunar(born.year, born.month, born.day, HOURS.index(data.hour) * 2)
|
||||
derived = compile_pillars(pillars)
|
||||
return {"pillars": asdict(pillars), "traits": derived.traits_text()}, pillars, derived
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
db_path: Path | str | None = None,
|
||||
models: ModelsConfig | None = None,
|
||||
router=None,
|
||||
web_dir: Path | None = None,
|
||||
stream_timeout: float = 60,
|
||||
) -> FastAPI:
|
||||
"""Dependencies can be injected for isolated fault/cancellation tests, never via browser flags."""
|
||||
db_path = db_path or os.environ.get(
|
||||
"CYBERGRIL_DB", str(Path.home() / ".local/share/cybergril/companion.db")
|
||||
)
|
||||
if models is None:
|
||||
config = Path(os.environ.get("CYBERGRIL_MODELS", "config/models.yaml"))
|
||||
models = ModelsConfig(**yaml.safe_load(config.read_text()))
|
||||
router = router or build_router_from_config(models)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
store = Store(db_path)
|
||||
app.state.store = store
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO api_meta(key,value) VALUES('epoch',1) ON CONFLICT(key) DO UPDATE SET value=value+1"
|
||||
)
|
||||
store.execute("UPDATE chat_message SET status='cancelled' WHERE status='pending'")
|
||||
app.state.epoch = store.query_one("SELECT value FROM api_meta WHERE key='epoch'")["value"]
|
||||
app.state.active = None
|
||||
app.state.chat_lock = asyncio.Lock()
|
||||
app.state.health_cache = (0, False)
|
||||
app.state.health_lock = asyncio.Lock()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if app.state.active:
|
||||
app.state.active["cancel"].set()
|
||||
await app.state.active["finished"].wait()
|
||||
for provider in set(router.provider_for(t) for t in router.available_tiers()):
|
||||
await provider.aclose()
|
||||
store.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def invalid(_request, exc):
|
||||
fields = {}
|
||||
for e in exc.errors():
|
||||
key = str(e["loc"][-1])
|
||||
msg = e["msg"].removeprefix("Value error, ")
|
||||
if e["type"] == "missing":
|
||||
msg = "请填写此项。" if key != "relation" else "请亲自选择你们的关系。"
|
||||
elif e["type"] == "literal_error":
|
||||
msg = "请亲自选择朋友或恋人。"
|
||||
elif e["type"] == "extra_forbidden":
|
||||
msg = "此字段不允许修改。"
|
||||
elif not any("\u4e00" <= c <= "\u9fff" for c in msg):
|
||||
msg = "字段格式无效,请检查后重试。"
|
||||
fields[key] = msg
|
||||
return JSONResponse({"detail": "请检查填写内容。", "fields": fields}, status_code=422)
|
||||
|
||||
def companion():
|
||||
row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1")
|
||||
if not row:
|
||||
raise HTTPException(404, "还没有创建真实伴侣。")
|
||||
return json.loads(row["identity_json"])
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
async with app.state.health_lock:
|
||||
checked, ok = app.state.health_cache
|
||||
if time.monotonic() - checked > 30:
|
||||
provider = router.route(Scene.DIALOGUE_MAIN).provider
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
ok = bool(provider and (await provider.health()).ok)
|
||||
except Exception:
|
||||
ok = False
|
||||
app.state.health_cache = (time.monotonic(), ok)
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "R1",
|
||||
"epoch": app.state.epoch,
|
||||
"companion_created": bool(app.state.store.query_one("SELECT id FROM companion")),
|
||||
"llm": {"ok": ok, "model": models.tiers["high"].model},
|
||||
"capabilities": {
|
||||
"text": True,
|
||||
**dict.fromkeys(
|
||||
["life", "memory", "voice", "vision", "weather", "materials", "backup"], False
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@app.get("/api/companion")
|
||||
async def get_companion():
|
||||
return companion()
|
||||
|
||||
@app.post("/api/companion/preview")
|
||||
async def preview(data: BirthInput):
|
||||
return compile_birth(data)[0]
|
||||
|
||||
@app.post("/api/companion", status_code=201)
|
||||
async def create(data: CreationInput):
|
||||
store = app.state.store
|
||||
if store.query_one("SELECT id FROM companion"):
|
||||
raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。")
|
||||
color, pillars, derived = compile_birth(data)
|
||||
identity = {"id": "1", **data.model_dump(), "color": color}
|
||||
try:
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO companion VALUES(1,?)", (json.dumps(identity, ensure_ascii=False),)
|
||||
)
|
||||
store.execute(
|
||||
"""INSERT INTO bazi_core
|
||||
(id,birth_input,pillars,day_master,day_master_elem,ten_gods,element_scores,strength,derived,created_at)
|
||||
VALUES(1,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
json.dumps({"birth": data.birth, "hour": data.hour, "tz": "Asia/Shanghai"}),
|
||||
json.dumps(asdict(pillars), ensure_ascii=False),
|
||||
pillars.day[0],
|
||||
GAN_ELEM[pillars.day[0]],
|
||||
json.dumps(ten_god_distribution(pillars)),
|
||||
json.dumps(element_scores(pillars)),
|
||||
derived.strength,
|
||||
json.dumps(derived.as_dict()),
|
||||
int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") from exc
|
||||
return identity
|
||||
|
||||
@app.patch("/api/relation")
|
||||
async def relation(data: RelationInput):
|
||||
identity = companion()
|
||||
identity.update(data.model_dump())
|
||||
with app.state.store.conn:
|
||||
app.state.store.execute(
|
||||
"UPDATE companion SET identity_json=? WHERE id=1",
|
||||
(json.dumps(identity, ensure_ascii=False),),
|
||||
)
|
||||
return identity
|
||||
|
||||
@app.get("/api/messages")
|
||||
async def messages(
|
||||
limit: int = Query(200, ge=1, le=200), before: int | None = Query(None, ge=1)
|
||||
):
|
||||
companion()
|
||||
rows = app.state.store.query(
|
||||
"SELECT * FROM chat_message WHERE id<? ORDER BY id DESC LIMIT ?",
|
||||
(before or 9223372036854775807, limit + 1),
|
||||
)
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return {
|
||||
"messages": [dict(r) for r in reversed(rows)],
|
||||
"next_before": rows[-1]["id"] if more else None,
|
||||
"epoch": app.state.epoch,
|
||||
}
|
||||
|
||||
def event(data, kind, **payload):
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{"t": kind, "epoch": app.state.epoch, "turn_id": data.turn_id, **payload},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(data: ChatInput, request: Request):
|
||||
identity = companion()
|
||||
if data.epoch != app.state.epoch:
|
||||
return JSONResponse(
|
||||
{"detail": "服务已重新启动,请同步后重试。", "epoch": app.state.epoch},
|
||||
status_code=409,
|
||||
)
|
||||
store = app.state.store
|
||||
# Serialize reservation, including replays. Concurrent retries of a completed
|
||||
# turn must never call the model twice or race the unique message constraint.
|
||||
async with app.state.chat_lock:
|
||||
previous = store.query_one(
|
||||
"SELECT * FROM chat_message WHERE turn_id=? AND role='user'", (data.turn_id,)
|
||||
)
|
||||
if previous and previous["text"] != data.text:
|
||||
raise HTTPException(409, "相同轮次不能改写消息。")
|
||||
if app.state.active:
|
||||
active = app.state.active
|
||||
if active["turn_id"] != data.turn_id:
|
||||
active["cancel"].set()
|
||||
await active["finished"].wait()
|
||||
saved = store.query_one(
|
||||
"SELECT text FROM chat_message WHERE turn_id=? AND role='companion' AND status='sent'",
|
||||
(data.turn_id,),
|
||||
)
|
||||
if saved:
|
||||
|
||||
async def replay():
|
||||
yield event(
|
||||
data, "chat.final", utterance={"text": saved["text"], "speech_tags": []}
|
||||
)
|
||||
|
||||
return StreamingResponse(replay(), media_type="text/event-stream")
|
||||
active = {
|
||||
"cancel": asyncio.Event(),
|
||||
"finished": asyncio.Event(),
|
||||
"turn_id": data.turn_id,
|
||||
}
|
||||
app.state.active = active
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'user',?,?,'pending') ON CONFLICT(turn_id,role) DO UPDATE SET status='pending'",
|
||||
(int(time.time() * 1000), data.text, data.turn_id),
|
||||
)
|
||||
turns = store.query("""SELECT u.text AS user_text, a.text AS assistant_text
|
||||
FROM chat_message a JOIN chat_message u ON u.turn_id=a.turn_id AND u.role='user'
|
||||
WHERE a.role='companion' AND a.status='sent' AND u.status='sent'
|
||||
ORDER BY a.id DESC LIMIT 20""")
|
||||
system = (
|
||||
f"你叫{identity['name']},与对方是{identity['relation']}。称呼对方:{identity['nickname'] or '你'}。"
|
||||
f"相处边界:{identity['boundary']}。出生底色仅供措辞参考:{identity['color']['traits']}。"
|
||||
"用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。"
|
||||
"当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。"
|
||||
"不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。"
|
||||
"不窥探用户未分享的信息,不以缺席、孤独或终止要挟对方,不输出内部数值或思考过程。"
|
||||
)
|
||||
context = (
|
||||
[LLMMessage(role="system", content=system)]
|
||||
+ [
|
||||
message
|
||||
for turn in reversed(turns)
|
||||
for message in (
|
||||
LLMMessage(role="user", content=turn["user_text"]),
|
||||
LLMMessage(role="assistant", content=turn["assistant_text"]),
|
||||
)
|
||||
]
|
||||
+ [LLMMessage(role="user", content=data.text)]
|
||||
)
|
||||
|
||||
async def generate():
|
||||
completed = False
|
||||
status, text, upstream, pending, cancelling = "cancelled", "", None, None, None
|
||||
usage = {}
|
||||
try:
|
||||
decision = router.route(
|
||||
Scene.DIALOGUE_MAIN,
|
||||
estimated_tokens=sum(len(m.content) for m in context) + 1024,
|
||||
)
|
||||
if not decision.provider:
|
||||
raise RuntimeError("No dialogue provider")
|
||||
history_budget = max(0, decision.context_budget - len(system) - len(data.text))
|
||||
recent = []
|
||||
# Order by completed turns, not adjacent message ids: a retried user
|
||||
# may have an older id than messages sent while that turn had failed.
|
||||
for turn in turns:
|
||||
size = len(turn["user_text"]) + len(turn["assistant_text"])
|
||||
if size > history_budget:
|
||||
break
|
||||
recent = [
|
||||
LLMMessage(role="user", content=turn["user_text"]),
|
||||
LLMMessage(role="assistant", content=turn["assistant_text"]),
|
||||
] + recent
|
||||
history_budget -= size
|
||||
context[:] = [context[0]] + recent + [context[-1]]
|
||||
cfg = models.tiers[decision.effective.value]
|
||||
upstream = decision.provider.stream(
|
||||
context, temperature=cfg.temperature, max_tokens=cfg.max_tokens or 1024
|
||||
)
|
||||
cancelling = asyncio.create_task(active["cancel"].wait())
|
||||
async with asyncio.timeout(stream_timeout):
|
||||
while True:
|
||||
pending = asyncio.create_task(anext(upstream))
|
||||
done, _ = await asyncio.wait(
|
||||
[pending, cancelling], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if cancelling in done or await request.is_disconnected():
|
||||
return
|
||||
try:
|
||||
delta = pending.result()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
if delta.usage:
|
||||
usage = delta.usage
|
||||
if delta.text:
|
||||
text += delta.text
|
||||
yield event(data, "chat.delta", delta=delta.text)
|
||||
if delta.done or delta.finish_reason:
|
||||
completed = True
|
||||
if delta.done:
|
||||
break
|
||||
if active["cancel"].is_set() or await request.is_disconnected():
|
||||
return
|
||||
if not text.strip() or not completed:
|
||||
raise RuntimeError("Empty upstream response")
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'companion',?,?,'sent')",
|
||||
(int(time.time() * 1000), text, data.turn_id),
|
||||
)
|
||||
store.execute(
|
||||
"UPDATE chat_message SET status='sent' WHERE turn_id=?", (data.turn_id,)
|
||||
)
|
||||
status = "sent"
|
||||
yield event(data, "chat.final", utterance={"text": text, "speech_tags": []})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
status = "failed"
|
||||
yield event(data, "chat.error", phi=ERROR, retryable=True)
|
||||
finally:
|
||||
with CancelScope(shield=True):
|
||||
# Cancel the in-flight __anext__ before closing the async generator.
|
||||
for task in (pending, cancelling):
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError, StopAsyncIteration, Exception):
|
||||
await task
|
||||
if upstream is not None:
|
||||
with suppress(Exception):
|
||||
await upstream.aclose()
|
||||
try:
|
||||
if status != "sent":
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"UPDATE chat_message SET status=? WHERE turn_id=? AND role='user'",
|
||||
(status, data.turn_id),
|
||||
)
|
||||
if usage:
|
||||
router.guard.record_usage(usage, Scene.DIALOGUE_MAIN.value)
|
||||
else:
|
||||
router.guard.record(
|
||||
sum(len(m.content) for m in context) + len(text),
|
||||
Scene.DIALOGUE_MAIN.value,
|
||||
)
|
||||
finally:
|
||||
if app.state.active is active:
|
||||
app.state.active = None
|
||||
active["finished"].set()
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
static = web_dir or Path(os.environ.get("CYBERGRIL_WEB", "web/dist"))
|
||||
if static.is_dir():
|
||||
app.mount("/", StaticFiles(directory=static, html=True), name="web")
|
||||
return app
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"""R1 invariants through HTTP and real loopback streaming, with isolated databases."""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cybergril.api.app import create_app
|
||||
from cybergril.contracts import HealthStatus, LLMDelta
|
||||
from cybergril.providers.llm.echo import EchoLLM
|
||||
from cybergril.think.router import build_router
|
||||
from cybergril.think.scenes import Tier
|
||||
|
||||
CREATE = {"name": "星河", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
|
||||
|
||||
|
||||
class ControlledLLM(EchoLLM):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = []
|
||||
self.closed = 0
|
||||
self.mode = "normal"
|
||||
self.health_calls = 0
|
||||
|
||||
async def health(self):
|
||||
self.health_calls += 1
|
||||
if self.mode == "health_timeout":
|
||||
await asyncio.sleep(30)
|
||||
return HealthStatus(ok=self.mode != "unreachable")
|
||||
|
||||
async def stream(self, messages, **kwargs):
|
||||
self.calls.append(messages)
|
||||
try:
|
||||
if self.mode == "error":
|
||||
raise OSError("secret upstream configuration must never be exposed")
|
||||
yield LLMDelta(text="答")
|
||||
if self.mode == "slow":
|
||||
await asyncio.sleep(30)
|
||||
if self.mode == "truncated":
|
||||
return
|
||||
yield LLMDelta(text="复")
|
||||
yield LLMDelta(done=True)
|
||||
finally:
|
||||
self.closed += 1
|
||||
|
||||
|
||||
def make_app(path, provider=None, **kwargs):
|
||||
provider = provider or ControlledLLM()
|
||||
router = build_router(
|
||||
providers={Tier.HIGH: provider, Tier.STANDARD: provider, Tier.LOW: provider}
|
||||
)
|
||||
return create_app(db_path=path, router=router, **kwargs), provider
|
||||
|
||||
|
||||
def turn(client, text="你好", turn_id=None, epoch=None):
|
||||
return client.post(
|
||||
"/api/chat",
|
||||
json={
|
||||
"text": text,
|
||||
"turn_id": turn_id or str(uuid4()),
|
||||
"epoch": epoch or client.get("/api/health").json()["epoch"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def events(response):
|
||||
return [
|
||||
json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")
|
||||
]
|
||||
|
||||
|
||||
def test_preview_validation_singleton_and_readonly(tmp_path):
|
||||
app, _ = make_app(tmp_path / "test.db")
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/companion").status_code == 404
|
||||
preview = client.post(
|
||||
"/api/companion/preview", json={k: CREATE[k] for k in ("name", "birth", "hour")}
|
||||
)
|
||||
assert preview.status_code == 200
|
||||
assert len(preview.json()["pillars"]["day"]) == 2
|
||||
assert client.get("/api/companion").status_code == 404
|
||||
assert app.state.store.query_one("SELECT count(*) n FROM bazi_core")["n"] == 0
|
||||
for bad in [
|
||||
dict(CREATE, name="<x>"),
|
||||
dict(CREATE, birth="2000-02-30"),
|
||||
dict(CREATE, birth="2020-01-01"),
|
||||
dict(CREATE, birth="1899-01-01"),
|
||||
dict(CREATE, hour="未知"),
|
||||
dict(CREATE, relation=""),
|
||||
dict(CREATE, nickname="x\x01"),
|
||||
{k: v for k, v in CREATE.items() if k != "relation"},
|
||||
dict(CREATE, derived={}),
|
||||
]:
|
||||
response = client.post("/api/companion", json=bad)
|
||||
assert response.status_code == 422, response.text
|
||||
assert response.json()["fields"]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
|
||||
codes = list(
|
||||
pool.map(lambda _: client.post("/api/companion", json=CREATE).status_code, range(5))
|
||||
)
|
||||
assert sorted(codes) == [201, 409, 409, 409, 409]
|
||||
identity = client.get("/api/companion").json()
|
||||
assert identity["color"] == preview.json()
|
||||
assert "derived" not in identity and "strength" not in identity
|
||||
assert (
|
||||
client.patch(
|
||||
"/api/relation", json={"relation": "恋人", "birth": "1990-01-01"}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert app.state.store.query_one("PRAGMA journal_mode")[0] == "wal"
|
||||
|
||||
|
||||
def test_history_context_relation_retry_and_restart(tmp_path):
|
||||
path = tmp_path / "test.db"
|
||||
app, provider = make_app(path)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/companion", json=CREATE)
|
||||
epoch = client.get("/api/health").json()["epoch"]
|
||||
tid = str(uuid4())
|
||||
result = events(turn(client, "我的暗号是蓝星", tid))
|
||||
assert result[-1]["t"] == "chat.final"
|
||||
assert all(e["turn_id"] == tid and e["epoch"] == epoch for e in result)
|
||||
assert result[0]["t"] == "chat.delta"
|
||||
turn(client, "记得暗号吗")
|
||||
assert any(m.content == "我的暗号是蓝星" for m in provider.calls[-1])
|
||||
assert (
|
||||
"星河" in provider.calls[-1][0].content
|
||||
and "尚未接通" not in provider.calls[-1][-1].content
|
||||
)
|
||||
assert len(client.get("/api/messages").json()["messages"]) == 4
|
||||
assert events(turn(client, "我的暗号是蓝星", tid))[-1]["t"] == "chat.final"
|
||||
assert len(provider.calls) == 2 # A lost final can be replayed without regenerating.
|
||||
assert turn(client, "tampered", tid).status_code == 409
|
||||
first = client.get("/api/messages?limit=2").json()
|
||||
second = client.get(f"/api/messages?limit=2&before={first['next_before']}").json()
|
||||
assert second["messages"][-1]["id"] < first["messages"][0]["id"]
|
||||
assert client.get("/api/messages?limit=201").status_code == 422
|
||||
core = dict(app.state.store.query_one("SELECT * FROM bazi_core"))
|
||||
updated = client.patch(
|
||||
"/api/relation", json={"relation": "恋人", "nickname": "小星", "boundary": "慢慢来"}
|
||||
).json()
|
||||
assert updated["relation"] == "恋人"
|
||||
assert dict(app.state.store.query_one("SELECT * FROM bazi_core")) == core
|
||||
assert len(client.get("/api/messages").json()["messages"]) == 4
|
||||
app2, _ = make_app(path)
|
||||
with TestClient(app2) as client:
|
||||
assert client.get("/api/health").json()["epoch"] == epoch + 1
|
||||
assert client.get("/api/companion").json() == updated
|
||||
assert len(client.get("/api/messages").json()["messages"]) == 4
|
||||
assert turn(client, epoch=epoch).json()["epoch"] == epoch + 1
|
||||
assert events(turn(client))[-1]["t"] == "chat.final"
|
||||
|
||||
|
||||
def test_errors_retry_context_excludes_failed_and_health_cache(tmp_path):
|
||||
app, provider = make_app(tmp_path / "test.db", stream_timeout=0.03)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/companion", json=CREATE)
|
||||
provider.mode = "unreachable"
|
||||
health = client.get("/api/health").json()
|
||||
assert not health["llm"]["ok"]
|
||||
assert not health["capabilities"]["life"]
|
||||
client.get("/api/health")
|
||||
assert provider.health_calls == 1
|
||||
for mode in ("error", "slow", "truncated"):
|
||||
provider.mode = mode
|
||||
tid = str(uuid4())
|
||||
response = turn(client, text="失败的消息" + mode, turn_id=tid)
|
||||
assert events(response)[-1]["t"] == "chat.error"
|
||||
assert "secret" not in response.text
|
||||
assert client.get("/api/messages").json()["messages"][-1]["status"] == "failed"
|
||||
provider.mode = "normal"
|
||||
response = turn(client, text="重试成功")
|
||||
assert events(response)[-1]["t"] == "chat.final"
|
||||
assert len(provider.calls[-1]) == 2 # system + current; no failed turns
|
||||
provider.mode = "error"
|
||||
tid = str(uuid4())
|
||||
turn(client, text="同一轮", turn_id=tid)
|
||||
count = len(client.get("/api/messages").json()["messages"])
|
||||
provider.mode = "normal"
|
||||
turn(client, text="同一轮", turn_id=tid)
|
||||
assert len(client.get("/api/messages").json()["messages"]) == count + 1
|
||||
assert provider.closed == len(provider.calls)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def live_server(app, port=0):
|
||||
sock = socket.socket()
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("127.0.0.1", port))
|
||||
sock.listen()
|
||||
server = uvicorn.Server(uvicorn.Config(app, log_level="error", lifespan="on"))
|
||||
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]})
|
||||
thread.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while not server.started:
|
||||
if not thread.is_alive() or time.monotonic() > deadline:
|
||||
raise RuntimeError("local test server failed to start")
|
||||
time.sleep(0.01)
|
||||
yield f"http://127.0.0.1:{sock.getsockname()[1]}"
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=10)
|
||||
sock.close()
|
||||
assert not thread.is_alive()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_disconnect_and_immediate_new_turn(tmp_path):
|
||||
app, provider = make_app(tmp_path / "test.db")
|
||||
with live_server(app) as url:
|
||||
async with httpx.AsyncClient(base_url=url) as client:
|
||||
await client.post("/api/companion", json=CREATE)
|
||||
epoch = (await client.get("/api/health")).json()["epoch"]
|
||||
provider.mode = "slow"
|
||||
tid = str(uuid4())
|
||||
async with client.stream(
|
||||
"POST", "/api/chat", json={"text": "取消我", "turn_id": tid, "epoch": epoch}
|
||||
) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
break
|
||||
# Closing the browser response must cancel upstream while its next token is blocked.
|
||||
deadline = time.monotonic() + 1
|
||||
while provider.closed < 1 and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
assert provider.closed == 1
|
||||
provider.mode = "normal"
|
||||
new_tid = str(uuid4())
|
||||
response = await client.post(
|
||||
"/api/chat", json={"text": "新问题", "turn_id": new_tid, "epoch": epoch}
|
||||
)
|
||||
assert events(response)[-1]["t"] == "chat.final"
|
||||
assert all(e["turn_id"] == new_tid for e in events(response))
|
||||
messages = (await client.get("/api/messages")).json()["messages"]
|
||||
assert messages[0]["status"] == "cancelled"
|
||||
assert len(messages) == 3 # discarded partial reply is never a complete answer
|
||||
assert len(provider.calls[-1]) == 2 # cancelled turn excluded from context
|
||||
|
||||
|
||||
def test_concurrent_identical_retry_generates_once(tmp_path):
|
||||
app, provider = make_app(tmp_path / "test.db")
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/companion", json=CREATE)
|
||||
epoch = client.get("/api/health").json()["epoch"]
|
||||
tid = str(uuid4())
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
responses = list(pool.map(lambda _: turn(client, "同一轮", tid, epoch), range(4)))
|
||||
assert all(events(response)[-1]["t"] == "chat.final" for response in responses)
|
||||
assert len(provider.calls) == 1
|
||||
assert len(client.get("/api/messages").json()["messages"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"status,data,expected",
|
||||
[
|
||||
(401, {}, False),
|
||||
(404, {}, False),
|
||||
(503, {}, False),
|
||||
(200, {"data": [{"id": "other"}]}, False),
|
||||
(200, {"data": [{"id": "test-model"}]}, True),
|
||||
],
|
||||
)
|
||||
async def test_model_health_requires_success_and_requested_model(status, data, expected):
|
||||
from cybergril.providers.llm.openai_compat import OpenAICompatLLM
|
||||
|
||||
provider = OpenAICompatLLM(base_url="http://isolated/v1", model="test-model")
|
||||
await provider._client.aclose()
|
||||
provider._client = httpx.AsyncClient(
|
||||
base_url="http://isolated/v1",
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(status, json=data)),
|
||||
)
|
||||
try:
|
||||
assert (await provider.health()).ok is expected
|
||||
finally:
|
||||
await provider.aclose()
|
||||
|
||||
|
||||
def test_health_timeout_is_bounded_and_cached(tmp_path):
|
||||
app, provider = make_app(tmp_path / "test.db")
|
||||
provider.mode = "health_timeout"
|
||||
with TestClient(app) as client:
|
||||
start = time.monotonic()
|
||||
assert client.get("/api/health").json()["llm"]["ok"] is False
|
||||
assert time.monotonic() - start < 6
|
||||
assert client.get("/api/health").json()["llm"]["ok"] is False
|
||||
assert provider.health_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_closes_real_openai_http_stream(tmp_path):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from cybergril.providers.llm.openai_compat import OpenAICompatLLM
|
||||
|
||||
closed = threading.Event()
|
||||
gateway = FastAPI()
|
||||
|
||||
@gateway.post("/v1/chat/completions")
|
||||
async def completion():
|
||||
async def chunks():
|
||||
try:
|
||||
yield 'data: {"choices":[{"delta":{"content":"部分"}}]}\n\n'
|
||||
await asyncio.sleep(30)
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
return StreamingResponse(chunks(), media_type="text/event-stream")
|
||||
|
||||
with live_server(gateway) as gateway_url:
|
||||
provider = OpenAICompatLLM(base_url=gateway_url + "/v1", model="controlled")
|
||||
app, _ = make_app(tmp_path / "test.db", provider)
|
||||
with live_server(app) as url:
|
||||
async with httpx.AsyncClient(base_url=url) as client:
|
||||
await client.post("/api/companion", json=CREATE)
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"/api/chat",
|
||||
json={"text": "断开上游", "turn_id": str(uuid4()), "epoch": 1},
|
||||
) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
break
|
||||
deadline = time.monotonic() + 1
|
||||
while not closed.is_set() and time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
assert closed.is_set() # Real OpenAICompatLLM's HTTP response was actually closed.
|
||||
|
||||
|
||||
def test_retry_old_turn_preserves_user_assistant_context_pairs(tmp_path):
|
||||
app, provider = make_app(tmp_path / "test.db")
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/companion", json=CREATE)
|
||||
tid = str(uuid4())
|
||||
provider.mode = "error"
|
||||
turn(client, "旧轮失败", tid)
|
||||
provider.mode = "normal"
|
||||
turn(client, "中间的新轮")
|
||||
turn(client, "旧轮失败", tid)
|
||||
turn(client, "最后一轮")
|
||||
context = provider.calls[-1]
|
||||
assert [m.role for m in context] == [
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
]
|
||||
assert [m.content for m in context[1::2]] == ["中间的新轮", "旧轮失败", "最后一轮"]
|
||||
|
||||
|
||||
def test_upstream_http_503_maps_to_chinese_sse_error(tmp_path):
|
||||
from cybergril.providers.llm.openai_compat import OpenAICompatLLM
|
||||
|
||||
provider = OpenAICompatLLM(base_url="http://isolated/v1", model="controlled")
|
||||
provider._client = httpx.AsyncClient(
|
||||
base_url="http://isolated/v1",
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(503, json={"error": "secret gateway path"})
|
||||
),
|
||||
)
|
||||
app, _ = make_app(tmp_path / "test.db", provider)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/companion", json=CREATE)
|
||||
response = turn(client, epoch=1)
|
||||
assert events(response)[-1]["t"] == "chat.error"
|
||||
assert "稍后再试" in response.text and "secret" not in response.text
|
||||
assert client.get("/api/messages").json()["messages"][0]["status"] == "failed"
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ChatAdapter } from './demo';
|
||||
import type { DemoState, Identity, Message, ServerEvent } from '../domain/model';
|
||||
import { defaultSettings } from '../domain/model';
|
||||
|
||||
export interface Color { pillars: Record<string, string>; traits: string }
|
||||
export interface RealIdentity extends Identity { color: Color }
|
||||
export interface Health { epoch: number; llm: { ok: boolean; model: string } }
|
||||
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try { response = await fetch(`/api/${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers }, signal: init?.signal ?? AbortSignal.timeout(10000) }); }
|
||||
catch (e) { if (init?.signal?.aborted) throw e; throw new Error('暂时连不上真实服务,请检查连接后重试。'); }
|
||||
const data = await response.json().catch(() => { throw new Error('真实服务暂时没有正确响应,请稍后重试。'); });
|
||||
if (!response.ok) throw new Error(data.fields ? Object.values(data.fields).join(' ') : data.detail || '这次没有完成,请稍后重试。');
|
||||
return data as T;
|
||||
}
|
||||
export async function loadReal(): Promise<DemoState | null> {
|
||||
let response: Response;
|
||||
try { response = await fetch('/api/companion', { signal: AbortSignal.timeout(10000) }); } catch { throw new Error('真实档案暂时连不上,请检查连接后重试。'); }
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error('真实档案暂时读不出来,请稍后重试。');
|
||||
const identity: RealIdentity = await response.json();
|
||||
return { version: 1, identity, settings: structuredClone(defaultSettings), events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() };
|
||||
}
|
||||
export async function loadMessages(): Promise<Message[]> {
|
||||
// Cursor pages are bounded by the server. All pages restore in deterministic id order.
|
||||
const messages: Message[] = [];
|
||||
let before: number | null = null;
|
||||
do {
|
||||
const page: { messages: { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status'] }[]; next_before: number | null } = await api(`messages?limit=200${before ? `&before=${before}` : ''}`);
|
||||
messages.unshift(...page.messages.map(m => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: [], turnId: m.turn_id, status: m.status })));
|
||||
before = page.next_before;
|
||||
} while (before);
|
||||
return messages;
|
||||
}
|
||||
// Streaming decoder retains incomplete UTF-8 bytes, CRLF and multi-line SSE frames.
|
||||
export async function readEvents(body: ReadableStream<Uint8Array>, onEvent: (e: ServerEvent) => void) {
|
||||
const reader = body.getReader(), decoder = new TextDecoder();
|
||||
let buffer = '', final = false;
|
||||
const consume = () => {
|
||||
let boundary: RegExpExecArray | null;
|
||||
while ((boundary = /\r?\n\r?\n/u.exec(buffer))) {
|
||||
const frame = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary[0].length);
|
||||
const data = frame.split(/\r?\n/u).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
||||
if (!data) continue;
|
||||
let e: ServerEvent;
|
||||
try { e = JSON.parse(data) as ServerEvent; } catch { throw new Error('收到的对话数据不完整,请重试这一轮。'); }
|
||||
|
||||
if (e.t === 'chat.final') final = true;
|
||||
onEvent(e);
|
||||
|
||||
}
|
||||
};
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
consume();
|
||||
if (done) break;
|
||||
}
|
||||
if (!final) throw new Error('连接中断了,你写的话还在,可以重试。');
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
}
|
||||
export class RealChatAdapter implements ChatAdapter {
|
||||
constructor(private syncEpoch: (epoch: number) => void) {}
|
||||
async send(text: string, turnId: string, _epoch: number, signal: AbortSignal, emit: (e: ServerEvent) => void) {
|
||||
let health = await api<Health>('health', { signal });
|
||||
this.syncEpoch(health.epoch);
|
||||
let response: Response | undefined;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, turn_id: turnId, epoch: health.epoch }), signal });
|
||||
if (response.status !== 409) break;
|
||||
const error = await response.json();
|
||||
if (!error.epoch) throw new Error(error.detail);
|
||||
health = { ...health, epoch: error.epoch }; this.syncEpoch(health.epoch);
|
||||
}
|
||||
if (!response?.ok || !response.body) throw new Error('她这会儿没接上话,稍后再试试。你写的话还在。');
|
||||
await readEvents(response.body, e => { if (e.turn_id === turnId && e.epoch === health.epoch && !signal.aborted) { if (e.t === 'chat.error') throw new Error(e.phi); emit(e); } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, loadReal, type Health } from '../adapters/real';
|
||||
import type { DemoState, Message, Theme } from '../domain/model';
|
||||
import { Background } from '../stage/Background';
|
||||
import { FallbackPresenter } from '../stage/Presenter';
|
||||
import { CreationWizard } from '../manage/CreationWizard';
|
||||
import { ManagePanel } from '../manage/ManagePanel';
|
||||
import { ChatPanel } from '../chat/ChatPanel';
|
||||
import { Button, Icon, Status } from '../shared/ui';
|
||||
|
||||
export function RealApp() {
|
||||
const [state, setState] = useState<DemoState | null>(null), [ready, setReady] = useState(false), [creating, setCreating] = useState(false), [error, setError] = useState(''), [health, setHealth] = useState<Health>(), [area, setArea] = useState('now'), [log, setLog] = useState(false), [presence, setPresence] = useState('文字已接通');
|
||||
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
|
||||
const latest = useRef(state); latest.current = state;
|
||||
const initialize = useCallback(async () => {
|
||||
setError(''); setReady(false);
|
||||
try { setHealth(await api<Health>('health')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); }
|
||||
catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); }
|
||||
}, []);
|
||||
useEffect(() => { void initialize(); }, [initialize]);
|
||||
useEffect(() => { document.documentElement.dataset.theme = theme; try { localStorage.setItem('cg.theme', theme); } catch { /* Preference stays in memory. */ } }, [theme]);
|
||||
useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]);
|
||||
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
|
||||
const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []);
|
||||
const ignoreVoice = useCallback(() => {}, []);
|
||||
function navigate(next: string) { setArea(next); setLog(false); }
|
||||
return <><Background/><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{ready ? '真实档案 · 文字对话' : '连接真实服务'}</span></div><div className="env"><span className="chip glass weather">天气尚未接通</span><span className="theme-switch glass">{(['moon', 'fog'] as Theme[]).map(t => <button key={t} data-t={t} aria-label={t === 'moon' ? '靛蓝月光' : '蓝紫雾光'} aria-pressed={theme === t} onClick={() => setTheme(t)}><i/><span>{t === 'moon' ? '月光' : '雾光'}</span></button>)}</span></div></header>
|
||||
{!ready ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><Status text={error || '正在读取服务器档案…'} error={!!error}/>{error && <><p>请检查真实服务是否可达。资料仍在服务器保存。</p><Button primary onClick={() => void initialize()}>重新连接</Button></>}</div></main> : <>
|
||||
{creating && <CreationWizard real fail={false} onCreate={setState} onEnter={() => setCreating(false)}/>}
|
||||
{!creating && state && <><FallbackPresenter real scene={{ mode: area === 'talk' ? 'talk' : 'daily', activity: '文字已接通 · 生活尚未接通', time_band: '', weather: '', outfit: '' }} presence={presence} observation="" onClose={() => navigate('now')} closeLabel="回到此刻"/>
|
||||
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
|
||||
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onClose={() => navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
|
||||
{area === 'days' && <section className="pane glass read open" aria-label="日子"><header><h2>日子与回忆</h2><button className="btn-round sm" aria-label="关闭日子" onClick={() => navigate('now')}><Icon name="x"/></button></header><div className="card"><h3>尚未接通</h3><p>生活循环与长期记忆将在后续接入,目前没有可展示的生活经历。聊天记录可在交谈中查看。</p></div></section>}
|
||||
<ManagePanel real state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/>
|
||||
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className="nav-item" aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}</button>)}</nav></>}
|
||||
</>}</>;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { newId } from '../domain/id';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DemoState, Message } from '../domain/model';
|
||||
import { RealChatAdapter } from '../adapters/real';
|
||||
import { DemoAdapter, TurnGate } from '../adapters/demo';
|
||||
import { publicView } from '../domain/privacy';
|
||||
import { Icon, Button, Modal } from '../shared/ui';
|
||||
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: {
|
||||
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: {
|
||||
real?: boolean;
|
||||
state: DemoState;
|
||||
onChange: (messages: Message[]) => void;
|
||||
onPresence: (p: string) => void;
|
||||
@@ -18,22 +20,22 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
focusLife: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => { try {
|
||||
return sessionStorage.getItem('cg.draft') || '';
|
||||
return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || '';
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
|
||||
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
|
||||
const gate = useRef(new TurnGate()), adapter = useRef(real ? new RealChatAdapter(epoch => { gate.current.epoch = epoch; }) : new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
|
||||
live.current = state.messages;
|
||||
const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion');
|
||||
const change = (messages: Message[]) => { live.current = messages; onChange(messages); };
|
||||
function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
|
||||
function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); }
|
||||
useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]);
|
||||
useEffect(() => { const current = gate.current; return () => { current.cancel(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]);
|
||||
useEffect(() => { try {
|
||||
sessionStorage.setItem('cg.draft', draft);
|
||||
sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft);
|
||||
}
|
||||
catch { /* Draft remains in memory. */ } }, [draft]);
|
||||
catch { /* Draft remains in memory. */ } }, [draft, real]);
|
||||
useEffect(() => { if (offline) {
|
||||
gate.current.resync();
|
||||
setBusy(false);
|
||||
@@ -51,17 +53,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
else if (logRef.current)
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
} }, [log, focusLife, view.messages.length]);
|
||||
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
|
||||
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
|
||||
async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline)
|
||||
return; stop(); const turnId = retry?.turnId || newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
|
||||
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
|
||||
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
|
||||
return; if (event.t === 'chat.delta') {
|
||||
setReply(x => x + event.delta);
|
||||
onPresence('在说话');
|
||||
}
|
||||
else {
|
||||
else if (event.t === 'chat.final') {
|
||||
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
|
||||
setReply('');
|
||||
if (real) setDraft(current => current === text ? '' : current);
|
||||
setBusy(false);
|
||||
onPresence(voice ? '在听你说(模拟)' : '在你面前');
|
||||
} }, fail);
|
||||
@@ -71,16 +74,18 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
return;
|
||||
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
|
||||
setBusy(false);
|
||||
setNotice((e as Error).message);
|
||||
setReply('');
|
||||
setDraft(text);
|
||||
setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message);
|
||||
onPresence('在你面前');
|
||||
} }
|
||||
const setVoiceMode = () => { if (voice) {
|
||||
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) {
|
||||
endVoice();
|
||||
return;
|
||||
} setPermission('mic'); };
|
||||
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || '演示回复 · 没有连接模型、语音或记忆后台'}</div></div>
|
||||
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || (real ? '真实文字对话 · 生活与长期记忆尚未接通' : '演示回复 · 没有连接模型、语音或记忆后台')}</div></div>
|
||||
{voice && <div className="voicebar glass"><span className="live"><i /></span><span className="vstate">{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button className="btn-ghost" onClick={() => { stop(); setNotice(fail?'刚才没听清,你再说一遍?(模拟识别失败)':'她停下来了,在等你。'); }}>打断她</button><button className="end" onClick={endVoice}>结束语音</button></div>}
|
||||
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (vision) {
|
||||
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) {
|
||||
setVision(false);
|
||||
onObservation('窗户关上了');
|
||||
}
|
||||
@@ -88,8 +93,8 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
setPermission('vision'); }}><Icon name="eye"/></button><textarea aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
|
||||
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">本机演示记录</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
|
||||
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={real ? '语音尚未接通' : voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
|
||||
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">{real ? '服务器聊天记录' : '本机演示记录'}</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
|
||||
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
|
||||
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
|
||||
setDenied(false);
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface SceneDesc {
|
||||
outfit: string;
|
||||
}
|
||||
export type ServerEvent = {
|
||||
t: 'chat.error'; epoch: number; turn_id: string; phi: string; retryable: boolean;
|
||||
} | {
|
||||
t: 'chat.delta';
|
||||
epoch: number;
|
||||
turn_id: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RealApp } from './app/RealApp';
|
||||
import { App } from './app/App';
|
||||
import './styles/tokens.css';
|
||||
import './styles/components.css';
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(new URLSearchParams(location.search).get('demo') === '1' ? <App /> : <RealApp />);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { api, type Color, type RealIdentity } from '../adapters/real';
|
||||
import { newId } from '../domain/id';
|
||||
import { useState } from 'react';
|
||||
import { validateCreation, type Identity } from '../domain/model';
|
||||
@@ -5,21 +6,28 @@ import { freshState } from '../demo/fixtures';
|
||||
import { delay } from '../adapters/demo';
|
||||
import { BaseColor, Button, Field, Status } from '../shared/ui';
|
||||
import type { DemoState } from '../domain/model';
|
||||
export function CreationWizard({ onCreate, onEnter, fail }: {
|
||||
export function CreationWizard({ onCreate, onEnter, fail, real = false }: {
|
||||
real?: boolean;
|
||||
onCreate: (s: DemoState) => void;
|
||||
onEnter: () => void;
|
||||
fail: boolean;
|
||||
}) {
|
||||
const [step, setStep] = useState(0), [name, setName] = useState(''), [birth, setBirth] = useState('2000-10-18'), [hour, setHour] = useState('亥时'), [relation, setRelation] = useState(''), [nickname, setNickname] = useState(''), [agreed, setAgreed] = useState(false), [error, setError] = useState(''), [phase, setPhase] = useState(-1), [busy, setBusy] = useState(false);
|
||||
const [color, setColor] = useState<Color>();
|
||||
const labels = ['出生资料', '她的底色', '你们的关系', '最终确认'];
|
||||
function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
|
||||
async function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
} setError(''); setStep(s => s + 1); }
|
||||
} setError(''); if (real && step === 0) { setBusy(true); try { setColor(await api<Color>('companion/preview', { method: 'POST', body: JSON.stringify({ name, birth, hour }) })); } catch (e) { setError((e as Error).message); return; } finally { setBusy(false); } } setStep(s => s + 1); }
|
||||
async function create() { const err = validateCreation(name, birth, relation); if (err || !agreed) {
|
||||
setError(err || '请先确认你已了解。');
|
||||
return;
|
||||
} setBusy(true); setError(''); try {
|
||||
if (real) {
|
||||
setPhase(0);
|
||||
const identity = await api<RealIdentity>('companion', { method: 'POST', body: JSON.stringify({ name, birth, hour, relation, nickname }) });
|
||||
setPhase(2); onCreate({ ...freshState(identity), messages: [], events: [], memories: [] }); setStep(4); return;
|
||||
}
|
||||
const c = new AbortController();
|
||||
for (let p = 0; p < 3; p++) {
|
||||
setPhase(p);
|
||||
@@ -37,11 +45,11 @@ export function CreationWizard({ onCreate, onEnter, fail }: {
|
||||
finally {
|
||||
setBusy(false);
|
||||
} }
|
||||
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
|
||||
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>{real ? '资料与对话保存在服务器,重开或换设备都能恢复同一位她。最终确认前不会建档。' : '这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。'}</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
|
||||
{step === 0 && <><h2>先认识她的开始</h2><p className="lead">名字由你起,生辰也可以在确认前修改。</p><Field label="她的名字"><input className="input" value={name} maxLength={20} onChange={e => setName(e.target.value)} autoComplete="off"/></Field><Field label="出生日期"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰"><select className="select" value={hour} onChange={e => setHour(e.target.value)}>{['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'].map(h => <option key={h}>{h}</option>)}</select></Field><Button onClick={() => { setBirth(`${1990 + Math.floor(Math.random() * 13)}-${String(1 + Math.floor(Math.random() * 12)).padStart(2, '0')}-18`); }}>自动生成一组生辰</Button></>}
|
||||
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor /></>}
|
||||
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor color={real ? color : undefined}/></>}
|
||||
{step === 2 && <><h2>由你选择关系</h2><p className="lead">没有预选答案。以后可以在设置中确认修改,记忆会保留。</p><div className="seg">{['朋友', '恋人'].map(r => <button aria-pressed={relation === r} key={r} onClick={() => setRelation(r)}>{r}</button>)}</div><Field label="她怎么称呼你(可选)"><input className="input" maxLength={40} value={nickname} onChange={e => setNickname(e.target.value)}/></Field></>}
|
||||
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>我明白:这是本机演示;创建后底色只读,只保留一位伴侣。</label>{phase >= 0 && <ul className="phases">{['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)'].map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
|
||||
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>{real ? '我明白:这将创建服务器上的真实档案;创建后底色只读,只保留一位伴侣。' : '我明白:这是本机演示;创建后底色只读,只保留一位伴侣。'}</label>{phase >= 0 && <ul className="phases">{(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
|
||||
{step === 4 && <div className="born"><div className="halo"/><h2>{name.trim()}住进来了</h2><p className="lead">刚开始,还没有共同的日子。聊着聊着就会有了。</p></div>}
|
||||
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary onClick={next}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
|
||||
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary disabled={busy} onClick={() => void next()}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { api, type RealIdentity } from '../adapters/real';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DemoState, Settings, Theme } from '../domain/model';
|
||||
import { delay } from '../adapters/demo';
|
||||
import { exportDemo, parseState } from '../adapters/storage';
|
||||
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
|
||||
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
|
||||
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme }: {
|
||||
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme, real = false }: {
|
||||
real?: boolean;
|
||||
state: DemoState;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -31,14 +33,18 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
|
||||
async function save(confirmed = false) { if (section==='relation' && draft.identity.relation !== state.identity.relation && !confirmed) {
|
||||
setConfirm('relation');
|
||||
return;
|
||||
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage('正在保存到本机…'); try {
|
||||
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage(real ? '正在保存…' : '正在保存到本机…'); try {
|
||||
if (real && section === 'relation') {
|
||||
const identity = await api<RealIdentity>('relation', { method: 'PATCH', signal: ctrl.signal, body: JSON.stringify({ relation: draft.identity.relation, nickname: draft.identity.nickname, boundary: draft.identity.boundary }) });
|
||||
if (!ctrl.signal.aborted) { onSave({ ...latestState.current, identity }); setMessage('已保存在服务器,聊天与底色保留。'); } return;
|
||||
}
|
||||
await delay(450, ctrl.signal);
|
||||
if (fail)
|
||||
throw new Error('这次没存好,你的修改还在,可以再试一次。');
|
||||
const current=latestState.current;
|
||||
const next = {...current, identity: section==='relation'?draft.identity:current.identity, settings:{...current.settings, ...(section==='city'?{city:draft.settings.city}:section==='services'?{services:draft.settings.services}:section==='display'?{reduced:draft.settings.reduced}:{})}};
|
||||
onSave(next);
|
||||
setMessage('已保存在本机演示中。');
|
||||
setMessage(real ? '显示偏好已应用。' : '已保存在本机演示中。');
|
||||
}
|
||||
catch (e) {
|
||||
if (ctrl.signal.aborted)
|
||||
@@ -85,15 +91,16 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
|
||||
setCheck('检查没通过:' + (e instanceof SyntaxError?'清单不是有效的 JSON,请重新选择。':(e as Error).message));
|
||||
} }
|
||||
const switchSection = (s: string) => { request.current?.abort(); generation.current++; setBusy(false); setSection(s); setDetail(true); setMessage(''); };
|
||||
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">仅本机演示</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
|
||||
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor /><div className="growth"><div><h4>最初就有的</h4>细腻、慢热(示例)</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
|
||||
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">{real ? '服务器档案' : '仅本机演示'}</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{real && id === 'backup' ? '备份' : label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : real && section === 'backup' ? '备份' : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : real ? '资料保存在服务器。尚未接通的功能暂不可用。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
|
||||
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor color={real ? (state.identity as RealIdentity).color : undefined}/><div className="growth"><div><h4>最初就有的</h4>{real ? (state.identity as RealIdentity).color.traits : '细腻、慢热(示例)'}</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : real ? '长期记忆尚未接通。聊天记录已独立保存。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
|
||||
{section === 'relation' && <><Field label="你们的关系"><select className="select" value={draft.identity.relation} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, relation: e.target.value as '朋友' | '恋人' } }))}><option>朋友</option><option>恋人</option></select></Field><Field label="她怎么称呼你"><input className="input" value={draft.identity.nickname} maxLength={40} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, nickname: e.target.value } }))}/></Field><Field label="相处边界"><textarea className="textarea" maxLength={2000} value={draft.identity.boundary} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, boundary: e.target.value } }))}/></Field><p className="d-foot">改关系需要再次确认,已有记忆与信任都保留,底色不变。</p></>}
|
||||
{section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
|
||||
{section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
|
||||
{section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
|
||||
{section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
|
||||
{!real && section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
|
||||
{!real && section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
|
||||
{!real && section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
|
||||
{!real && section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
|
||||
{section === 'display' && <><div className="opt-row">{(['moon', 'fog'] as Theme[]).map(t => <button className="opt" data-t={t} key={t} aria-pressed={theme === t} onClick={() => setTheme(t)}><div className="sw"/><b>{t === 'moon' ? '靛蓝月光' : '蓝紫雾光'}</b><span>立即切换,保留你的选择</span></button>)}</div><label className="switch-row">减少动效<input type="checkbox" checked={draft.settings.reduced} onChange={e => settings({ reduced: e.target.checked })}/></label><p className="d-foot">也会自动尊重系统的减少动效设置。切到其他网页时暂停背景动画。</p></>}
|
||||
<div ref={feedback}><Status text={message} error={error}/></div>{['relation', 'city', 'services', 'display'].includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
|
||||
{real && ['city', 'appearance', 'backup', 'services'].includes(section) && <div className="card"><h4>尚未接通</h4><p>{section === 'services' ? '文字对话已接真实模型。语音、视觉与长期记忆尚未接通。' : '这项功能将在后续批次接入,目前不执行保存、导入或备份。'}</p></div>}
|
||||
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
|
||||
{confirm && <Modal title={confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'relation')
|
||||
void save(true); if (which === 'appearance') {
|
||||
try {
|
||||
|
||||
@@ -52,4 +52,6 @@ export function Modal({ title, children, onClose, onConfirm, confirm = '确认',
|
||||
} }; document.addEventListener('keydown', key, true); return () => { document.removeEventListener('keydown', key, true); prev?.focus(); }; }, []);
|
||||
return <><div className="scrim open" onClick={onClose}/><div className={`modal glass open ${danger ? 'danger' : ''}`} ref={root} tabIndex={-1} role="dialog" aria-modal="true" aria-label={title}><div className="mi"><Icon name={danger ? 'warn' : 'sparkle'}/></div><h3>{title}</h3><div className="mb">{children}</div><div className="acts"><Button onClick={onClose}>取消</Button><Button primary disabled={disabled} onClick={onConfirm}>{confirm}</Button></div></div></>;
|
||||
}
|
||||
export function BaseColor() { return <><div className="card"><h4><Icon name="lock"/>出生底色 · 演示预览</h4><div className="pillars">{['年柱', '月柱', '日柱', '时柱'].map(p => <div key={p}><small>{p}</small><b>待接</b></div>)}</div><div className="lean"><span>细腻</span><span>慢热</span><span>有自己的节奏</span></div><p>这些是演示倾向,不是固定台词。真实排盘待接 Python 八字编译器,不以示例代替计算。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读,成长另存。</p></>; }
|
||||
export function BaseColor({ color }: { color?: { pillars: Record<string, string>; traits: string } } = {}) {
|
||||
if (color) return <><div className="card"><h4><Icon name="lock"/>出生底色 · 真实排盘</h4><div className="pillars">{[['year','年柱'], ['month','月柱'], ['day','日柱'], ['time','时柱']].map(([key, label]) => <div key={key}><small>{label}</small><b>{color.pillars[key]}</b></div>)}</div><p>{color.traits}。底色是倾向,保留诠释与成长的空间。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读。</p></>;
|
||||
return <><div className="card"><h4><Icon name="lock"/>出生底色 · 演示预览</h4><div className="pillars">{['年柱', '月柱', '日柱', '时柱'].map(p => <div key={p}><small>{p}</small><b>待接</b></div>)}</div><div className="lean"><span>细腻</span><span>慢热</span><span>有自己的节奏</span></div><p>这些是演示倾向,不是固定台词。真实排盘待接 Python 八字编译器,不以示例代替计算。</p></div><p className="lock"><Icon name="lock"/>确认创建后,生辰与底色只读,成长另存。</p></>; }
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { SceneDesc } from '../domain/model';
|
||||
import { Icon } from '../shared/ui';
|
||||
export interface PresenterProps {
|
||||
scene: SceneDesc;
|
||||
real?: boolean;
|
||||
onClose: () => void;
|
||||
closeLabel: string;
|
||||
presence: string;
|
||||
observation: string;
|
||||
}
|
||||
// Only consumes scene. No life writes, no synthesis, no microphone/camera APIs.
|
||||
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation }: PresenterProps) {
|
||||
export function FallbackPresenter({ scene, onClose, closeLabel, presence, observation, real = false }: PresenterProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">演示状态</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>来自演示生活状态<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
|
||||
return <><section className="portal" aria-label="她的视窗"><div className="life">{!failed && <><img className="img-room" src="/assets/her-room.jpg" alt="卧室窗边的人物参考静帧" onError={() => setFailed(true)}/><img className="img-talk" src="/assets/her-talk.jpg" alt="同一人物的交谈近景参考静帧" onError={() => setFailed(true)}/></>}</div>{failed && <div className="empty">画面暂时没打开,文字仍可使用。</div>}<div className="tint"/><div className="doing"><span className="chip glass"><Icon name="book"/>{scene.activity}</span><span className="tag-demo">{real ? '生活尚未接通' : '演示状态'}</span></div><div className="topleft"><span className="chip">{presence}</span>{observation && <span className="chip observe show">{observation}</span>}</div><button className="close" onClick={onClose}><Icon name="back"/>{closeLabel}</button><span className="mark">示意画面 · 参考静帧</span></section><div className="mini-cap"><b>她此刻 · {scene.activity}</b>{real ? '生活尚未接通' : '来自演示生活状态'}<br />画面为示意静帧<button className="btn-text" onClick={onClose}>回到她身边</button></div></>;
|
||||
}
|
||||
|
||||
@@ -56,10 +56,8 @@ try:
|
||||
page.set_default_timeout(7000)
|
||||
page.on('pageerror', lambda e: errors.append(str(e)))
|
||||
page.on('request', lambda r: requests.append(r.url))
|
||||
page.goto(url)
|
||||
expect(page.get_by_text('真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。')).to_be_visible()
|
||||
expect(page.get_by_role('button', name='演示场景', exact=False)).to_have_count(0)
|
||||
page.get_by_role('link', name='进入本机演示').click()
|
||||
page.goto(url + '/?demo=1')
|
||||
expect(page.get_by_role('button', name='演示场景', exact=False)).to_be_visible()
|
||||
shot(page, f'{width}-creation-moon')
|
||||
click(page, '下一步')
|
||||
expect(page.get_by_role('alert')).to_contain_text('名字')
|
||||
@@ -198,7 +196,8 @@ try:
|
||||
context.close()
|
||||
assert not errors, errors
|
||||
assert not [r for r in requests if not r.startswith(url)], requests
|
||||
passed('No uncaught browser errors, external requests, microphone or camera requests')
|
||||
assert not [r for r in requests if '/api/' in r], requests
|
||||
passed('No uncaught browser errors, API/external requests, microphone or camera requests')
|
||||
browser.close()
|
||||
finally:
|
||||
server.shutdown(); server.server_close(); thread.join()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RealChatAdapter, readEvents } from '../src/adapters/real';
|
||||
import { TurnGate } from '../src/adapters/demo';
|
||||
import type { ServerEvent } from '../src/domain/model';
|
||||
const event = (t: string, payload: object = {}) => ({ t, epoch: 2, turn_id: 'turn', ...payload });
|
||||
const frame = (e: object) => `data: ${JSON.stringify(e)}\r\n\r\n`;
|
||||
function body(text: string) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return new ReadableStream<Uint8Array>({ start(controller) { for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); controller.close(); } });
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
describe('real SSE transport', () => {
|
||||
it('decodes fragmented Chinese, CRLF, comment and multiple events without losing bytes', async () => {
|
||||
const output: ServerEvent[] = [];
|
||||
await readEvents(body(': ping\r\n\r\n' + frame(event('chat.delta', { delta: '你好🌙' })) + frame(event('chat.final', { utterance: { text: '你好🌙', speech_tags: [] } }))), e => output.push(e));
|
||||
expect(output).toHaveLength(2); expect(output[0]).toHaveProperty('delta', '你好🌙');
|
||||
});
|
||||
it('fails closed on missing final, invalid JSON and SSE errors', async () => {
|
||||
await expect(readEvents(body(frame(event('chat.delta', { delta: '半句' }))), () => {})).rejects.toThrow('连接中断');
|
||||
await expect(readEvents(body('data: {oops}\n\n'), () => {})).rejects.toThrow();
|
||||
await expect(readEvents(body(frame(event('chat.error', { phi: '暂时没连上', retryable: true }))), e => { if (e.t === 'chat.error') throw new Error(e.phi); })).rejects.toThrow('暂时没连上');
|
||||
});
|
||||
it('synchronizes a restarted server epoch and rejects foreign/late turns', async () => {
|
||||
const gate = new TurnGate(), signal = gate.begin('turn'), emit = vi.fn();
|
||||
const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ epoch: 2, llm: { ok: true } }))).mockResolvedValueOnce(new Response(body(frame({ ...event('chat.delta', { delta: '旧' }), epoch: 1 }) + frame({ ...event('chat.delta', { delta: '他轮' }), turn_id: 'other' }) + frame(event('chat.final', { utterance: { text: '新', speech_tags: [] } })))));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await new RealChatAdapter(e => { gate.epoch = e; }).send('你好', 'turn', 1, signal, e => { if (gate.accepts(e)) emit(e); });
|
||||
expect(emit).toHaveBeenCalledTimes(1); expect(gate.epoch).toBe(2);
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body).epoch).toBe(2);
|
||||
expect(fetch.mock.calls[1][1].signal).toBe(signal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Foreground real Chromium acceptance. Uses temporary DB + loopback, never deployed data.
|
||||
Run: .venv311/bin/python web/tests/real_browser_check.py
|
||||
Requires Chromium installed by Playwright; makes at most three real model turns.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT/'tests'))
|
||||
from test_api import live_server, make_app # noqa: E402
|
||||
from cybergril.api.app import create_app # noqa: E402
|
||||
|
||||
CONTROLLED = '--controlled' in sys.argv
|
||||
OUT = ROOT/'docs/r1' / ('controlled' if CONTROLLED else '.')
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
results = {'checks': [], 'real_dialogue': [], 'console_errors': []}
|
||||
|
||||
def passed(text):
|
||||
if CONTROLLED: text = text.replace('real gateway:', 'controlled provider:')
|
||||
print('PASS', text, flush=True)
|
||||
results['checks'].append(text)
|
||||
|
||||
def nav(page, text):
|
||||
page.get_by_role('navigation', name='主导航').get_by_role('button', name=text, exact=True).click()
|
||||
|
||||
def click(page, text):
|
||||
page.get_by_role('button', name=text, exact=True).click()
|
||||
|
||||
def shot(page, name):
|
||||
if CONTROLLED: return
|
||||
page.screenshot(path=str(OUT/(name+'.png')))
|
||||
|
||||
def send(page, text, source='real gateway'):
|
||||
page.get_by_role('textbox', name='想和她说的话').fill(text)
|
||||
click(page, '发送')
|
||||
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible(timeout=65000)
|
||||
assert not page.locator('.dialog .failed').count(), page.locator('.dialog').inner_text()
|
||||
reply = page.locator('.dialog .her').inner_text()
|
||||
results['real_dialogue'].append({'input':text,'reply':reply,'source':'controlled provider' if CONTROLLED else source})
|
||||
print('CONTROLLED_REPLY' if CONTROLLED else 'REAL_REPLY', reply, flush=True)
|
||||
return reply
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='cybergril-r1-') as directory, sync_playwright() as pw:
|
||||
db = Path(directory)/'acceptance.db'
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox'])
|
||||
desktop = browser.new_context(viewport={'width':1440,'height':900}, reduced_motion='reduce')
|
||||
page = desktop.new_page()
|
||||
page.on('pageerror', lambda e: results['console_errors'].append(str(e)))
|
||||
page.add_init_script("""const get=Storage.prototype.getItem;Storage.prototype.getItem=function(key){if(key==='cybergril.demo.v1'){window.demoReads=(window.demoReads||0)+1;throw new Error('REAL MODE READ DEMO');}return get.call(this,key)};""")
|
||||
if CONTROLLED: real_app, initial_provider = make_app(db)
|
||||
else: real_app = create_app(db_path=db)
|
||||
with live_server(real_app) as url:
|
||||
port = urlsplit(url).port
|
||||
page.goto(url)
|
||||
expect(page.get_by_text('1 / 4 · 出生资料')).to_be_visible()
|
||||
page.get_by_label('她的名字', exact=True).fill('星河')
|
||||
page.get_by_label('出生日期', exact=True).fill('2000-10-18')
|
||||
click(page,'下一步')
|
||||
expect(page.get_by_text('出生底色 · 真实排盘')).to_be_visible()
|
||||
assert real_app.state.store.query_one('SELECT count(*) n FROM companion')['n']==0
|
||||
shot(page,'desktop-preview-moon')
|
||||
click(page,'下一步')
|
||||
for relation in ('朋友','恋人'):
|
||||
expect(page.get_by_role('button', name=relation, exact=True)).to_have_attribute('aria-pressed','false')
|
||||
click(page,'下一步')
|
||||
expect(page.get_by_role('alert')).to_contain_text('选择')
|
||||
click(page,'朋友'); click(page,'下一步')
|
||||
page.get_by_role('checkbox').check(); click(page,'确认创建'); click(page,'去看看她')
|
||||
expect(page.locator('.who .name')).to_have_text('星河')
|
||||
passed('desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship')
|
||||
click(page,'和她说话')
|
||||
composer=page.get_by_role('textbox',name='想和她说的话')
|
||||
composer.fill('中文输入法')
|
||||
composer.evaluate("el=>el.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true,isComposing:true}))")
|
||||
expect(composer).to_have_value('中文输入法')
|
||||
composer.press('Shift+Enter'); expect(composer).to_have_value('中文输入法\n')
|
||||
chunks=[]
|
||||
page.on('response', lambda r: chunks.append(r.status) if '/api/chat' in r.url else None)
|
||||
send(page,'请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。')
|
||||
reply2=send(page,'刚才我们的暗号是什么?请直接回答。')
|
||||
if not CONTROLLED: assert '蓝色风铃' in reply2
|
||||
assert len(real_app.state.store.query('SELECT * FROM chat_message'))==4
|
||||
shot(page,'desktop-real-chat-moon')
|
||||
passed('controlled provider: two complete streamed turns' if CONTROLLED else 'real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context')
|
||||
if CONTROLLED: initial_provider.mode='slow'
|
||||
composer.fill('请用中文写一段较长的故事,约800字。'); click(page,'发送')
|
||||
expect(page.get_by_role('button',name='取消本轮回复')).to_be_visible()
|
||||
expect(page.locator('.dialog .her')).not_to_contain_text('她在想',timeout=35000)
|
||||
click(page,'取消本轮回复')
|
||||
# This finite local wait lets the HTTP disconnect reach the isolated service.
|
||||
expect(page.get_by_role('button',name='取消本轮回复')).not_to_be_visible()
|
||||
deadline=time.monotonic()+2
|
||||
while real_app.state.active and time.monotonic()<deadline: time.sleep(.01)
|
||||
assert real_app.state.active is None
|
||||
rows=real_app.state.store.query('SELECT * FROM chat_message')
|
||||
assert len(rows)==5 and rows[-1]['status']=='cancelled'
|
||||
passed('real gateway: cancel after first delta, upstream request closed, no complete assistant row')
|
||||
page.reload(); nav(page,'交谈'); click(page,'完整记录')
|
||||
expect(page.locator('.log-panel')).to_contain_text('蓝色风铃')
|
||||
shot(page,'desktop-history-moon')
|
||||
mobile=browser.new_context(viewport={'width':390,'height':844},reduced_motion='reduce',is_mobile=True,has_touch=True)
|
||||
mp=mobile.new_page(); mp.goto(url)
|
||||
expect(mp.locator('.who .name')).to_have_text('星河')
|
||||
nav(mp,'交谈'); click(mp,'完整记录')
|
||||
expect(mp.locator('.log-panel')).to_contain_text('蓝色风铃')
|
||||
shot(mp,'mobile-history-moon'); click(mp,'关闭记录')
|
||||
click(mp,'蓝紫雾光'); shot(mp,'mobile-chat-fog')
|
||||
assert mp.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
||||
assert page.evaluate('window.demoReads || 0')==0
|
||||
passed('independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter')
|
||||
# A real process lifespan restart on the same origin, with a controlled provider for fault paths.
|
||||
app, provider = make_app(db)
|
||||
with live_server(app, port) as restarted_url:
|
||||
assert restarted_url==url
|
||||
page.get_by_role('button',name='关闭记录').click()
|
||||
send(page,'重启后继续','controlled provider')
|
||||
assert app.state.epoch==2
|
||||
passed('server restart on same origin: epoch 1→2, existing page sends automatically, data retained')
|
||||
provider.mode='error'
|
||||
composer=page.get_by_role('textbox',name='想和她说的话')
|
||||
composer.fill('保留的失败草稿'); click(page,'发送')
|
||||
expect(page.locator('.dialog .failed')).to_be_visible()
|
||||
expect(composer).to_have_value('保留的失败草稿')
|
||||
shot(page,'desktop-model-error')
|
||||
count=app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
|
||||
provider.mode='normal'; click(page,'重试')
|
||||
expect(page.locator('.dialog .failed')).not_to_be_visible()
|
||||
expect(page.get_by_role('button',name='取消本轮回复')).not_to_be_visible()
|
||||
expect(composer).to_have_value('')
|
||||
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']==count+1
|
||||
passed('controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row')
|
||||
provider.mode='slow'
|
||||
composer.fill('取消并紧接新问题'); click(page,'发送')
|
||||
expect(page.locator('.dialog .her')).to_contain_text('答')
|
||||
click(page,'取消本轮回复')
|
||||
provider.mode='normal'; send(page,'新轮不会串台','controlled provider')
|
||||
passed('controlled blocked upstream: immediate cancellation/new question isolation')
|
||||
nav(mp,'设置')
|
||||
mp.get_by_role('navigation',name='设置分区').get_by_role('button',name='关系',exact=True).click()
|
||||
mp.get_by_label('你们的关系',exact=True).select_option('恋人'); click(mp,'取消修改')
|
||||
expect(mp.get_by_label('你们的关系',exact=True)).to_have_value('朋友')
|
||||
mp.get_by_label('你们的关系',exact=True).select_option('恋人'); click(mp,'保存'); click(mp,'取消')
|
||||
assert app.state.store.query_one('SELECT identity_json FROM companion')['identity_json'].find('恋人')<0
|
||||
click(mp,'保存'); mp.get_by_role('dialog').get_by_role('button',name='确认',exact=True).click()
|
||||
expect(mp.get_by_role('status')).to_contain_text('已保存在服务器')
|
||||
shot(mp,'mobile-relation-fog')
|
||||
nav(mp,'此刻'); mp.reload()
|
||||
expect(mp.locator('.meta')).to_contain_text('恋人')
|
||||
nav(mp,'日子'); expect(mp.get_by_text('尚未接通',exact=True)).to_be_visible()
|
||||
assert mp.locator('.demo').count()==0
|
||||
passed('mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console')
|
||||
# Model timeout and abrupt SSE are separately exercised by deterministic backend/unit tests.
|
||||
browser.close()
|
||||
assert not results['console_errors'], results['console_errors']
|
||||
finally:
|
||||
(OUT/'browser-results.json').write_text(json.dumps(results,ensure_ascii=False,indent=2)+'\n')
|
||||