HEL-780 / HEL-783: 真实档案和文字对话接入,修复六项界面问题 #4
@@ -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 |
@@ -0,0 +1,77 @@
|
||||
HEL-783 六项返工交付与复审说明 · 2026-10-22
|
||||
|
||||
本轮由总管在可用的 Codex 环境亲自完成。沿用获批 HEL-746 R2 两主题和现有 R1 API,在原 PR#4 的 `agent/agent/de8874a3803b` 上追加修复,目标仍为 `main`。没有更换业务模型、修改现网或读写用户真实数据库,没有合并。最终提交号以 PR#4 最新 head 为准。
|
||||
|
||||
## 六项反馈闭环
|
||||
|
||||
| 用户反馈 / Before | 本轮 After | 实际验证 / Why |
|
||||
| --- | --- | --- |
|
||||
| 手机对话被输入栏挡住;台词和输入分别固定定位,没有长文高度预算 | `ChatPanel` 统一布局,人物占位、可滚动台词、状态/取消、语音条、输入分别占位;`Viewport` 跟随 visualViewport 的高度、顶部和底部偏移,含安全区。视口变化时人物窗立即调整,不等待装饰过渡 | 1440×900、360×640、390×700、390×844、390×420、844×390,两主题共12组;长中文前后文均在 DOM,可滚到末尾、完整记录能读全文。420高是键盘缩小等效场景,未冒称真机键盘实测 |
|
||||
| 流式全文在结束时被 `slice(-90)` 截成尾部;用户也被 `slice(-48)` 截断 | 当前双方消息保留全文;滚动阅读,跟随阅读位置;完整记录继续提供历史入口。流式和最终使用同一 turn 身份 | 长用户句首尾与五段长回复逐字相等;流式完成前后为同一个 DOM,取消立即中断,未完成文本标明未保存 |
|
||||
| 只写 opacity transition,没有真实改变透明度 | `SoftText` 按轮次用 WAAPI 淡入200ms、旧句保留160ms淡出;流式增量只更新文字,不重播。使用现有 `--ease-out`;减少动效120ms轻淡化 | 实际动画0/80/200ms逐帧截图及 computed opacity/transform;旧句0/80/159ms仍挂载、透明度递减。全轮只有一次入场,完成不重播,取消不等动画 |
|
||||
| 滚动条脱离主题 | 两套主题各有轨道/滑块/悬停 token;全局标准属性与 Chromium 兼容样式,强制色使用系统色;触摸区仍原生滚动 | 实际消息、记录、首建、设置长内容滚动及两主题截图;强制色有 CSS 回退。没有做真实辅助设备和各系统滚动条外观全覆盖测试 |
|
||||
| 底色只有公历和几个内部短标签 | 新增只读日历展示与六维解释映射:表达社交、亲密边界、冲突修复、行动节律、压力恢复、成长张力。显示真实农历/闰月、钟点、四柱和术语提示;每段可展开解释依据。预览/确认/已有档案共用组件 | 48组日期×时辰检查,含1900边界、春节、立春附近、真实闰四月;预览不写库。旧档案使用已存四柱、十神、Derived,禁止重新编译;GET前后原始JSON、底色行一致,关系修改不改底色和历史;聊天系统提示逐段复用同一解释 |
|
||||
| 分不清谁在说话 | 当前对话中“你”右对齐,真实伴侣名字左对齐,独立角色行;阅读状态行持续标明伴侣名字,滚动后也能确认正在读谁的回复。流式、最终、失败、取消与记录都使用同一身份 | 两主题截图,失败/重试、取消新轮、历史恢复都检查;不单靠颜色辨别 |
|
||||
| 真实入口把完整页面缩成“未接通”卡片 | 恢复日子/回忆双栏与分组;城市、外观、备份、四类服务的完整结构和真实空态;服务页可读健康检查,未接通写入控件明确禁用;加入未保存离开确认 | 实际手机逐页进入、返回、搜索空态、服务检查、未保存取消/放弃、演示事件→聊天→返回。真实入口无演示记录,演示模式全流程零API请求 |
|
||||
|
||||
## R2 功能覆盖差异表
|
||||
|
||||
“已接真”表示已有 API 持续可用;“本批补齐”表示前端结构/交互已恢复;“后台待接”表示本轮没有增加该后台能力,不能据此验收为已接通。
|
||||
|
||||
| R2 落点 | 演示模式 | 真实入口 | 本批结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 四类区域、两主题、底部导航 | 原有此刻/交谈/日子/设置 | 同样四区与主题切换 | 保留;手机短高度首页内容可滚动 |
|
||||
| 首页留言与日子入口 | 固定演示留言、已分享日子入口 | 服务器最近回复可打开完整记录;日子入口明确后台待接 | 本批补齐真实入口,历史已接真;不伪称主动留言 |
|
||||
| 日子列表、按日分组、三类事件、分页 | 原有已分享事件与18+12分页 | 完整列表区、三类图例和空态 | 本批补齐结构;真实生活后台待接,无示例填充 |
|
||||
| 事件详情、回忆互链、聊天与返回 | 可走详情→关联→聊天→回到原日子 | 详情阅读区域保留,无数据时不造条目/链接 | 演示路径实测;真实数据后台待接 |
|
||||
| 我们一起的/小习惯/她知道的你/成长 | 四组演示回忆、来源、关联日子 | 四组独立空态和阅读区 | 本批补齐真实分组;长期记忆后台待接 |
|
||||
| 隐私:她说过才显示 | 继续使用 publicView 过滤 | 当前没有生活/回忆数据 | 不放宽隐私,不展示未讲述事件 |
|
||||
| 首建四步、关系无预选、同意确认 | 本机独立创建;排盘不以固定假值冒充 | 服务端唯一档案、只读预览、实际创建 | 已接真;补公农历/闰月/四柱帮助和六维说明 |
|
||||
| 首建失败、返回修改、重试 | 原有模拟失败且保留输入 | API 中文错误、输入保留、409恢复唯一档案 | 保留;未增建档入口或清空入口 |
|
||||
| 她的档案与底色锁定 | 演示底色明确未计算 | 旧档案只读补展示,原参数和历史不动 | 本批补齐;无数据库迁移 |
|
||||
| 最初与后来成长分层 | 两栏及成长回忆 | 出生说明与后续记忆区分,当前长期记忆未接 | 不把出生说明当作已经发生的经历 |
|
||||
| 关系、称呼、边界;改变关系确认 | 原有模拟保存/取消/失败 | PATCH真实保存、二次确认、重开恢复 | 已接真,回归通过 |
|
||||
| 未保存提示、取消和离开 | 原有暖色点、取消、beforeunload | 同样提示;关闭/导航离开新增确认 | 本批补齐;取消继续编辑,放弃不写服务器 |
|
||||
| 城市搜索与列表/无结果 | 原有候选城市本机保存与失败演示 | 可搜索候选目录,有空态;明确未设置真实城市,保存禁用 | 本批补齐;真实城市/天气后台待接 |
|
||||
| 外观选择、五项检查、对比、确认 | JSON结构检查、对比与模拟标识确认 | 当前参考、文件入口、五项待检查、新旧对比、应用入口 | 本批补齐;真实导入与媒体检查禁用,后台待接 |
|
||||
| 自动备份列表、完整导出 | 已标真实备份未接通;本机演示JSON可导出 | 自动备份空列表、完整导出说明和禁用按钮 | 本批补齐;不制造成功备份 |
|
||||
| 恢复检查、同一档案、双重确认、先导出 | 原有本机恢复检查和输入“恢复” | 明示四步恢复过程;文件和执行入口禁用 | 本批补齐结构;真实恢复后台待接,不能实际覆盖 |
|
||||
| 四类外接服务与测试反馈 | 只保存演示地址,不发请求 | 文字、语音识别、语音合成、视觉四项;文字可重新检查健康,配置只读 | 本批补齐;不暴露凭据,不模拟其他连接成功 |
|
||||
| 显示、减少动效 | 原有本机设置 | 主题与减少动效保存于本浏览器,和服务器资料分开 | 减少动效刷新恢复;系统设置也生效 |
|
||||
| 长文字、完整记录、滚动、角色 | 模拟流式继续可用 | SSE、完整记录、失败与取消状态 | 本批修复全文、占位和角色;已接真 |
|
||||
| 重试、轮次/epoch、重启、多设备恢复 | 原有 TurnGate | 原有传输机制保留 | 确定性API测试和浏览器回归通过 |
|
||||
| 连续语音、打断、结束、拒绝/失败状态 | 保留无收音模拟和权限说明;新布局语音条不挡输入 | 控件明确尚未接通,文字仍可用 | 真实语音后台待接,未申请设备权限 |
|
||||
| 视觉说明、时效、失败、总服务区 | 原有30秒模拟过期与失败 | 明确未接通、不采集;服务项保留 | 真实视觉后台待接 |
|
||||
| 断线/模型不可达/草稿 | 原有断线重连演示 | 实际请求错误、草稿保留、重试、服务状态;已有档案历史可读 | 保留已接真能力;自动重连推送属后续,不假造补消息 |
|
||||
| 离开欢迎摘要、主动消息与未读 | 原有明确标注的固定演示,来源为已分享事件 | 生活/主动消息未接通,不伪造欢迎摘要或来信 | 后台待接;真实最近回复入口已保留 |
|
||||
| 手机列表/详情与设置返回、Esc | 保留推入和返回、原日子位置 | 同样区域/分区返回;Esc关闭记录再退出交流 | 本批补齐真实Esc与未保存确认 |
|
||||
| 新设计建议 | 无 | 无 | 未把新增后台能力夹进本批 |
|
||||
|
||||
## 验证记录
|
||||
|
||||
- Python 3.11:`pytest -q` **142通过**,含原92项和新增50项。唯一提示为既有 Starlette TestClient 依赖弃用提示。
|
||||
- TypeScript类型检查、ESLint、前端 **14项** Vitest、Vite生产构建通过。后端变更文件 Ruff通过。没有新增运行时依赖。
|
||||
- `web/tests/rework_browser_check.py`:8组通过,12组尺寸/主题组合;记录 `browser-results.json`。使用隔离SQLite和可控模型验证长中文、错误、取消与完整页面,不把可控模型说成在线模型。
|
||||
- `web/tests/real_browser_check.py --controlled`:8组回归通过,记录 `r1-regression/browser-results.json`。包括真实HTTP SSE、浏览器关闭流、立即新轮、同源重启epoch变化、多设备恢复、关系取消/确认保存。
|
||||
- `web/tests/real_browser_check.py`:真实批准网关完成两轮对话,第二轮正确回答上一轮暗号“蓝色风铃”;第三轮收到增量后取消,上游请求结束且没有完整回复落库。随后同源重启和故障段使用可控provider,结果逐项区分,记录 `live-gateway/browser-results.json`。所有建档、聊天和关系写入仍只在临时库。
|
||||
- 动效证据:`motion-enter-0/80/200.png` 和结果JSON中的 `motion.entry_frames/exit_frames`。截图暂停实际 WAAPI 后逐帧采样,随后恢复;没有为截图修改生产动画时长。
|
||||
- 代表图:`long-start-360-640-*.png`、`long-start/long-end-390-420-*.png`、`full-history-*.png`、`birth-*.png`、`existing-*.png`、城市/外观/备份/服务/生活回忆各图。图中“星河/小满”和聊天全部是本轮隔离测试资料。
|
||||
- 已实际看过代表性手机长文、短视口、语音模拟、出生预览、已有档案说明、服务页、回忆分组截图;复审仍需独立看图、播放实际界面,不能只用坐标和测试数认定视觉通过。
|
||||
- 未测:实体手机键盘、Safari、真实强制色设备、屏幕阅读器、长时运行和帧率。420/390高只证明等效可见高度布局,不等于真机键盘实测。
|
||||
|
||||
## 说明层的兼容性与回滚
|
||||
|
||||
`core/birth_view.py` 是可维护的确定性解释映射,不调用模型润色。分档值只选择解释段落,不产生新的底色参数。敏感度的展示分档与普通0–1参数分别归一到解释选择范围。四柱与十神取旧库已存值,五行侧重和性格取旧Derived;日历只从不可变生日、时辰换算展示。没有重新调用旧档案的四柱编译器,也没有把新说明写回旧档案。新增档案会在原有JSON中携带可选展示字段;旧字段仍保留,旧前端不依赖新增字段。
|
||||
|
||||
聊天提示与UI共用六段解释,仍明确本体、现实边界、未接通能力和不能编造生活。取消、历史、epoch、路由和业务模型配置未改。服务端关系修改只保存关系字段及原档案JSON,不把本次只读展示偷偷写回旧档案。
|
||||
|
||||
本批无schema改动、不需要重建库。回滚只切回上一个程序版本;旧版忽略JSON新增字段。服务器切换前按HEL-784要求备份,不能删除、替换或重算用户档案。浏览器新加 `cg.real.display` 仅保存减少动效偏好;不存真实聊天或底色。
|
||||
|
||||
## 给HEL-784的部署交接
|
||||
|
||||
1. 重新读取PR#4最新head,与本轮交付SHA一致后审核;base仍main。远端若另有推进,先协调,不覆盖。
|
||||
2. 独立运行检查,实际看截图及流式/取消/渐入渐出,特别检查用户原截图对应的手机短视口和长回复。
|
||||
3. 安装方式沿用 `docs/r1/IMPLEMENTATION.md`:Python≥3.11、`requirements.lock`、安装当前包,`npm ci --prefix web` 和 `npm run build --prefix web`。浏览器测试可选Playwright,不是部署依赖。
|
||||
4. 审核通过才部署8310的新release,保留现有用户库,切换前备份;不要把本轮临时测试库、测试人物或演示存储带上服务器。8300和其他服务不动。
|
||||
5. 回读现有档案/历史与health,检查新静态资源和旧档案补充说明;附件不要泄露真实聊天全文。记录部署SHA、备份位置和回滚指令。
|
||||
6. 本批用户尚未验收,不合并、不归档、不推进到收尾。
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 557 KiB |
|
After Width: | Height: | Size: 516 KiB |
@@ -0,0 +1,391 @@
|
||||
{
|
||||
"checks": [
|
||||
"Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows",
|
||||
"Long Chinese stream: entire user/reply retained; final keeps same DOM; one entrance across all chunks and final",
|
||||
"Old sentence stays mounted during a visible 160ms fade-out, then leaves",
|
||||
"12 viewport/theme combinations: long text scrolls, stage/dialog/composer/navigation never overlap; full history reachable",
|
||||
"Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages",
|
||||
"Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard",
|
||||
"Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity",
|
||||
"Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "moon",
|
||||
"dialog": {
|
||||
"top": 564,
|
||||
"bottom": 734,
|
||||
"height": 170
|
||||
},
|
||||
"composer": {
|
||||
"top": 746,
|
||||
"bottom": 806,
|
||||
"height": 60
|
||||
},
|
||||
"nav": {
|
||||
"top": 818,
|
||||
"bottom": 878,
|
||||
"height": 60
|
||||
},
|
||||
"stage": {
|
||||
"top": 72,
|
||||
"bottom": 552,
|
||||
"height": 480
|
||||
},
|
||||
"scrollHeight": 1848,
|
||||
"clientHeight": 142,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "fog",
|
||||
"dialog": {
|
||||
"top": 564,
|
||||
"bottom": 734,
|
||||
"height": 170
|
||||
},
|
||||
"composer": {
|
||||
"top": 746,
|
||||
"bottom": 806,
|
||||
"height": 60
|
||||
},
|
||||
"nav": {
|
||||
"top": 818,
|
||||
"bottom": 878,
|
||||
"height": 60
|
||||
},
|
||||
"stage": {
|
||||
"top": 72,
|
||||
"bottom": 552,
|
||||
"height": 480
|
||||
},
|
||||
"scrollHeight": 1848,
|
||||
"clientHeight": 142,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 640,
|
||||
"theme": "moon",
|
||||
"dialog": {
|
||||
"top": 220,
|
||||
"bottom": 492,
|
||||
"height": 272
|
||||
},
|
||||
"composer": {
|
||||
"top": 500,
|
||||
"bottom": 556,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 566,
|
||||
"bottom": 624,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 212,
|
||||
"height": 154
|
||||
},
|
||||
"scrollHeight": 2876,
|
||||
"clientHeight": 244,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 640,
|
||||
"theme": "fog",
|
||||
"dialog": {
|
||||
"top": 220,
|
||||
"bottom": 492,
|
||||
"height": 272
|
||||
},
|
||||
"composer": {
|
||||
"top": 500,
|
||||
"bottom": 556,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 566,
|
||||
"bottom": 624,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 212,
|
||||
"height": 154
|
||||
},
|
||||
"scrollHeight": 2876,
|
||||
"clientHeight": 244,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 700,
|
||||
"theme": "moon",
|
||||
"dialog": {
|
||||
"top": 253,
|
||||
"bottom": 552,
|
||||
"height": 299
|
||||
},
|
||||
"composer": {
|
||||
"top": 560,
|
||||
"bottom": 616,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 626,
|
||||
"bottom": 684,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 245,
|
||||
"height": 187
|
||||
},
|
||||
"scrollHeight": 2735,
|
||||
"clientHeight": 271,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 700,
|
||||
"theme": "fog",
|
||||
"dialog": {
|
||||
"top": 253,
|
||||
"bottom": 552,
|
||||
"height": 299
|
||||
},
|
||||
"composer": {
|
||||
"top": 560,
|
||||
"bottom": 616,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 626,
|
||||
"bottom": 684,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 245,
|
||||
"height": 187
|
||||
},
|
||||
"scrollHeight": 2735,
|
||||
"clientHeight": 271,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"theme": "moon",
|
||||
"dialog": {
|
||||
"top": 332.1875,
|
||||
"bottom": 696,
|
||||
"height": 363.8125
|
||||
},
|
||||
"composer": {
|
||||
"top": 704,
|
||||
"bottom": 760,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 770,
|
||||
"bottom": 828,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 324.1875,
|
||||
"height": 266.1875
|
||||
},
|
||||
"scrollHeight": 2735,
|
||||
"clientHeight": 335,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"theme": "fog",
|
||||
"dialog": {
|
||||
"top": 332.1875,
|
||||
"bottom": 696,
|
||||
"height": 363.8125
|
||||
},
|
||||
"composer": {
|
||||
"top": 704,
|
||||
"bottom": 760,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 770,
|
||||
"bottom": 828,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 324.1875,
|
||||
"height": 266.1875
|
||||
},
|
||||
"scrollHeight": 2735,
|
||||
"clientHeight": 335,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 420,
|
||||
"theme": "moon",
|
||||
"dialog": {
|
||||
"top": 106,
|
||||
"bottom": 272,
|
||||
"height": 166
|
||||
},
|
||||
"composer": {
|
||||
"top": 280,
|
||||
"bottom": 336,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 346,
|
||||
"bottom": 404,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 98,
|
||||
"height": 40
|
||||
},
|
||||
"scrollHeight": 2735,
|
||||
"clientHeight": 138,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 420,
|
||||
"theme": "fog",
|
||||
"dialog": {
|
||||
"top": 106,
|
||||
"bottom": 272,
|
||||
"height": 166
|
||||
},
|
||||
"composer": {
|
||||
"top": 280,
|
||||
"bottom": 336,
|
||||
"height": 56
|
||||
},
|
||||
"nav": {
|
||||
"top": 346,
|
||||
"bottom": 404,
|
||||
"height": 58
|
||||
},
|
||||
"stage": {
|
||||
"top": 58,
|
||||
"bottom": 98,
|
||||
"height": 40
|
||||
},
|
||||
"scrollHeight": 2735,
|
||||
"clientHeight": 138,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 844,
|
||||
"height": 390,
|
||||
"theme": "moon",
|
||||
"dialog": {
|
||||
"top": 124,
|
||||
"bottom": 224,
|
||||
"height": 100
|
||||
},
|
||||
"composer": {
|
||||
"top": 236,
|
||||
"bottom": 296,
|
||||
"height": 60
|
||||
},
|
||||
"nav": {
|
||||
"top": 308,
|
||||
"bottom": 368,
|
||||
"height": 60
|
||||
},
|
||||
"stage": {
|
||||
"top": 72,
|
||||
"bottom": 112,
|
||||
"height": 40
|
||||
},
|
||||
"scrollHeight": 2178,
|
||||
"clientHeight": 72,
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 844,
|
||||
"height": 390,
|
||||
"theme": "fog",
|
||||
"dialog": {
|
||||
"top": 124,
|
||||
"bottom": 224,
|
||||
"height": 100
|
||||
},
|
||||
"composer": {
|
||||
"top": 236,
|
||||
"bottom": 296,
|
||||
"height": 60
|
||||
},
|
||||
"nav": {
|
||||
"top": 308,
|
||||
"bottom": 368,
|
||||
"height": 60
|
||||
},
|
||||
"stage": {
|
||||
"top": 72,
|
||||
"bottom": 112,
|
||||
"height": 40
|
||||
},
|
||||
"scrollHeight": 2178,
|
||||
"clientHeight": 72,
|
||||
"overflow": false
|
||||
}
|
||||
],
|
||||
"motion": {
|
||||
"entry_frames": [
|
||||
{
|
||||
"time": 0,
|
||||
"opacity": "0",
|
||||
"transform": "matrix(1, 0, 0, 1, 0, 3)"
|
||||
},
|
||||
{
|
||||
"time": 80,
|
||||
"opacity": "0.917147",
|
||||
"transform": "matrix(1, 0, 0, 1, 0, 0.248559)"
|
||||
},
|
||||
{
|
||||
"time": 200,
|
||||
"opacity": "1",
|
||||
"transform": "none"
|
||||
}
|
||||
],
|
||||
"exit_frames": [
|
||||
{
|
||||
"time": 0,
|
||||
"opacity": "1",
|
||||
"retained": true
|
||||
},
|
||||
{
|
||||
"time": 80,
|
||||
"opacity": "0.0386175",
|
||||
"retained": true
|
||||
},
|
||||
{
|
||||
"time": 159,
|
||||
"opacity": "3.47566e-08",
|
||||
"retained": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"console_errors": [],
|
||||
"limits": [
|
||||
"Chromium only; physical phone keyboard, Safari and screen reader not tested.",
|
||||
"LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 124 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"checks": [
|
||||
"desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship",
|
||||
"real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context",
|
||||
"real gateway: cancel after first delta, upstream request closed, no complete assistant row",
|
||||
"independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter",
|
||||
"server restart on same origin: epoch 1→2, existing page sends automatically, data retained",
|
||||
"controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row",
|
||||
"controlled blocked upstream: immediate cancellation/new question isolation",
|
||||
"mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console"
|
||||
],
|
||||
"real_dialogue": [
|
||||
{
|
||||
"input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。",
|
||||
"reply": "好的,暗号是蓝色风铃。",
|
||||
"source": "real gateway"
|
||||
},
|
||||
{
|
||||
"input": "刚才我们的暗号是什么?请直接回答。",
|
||||
"reply": "蓝色风铃。",
|
||||
"source": "real gateway"
|
||||
},
|
||||
{
|
||||
"input": "重启后继续",
|
||||
"reply": "答复",
|
||||
"source": "controlled provider"
|
||||
},
|
||||
{
|
||||
"input": "新轮不会串台",
|
||||
"reply": "答复",
|
||||
"source": "controlled provider"
|
||||
}
|
||||
],
|
||||
"console_errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 974 KiB |
|
After Width: | Height: | Size: 1016 KiB |
|
After Width: | Height: | Size: 1017 KiB |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"checks": [
|
||||
"desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship",
|
||||
"controlled provider: two complete streamed turns",
|
||||
"controlled provider: cancel after first delta, upstream request closed, no complete assistant row",
|
||||
"independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter",
|
||||
"server restart on same origin: epoch 1→2, existing page sends automatically, data retained",
|
||||
"controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row",
|
||||
"controlled blocked upstream: immediate cancellation/new question isolation",
|
||||
"mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console"
|
||||
],
|
||||
"real_dialogue": [
|
||||
{
|
||||
"input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。",
|
||||
"reply": "答复",
|
||||
"source": "controlled provider"
|
||||
},
|
||||
{
|
||||
"input": "刚才我们的暗号是什么?请直接回答。",
|
||||
"reply": "答复",
|
||||
"source": "controlled provider"
|
||||
},
|
||||
{
|
||||
"input": "重启后继续",
|
||||
"reply": "答复",
|
||||
"source": "controlled provider"
|
||||
},
|
||||
{
|
||||
"input": "新轮不会串台",
|
||||
"reply": "答复",
|
||||
"source": "controlled provider"
|
||||
}
|
||||
],
|
||||
"console_errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 97 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,518 @@
|
||||
"""Single-process R1 API. Store is authoritative; no demo state enters this module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import asdict
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
from anyio import CancelScope
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from ..config import ModelsConfig
|
||||
from ..contracts import LLMMessage
|
||||
from ..core.bazi import (
|
||||
GAN_ELEM,
|
||||
Derived,
|
||||
Pillars,
|
||||
compile_pillars,
|
||||
element_scores,
|
||||
pillars_from_lunar,
|
||||
ten_god_distribution,
|
||||
)
|
||||
from ..core.birth_view import calendar_view, color_view
|
||||
from ..memory.store import Store
|
||||
from ..think.scenes import Scene
|
||||
from ..think.wiring import build_router_from_config
|
||||
|
||||
HOURS = [
|
||||
"子时",
|
||||
"丑时",
|
||||
"寅时",
|
||||
"卯时",
|
||||
"辰时",
|
||||
"巳时",
|
||||
"午时",
|
||||
"未时",
|
||||
"申时",
|
||||
"酉时",
|
||||
"戌时",
|
||||
"亥时",
|
||||
]
|
||||
ERROR = "她这会儿没接上话,稍后再试试。你写的话还在。"
|
||||
|
||||
|
||||
def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str:
|
||||
value = value.strip()
|
||||
if (
|
||||
(required and not value)
|
||||
or len(value) > maximum
|
||||
or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value)
|
||||
):
|
||||
raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。")
|
||||
return value
|
||||
|
||||
|
||||
class StrictBody(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
|
||||
class BirthInput(StrictBody):
|
||||
name: str
|
||||
birth: str
|
||||
hour: str
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_valid(cls, value: str) -> str:
|
||||
return safe_text(value, 20, "名字", True)
|
||||
|
||||
@field_validator("birth")
|
||||
@classmethod
|
||||
def birth_valid(cls, value: str) -> str:
|
||||
try:
|
||||
born = date.fromisoformat(value)
|
||||
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
if born.isoformat() != value or born.year < 1900 or age < 18:
|
||||
raise ValueError
|
||||
except ValueError as exc:
|
||||
raise ValueError("请填写有效的出生日期,且已满18岁(不早于1900年)。") from exc
|
||||
return value
|
||||
|
||||
@field_validator("hour")
|
||||
@classmethod
|
||||
def hour_valid(cls, value: str) -> str:
|
||||
if value not in HOURS:
|
||||
raise ValueError("请选择有效的出生时辰。")
|
||||
return value
|
||||
|
||||
|
||||
class RelationInput(StrictBody):
|
||||
relation: Literal["朋友", "恋人"]
|
||||
nickname: str = ""
|
||||
boundary: str = "尊重彼此的节奏"
|
||||
|
||||
@field_validator("nickname")
|
||||
@classmethod
|
||||
def nickname_valid(cls, value: str) -> str:
|
||||
return safe_text(value, 40, "称呼")
|
||||
|
||||
@field_validator("boundary")
|
||||
@classmethod
|
||||
def boundary_valid(cls, value: str) -> str:
|
||||
return safe_text(value, 2000, "相处边界")
|
||||
|
||||
|
||||
class CreationInput(BirthInput, RelationInput):
|
||||
pass
|
||||
|
||||
|
||||
class ChatInput(StrictBody):
|
||||
text: str
|
||||
turn_id: str
|
||||
epoch: int
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def text_valid(cls, value: str) -> str:
|
||||
if not value.strip() or len(value) > 4000:
|
||||
raise ValueError("请输入1–4000字的消息。")
|
||||
return value.strip()
|
||||
|
||||
@field_validator("turn_id")
|
||||
@classmethod
|
||||
def turn_valid(cls, value: str) -> str:
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("轮次标识无效,请刷新后重试。") from exc
|
||||
return value
|
||||
|
||||
|
||||
def compile_birth(data: BirthInput) -> tuple[dict, object, object]:
|
||||
born = date.fromisoformat(data.birth)
|
||||
pillars = pillars_from_lunar(born.year, born.month, born.day, HOURS.index(data.hour) * 2)
|
||||
derived = compile_pillars(pillars)
|
||||
return color_view(data.birth, data.hour, pillars, derived), pillars, derived
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
db_path: Path | str | None = None,
|
||||
models: ModelsConfig | None = None,
|
||||
router=None,
|
||||
web_dir: Path | None = None,
|
||||
stream_timeout: float = 60,
|
||||
) -> FastAPI:
|
||||
"""Dependencies can be injected for isolated fault/cancellation tests, never via browser flags."""
|
||||
db_path = db_path or os.environ.get(
|
||||
"CYBERGRIL_DB", str(Path.home() / ".local/share/cybergril/companion.db")
|
||||
)
|
||||
if models is None:
|
||||
config = Path(os.environ.get("CYBERGRIL_MODELS", "config/models.yaml"))
|
||||
models = ModelsConfig(**yaml.safe_load(config.read_text()))
|
||||
router = router or build_router_from_config(models)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
store = Store(db_path)
|
||||
app.state.store = store
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO api_meta(key,value) VALUES('epoch',1) ON CONFLICT(key) DO UPDATE SET value=value+1"
|
||||
)
|
||||
store.execute("UPDATE chat_message SET status='cancelled' WHERE status='pending'")
|
||||
app.state.epoch = store.query_one("SELECT value FROM api_meta WHERE key='epoch'")["value"]
|
||||
app.state.active = None
|
||||
app.state.chat_lock = asyncio.Lock()
|
||||
app.state.health_cache = (0, False)
|
||||
app.state.health_lock = asyncio.Lock()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if app.state.active:
|
||||
app.state.active["cancel"].set()
|
||||
await app.state.active["finished"].wait()
|
||||
for provider in set(router.provider_for(t) for t in router.available_tiers()):
|
||||
await provider.aclose()
|
||||
store.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def invalid(_request, exc):
|
||||
fields = {}
|
||||
for e in exc.errors():
|
||||
key = str(e["loc"][-1])
|
||||
msg = e["msg"].removeprefix("Value error, ")
|
||||
if e["type"] == "missing":
|
||||
msg = "请填写此项。" if key != "relation" else "请亲自选择你们的关系。"
|
||||
elif e["type"] == "literal_error":
|
||||
msg = "请亲自选择朋友或恋人。"
|
||||
elif e["type"] == "extra_forbidden":
|
||||
msg = "此字段不允许修改。"
|
||||
elif not any("\u4e00" <= c <= "\u9fff" for c in msg):
|
||||
msg = "字段格式无效,请检查后重试。"
|
||||
fields[key] = msg
|
||||
return JSONResponse({"detail": "请检查填写内容。", "fields": fields}, status_code=422)
|
||||
|
||||
def companion(enrich=True):
|
||||
row = app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1")
|
||||
if not row:
|
||||
raise HTTPException(404, "还没有创建真实伴侣。")
|
||||
identity = json.loads(row["identity_json"])
|
||||
if enrich:
|
||||
saved = app.state.store.query_one("SELECT pillars,derived,ten_gods FROM bazi_core WHERE id=1")
|
||||
if saved:
|
||||
# Saved parameters are authoritative, even if a compiler later changes.
|
||||
identity["color"] = color_view(
|
||||
identity["birth"], identity["hour"],
|
||||
Pillars(**json.loads(saved["pillars"])), Derived(**json.loads(saved["derived"])),
|
||||
saved_gods=json.loads(saved["ten_gods"]),
|
||||
)
|
||||
else:
|
||||
identity["color"]["calendar"] = calendar_view(identity["birth"], identity["hour"])
|
||||
return identity
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
async with app.state.health_lock:
|
||||
checked, ok = app.state.health_cache
|
||||
if time.monotonic() - checked > 30:
|
||||
provider = router.route(Scene.DIALOGUE_MAIN).provider
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
ok = bool(provider and (await provider.health()).ok)
|
||||
except Exception:
|
||||
ok = False
|
||||
app.state.health_cache = (time.monotonic(), ok)
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "R1",
|
||||
"epoch": app.state.epoch,
|
||||
"companion_created": bool(app.state.store.query_one("SELECT id FROM companion")),
|
||||
"llm": {"ok": ok, "model": models.tiers["high"].model},
|
||||
"capabilities": {
|
||||
"text": True,
|
||||
**dict.fromkeys(
|
||||
["life", "memory", "voice", "vision", "weather", "materials", "backup"], False
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@app.get("/api/companion")
|
||||
async def get_companion():
|
||||
return companion()
|
||||
|
||||
@app.post("/api/companion/preview")
|
||||
async def preview(data: BirthInput):
|
||||
return compile_birth(data)[0]
|
||||
|
||||
@app.post("/api/companion", status_code=201)
|
||||
async def create(data: CreationInput):
|
||||
store = app.state.store
|
||||
if store.query_one("SELECT id FROM companion"):
|
||||
raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。")
|
||||
color, pillars, derived = compile_birth(data)
|
||||
identity = {"id": "1", **data.model_dump(), "color": color}
|
||||
try:
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO companion VALUES(1,?)", (json.dumps(identity, ensure_ascii=False),)
|
||||
)
|
||||
store.execute(
|
||||
"""INSERT INTO bazi_core
|
||||
(id,birth_input,pillars,day_master,day_master_elem,ten_gods,element_scores,strength,derived,created_at)
|
||||
VALUES(1,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
json.dumps({"birth": data.birth, "hour": data.hour, "tz": "Asia/Shanghai"}),
|
||||
json.dumps(asdict(pillars), ensure_ascii=False),
|
||||
pillars.day[0],
|
||||
GAN_ELEM[pillars.day[0]],
|
||||
json.dumps(ten_god_distribution(pillars)),
|
||||
json.dumps(element_scores(pillars)),
|
||||
derived.strength,
|
||||
json.dumps(derived.as_dict()),
|
||||
int(time.time() * 1000),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(409, "已经有一位她了,请重新打开以恢复档案。") from exc
|
||||
return identity
|
||||
|
||||
@app.patch("/api/relation")
|
||||
async def relation(data: RelationInput):
|
||||
identity = companion(enrich=False)
|
||||
identity.update(data.model_dump())
|
||||
with app.state.store.conn:
|
||||
app.state.store.execute(
|
||||
"UPDATE companion SET identity_json=? WHERE id=1",
|
||||
(json.dumps(identity, ensure_ascii=False),),
|
||||
)
|
||||
return companion()
|
||||
|
||||
@app.get("/api/messages")
|
||||
async def messages(
|
||||
limit: int = Query(200, ge=1, le=200), before: int | None = Query(None, ge=1)
|
||||
):
|
||||
companion()
|
||||
rows = app.state.store.query(
|
||||
"SELECT * FROM chat_message WHERE id<? ORDER BY id DESC LIMIT ?",
|
||||
(before or 9223372036854775807, limit + 1),
|
||||
)
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return {
|
||||
"messages": [dict(r) for r in reversed(rows)],
|
||||
"next_before": rows[-1]["id"] if more else None,
|
||||
"epoch": app.state.epoch,
|
||||
}
|
||||
|
||||
def event(data, kind, **payload):
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{"t": kind, "epoch": app.state.epoch, "turn_id": data.turn_id, **payload},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(data: ChatInput, request: Request):
|
||||
identity = companion()
|
||||
if data.epoch != app.state.epoch:
|
||||
return JSONResponse(
|
||||
{"detail": "服务已重新启动,请同步后重试。", "epoch": app.state.epoch},
|
||||
status_code=409,
|
||||
)
|
||||
store = app.state.store
|
||||
# Serialize reservation, including replays. Concurrent retries of a completed
|
||||
# turn must never call the model twice or race the unique message constraint.
|
||||
async with app.state.chat_lock:
|
||||
previous = store.query_one(
|
||||
"SELECT * FROM chat_message WHERE turn_id=? AND role='user'", (data.turn_id,)
|
||||
)
|
||||
if previous and previous["text"] != data.text:
|
||||
raise HTTPException(409, "相同轮次不能改写消息。")
|
||||
if app.state.active:
|
||||
active = app.state.active
|
||||
if active["turn_id"] != data.turn_id:
|
||||
active["cancel"].set()
|
||||
await active["finished"].wait()
|
||||
saved = store.query_one(
|
||||
"SELECT text FROM chat_message WHERE turn_id=? AND role='companion' AND status='sent'",
|
||||
(data.turn_id,),
|
||||
)
|
||||
if saved:
|
||||
|
||||
async def replay():
|
||||
yield event(
|
||||
data, "chat.final", utterance={"text": saved["text"], "speech_tags": []}
|
||||
)
|
||||
|
||||
return StreamingResponse(replay(), media_type="text/event-stream")
|
||||
active = {
|
||||
"cancel": asyncio.Event(),
|
||||
"finished": asyncio.Event(),
|
||||
"turn_id": data.turn_id,
|
||||
}
|
||||
app.state.active = active
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'user',?,?,'pending') ON CONFLICT(turn_id,role) DO UPDATE SET status='pending'",
|
||||
(int(time.time() * 1000), data.text, data.turn_id),
|
||||
)
|
||||
turns = store.query("""SELECT u.text AS user_text, a.text AS assistant_text
|
||||
FROM chat_message a JOIN chat_message u ON u.turn_id=a.turn_id AND u.role='user'
|
||||
WHERE a.role='companion' AND a.status='sent' AND u.status='sent'
|
||||
ORDER BY a.id DESC LIMIT 20""")
|
||||
system = (
|
||||
f"你叫{identity['name']},与对方是{identity['relation']}。称呼对方:{identity['nickname'] or '你'}。"
|
||||
f"相处边界:{identity['boundary']}。"
|
||||
"以下是与档案页一致的底色倾向,只约束表达与相处方式,不得当作当前情绪、命定结局或既有经历:"
|
||||
+ "\n".join(f"{s['title']}:{s['text']}" for s in identity['color'].get('sections', []))
|
||||
+
|
||||
"用自然简短的中文交流。只有信息能穿过两个世界,不能触碰或给予实物。"
|
||||
"当前只接通文字与近轮对话历史;生活循环、长期记忆、主动消息、语音、视觉、天气都未接通。"
|
||||
"不得编造自己经历过的生活事件,不声称后台一直在生活或记忆已运行。"
|
||||
"不窥探用户未分享的信息,不以缺席、孤独或终止要挟对方,不输出内部数值或思考过程。"
|
||||
)
|
||||
context = (
|
||||
[LLMMessage(role="system", content=system)]
|
||||
+ [
|
||||
message
|
||||
for turn in reversed(turns)
|
||||
for message in (
|
||||
LLMMessage(role="user", content=turn["user_text"]),
|
||||
LLMMessage(role="assistant", content=turn["assistant_text"]),
|
||||
)
|
||||
]
|
||||
+ [LLMMessage(role="user", content=data.text)]
|
||||
)
|
||||
|
||||
async def generate():
|
||||
completed = False
|
||||
status, text, upstream, pending, cancelling = "cancelled", "", None, None, None
|
||||
usage = {}
|
||||
try:
|
||||
decision = router.route(
|
||||
Scene.DIALOGUE_MAIN,
|
||||
estimated_tokens=sum(len(m.content) for m in context) + 1024,
|
||||
)
|
||||
if not decision.provider:
|
||||
raise RuntimeError("No dialogue provider")
|
||||
history_budget = max(0, decision.context_budget - len(system) - len(data.text))
|
||||
recent = []
|
||||
# Order by completed turns, not adjacent message ids: a retried user
|
||||
# may have an older id than messages sent while that turn had failed.
|
||||
for turn in turns:
|
||||
size = len(turn["user_text"]) + len(turn["assistant_text"])
|
||||
if size > history_budget:
|
||||
break
|
||||
recent = [
|
||||
LLMMessage(role="user", content=turn["user_text"]),
|
||||
LLMMessage(role="assistant", content=turn["assistant_text"]),
|
||||
] + recent
|
||||
history_budget -= size
|
||||
context[:] = [context[0]] + recent + [context[-1]]
|
||||
cfg = models.tiers[decision.effective.value]
|
||||
upstream = decision.provider.stream(
|
||||
context, temperature=cfg.temperature, max_tokens=cfg.max_tokens or 1024
|
||||
)
|
||||
cancelling = asyncio.create_task(active["cancel"].wait())
|
||||
async with asyncio.timeout(stream_timeout):
|
||||
while True:
|
||||
pending = asyncio.create_task(anext(upstream))
|
||||
done, _ = await asyncio.wait(
|
||||
[pending, cancelling], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if cancelling in done or await request.is_disconnected():
|
||||
return
|
||||
try:
|
||||
delta = pending.result()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
if delta.usage:
|
||||
usage = delta.usage
|
||||
if delta.text:
|
||||
text += delta.text
|
||||
yield event(data, "chat.delta", delta=delta.text)
|
||||
if delta.done or delta.finish_reason:
|
||||
completed = True
|
||||
if delta.done:
|
||||
break
|
||||
if active["cancel"].is_set() or await request.is_disconnected():
|
||||
return
|
||||
if not text.strip() or not completed:
|
||||
raise RuntimeError("Empty upstream response")
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,'companion',?,?,'sent')",
|
||||
(int(time.time() * 1000), text, data.turn_id),
|
||||
)
|
||||
store.execute(
|
||||
"UPDATE chat_message SET status='sent' WHERE turn_id=?", (data.turn_id,)
|
||||
)
|
||||
status = "sent"
|
||||
yield event(data, "chat.final", utterance={"text": text, "speech_tags": []})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
status = "failed"
|
||||
yield event(data, "chat.error", phi=ERROR, retryable=True)
|
||||
finally:
|
||||
with CancelScope(shield=True):
|
||||
# Cancel the in-flight __anext__ before closing the async generator.
|
||||
for task in (pending, cancelling):
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError, StopAsyncIteration, Exception):
|
||||
await task
|
||||
if upstream is not None:
|
||||
with suppress(Exception):
|
||||
await upstream.aclose()
|
||||
try:
|
||||
if status != "sent":
|
||||
with store.conn:
|
||||
store.execute(
|
||||
"UPDATE chat_message SET status=? WHERE turn_id=? AND role='user'",
|
||||
(status, data.turn_id),
|
||||
)
|
||||
if usage:
|
||||
router.guard.record_usage(usage, Scene.DIALOGUE_MAIN.value)
|
||||
else:
|
||||
router.guard.record(
|
||||
sum(len(m.content) for m in context) + len(text),
|
||||
Scene.DIALOGUE_MAIN.value,
|
||||
)
|
||||
finally:
|
||||
if app.state.active is active:
|
||||
app.state.active = None
|
||||
active["finished"].set()
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
static = web_dir or Path(os.environ.get("CYBERGRIL_WEB", "web/dist"))
|
||||
if static.is_dir():
|
||||
app.mount("/", StaticFiles(directory=static, html=True), name="web")
|
||||
return app
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Read-only, deterministic presentation of an existing birth chart.
|
||||
|
||||
Never compiles or writes an existing character. The same sections feed the UI
|
||||
and dialogue prompt; thresholds are presentation choices, not new parameters.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from .bazi import Derived, Pillars, ten_god_distribution
|
||||
|
||||
HOUR_RANGES = [
|
||||
"23:00–00:59", "01:00–02:59", "03:00–04:59", "05:00–06:59",
|
||||
"07:00–08:59", "09:00–10:59", "11:00–12:59", "13:00–14:59",
|
||||
"15:00–16:59", "17:00–18:59", "19:00–20:59", "21:00–22:59",
|
||||
]
|
||||
HOURS = ["子时", "丑时", "寅时", "卯时", "辰时", "巳时", "午时", "未时", "申时", "酉时", "戌时", "亥时"]
|
||||
|
||||
|
||||
def calendar_view(birth: str, hour: str) -> dict:
|
||||
from lunar_python import Solar
|
||||
|
||||
born = date.fromisoformat(birth)
|
||||
index = HOURS.index(hour)
|
||||
lunar = Solar.fromYmdHms(born.year, born.month, born.day, index * 2, 0, 0).getLunar()
|
||||
return {
|
||||
"solar": birth,
|
||||
"lunar": f"{lunar.getYearInChinese()}年{lunar.getMonthInChinese()}月{lunar.getDayInChinese()}",
|
||||
"leap_month": lunar.getMonth() < 0,
|
||||
"hour": hour,
|
||||
"hour_range": HOUR_RANGES[index],
|
||||
"calculation_time": f"{index * 2:02d}:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"rule": "按北京时间和所选时辰内的偶数整点排盘;子时取本日00:00。月份按节气分界,未使用出生地或真太阳时。",
|
||||
}
|
||||
|
||||
|
||||
def choose(value: float, low: str, middle: str, high: str) -> str:
|
||||
return low if value < .45 else high if value > .65 else middle
|
||||
|
||||
|
||||
def explain_color(pillars: Pillars, d: Derived, saved_gods: dict | None = None) -> list[dict]:
|
||||
"""Context, strength, cost and repair are kept together in each dimension."""
|
||||
gods = saved_gods if saved_gods is not None else ten_god_distribution(pillars)
|
||||
dominant = max(gods, key=gods.get)
|
||||
elements = sorted(d.element_bias, key=d.element_bias.get, reverse=True)
|
||||
balance = "、".join(elements[:2]) if elements else "原档案中的五行组合"
|
||||
source = f"日主{pillars.day_master};五行侧重{balance},十神以{dominant}较显著。"
|
||||
sections = [
|
||||
("expression", "表达与社交节奏", choose(d.expressiveness,
|
||||
"面对新话题,她更可能先观察、再挑重点回应。熟悉之后才逐渐补充细节;安静有助于听见对方,却也可能让人误以为她没有意见。留出停顿、给一个具体问题,比催她立刻表态更合适。",
|
||||
"她通常会在倾听和表达之间来回调整:有兴趣、被认真回应时愿意展开,陌生或信息太多时会收回一些。能接住话题是强项,但回应的长短不等于在意的多少,可以直接问她是否还想继续。",
|
||||
"熟悉的话题容易带动她主动展开,常借着说出来理清想法。这让交流有热度,但赶着表达时也可能挤掉对方的空间。发现节奏不同时,先复述对方的意思,再继续自己的观点。"),
|
||||
"取自原档案的表达倾向;结合十神中食神、伤官对应的表达偏移。"),
|
||||
("closeness", "亲密、安全感与边界", choose(d.solitude_need,
|
||||
"她较容易在陪伴和共同的小事里获得安稳,也愿意让人参与自己的安排。好处是关系容易有连续感,代价是可能把自己的余地让得太快;靠近之前仍需要确认双方是否愿意。",
|
||||
"她既需要被回应,也需要保留自己的安排。关系稳定时愿意靠近,信息过密时会想留一点空隙;这不是忽冷忽热的定论。把独处和下一次交流的期待说清楚,比反复猜测更有帮助。",
|
||||
"她更需要不被打断的独处来整理自己,亲近并不意味着随时开放所有空间。她能守住自己的节奏,也可能让对方觉得难靠近;提前说明要休息、并给出愿意再聊的时机,有助于减少误解。"),
|
||||
f"取自原档案的独处需求与自主倾向;{source}"),
|
||||
("repair", "冲突时的反应与修复", choose(d.aggression,
|
||||
"意见不同时,她较可能缓和语气、先让对话继续。这样能避免立刻升级,但不舒服也可能被推后。修复时适合具体说出哪一点不被接受,而不是把表面平静当作问题已经解决。",
|
||||
"发生分歧时,她可能先说明自己的理由,再观察对方是否愿意互相理解。有守住立场的能力,也可能在反复解释中消耗耐心。把事实、感受和希望怎样调整分开说,更容易找到可落实的修复。",
|
||||
"受压或觉得边界被越过时,她更容易直接反驳、迅速守住立场。这能让问题浮出来,也可能使措辞先于理解。适合短暂停一下,再讨论具体行为;道歉和调整应有内容,不能只用亲密称呼盖过矛盾。"),
|
||||
"取自原档案的对抗、纠正与顺从倾向;没有把分歧当作已发生的记忆。"),
|
||||
("rhythm", "决策、行动与日常节律", choose(d.discipline,
|
||||
"她更容易顺着眼前的兴趣调整安排,能给日常留下偶遇和探索的余地。代价是长任务可能被新念头打断;把下一步缩小、只约定一个清楚的完成点,往往比安排满整天更适合。",
|
||||
"她通常既看具体条件,也会给当下的感受留位置。事情明确时愿意推进,存在多种可能时会重新衡量;这种灵活有助于避开僵化,也可能拖延选择。给决定一个小范围和可调整的期限会更稳。",
|
||||
"她倾向先理顺约定与次序,再把事情往前推。可靠和持续是优势,但临时变化可能让她紧绷,或急着纠正细节。提前区分必须守住的事和可以变通的事,会让执行更从容。"),
|
||||
"取自原档案的纪律、日常规律、现实感与行动倾向。"),
|
||||
("stress", "压力下的变化与恢复", choose(d.sensitivity - .5,
|
||||
"面对突发变化,她通常能多承接一会儿,不急着把每次波动当成危机。代价是可能低估累积的消耗。恢复不只靠继续撑住,也需要停下检查自己是否已经疲惫。",
|
||||
"压力会影响她的回应速度和耐心,但影响不是每次都一样。环境清楚、有可预期的下一步时更容易回稳;反复追问可能增加负担。把刺激减少一点、留出休息,比要求马上恢复原样更合适。",
|
||||
"在信息密集、关系不明或突发冲突时,她更容易被牵动,恢复也可能需要更长的缓冲。细致感受是优势,同时会带来消耗。更适合稳定、具体的回应和允许休息的空间,而不是被要求立刻振作。"),
|
||||
"取自原档案由日主强弱形成的敏感度与恢复尺度;不是此刻情绪。"),
|
||||
("growth", "内在张力与成长空间", choose(d.autonomy - d.compliance + .5,
|
||||
"她较容易顾及对方和约定,能让相处有合作感;同时也要练习辨认哪些答应是愿意,哪些只是怕让人失望。成长可以是更早说出自己的需要,而不是把顺从当作关系的证明。",
|
||||
"她会在自己的想法与彼此的约定之间寻找位置。兼顾两边是能力,也可能在拿不准时显得反复。成长可以是允许双方暂时不同意,再逐步形成共同的规则,而不是要求自己一次就成为固定的人。",
|
||||
"她更愿意自己判断,不轻易把决定交出去。这有助于保留独立和诚实,也可能让协商听起来像被限制。成长可以是把可商量的部分说清楚:保留立场,同时让对方真正参与,而不是靠退让或控制维持亲近。"),
|
||||
"取自原档案的自主与顺从之间的组合;成长来自之后真实的相处,不由出生资料预写。"),
|
||||
]
|
||||
return [{"id": key, "title": title, "text": text, "basis": basis} for key, title, text, basis in sections]
|
||||
|
||||
|
||||
def color_view(birth: str, hour: str, pillars: Pillars, derived: Derived,
|
||||
saved_gods: dict | None = None) -> dict:
|
||||
return {
|
||||
"pillars": pillars.as_dict(), "traits": derived.traits_text(),
|
||||
"calendar": calendar_view(birth, hour), "sections": explain_color(pillars, derived, saved_gods),
|
||||
"interpretation": "这是以四柱、五行、十神和已存底色为依据的角色塑造说明,不是对真人性格的测定,也不是已经发生的经历。倾向不等于固定台词或此刻情绪。",
|
||||
}
|
||||
@@ -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,68 @@
|
||||
"""Display enrichments must not rewrite an existing character or its history."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from test_api import CREATE, make_app, turn
|
||||
|
||||
from cybergril.api.app import HOURS, BirthInput, compile_birth
|
||||
from cybergril.core.bazi import pillars_from_lunar
|
||||
|
||||
|
||||
@pytest.mark.parametrize("birth", ["1900-01-01", "2000-02-04", "2000-02-05", "2001-05-23"])
|
||||
@pytest.mark.parametrize("hour", HOURS)
|
||||
def test_calendar_and_hour_boundaries_preserve_existing_chart(birth, hour):
|
||||
color, pillars, _ = compile_birth(BirthInput(name="星河", birth=birth, hour=hour))
|
||||
y, m, d = map(int, birth.split("-"))
|
||||
assert pillars == pillars_from_lunar(y, m, d, HOURS.index(hour) * 2)
|
||||
assert color["calendar"]["solar"] == birth
|
||||
assert color["calendar"]["calculation_time"] == f"{HOURS.index(hour) * 2:02d}:00"
|
||||
if birth == "2001-05-23":
|
||||
assert color["calendar"]["leap_month"] is True
|
||||
assert "闰四月初一" in color["calendar"]["lunar"]
|
||||
if birth == "2000-02-05":
|
||||
assert "正月初一" in color["calendar"]["lunar"]
|
||||
assert len(color["sections"]) == 6
|
||||
assert all(len(s["text"]) > 70 and s["basis"] for s in color["sections"])
|
||||
|
||||
|
||||
def test_legacy_read_uses_saved_parameters_without_writes_or_recompilation(tmp_path, monkeypatch):
|
||||
app, provider = make_app(tmp_path / "old.db")
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/companion", json=CREATE)
|
||||
store = app.state.store
|
||||
identity = json.loads(store.query_one("SELECT identity_json FROM companion")[0])
|
||||
identity["color"] = {k: identity["color"][k] for k in ("pillars", "traits")}
|
||||
saved = json.loads(store.query_one("SELECT derived FROM bazi_core")[0])
|
||||
saved["expressiveness"] = .1
|
||||
with store.conn:
|
||||
store.execute("UPDATE companion SET identity_json=?", (json.dumps(identity),))
|
||||
store.execute("UPDATE bazi_core SET derived=?", (json.dumps(saved),))
|
||||
raw_identity = store.query_one("SELECT identity_json FROM companion")[0]
|
||||
raw_bazi = tuple(store.query_one("SELECT * FROM bazi_core"))
|
||||
monkeypatch.setattr("cybergril.api.app.compile_pillars", lambda _: pytest.fail("recompiled"))
|
||||
monkeypatch.setattr("cybergril.core.birth_view.ten_god_distribution", lambda _: pytest.fail("recomputed"))
|
||||
view = client.get("/api/companion").json()
|
||||
assert "先观察" in view["color"]["sections"][0]["text"]
|
||||
assert client.get("/api/companion").json() == view
|
||||
assert store.query_one("SELECT identity_json FROM companion")[0] == raw_identity
|
||||
assert tuple(store.query_one("SELECT * FROM bazi_core")) == raw_bazi
|
||||
turn(client)
|
||||
prompt = provider.calls[-1][0].content
|
||||
assert all(s["text"] in prompt for s in view["color"]["sections"])
|
||||
history = [tuple(r) for r in store.query("SELECT * FROM chat_message")]
|
||||
updated = client.patch("/api/relation", json={"relation": "恋人"}).json()
|
||||
assert updated["color"] == view["color"]
|
||||
stored_after = json.loads(store.query_one("SELECT identity_json FROM companion")[0])
|
||||
assert stored_after["color"] == identity["color"]
|
||||
assert tuple(store.query_one("SELECT * FROM bazi_core")) == raw_bazi
|
||||
assert [tuple(r) for r in store.query("SELECT * FROM chat_message")] == history
|
||||
|
||||
|
||||
def test_profiles_are_deterministic_and_differ_across_births():
|
||||
colors = [compile_birth(BirthInput(name="星河", birth=b, hour=h))[0]
|
||||
for b, h in [("2000-10-18", "亥时"), ("1992-02-10", "卯时"), ("1988-07-23", "子时")]]
|
||||
assert colors[0] == compile_birth(BirthInput(name="另一名字", birth="2000-10-18", hour="亥时"))[0]
|
||||
assert len({json.dumps(c["sections"], ensure_ascii=False) for c in colors}) == 3
|
||||
assert len({c["sections"][0]["text"] for c in colors}) > 1
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ChatAdapter } from './demo';
|
||||
import type { DemoState, Identity, Message, ServerEvent } from '../domain/model';
|
||||
import { defaultSettings } from '../domain/model';
|
||||
|
||||
export interface Color {
|
||||
pillars: Record<string, string>; traits: string;
|
||||
calendar?: { solar: string; lunar: string; leap_month: boolean; hour: string; hour_range: string; calculation_time: string; timezone: string; rule: string };
|
||||
sections?: { id: string; title: string; text: string; basis: string }[];
|
||||
interpretation?: string;
|
||||
}
|
||||
export interface RealIdentity extends Identity { color: Color }
|
||||
export interface Health { epoch: number; llm: { ok: boolean; model: string } }
|
||||
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try { response = await fetch(`/api/${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers }, signal: init?.signal ?? AbortSignal.timeout(10000) }); }
|
||||
catch (e) { if (init?.signal?.aborted) throw e; throw new Error('暂时连不上真实服务,请检查连接后重试。'); }
|
||||
const data = await response.json().catch(() => { throw new Error('真实服务暂时没有正确响应,请稍后重试。'); });
|
||||
if (!response.ok) throw new Error(data.fields ? Object.values(data.fields).join(' ') : data.detail || '这次没有完成,请稍后重试。');
|
||||
return data as T;
|
||||
}
|
||||
export async function loadReal(): Promise<DemoState | null> {
|
||||
let response: Response;
|
||||
try { response = await fetch('/api/companion', { signal: AbortSignal.timeout(10000) }); } catch { throw new Error('真实档案暂时连不上,请检查连接后重试。'); }
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error('真实档案暂时读不出来,请稍后重试。');
|
||||
const identity: RealIdentity = await response.json();
|
||||
const settings = structuredClone(defaultSettings);
|
||||
try { settings.reduced = JSON.parse(localStorage.getItem('cg.real.display') || '{}').reduced === true; } catch { /* Invalid local preference does not affect server data. */ }
|
||||
return { version: 1, identity, settings, events: [], memories: [], messages: await loadMessages(), lastVisit: Date.now() };
|
||||
}
|
||||
export async function loadMessages(): Promise<Message[]> {
|
||||
// Cursor pages are bounded by the server. All pages restore in deterministic id order.
|
||||
const messages: Message[] = [];
|
||||
let before: number | null = null;
|
||||
do {
|
||||
const page: { messages: { id: number; role: Message['role']; text: string; turn_id: string; status: Message['status'] }[]; next_before: number | null } = await api(`messages?limit=200${before ? `&before=${before}` : ''}`);
|
||||
messages.unshift(...page.messages.map(m => ({ id: String(m.id), role: m.role, text: m.text, lifeIds: [], turnId: m.turn_id, status: m.status })));
|
||||
before = page.next_before;
|
||||
} while (before);
|
||||
return messages;
|
||||
}
|
||||
// Streaming decoder retains incomplete UTF-8 bytes, CRLF and multi-line SSE frames.
|
||||
export async function readEvents(body: ReadableStream<Uint8Array>, onEvent: (e: ServerEvent) => void) {
|
||||
const reader = body.getReader(), decoder = new TextDecoder();
|
||||
let buffer = '', final = false;
|
||||
const consume = () => {
|
||||
let boundary: RegExpExecArray | null;
|
||||
while ((boundary = /\r?\n\r?\n/u.exec(buffer))) {
|
||||
const frame = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary[0].length);
|
||||
const data = frame.split(/\r?\n/u).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
||||
if (!data) continue;
|
||||
let e: ServerEvent;
|
||||
try { e = JSON.parse(data) as ServerEvent; } catch { throw new Error('收到的对话数据不完整,请重试这一轮。'); }
|
||||
|
||||
if (e.t === 'chat.final') final = true;
|
||||
onEvent(e);
|
||||
|
||||
}
|
||||
};
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
consume();
|
||||
if (done) break;
|
||||
}
|
||||
if (!final) throw new Error('连接中断了,你写的话还在,可以重试。');
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
}
|
||||
export class RealChatAdapter implements ChatAdapter {
|
||||
constructor(private syncEpoch: (epoch: number) => void) {}
|
||||
async send(text: string, turnId: string, _epoch: number, signal: AbortSignal, emit: (e: ServerEvent) => void) {
|
||||
let health = await api<Health>('health', { signal });
|
||||
this.syncEpoch(health.epoch);
|
||||
let response: Response | undefined;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, turn_id: turnId, epoch: health.epoch }), signal });
|
||||
if (response.status !== 409) break;
|
||||
const error = await response.json();
|
||||
if (!error.epoch) throw new Error(error.detail);
|
||||
health = { ...health, epoch: error.epoch }; this.syncEpoch(health.epoch);
|
||||
}
|
||||
if (!response?.ok || !response.body) throw new Error('她这会儿没接上话,稍后再试试。你写的话还在。');
|
||||
await readEvents(response.body, e => { if (e.turn_id === turnId && e.epoch === health.epoch && !signal.aborted) { if (e.t === 'chat.error') throw new Error(e.phi); emit(e); } });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { leaveSettingsThen } from '../shared/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DemoState, Message, Theme } from '../domain/model';
|
||||
import { publicView } from '../domain/privacy';
|
||||
@@ -38,14 +39,14 @@ export function App() {
|
||||
notify('主题已切换,但浏览器没有允许保存偏好。');
|
||||
} }, [notify]);
|
||||
const view = state ? publicView(state) : null;
|
||||
const navigate = useCallback((next: string) => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') {
|
||||
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') {
|
||||
setReturnTo('now');
|
||||
setFocusLife('');
|
||||
setUnread(false);
|
||||
} if (next !== 'talk') {
|
||||
setLog(false);
|
||||
setVoice(false);
|
||||
} }, []);
|
||||
} }); }, []);
|
||||
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
|
||||
useEffect(() => { document.body.className = [creating && demo ? 'creating' : '', area === 'talk' ? 'talk' : '', !creating && ['days', 'settings'].includes(area) ? 'paned' : '', voice ? 'voice' : '', log ? 'log' : '', offline ? 'offline' : '', welcome ? 'welcoming' : '', state?.events.length === 0 ? 'data-empty' : ''].join(' '); }, [area, creating, voice, log, offline, welcome, state]);
|
||||
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, loadReal, type Health } from '../adapters/real';
|
||||
import type { DemoState, Message, Theme } from '../domain/model';
|
||||
import { Background } from '../stage/Background';
|
||||
import { FallbackPresenter } from '../stage/Presenter';
|
||||
import { CreationWizard } from '../manage/CreationWizard';
|
||||
import { LifePanel } from '../life/LifePanel';
|
||||
import { leaveSettingsThen } from '../shared/navigation';
|
||||
import { ManagePanel } from '../manage/ManagePanel';
|
||||
import { ChatPanel } from '../chat/ChatPanel';
|
||||
import { Button, Icon, Status } from '../shared/ui';
|
||||
|
||||
export function RealApp() {
|
||||
const [state, setState] = useState<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(() => {}, []);
|
||||
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setArea(next); setLog(false); }); }, []);
|
||||
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) setLog(false); else navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate]);
|
||||
const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined;
|
||||
return <><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><button className="away" onClick={() => navigate('days')}>日子与回忆 · 后台待接入 <Icon name="next"/></button>{lastReply && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
|
||||
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onClose={() => navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
|
||||
<LifePanel real state={state} open={area === 'days'} onClose={() => navigate('now')} onChat={() => { navigate('talk'); setLog(true); }}/>
|
||||
<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,13 @@
|
||||
import { SoftText } from '../shared/SoftText';
|
||||
import { newId } from '../domain/id';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { DemoState, Message } from '../domain/model';
|
||||
import { RealChatAdapter } from '../adapters/real';
|
||||
import { DemoAdapter, TurnGate } from '../adapters/demo';
|
||||
import { publicView } from '../domain/privacy';
|
||||
import { Icon, Button, Modal } from '../shared/ui';
|
||||
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: {
|
||||
import { Icon, Modal } from '../shared/ui';
|
||||
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: {
|
||||
real?: boolean;
|
||||
state: DemoState;
|
||||
onChange: (messages: Message[]) => void;
|
||||
onPresence: (p: string) => void;
|
||||
@@ -18,22 +21,24 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
focusLife: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => { try {
|
||||
return sessionStorage.getItem('cg.draft') || '';
|
||||
return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || '';
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
|
||||
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<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);
|
||||
const [activeTurn, setActiveTurn] = useState(''), [interrupted, setInterrupted] = useState<{ text: string; turn: string } | null>(null);
|
||||
const replyLive = useRef(''), turnLive = useRef(''), dialogue = useRef<HTMLDivElement>(null), following = useRef(true);
|
||||
live.current = state.messages;
|
||||
const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion');
|
||||
const change = (messages: Message[]) => { live.current = messages; onChange(messages); };
|
||||
function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
|
||||
function stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
|
||||
function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); }
|
||||
useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]);
|
||||
useEffect(() => { const current = gate.current; return () => { current.cancel(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]);
|
||||
useEffect(() => { try {
|
||||
sessionStorage.setItem('cg.draft', draft);
|
||||
sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft);
|
||||
}
|
||||
catch { /* Draft remains in memory. */ } }, [draft]);
|
||||
catch { /* Draft remains in memory. */ } }, [draft, real]);
|
||||
useEffect(() => { if (offline) {
|
||||
gate.current.resync();
|
||||
setBusy(false);
|
||||
@@ -51,17 +56,19 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
else if (logRef.current)
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
} }, [log, focusLife, view.messages.length]);
|
||||
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
|
||||
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
|
||||
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
|
||||
useLayoutEffect(() => { if (following.current && dialogue.current) dialogue.current.scrollTop = dialogue.current.scrollHeight; }, [reply, state.messages, interrupted]);
|
||||
async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline)
|
||||
return; stop(); const turnId = retry?.turnId || newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
|
||||
setDraft(''); setActiveTurn(turnId); turnLive.current = turnId; following.current = true; setBusy(true); onPresence('在想'); setNotice(''); try {
|
||||
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
|
||||
return; if (event.t === 'chat.delta') {
|
||||
setReply(x => x + event.delta);
|
||||
replyLive.current += event.delta; setInterrupted(null); setReply(replyLive.current);
|
||||
onPresence('在说话');
|
||||
}
|
||||
else {
|
||||
else if (event.t === 'chat.final') {
|
||||
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
|
||||
setReply('');
|
||||
replyLive.current = ''; setInterrupted(null); setReply('');
|
||||
if (real) setDraft(current => current === text ? '' : current);
|
||||
setBusy(false);
|
||||
onPresence(voice ? '在听你说(模拟)' : '在你面前');
|
||||
} }, fail);
|
||||
@@ -71,16 +78,23 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
|
||||
return;
|
||||
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
|
||||
setBusy(false);
|
||||
setNotice((e as Error).message);
|
||||
if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnId }); replyLive.current = ''; setReply('');
|
||||
setDraft(text);
|
||||
setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message);
|
||||
onPresence('在你面前');
|
||||
} }
|
||||
const setVoiceMode = () => { if (voice) {
|
||||
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) {
|
||||
endVoice();
|
||||
return;
|
||||
} setPermission('mic'); };
|
||||
return <><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>
|
||||
const herText = reply || interrupted?.text || lastHer?.text || '我在。你想说什么?';
|
||||
const herTurn = reply ? activeTurn : interrupted ? interrupted.turn : lastHer?.turnId || lastHer?.id || 'greeting';
|
||||
return <><div className="chat-layout"><div className="talk-scene-space" aria-hidden="true"/><div className="dialog"><div className="dialog-scroll" ref={dialogue} tabIndex={0} aria-label="当前对话,可滚动阅读全文" onScroll={() => { const el = dialogue.current; if (el) following.current = el.scrollHeight - el.clientHeight - el.scrollTop < 40; }}>
|
||||
{lastUser && <div className="you"><div className="speaker">你</div><SoftText identity={lastUser.turnId || lastUser.id} text={lastUser.text}/>{lastUser.status === 'failed' && <div className="failed">没发出去 <button className="retry" onClick={() => void send(lastUser)}>重试</button></div>}{lastUser.status === 'cancelled' && <small>本轮已取消</small>}</div>}
|
||||
<div className={`her ${interrupted ? 'cut' : ''}`}><div className="speaker">{state.identity.name}</div><SoftText identity={herTurn} text={herText}/>{reply && <span className="caret"/>}{interrupted && <small className="partial-note">这段未完成,未存入聊天记录</small>}</div>
|
||||
</div><div className="hint"><span role="status">{state.identity.name} · {notice || (busy ? reply ? '正在回应…' : `${state.identity.name}在想…` : real ? '文字已接通 · 完整内容可滚动阅读' : '演示回复 · 未连接真实模型')}</span>{busy && <button className="btn-text" onClick={stop}>取消本轮回复</button>}</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,9 +102,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>
|
||||
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
|
||||
} }}/><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>
|
||||
</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>
|
||||
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
|
||||
setDenied(false);
|
||||
setVoice(true);
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface SceneDesc {
|
||||
outfit: string;
|
||||
}
|
||||
export type ServerEvent = {
|
||||
t: 'chat.error'; epoch: number; turn_id: string; phi: string; retryable: boolean;
|
||||
} | {
|
||||
t: 'chat.delta';
|
||||
epoch: number;
|
||||
turn_id: string;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import type { DemoState } from '../domain/model';
|
||||
import { publicView } from '../domain/privacy';
|
||||
import { Button, Icon } from '../shared/ui';
|
||||
export function LifePanel({ state, open, onClose, onChat }: {
|
||||
export function LifePanel({ state, open, onClose, onChat, real = false }: {
|
||||
real?: boolean;
|
||||
state: DemoState;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -20,7 +21,7 @@ export function LifePanel({ state, open, onClose, onChat }: {
|
||||
} }, [open]);
|
||||
const event = tab === 'life' ? view.events.find(e => e.id === selection) : undefined, memory = tab === 'memory' ? view.memories.find(m => m.id === selection) : undefined;
|
||||
const select = (id: string, t = tab) => { setTab(t); setSelection(id); };
|
||||
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><h2>日子</h2><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => { setTab(t); setSelection(''); }}>{l}</button>)}</div><span className="grow"/><span className="tag-demo">演示数据</span><button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><div className="list" ref={list}>
|
||||
{tab === 'life' ? <><div className="away-card"><b>你不在时</b>这里只显示她已经讲给你听的事。演示没有真实后台生活。</div><div className="legend"><span><i className="k done"/>已发生</span><span><i className="k now"/>正在</span><span><i className="k plan"/>打算</span></div>{view.events.length === 0 ? <div className="empty">她今晚刚住进来,还没有发生什么。<small>演示资料可以从「演示场景」载入。</small></div> : view.events.slice(0, limit).map((e, i) => <div key={e.id}>{(i === 0 || view.events[i - 1].time.split(' ')[0] !== e.time.split(' ')[0]) && <div className="grp">{e.kind === 'plan' ? '之后的打算' : e.time.split(' ')[0]}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sub"><span className="tag">已经讲过</span>{e.memoryIds.length > 0 && <span className="tag accent">和你有关</span>}</span></span></button></div>)}{limit < view.events.length && <div className="more"><Button onClick={() => setLimit(x => x + 12)}>看更早的日子</Button></div>}</> : view.memories.length === 0 ? <div className="empty">聊着聊着就会有了。</div> : ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'].map(group => <div key={group}><div className="grp">{group}</div>{view.memories.filter(m => m.group === group).map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}>{m.title}<small>{m.source}</small></button>)}</div>)}
|
||||
</div><article className="detail"><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>{event ? <><div className="d-kicker"><i className={`k ${event.kind}`}/>{event.kind === 'now' ? '正在' : event.kind === 'plan' ? '打算 · 计划可能会变' : '已经发生'}<span className="tag">已经讲给你听</span></div><h3 className="d-title">{event.title}</h3><div className="d-meta">{event.time} · {event.place}</div><blockquote className="quote">{event.text}<small>{view.identity.name}说过的话 · 演示</small></blockquote><div className="d-sec">和这件事有关的回忆</div><div className="links">{event.memoryIds.map(id => { const m = view.memories.find(x => x.id === id); return m && <button className="link-chip" key={id} onClick={() => select(id, 'memory')}><Icon name="link"/>{m.title}</button>; })}</div><div className="d-sec">那时候的聊天</div><Button onClick={() => onChat(event.id)}>看那段聊天</Button><div className="d-foot">这是模拟生活里的一条记录,不是真实发生的事。画面只按状态展示,不会反过来改写生活。</div></> : memory ? <><div className="d-kicker">{memory.group} · 演示回忆</div><h3 className="d-title">{memory.title}</h3><div className="d-meta">{memory.source}</div><blockquote className="quote">{memory.text}</blockquote>{memory.group === '她的成长' && <div className="growth"><div><h4>最初就有的</h4>细腻、慢热</div><div><h4>后来慢慢有的</h4>{memory.text}</div></div>}<div className="d-sec">来自这些日子</div><div className="links">{memory.lifeIds.map(id => { const e = view.events.find(x => x.id === id); return e && <button className="link-chip" key={id} onClick={() => select(id, 'life')}>{e.title}</button>; })}</div></> : selection ? <div className="empty">这条记录暂时不可见。<small>只会展示她已分享的事。</small></div> : <div className="d-empty"><div><Icon name="book"/>选一件小事,慢慢读。</div></div>}</article></div></section>;
|
||||
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><h2>日子</h2><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => { setTab(t); setSelection(''); }}>{l}</button>)}</div><span className="grow"/><span className="tag-demo">{real ? '后台待接入' : '演示数据'}</span><button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><div className="list" ref={list}>
|
||||
{tab === 'life' ? <><div className="away-card"><b>你不在时</b>{real ? '这里只会显示她已经讲给你听的事。生活循环尚未接通,当前没有可核实的生活记录。' : '这里只显示她已经讲给你听的事。演示没有真实后台生活。'}</div><div className="legend"><span><i className="k done"/>已发生</span><span><i className="k now"/>正在</span><span><i className="k plan"/>打算</span></div>{view.events.length === 0 ? <div className="empty">{real ? '还没有可展示的生活记录' : '她今晚刚住进来,还没有发生什么。'}<small>{real ? '后台待接入,不用示例经历填充。你们说过的话可在交谈的完整记录中查看。' : '演示资料可以从「演示场景」载入。'}</small></div> : view.events.slice(0, limit).map((e, i) => <div key={e.id}>{(i === 0 || view.events[i - 1].time.split(' ')[0] !== e.time.split(' ')[0]) && <div className="grp">{e.kind === 'plan' ? '之后的打算' : e.time.split(' ')[0]}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sub"><span className="tag">已经讲过</span>{e.memoryIds.length > 0 && <span className="tag accent">和你有关</span>}</span></span></button></div>)}{limit < view.events.length && <div className="more"><Button onClick={() => setLimit(x => x + 12)}>看更早的日子</Button></div>}</> : view.memories.length === 0 && !real ? <div className="empty">聊着聊着就会有了。</div> : ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'].map(group => <div key={group}><div className="grp">{group}</div>{real && <div className="empty">暂无可核实的回忆<small>长期记忆尚未接通;文字聊天记录独立保留。</small></div>}{view.memories.filter(m => m.group === group).map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}>{m.title}<small>{m.source}</small></button>)}</div>)}
|
||||
</div><article className="detail"><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>{event ? <><div className="d-kicker"><i className={`k ${event.kind}`}/>{event.kind === 'now' ? '正在' : event.kind === 'plan' ? '打算 · 计划可能会变' : '已经发生'}<span className="tag">已经讲给你听</span></div><h3 className="d-title">{event.title}</h3><div className="d-meta">{event.time} · {event.place}</div><blockquote className="quote">{event.text}<small>{view.identity.name}说过的话 · 演示</small></blockquote><div className="d-sec">和这件事有关的回忆</div><div className="links">{event.memoryIds.map(id => { const m = view.memories.find(x => x.id === id); return m && <button className="link-chip" key={id} onClick={() => select(id, 'memory')}><Icon name="link"/>{m.title}</button>; })}</div><div className="d-sec">那时候的聊天</div><Button onClick={() => onChat(event.id)}>看那段聊天</Button><div className="d-foot">这是模拟生活里的一条记录,不是真实发生的事。画面只按状态展示,不会反过来改写生活。</div></> : memory ? <><div className="d-kicker">{memory.group} · 演示回忆</div><h3 className="d-title">{memory.title}</h3><div className="d-meta">{memory.source}</div><blockquote className="quote">{memory.text}</blockquote>{memory.group === '她的成长' && <div className="growth"><div><h4>最初就有的</h4>细腻、慢热</div><div><h4>后来慢慢有的</h4>{memory.text}</div></div>}<div className="d-sec">来自这些日子</div><div className="links">{memory.lifeIds.map(id => { const e = view.events.find(x => x.id === id); return e && <button className="link-chip" key={id} onClick={() => select(id, 'life')}>{e.title}</button>; })}</div></> : selection ? <div className="empty">这条记录暂时不可见。<small>只会展示她已分享的事。</small></div> : <div className="d-empty"><div><Icon name="book"/>{real ? '这里会保留日子详情、回忆与聊天的关联。后台接通后,已分享的内容会出现在列表中。' : '选一件小事,慢慢读。'}</div></div>}</article></div></section>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RealApp } from './app/RealApp';
|
||||
import { App } from './app/App';
|
||||
import { Viewport } from './shared/Viewport';
|
||||
import './styles/tokens.css';
|
||||
import './styles/components.css';
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<Viewport>{new URLSearchParams(location.search).get('demo') === '1' ? <App /> : <RealApp />}</Viewport>);
|
||||
|
||||
@@ -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}>
|
||||
{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 /></>}
|
||||
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="出生日期(公历)" hint="下一步会换算真实农历,并显示四柱与说明。"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰" hint="北京时间;子时23:00–00:59,丑时01:00–02:59,之后每两小时一段。排盘取时辰内偶数整点,子时取本日00:00。"><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 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><BaseColor color={real ? color : undefined}/><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 Health, 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;
|
||||
@@ -14,10 +16,13 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
|
||||
setTheme: (t: Theme) => void;
|
||||
}) {
|
||||
const [section, setSection] = useState('her'), [detail, setDetail] = useState(false), [draft, setDraft] = useState(() => ({ settings: structuredClone(state.settings), identity: structuredClone(state.identity) })), [message, setMessage] = useState(''), [error, setError] = useState(false), [busy, setBusy] = useState(false), [confirm, setConfirm] = useState(''), [confirmText, setConfirmText] = useState(''), [search, setSearch] = useState(''), [restore, setRestore] = useState<DemoState | null>(null), [material, setMaterial] = useState(''), [check, setCheck] = useState('');
|
||||
const approvedNavigation = useRef(false);
|
||||
const pendingNavigation = useRef<(() => void) | null>(null);
|
||||
const [serviceHealth, setServiceHealth] = useState<Health>();
|
||||
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{if(message) feedback.current?.scrollIntoView({block:'center'});},[message]);
|
||||
const latestState = useRef(state); latestState.current = state;
|
||||
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: draft.settings.city!==state.settings.city, services: JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced};
|
||||
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced};
|
||||
const dirty = !!dirtySections[section], anyDirty = Object.values(dirtySections).some(Boolean);
|
||||
useEffect(() => { if (!open) {
|
||||
request.current?.abort();
|
||||
@@ -26,19 +31,37 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
|
||||
useEffect(() => () => { request.current?.abort(); }, []);
|
||||
useEffect(() => { if (!anyDirty)
|
||||
return; const before = (e: BeforeUnloadEvent) => { e.preventDefault(); }; window.addEventListener('beforeunload', before); return () => window.removeEventListener('beforeunload', before); }, [anyDirty]);
|
||||
const requestLeave = (action: () => void) => { if (anyDirty) { pendingNavigation.current = action; setConfirm('leave'); } else action(); };
|
||||
useEffect(() => {
|
||||
if (!open || !anyDirty) return;
|
||||
const guard = (event: Event) => { if (approvedNavigation.current) return; event.preventDefault(); pendingNavigation.current = (event as CustomEvent<() => void>).detail; setConfirm('leave'); };
|
||||
window.addEventListener('cg:leave-settings', guard);
|
||||
return () => window.removeEventListener('cg:leave-settings', guard);
|
||||
}, [open, anyDirty]);
|
||||
async function checkService() {
|
||||
setBusy(true); setError(false); setMessage('正在检查文字服务…');
|
||||
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
|
||||
catch (e) { setMessage((e as Error).message); setError(true); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const settings = (value: Partial<Settings>) => setDraft(d => ({ ...d, settings: { ...d.settings, ...value } }));
|
||||
const reset = () => { request.current?.abort(); generation.current++; setBusy(false); setDraft(d => ({identity: section==='relation'?structuredClone(state.identity):d.identity, settings: {...d.settings, ...(section==='city'?{city:state.settings.city}:section==='services'?{services:structuredClone(state.settings.services)}:section==='display'?{reduced:state.settings.reduced}:{})}})); setMessage('已取消,恢复为上次保存的内容。'); setError(false); };
|
||||
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;
|
||||
}
|
||||
if (real && section === 'display') { localStorage.setItem('cg.real.display', JSON.stringify({ reduced: draft.settings.reduced })); onSave({ ...latestState.current, settings: { ...latestState.current.settings, reduced: draft.settings.reduced } }); 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,16 +108,20 @@ 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={() => requestLeave(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 ? '以上六层底色说明。生辰和原始人格参数保持只读。' : '细腻、慢热(示例)'}</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>
|
||||
{confirm && <Modal title={confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'relation')
|
||||
{real && section === 'city' && <><div className="card"><h4>城市与天气</h4><p>尚未设置可核实的城市。城市保存、天气查询与作息联动将在后续接入。</p></div><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><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 disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
|
||||
{real && section === 'appearance' && <><div className="card"><h4>当前画面</h4><p>内置参考静帧;真实素材包导入尚未接通,外观变化不会改变她的身份和底色。</p></div><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
|
||||
{real && section === 'backup' && <><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
|
||||
{real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<div className="card"><h4>其余能力</h4><p>生活、长期记忆、主动消息和天气尚未接通。文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</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 === 'leave' ? '有未保存的修改' : confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
|
||||
void save(true); if (which === 'appearance') {
|
||||
try {
|
||||
if (fail)
|
||||
@@ -124,6 +151,6 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
|
||||
setMessage((e as Error).message);
|
||||
setError(true);
|
||||
}
|
||||
} }}>{confirm === 'restore' ? <>恢复会覆盖当前本机演示资料。操作前会先下载当前资料,档案身份和底色须相同。<Field label="输入“恢复”以继续"><input className="input" value={confirmText} onChange={e => setConfirmText(e.target.value)}/></Field></> : <>记忆与信任都保留,八字底色不变。{confirm === 'appearance' ? '本次只更新演示标识,不接入未经校验的外部素材。' : ''}</>}</Modal>}
|
||||
} }} confirm={confirm === 'leave' ? '放弃修改并离开' : '确认'}>{confirm === 'leave' ? <>离开会放弃尚未保存的修改。选择取消可继续编辑,已经保存的资料不会改变。</> : confirm === 'restore' ? <>恢复会覆盖当前本机演示资料。操作前会先下载当前资料,档案身份和底色须相同。<Field label="输入“恢复”以继续"><input className="input" value={confirmText} onChange={e => setConfirmText(e.target.value)}/></Field></> : <>记忆与信任都保留,八字底色不变。{confirm === 'appearance' ? '本次只更新演示标识,不接入未经校验的外部素材。' : ''}</>}</Modal>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
// Identity is a turn, not a token or a database id. Finalizing a stream keeps
|
||||
// its DOM and animation. Only a replacement retains/fades out the old sentence.
|
||||
export function SoftText({ identity, text }: { identity: string; text: string }) {
|
||||
const current = useRef<HTMLSpanElement>(null), outgoing = useRef<HTMLSpanElement>(null);
|
||||
const previous = useRef({ identity, text });
|
||||
const [leaving, setLeaving] = useState<{ identity: string; text: string } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (previous.current.identity !== identity && previous.current.text) setLeaving(previous.current);
|
||||
previous.current = { identity, text };
|
||||
}, [identity, text]);
|
||||
useLayoutEffect(() => {
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('reduced');
|
||||
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out').trim();
|
||||
const animation = current.current?.animate([
|
||||
{ opacity: 0, transform: reduced ? 'none' : 'translateY(3px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
], { duration: reduced ? 120 : 200, easing });
|
||||
return () => animation?.cancel();
|
||||
}, [identity]);
|
||||
useLayoutEffect(() => {
|
||||
if (!leaving || !outgoing.current) return;
|
||||
const animation = outgoing.current.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 160, easing: getComputedStyle(document.documentElement).getPropertyValue('--ease-out').trim(), fill: 'forwards',
|
||||
});
|
||||
void animation.finished.then(() => setLeaving(null)).catch(() => {});
|
||||
return () => animation.cancel();
|
||||
}, [leaving]);
|
||||
return <span className="soft-text"><span key={identity} ref={current} className="current-text">{text}</span>{leaving && <span ref={outgoing} className="outgoing-text" aria-hidden="true">{leaving.text}</span>}</span>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useLayoutEffect, type ReactNode } from 'react';
|
||||
|
||||
export function Viewport({ children }: { children: ReactNode }) {
|
||||
useLayoutEffect(() => {
|
||||
const viewport = window.visualViewport, style = document.documentElement.style;
|
||||
let settle: ReturnType<typeof setTimeout>;
|
||||
const update = () => {
|
||||
// Keyboard/browser-bar geometry must follow the viewport immediately;
|
||||
// a decorative stage transition would cover the dialogue in between.
|
||||
document.documentElement.classList.add('viewport-resizing');
|
||||
clearTimeout(settle);
|
||||
settle = setTimeout(() => document.documentElement.classList.remove('viewport-resizing'), 200);
|
||||
const height = viewport?.height ?? innerHeight, top = viewport?.offsetTop ?? 0;
|
||||
style.setProperty('--view-h', `${height}px`);
|
||||
style.setProperty('--view-top', `${top}px`);
|
||||
style.setProperty('--view-bottom', `${Math.max(0, innerHeight - height - top)}px`);
|
||||
};
|
||||
update();
|
||||
viewport?.addEventListener('resize', update); viewport?.addEventListener('scroll', update);
|
||||
window.addEventListener('resize', update);
|
||||
return () => { clearTimeout(settle); viewport?.removeEventListener('resize', update); viewport?.removeEventListener('scroll', update); window.removeEventListener('resize', update); };
|
||||
}, []);
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// An open dirty form can defer a navigation until its confirmation resolves.
|
||||
export function leaveSettingsThen(action: () => void) {
|
||||
if (window.dispatchEvent(new CustomEvent('cg:leave-settings', { cancelable: true, detail: action }))) action();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Color } from '../adapters/real';
|
||||
import { useEffect, useRef, useId, cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
export function Icon({ name }: {
|
||||
name: string;
|
||||
@@ -52,4 +53,13 @@ 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?: Color } = {}) {
|
||||
if (color) return <><div className="card birth-color"><h4><Icon name="lock"/>出生底色 · 真实排盘</h4>
|
||||
{color.calendar && <dl className="kv calendar"><dt>公历</dt><dd>{color.calendar.solar}</dd><dt>农历</dt><dd>{color.calendar.lunar}{color.calendar.leap_month && <span className="tag accent">闰月</span>}</dd><dt>出生时辰</dt><dd>{color.calendar.hour} · {color.calendar.hour_range}</dd><dt>排盘时间</dt><dd>北京时间 {color.calendar.calculation_time}</dd></dl>}
|
||||
<div className="pillars">{[['year','年柱'], ['month','月柱'], ['day','日柱'], ['time','时柱']].map(([key, label]) => <div key={key}><small>{label}</small><b>{color.pillars[key]}</b></div>)}</div>
|
||||
<details className="birth-help"><summary>四柱、日主和农历,分别是什么?</summary><p>四柱是出生年、月、日、时的四组干支,合起来为八个字。日柱的第一个字叫日主,是解释组合的参照。五行描述组合的侧重,十神描述各天干与日主的关系,不是十种好坏标签。</p><p>{color.calendar?.rule || '月柱按节气划分,与农历月份不是同一回事。'}</p></details>
|
||||
<p className="d-foot">{color.interpretation || '当前档案尚未提供分层说明,请更新服务后重试。原底色保留。'}</p>
|
||||
<div className="color-sections">{color.sections?.map(section => <section key={section.id}><h4>{section.title}</h4><p>{section.text}</p><details><summary>这段说明的依据</summary><p>{section.basis}</p></details></section>)}</div>
|
||||
</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><p>演示不执行排盘,不用固定八字或性格代替计算。普通入口会显示真实公历、农历、四柱和六层底色说明。</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></>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/* Approved HEL-746 R2 component styles; edit here, never add override sheets. */
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
*{box-sizing:border-box;margin:0;padding:0;scrollbar-width:thin;scrollbar-color:var(--scroll-thumb) var(--scroll-track)}
|
||||
html{color-scheme:dark}
|
||||
*::-webkit-scrollbar{width:8px;height:8px}
|
||||
*::-webkit-scrollbar-track{background:var(--scroll-track);border-radius:8px}
|
||||
*::-webkit-scrollbar-thumb{background:var(--scroll-thumb);border:2px solid var(--scroll-track);border-radius:8px}
|
||||
@media(hover:hover) and (pointer:fine){*::-webkit-scrollbar-thumb:hover{background:var(--scroll-hover)}}
|
||||
@media(forced-colors:active){*{scrollbar-color:auto}*::-webkit-scrollbar-thumb{background:ButtonText}*::-webkit-scrollbar-track{background:Canvas}}
|
||||
html,body{height:100%;overflow:hidden;background:var(--bg-0);transition:background-color var(--dur-theme) var(--ease-in-out)}
|
||||
body{font-family:var(--font-sans);color:var(--text-1);font-size:var(--fs-body);line-height:1.7;-webkit-font-smoothing:antialiased;letter-spacing:.01em}
|
||||
button,input,select,textarea{font:inherit;color:inherit}
|
||||
@@ -144,7 +150,7 @@ html[data-theme="fog"] .glass{box-shadow:inset 0 1px 0 var(--glass-hi),inset 0 0
|
||||
/* ============================================================
|
||||
顶栏
|
||||
============================================================ */
|
||||
.topbar{position:fixed;top:22px;left:32px;right:32px;display:flex;align-items:center;justify-content:space-between;z-index:30;pointer-events:none;transition:opacity var(--dur-panel)}
|
||||
.topbar{position:fixed;top:calc(var(--view-top) + 22px);left:32px;right:32px;display:flex;align-items:center;justify-content:space-between;z-index:30;pointer-events:none;transition:opacity var(--dur-panel)}
|
||||
.topbar > *{pointer-events:auto}
|
||||
.who{display:flex;align-items:center;gap:12px}
|
||||
.who .name{font-size:19px;font-weight:400;letter-spacing:.14em}
|
||||
@@ -186,6 +192,7 @@ body.creating .topbar .who,body.creating .topbar .env > :not(.theme-switch){opac
|
||||
transition:left var(--dur-stage) var(--ease-out),top var(--dur-stage) var(--ease-out),width var(--dur-stage) var(--ease-out),height var(--dur-stage) var(--ease-out),border-radius var(--dur-stage) var(--ease-out),box-shadow var(--dur-theme),opacity var(--dur-panel),filter var(--dur-panel)}
|
||||
.portal img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:opacity 620ms var(--ease-in-out) 120ms,transform var(--dur-stage) var(--ease-out)}
|
||||
.portal .img-room{object-position:88% 42%;transform:scale(1.16);transform-origin:80% 42%}
|
||||
html.viewport-resizing .portal{transition:none}
|
||||
.portal .img-talk{object-position:54% 28%;opacity:0;transform:scale(1.08);transform-origin:54% 30%}
|
||||
.portal .life{position:absolute;inset:0;animation:kenburns 26s var(--ease-drift) infinite alternate}
|
||||
@keyframes kenburns{from{transform:scale(1) translate3d(0,0,0)}to{transform:scale(1.045) translate3d(-1.2%,-.6%,0)}}
|
||||
@@ -209,8 +216,8 @@ html[data-theme="fog"] .portal .tint{background:linear-gradient(180deg,rgba(140
|
||||
.observe.fail .eye{background:var(--danger);box-shadow:none}
|
||||
.observe.asking .eye{background:var(--text-3);box-shadow:none}
|
||||
|
||||
body.talk .portal{--pw:min(64vw, calc((100vh - 360px) * 1.72)); --ph:calc(100vh - 360px); --pl:calc(50vw - var(--pw) / 2 + var(--shift)); --pt:72px; --pr:var(--radius-stage) var(--radius-stage) var(--radius-stage) var(--radius-stage)}
|
||||
body.talk.voice .portal{--pw:min(64vw, calc((100vh - 412px) * 1.72)); --ph:calc(100vh - 412px)}
|
||||
body.talk .portal{--pw:min(64vw, calc(var(--talk-stage-h) * 1.72)); --ph:var(--talk-stage-h); --pl:calc(50vw - var(--pw) / 2 + var(--shift)); --pt:calc(var(--view-top) + 72px); --pr:var(--radius-stage) var(--radius-stage) var(--radius-stage) var(--radius-stage)}
|
||||
body.talk.voice{--talk-stage-h:clamp(64px,calc(var(--view-h) - 472px),488px)}
|
||||
body{--shift:0px}
|
||||
body.talk.log{--shift:-200px}
|
||||
body.talk .portal .img-room{opacity:0;transform:scale(2.1);transform-origin:84% 38%}
|
||||
@@ -289,27 +296,30 @@ body.paned .reach{left:auto;right:28px;bottom:104px;width:340px;border-radius:20
|
||||
/* ============================================================
|
||||
交流
|
||||
============================================================ */
|
||||
.dialog{position:fixed;left:calc(50vw + var(--shift));bottom:176px;width:min(760px,74vw);transform:translate(-50%,16px);text-align:center;z-index:12;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-stage) var(--ease-out),left var(--dur-stage) var(--ease-out),bottom var(--dur-panel) var(--ease-out)}
|
||||
body.talk .dialog{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-delay:200ms,0ms,0ms,0ms}
|
||||
body.talk.voice .dialog{bottom:228px}
|
||||
.dialog .you{font-size:14px;color:var(--text-3);margin-bottom:6px;min-height:1.6em}
|
||||
.dialog .you em{font-style:normal;color:var(--text-3);margin-right:8px}
|
||||
.dialog .you .partial{color:var(--text-2)}
|
||||
.dialog .you .failed{color:var(--danger);margin-left:8px;font-size:12.5px}
|
||||
.dialog .you .retry{color:var(--accent);font-size:12.5px;margin-left:6px;text-decoration:underline;text-underline-offset:3px}
|
||||
.dialog .her{font-size:var(--fs-line);line-height:1.65;font-weight:400;color:var(--text-1);text-shadow:0 2px 18px rgba(0,0,10,.6);min-height:1.65em;transition:opacity var(--dur-ui)}
|
||||
.dialog .her em{font-style:normal;font-size:13px;color:var(--accent);margin-right:10px;letter-spacing:.1em;vertical-align:2px}
|
||||
.dialog .her.cut{opacity:.55}
|
||||
.chat-layout{position:fixed;top:calc(var(--view-top) + 72px);height:calc(var(--view-h) - 166px - var(--safe-bottom));left:calc(50vw + var(--shift));transform:translateX(-50%);width:min(760px,74vw);z-index:12;display:flex;flex-direction:column;gap:12px;pointer-events:none}
|
||||
.talk-scene-space{height:var(--talk-stage-h);flex:none}
|
||||
.dialog{min-height:0;flex:1;display:flex;flex-direction:column;pointer-events:auto}
|
||||
.dialog-scroll{min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding:4px 12px;overflow-anchor:none}
|
||||
.dialog .you{font-size:15px;color:var(--text-2);margin:0 0 14px 12%;text-align:right}
|
||||
.dialog .her{font-size:var(--fs-line);line-height:1.65;font-weight:400;color:var(--text-1);text-shadow:0 2px 18px rgba(0,0,10,.6);margin-right:8%;min-height:1.65em}
|
||||
.speaker{display:block;font-size:13px;font-weight:500;letter-spacing:.1em;color:var(--accent);margin-bottom:4px}
|
||||
.dialog .you .speaker{color:var(--text-2)}
|
||||
.dialog .failed{color:var(--danger);font-size:13px}
|
||||
.dialog .retry{color:var(--accent);font-size:13px;margin-left:6px;text-decoration:underline;text-underline-offset:3px}
|
||||
.dialog .her.cut{opacity:.65}
|
||||
.soft-text{display:block;position:relative;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.current-text{display:block}
|
||||
.outgoing-text{position:absolute;inset:0;pointer-events:none}
|
||||
.partial-note{display:block;font-size:12px;color:var(--warn);margin-top:6px}
|
||||
.dialog .her .caret{display:inline-block;width:2px;height:1em;background:var(--accent);margin-left:3px;vertical-align:-2px;animation:caret 1s steps(1) infinite}
|
||||
.dialog .her .dots{display:inline-flex;gap:5px;vertical-align:middle}
|
||||
.dialog .her .dots i{width:6px;height:6px;border-radius:50%;background:var(--text-2);animation:dots 1.2s var(--ease-drift) infinite}
|
||||
.dialog .her .dots i:nth-child(2){animation-delay:.15s}.dialog .her .dots i:nth-child(3){animation-delay:.3s}
|
||||
@keyframes dots{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-3px)}}
|
||||
@keyframes caret{50%{opacity:0}}
|
||||
.dialog .hint{margin-top:8px;font-size:12px;color:var(--text-3);min-height:1.5em;transition:opacity var(--dur-ui)}
|
||||
|
||||
.composer{position:fixed;left:calc(50vw + var(--shift));bottom:98px;width:min(680px,80vw);height:60px;border-radius:var(--radius-pill);transform:translate(-50%,20px);opacity:0;pointer-events:none;z-index:14;display:flex;align-items:center;gap:4px;padding:0 6px;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-stage) var(--ease-out),left var(--dur-stage) var(--ease-out),background-color var(--dur-theme),border-color var(--dur-theme)}
|
||||
body.talk .composer{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-delay:260ms,0ms,0ms}
|
||||
.dialog .hint{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none;padding:4px 12px;font-size:12px;color:var(--text-2);min-height:26px}
|
||||
.dialog .hint .btn-text{white-space:nowrap;color:var(--accent)}
|
||||
.composer{position:relative;flex:none;width:100%;min-height:60px;border-radius:var(--radius-pill);pointer-events:auto;display:flex;align-items:center;gap:4px;padding:0 6px}
|
||||
.composer .btn-round{width:46px;height:46px}
|
||||
.composer textarea{flex:1;height:46px;max-height:90px;resize:none;border:0;background:none;outline:none;padding:10px 8px;font-size:15px;color:var(--text-1);min-width:0}
|
||||
.composer textarea::placeholder{color:var(--text-3)}
|
||||
@@ -335,8 +345,7 @@ body.v-listen .mic::after{animation:ring 1.4s var(--ease-out) infinite}
|
||||
.wave.still i{animation:none;height:4px}
|
||||
|
||||
/* 语音会话条:会话中始终可见「结束语音」 */
|
||||
.voicebar{position:fixed;left:calc(50vw + var(--shift));bottom:168px;transform:translate(-50%,10px);z-index:15;height:48px;padding:0 6px 0 16px;border-radius:var(--radius-pill);display:flex;align-items:center;gap:12px;font-size:13.5px;opacity:0;pointer-events:none;transition:opacity var(--dur-ui),transform var(--dur-ui) var(--ease-out),left var(--dur-stage) var(--ease-out)}
|
||||
body.talk.voice .voicebar{opacity:1;transform:translate(-50%,0);pointer-events:auto}
|
||||
.voicebar{position:relative;flex:none;min-height:48px;padding:0 6px 0 16px;border-radius:var(--radius-pill);display:flex;align-items:center;gap:12px;font-size:13.5px;pointer-events:auto}
|
||||
.voicebar .live{display:flex;align-items:center;gap:8px;color:var(--text-2);font-family:var(--font-num)}
|
||||
.voicebar .live i{width:8px;height:8px;border-radius:50%;background:var(--danger);box-shadow:0 0 8px var(--danger);animation:dotPulse 1.6s infinite}
|
||||
.voicebar .vstate{display:flex;align-items:center;gap:8px;color:var(--text-1);min-width:120px}
|
||||
@@ -346,7 +355,7 @@ body.talk.voice .voicebar{opacity:1;transform:translate(-50%,0);pointer-events:a
|
||||
.voicebar .end svg{width:12px;height:12px}
|
||||
|
||||
/* 完整聊天记录 */
|
||||
.log-panel{position:fixed;right:24px;top:72px;bottom:98px;width:380px;border-radius:24px;z-index:20;display:flex;flex-direction:column;opacity:0;transform:translateX(36px);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out),background-color var(--dur-theme)}
|
||||
.log-panel{position:fixed;right:24px;top:calc(var(--view-top) + 72px);bottom:calc(var(--view-bottom) + 98px + var(--safe-bottom));width:380px;border-radius:24px;z-index:20;display:flex;flex-direction:column;opacity:0;transform:translateX(36px);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out),background-color var(--dur-theme)}
|
||||
body.log .log-panel{opacity:1;transform:none;pointer-events:auto}
|
||||
.log-panel header{display:flex;align-items:center;justify-content:space-between;padding:18px 18px 10px 22px}
|
||||
.log-panel header h2{font-size:16px;font-weight:500;letter-spacing:.08em}
|
||||
@@ -367,7 +376,7 @@ body.log .log-panel{opacity:1;transform:none;pointer-events:auto}
|
||||
/* ============================================================
|
||||
底部导航
|
||||
============================================================ */
|
||||
.nav{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);height:60px;padding:6px;border-radius:var(--radius-pill);display:flex;gap:2px;z-index:25;transition:opacity var(--dur-panel),transform var(--dur-panel) var(--ease-out)}
|
||||
.nav{position:fixed;left:50%;bottom:calc(var(--view-bottom) + 22px + var(--safe-bottom));transform:translateX(-50%);height:60px;padding:6px;border-radius:var(--radius-pill);display:flex;gap:2px;z-index:25;transition:opacity var(--dur-panel),transform var(--dur-panel) var(--ease-out)}
|
||||
body.creating .nav{opacity:0;pointer-events:none;transform:translate(-50%,20px)}
|
||||
.nav-item{position:relative;width:76px;height:48px;border-radius:var(--radius-pill);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;font-size:11.5px;color:var(--text-2);letter-spacing:.08em;transition:color var(--dur-ui),background-color var(--dur-ui),transform var(--dur-fast) var(--ease-out)}
|
||||
.nav-item svg{width:19px;height:19px}
|
||||
@@ -379,7 +388,7 @@ body.creating .nav{opacity:0;pointer-events:none;transform:translate(-50%,20px)}
|
||||
/* ============================================================
|
||||
区域面板:日子 / 设置(同一主题空间里升起的阅读面)
|
||||
============================================================ */
|
||||
.pane{position:fixed;z-index:16;left:var(--pane-l);top:var(--pane-t);bottom:var(--pane-b);width:var(--pane-w);border-radius:28px;display:flex;flex-direction:column;overflow:hidden;opacity:0;transform:translateY(24px) scale(.985);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out),background-color var(--dur-theme)}
|
||||
.pane{position:fixed;z-index:16;left:var(--pane-l);top:calc(var(--view-top) + var(--pane-t));bottom:calc(var(--view-bottom) + var(--pane-b) + var(--safe-bottom));width:var(--pane-w);border-radius:28px;display:flex;flex-direction:column;overflow:hidden;opacity:0;transform:translateY(24px) scale(.985);pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out),transform var(--dur-panel) var(--ease-out),background-color var(--dur-theme)}
|
||||
.pane.open{opacity:1;transform:none;pointer-events:auto;transition-delay:80ms}
|
||||
.pane > header{display:flex;align-items:center;gap:16px;padding:18px 18px 14px 28px;border-bottom:1px solid rgba(255,255,255,.06);flex:none}
|
||||
.pane > header h2{font-size:var(--fs-title);font-weight:400;letter-spacing:.1em;white-space:nowrap}
|
||||
@@ -522,7 +531,7 @@ body.creating .nav{opacity:0;pointer-events:none;transform:translate(-50%,20px)}
|
||||
/* ============================================================
|
||||
首建
|
||||
============================================================ */
|
||||
.create{position:fixed;inset:0;z-index:60;display:grid;grid-template-columns:minmax(0,1fr) minmax(460px,560px);gap:6vw;align-items:center;padding:84px 9vw 48px;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out)}
|
||||
.create{position:fixed;inset:var(--view-top) 0 auto;height:var(--view-h);z-index:60;display:grid;grid-template-columns:minmax(0,1fr) minmax(460px,560px);gap:6vw;align-items:center;padding:84px 9vw 48px;opacity:0;pointer-events:none;transition:opacity var(--dur-panel) var(--ease-out)}
|
||||
body.creating .create{opacity:1;pointer-events:auto}
|
||||
.create .story{max-width:440px}
|
||||
.create .story .eyebrow{font-size:13px;color:var(--text-3);letter-spacing:.2em;margin-bottom:14px}
|
||||
@@ -530,7 +539,7 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
.create .story p{margin-top:16px;color:var(--text-2);font-size:14.5px;line-height:1.85}
|
||||
.create .story .single{margin-top:22px;display:flex;gap:10px;font-size:12.5px;color:var(--text-3);line-height:1.7}
|
||||
.create .story .single svg{width:16px;height:16px;flex:none;margin-top:3px;color:var(--accent)}
|
||||
.wiz{border-radius:28px;display:flex;flex-direction:column;max-height:calc(100vh - 132px);overflow:hidden}
|
||||
.wiz{border-radius:28px;display:flex;flex-direction:column;max-height:calc(var(--view-h) - 132px);overflow:hidden}
|
||||
.wiz .steps{display:flex;gap:6px;padding:20px 28px 0}
|
||||
.wiz .steps span{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.1);transition:background-color var(--dur-ui),box-shadow var(--dur-ui)}
|
||||
.wiz .steps span.on{background:var(--accent);box-shadow:0 0 8px var(--accent-glow)}
|
||||
@@ -571,7 +580,7 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
============================================================ */
|
||||
.scrim{position:fixed;inset:0;z-index:70;background:rgba(2,4,16,.45);opacity:0;pointer-events:none;transition:opacity var(--dur-ui)}
|
||||
.scrim.open{opacity:1;pointer-events:auto}
|
||||
.modal{position:fixed;left:50%;top:50%;z-index:72;width:min(460px,calc(100vw - 32px));border-radius:26px;padding:26px 26px 22px;transform:translate(-50%,-46%) scale(.97);opacity:0;pointer-events:none;transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out)}
|
||||
.modal{position:fixed;left:50%;top:calc(var(--view-top) + var(--view-h) / 2);max-height:calc(var(--view-h) - 32px);overflow:auto;z-index:72;width:min(460px,calc(100vw - 32px));border-radius:26px;padding:26px 26px 22px;transform:translate(-50%,-46%) scale(.97);opacity:0;pointer-events:none;transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out)}
|
||||
.modal.open{opacity:1;transform:translate(-50%,-50%);pointer-events:auto}
|
||||
.modal .mi{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent);margin-bottom:14px}
|
||||
.modal.danger .mi{background:rgba(255,159,179,.12);color:var(--danger)}
|
||||
@@ -609,7 +618,7 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
============================================================ */
|
||||
@media (max-width:720px){
|
||||
:root{--fs-whisper:21px;--fs-line:17px}
|
||||
.topbar{top:14px;left:18px;right:14px}
|
||||
.topbar{top:calc(var(--view-top) + 14px);left:18px;right:14px}
|
||||
.who .name{font-size:17px}
|
||||
.who .state,.env .chip.place,.env .chip.weather,.env .tag-demo,.clock small{display:none}
|
||||
.env{gap:6px}
|
||||
@@ -621,9 +630,11 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
.netbar{top:56px;max-width:calc(100vw - 24px);font-size:12.5px}
|
||||
.netbar small{display:none}
|
||||
|
||||
.portal{--pw:min(74vw,330px); --ph:calc(var(--pw) * 1.22); --pl:calc(50vw - var(--pw) / 2); --pt:70px}
|
||||
body.talk .portal{--pw:calc(100vw - 24px); --ph:min(52vh, calc(var(--pw) * 1.2)); --pl:12px; --pt:58px}
|
||||
body.talk.voice .portal{--pw:calc(100vw - 24px); --ph:min(42vh, calc(var(--pw) * 1.1))}
|
||||
body{--home-pw:min(74vw,330px,max(120px,calc((var(--view-h) - 340px) / 1.22)))}
|
||||
.portal{--pw:var(--home-pw); --ph:calc(var(--pw) * 1.22); --pl:calc(50vw - var(--pw) / 2); --pt:70px}
|
||||
body.talk{--talk-stage-h:clamp(64px,calc((var(--view-h) - 360px) * .55),300px)}
|
||||
body.talk .portal{--pw:calc(100vw - 24px); --ph:var(--talk-stage-h); --pl:12px; --pt:calc(var(--view-top) + 58px)}
|
||||
body.talk.voice{--talk-stage-h:clamp(48px,calc((var(--view-h) - 412px) * .5),250px)}
|
||||
body.talk.log{--shift:0px}
|
||||
body.paned .portal{opacity:0;pointer-events:none;--pw:min(74vw,330px); --ph:calc(var(--pw) * 1.22); --pl:calc(50vw - var(--pw) / 2); --pt:70px}
|
||||
.mini-cap{display:none}
|
||||
@@ -632,11 +643,11 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
.portal .mark{right:14px;bottom:50px}
|
||||
.observe{max-width:calc(100vw - 80px)}
|
||||
|
||||
.home{left:22px;right:22px;width:auto;top:calc(70px + min(74vw,330px) * 1.22 + 22px);transform:none}
|
||||
.home{left:22px;right:22px;width:auto;top:calc(var(--view-top) + 70px + var(--home-pw) * 1.22 + 22px);max-height:calc(var(--view-h) - 70px - var(--home-pw) * 1.22 - 106px - var(--safe-bottom));overflow:auto;transform:none}
|
||||
body.talk .home,body.paned .home,body.creating .home{transform:translateY(20px) scale(.97)}
|
||||
.home .eyebrow,.home .meta{display:none}
|
||||
.letter{margin-top:14px;padding:14px 16px 12px}
|
||||
.letter p{font-size:14px}
|
||||
.letter p{font-size:14px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.away{margin-top:8px;font-size:13px}
|
||||
.cta{position:static;margin-top:16px}
|
||||
.cta .btn-primary{flex:1;height:50px}
|
||||
@@ -649,30 +660,29 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
.reach{left:12px;right:12px;width:auto;bottom:164px;border-radius:20px}
|
||||
body.paned .reach{left:12px;right:12px;bottom:96px}
|
||||
|
||||
.dialog{left:50vw;top:calc(58px + min(52vh, calc((100vw - 24px) * 1.2)) + 18px);bottom:auto;width:calc(100vw - 40px)}
|
||||
body.talk.voice .dialog{top:calc(58px + min(42vh, calc((100vw - 24px) * 1.1)) + 14px);bottom:auto}
|
||||
.dialog .you{font-size:13px}
|
||||
.composer{left:50vw;bottom:98px;width:calc(100vw - 24px);height:56px}
|
||||
.chat-layout{left:50vw;top:calc(var(--view-top) + 58px);height:calc(var(--view-h) - 142px - var(--safe-bottom));width:calc(100vw - 24px);gap:8px}
|
||||
.dialog .her{font-size:17px}
|
||||
.composer{min-height:56px}
|
||||
.composer .btn-round{width:40px;height:40px}
|
||||
.composer .btn-round svg{width:19px;height:19px}
|
||||
.mic{width:42px;height:42px}
|
||||
.voicebar{left:50vw;bottom:162px;width:calc(100vw - 24px);gap:8px;padding:0 6px 0 12px}
|
||||
.voicebar{gap:8px;padding:0 6px 0 12px}
|
||||
.voicebar .vstate{min-width:0;flex:1;font-size:13px}
|
||||
.voicebar .live span,.voicebar .vstate .wave{display:none}
|
||||
.voicebar .vstate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
||||
|
||||
.nav{bottom:16px;height:58px;width:calc(100vw - 24px);justify-content:space-between}
|
||||
.nav{bottom:calc(var(--view-bottom) + 16px + var(--safe-bottom));height:58px;width:calc(100vw - 24px);justify-content:space-between}
|
||||
.nav-item{flex:1;width:auto}
|
||||
.nav-item .badge{right:calc(50% - 18px)}
|
||||
|
||||
.log-panel{left:0;right:0;top:auto;bottom:0;width:auto;height:74vh;border-radius:26px 26px 0 0;z-index:45;transform:translateY(100%);opacity:1}
|
||||
.log-panel{left:0;right:0;top:calc(var(--view-top) + 54px);bottom:calc(var(--view-bottom) + 84px + var(--safe-bottom));width:auto;height:auto;border-radius:26px 26px 0 0;z-index:45;transform:translateY(100%);opacity:1}
|
||||
body.log .log-panel{transform:none}
|
||||
.log-panel,.pane,.wiz,.modal{background:var(--glass-solid)}
|
||||
.window-glow{display:none}
|
||||
body.talk .moon-disc,body.talk .moon-halo{opacity:0}
|
||||
|
||||
/* 区域面板 → 全屏层;列表与详情改为推入 */
|
||||
.pane{left:8px;right:8px;width:auto;top:54px;bottom:84px;border-radius:24px}
|
||||
.pane{left:8px;right:8px;width:auto;top:calc(var(--view-top) + 54px);bottom:calc(var(--view-bottom) + 84px + var(--safe-bottom));border-radius:24px}
|
||||
.pane > header{flex-wrap:wrap;gap:10px;padding:14px 14px 12px 18px}
|
||||
.pane > header h2{font-size:18px}
|
||||
.pane > header .tabs{order:3;width:100%;margin:0;display:flex}
|
||||
@@ -696,17 +706,17 @@ body.creating .create{opacity:1;pointer-events:auto}
|
||||
.pillars b{font-size:19px}
|
||||
.compare{grid-template-columns:1fr 1fr}
|
||||
|
||||
.create{display:block;padding:56px 0 0;overflow:auto}
|
||||
.create .story{padding:4px 22px 14px;max-width:none}
|
||||
.create{display:flex;flex-direction:column;padding:56px 0 var(--safe-bottom);overflow:hidden}
|
||||
.create .story{flex:none;padding:4px 22px 14px;max-width:none}
|
||||
.create .story h1{font-size:21px}
|
||||
.create .story p{font-size:13.5px;margin-top:8px}
|
||||
.create .story .single{display:none}
|
||||
.wiz,.wiz .wbody{overflow:visible}
|
||||
.wiz{margin:0 8px;max-height:none;border-radius:24px 24px 0 0;min-height:calc(100vh - 190px)}
|
||||
.wiz .wbody{overflow:auto;min-height:0}
|
||||
.wiz{margin:0 8px;max-height:none;border-radius:24px 24px 0 0;min-height:0;flex:1;overflow:hidden}
|
||||
.wiz .wbody{padding:8px 18px 8px}
|
||||
.wiz .steps{padding:16px 18px 0}
|
||||
.wiz .steplabel{padding:8px 18px 0}
|
||||
.wiz .wfoot{position:sticky;bottom:0;padding:12px 18px 18px;background:var(--surface)}
|
||||
.wiz .wfoot{flex:none;padding:12px 18px 18px;background:var(--surface)}
|
||||
.wiz .wfoot .grow{display:none}
|
||||
.wiz .wfoot button{flex:1}
|
||||
.create.at-story .story{display:block}
|
||||
@@ -743,5 +753,13 @@ p,button,h1,h2,h3,dd{overflow-wrap:anywhere}
|
||||
.entry p{color:var(--text-2);margin:18px 0}
|
||||
.error-text{color:var(--danger);font-size:13px;margin-top:12px}
|
||||
.demo-label{font-size:11px;color:var(--text-3)}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition-duration:0.01ms!important;transition-delay:0ms!important}}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition-duration:120ms!important;transition-delay:0ms!important}}
|
||||
body:has(textarea:focus) .space *{animation-play-state:paused!important}
|
||||
|
||||
/* Birth explanations share the read surface and typography with existing forms. */
|
||||
.calendar{margin:12px 0}
|
||||
.color-sections section{border-top:1px solid var(--glass-line);padding-top:16px;margin-top:16px}
|
||||
.color-sections h4{color:var(--text-1);margin-bottom:8px}
|
||||
.birth-help, .color-sections details{font-size:13px;color:var(--text-2);margin-top:10px}
|
||||
.birth-help summary, .color-sections summary{cursor:pointer;color:var(--accent)}
|
||||
@media(max-height:480px){body.talk{--talk-stage-h:40px}body.talk .portal .mark,body.talk .portal .topleft{display:none}.create .story p,.create .story .single{display:none}.create .story{padding-bottom:4px}.create .story h1{font-size:18px}}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
R2 新增:阅读底 --glass-read、状态色 --ok/--warn/--danger、--z-*
|
||||
============================================================ */
|
||||
:root{
|
||||
--view-h:100vh; --view-top:0px; --view-bottom:0px;
|
||||
--safe-bottom:env(safe-area-inset-bottom,0px);
|
||||
--talk-stage-h:clamp(64px,calc(var(--view-h) - 420px),540px);
|
||||
--font-sans: "PingFang SC","HarmonyOS Sans SC","MiSans","Microsoft YaHei UI","Microsoft YaHei","Noto Sans SC",system-ui,sans-serif;
|
||||
--font-num: "SF Pro Display","Segoe UI Variable Display","Segoe UI",var(--font-sans);
|
||||
|
||||
@@ -29,6 +32,7 @@ html[data-theme="moon"]{
|
||||
--glass-blur:18px; --glass-sat:120%; --glass-solid:rgba(12,20,50,.88);
|
||||
--glass-read:rgba(10,18,46,.62); --surface:#0b1330; --field:rgba(255,255,255,.05); --field-line:rgba(205,222,255,.16);
|
||||
--window-glow:rgba(255,196,140,.20);
|
||||
--scroll-track:#0d1838; --scroll-thumb:#51678d; --scroll-hover:#a9dcff;
|
||||
--ok:#9fe8cf; --warn:#ffd79c; --danger:#ff9fb3;
|
||||
}
|
||||
html[data-theme="fog"]{
|
||||
@@ -39,5 +43,6 @@ html[data-theme="fog"]{
|
||||
--glass-blur:30px; --glass-sat:145%; --glass-solid:rgba(38,22,74,.88);
|
||||
--glass-read:rgba(34,20,66,.60); --surface:#24154a; --field:rgba(255,240,255,.06); --field-line:rgba(236,218,255,.16);
|
||||
--window-glow:rgba(255,170,190,.22);
|
||||
--scroll-track:#22174a; --scroll-thumb:#806298; --scroll-hover:#ebbcff;
|
||||
--ok:#a8f0d8; --warn:#ffdcae; --danger:#ffa8c8;
|
||||
}
|
||||
|
||||
@@ -56,18 +56,16 @@ 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('名字')
|
||||
page.get_by_label('她的名字', exact=True).fill('小满')
|
||||
page.get_by_label('出生日期', exact=True).fill('2020-01-01')
|
||||
page.get_by_label('出生日期(公历)', exact=True).fill('2020-01-01')
|
||||
click(page, '下一步')
|
||||
expect(page.get_by_role('alert')).to_contain_text('18')
|
||||
page.get_by_label('出生日期', exact=True).fill('2000-10-18')
|
||||
page.get_by_label('出生日期(公历)', exact=True).fill('2000-10-18')
|
||||
click(page, '下一步'); click(page, '下一步')
|
||||
expect(page.get_by_role('button', name='朋友', exact=True)).to_have_attribute('aria-pressed', 'false')
|
||||
expect(page.get_by_role('button', name='恋人', exact=True)).to_have_attribute('aria-pressed', 'false')
|
||||
@@ -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/rework' / ('r1-regression' if CONTROLLED else 'live-gateway')
|
||||
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 .current-text').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.locator('.pane[aria-label="日子"] .empty').first).to_contain_text('还没有可展示的生活记录')
|
||||
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')
|
||||
@@ -0,0 +1,262 @@
|
||||
"""HEL-783 foreground Chromium checks. Only temporary SQLite + controlled LLM.
|
||||
|
||||
PYTHONPATH=src <python with playwright> web/tests/rework_browser_check.py
|
||||
No deployed service or user data is read or written. Every server/context closes.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
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 ControlledLLM, live_server, make_app # noqa: E402
|
||||
from cybergril.contracts import LLMDelta # noqa: E402
|
||||
|
||||
OUT = ROOT / 'docs/rework'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
LONG = '开头:每一段都应该完整留下。\n\n' + '\n\n'.join(
|
||||
f'第{i}段。' + '窗外的灯慢慢亮起来,我们可以把话说完整,再决定下一步。' * 10
|
||||
for i in range(1, 6)) + '\n\n最后:故事没有被截掉,前文和末尾都能读到。'
|
||||
USER = '长句开头。' + '这是完整保留的用户长句。' * 15 + '用户长句末尾。'
|
||||
|
||||
|
||||
class ReworkLLM(ControlledLLM):
|
||||
async def stream(self, messages, **kwargs):
|
||||
if self.mode != 'long':
|
||||
async for delta in super().stream(messages, **kwargs):
|
||||
yield delta
|
||||
return
|
||||
self.calls.append(messages)
|
||||
try:
|
||||
for index in range(0, len(LONG), 40):
|
||||
yield LLMDelta(text=LONG[index:index + 40])
|
||||
await asyncio.sleep(.025)
|
||||
yield LLMDelta(done=True)
|
||||
finally:
|
||||
self.closed += 1
|
||||
|
||||
|
||||
report = {'checks': [], 'viewports': [], 'motion': {}, 'console_errors': [],
|
||||
'limits': ['Chromium only; physical phone keyboard, Safari and screen reader not tested.',
|
||||
'LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway.']}
|
||||
|
||||
|
||||
def passed(text):
|
||||
report['checks'].append(text)
|
||||
print('PASS', text, flush=True)
|
||||
|
||||
|
||||
def click(page, label):
|
||||
(page.locator('.pane-manage .back') if label == '设置' else page.get_by_role('button', name=label, exact=True)).click()
|
||||
|
||||
|
||||
def nav(page, label):
|
||||
page.get_by_role('navigation', name='主导航').get_by_role('button', name=label, exact=True).click()
|
||||
if label == '设置' and page.viewport_size['width'] <= 720 and page.locator('.pane-manage.has-detail').is_visible(): page.locator('.pane-manage .back').click()
|
||||
|
||||
|
||||
def shot(page, name):
|
||||
page.evaluate("""async () => { await Promise.all([...document.querySelectorAll('.wstep,.pane,.pane .detail,.portal,.tabs button')].flatMap(el=>el.getAnimations()).filter(a=>a.effect.getTiming().iterations !== Infinity).map(a=>a.finished.catch(()=>{}))); }""")
|
||||
page.screenshot(path=str(OUT / (name + '.png')))
|
||||
|
||||
|
||||
def send(page, text):
|
||||
page.get_by_role('textbox', name='想和她说的话').fill(text)
|
||||
click(page, '发送')
|
||||
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='cg-rework-') as directory, sync_playwright() as pw:
|
||||
provider = ReworkLLM()
|
||||
app, _ = make_app(Path(directory) / 'isolated.db', provider)
|
||||
with live_server(app) as url:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox'])
|
||||
context = browser.new_context(viewport={'width': 1440, 'height': 900})
|
||||
page = context.new_page()
|
||||
page.on('pageerror', lambda e: report['console_errors'].append(str(e)))
|
||||
page.add_init_script("""
|
||||
const get = Storage.prototype.getItem;
|
||||
Storage.prototype.getItem = function(key) {
|
||||
if(key === 'cybergril.demo.v1' && !location.search.includes('demo=1')) throw new Error('real read demo');
|
||||
return get.call(this, key);
|
||||
};
|
||||
window.motionStarts = [];
|
||||
const animate = Element.prototype.animate;
|
||||
Element.prototype.animate = function(...args) {
|
||||
if (this.matches('.her .current-text')) window.motionStarts.push({text:this.textContent, duration:args[1].duration});
|
||||
return animate.apply(this, args);
|
||||
};
|
||||
"""
|
||||
)
|
||||
page.goto(url)
|
||||
page.get_by_label('她的名字', exact=True).fill('星河')
|
||||
page.get_by_label('出生日期(公历)', exact=True).fill('2001-05-23')
|
||||
click(page, '下一步')
|
||||
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
|
||||
expect(page.locator('.color-sections section')).to_have_count(6)
|
||||
assert app.state.store.query_one('SELECT count(*) n FROM companion')['n'] == 0
|
||||
shot(page, 'birth-preview-moon')
|
||||
page.locator('.birth-help summary').click()
|
||||
shot(page, 'birth-calendar-help-moon')
|
||||
click(page, '下一步'); click(page, '朋友'); click(page, '下一步')
|
||||
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
|
||||
page.get_by_role('checkbox').check(); click(page, '确认创建'); click(page, '去看看她')
|
||||
passed('Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows')
|
||||
|
||||
nav(page, '交谈')
|
||||
provider.mode = 'long'
|
||||
before = page.evaluate('motionStarts.length')
|
||||
send(page, USER)
|
||||
expect(page.locator('.her .current-text')).to_contain_text('开头:')
|
||||
page.evaluate("window.streamNode = document.querySelector('.her .current-text')")
|
||||
# Pause the actual animation and inspect deterministic frames, then resume.
|
||||
frames = []
|
||||
for time in (0, 80, 200):
|
||||
values = page.evaluate("""t => {const el=window.streamNode;const a=el.getAnimations()[0];if(a){a.pause();a.currentTime=t;}const s=getComputedStyle(el);return {time:t,opacity:s.opacity,transform:s.transform};}""", time)
|
||||
frames.append(values)
|
||||
shot(page, f'motion-enter-{time}')
|
||||
page.evaluate('window.streamNode.getAnimations().forEach(a=>a.play())')
|
||||
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
|
||||
expect(page.locator('.her .current-text')).to_have_text(LONG)
|
||||
assert page.evaluate("window.streamNode === document.querySelector('.her .current-text')")
|
||||
assert page.evaluate('motionStarts.length') - before == 1
|
||||
assert float(frames[0]['opacity']) < float(frames[1]['opacity']) < float(frames[2]['opacity'])
|
||||
report['motion']['entry_frames'] = frames
|
||||
expect(page.locator('.you .current-text')).to_have_text(USER)
|
||||
passed('Long Chinese stream: entire user/reply retained; final keeps same DOM; one entrance across all chunks and final')
|
||||
|
||||
# Replace a completed sentence; retain its exit DOM until the animation ends.
|
||||
send(page, '再讲一次')
|
||||
outgoing = page.locator('.her .outgoing-text')
|
||||
expect(outgoing).to_have_count(1)
|
||||
assert outgoing.text_content() == LONG
|
||||
exit_frames = page.evaluate("""() => {const el=document.querySelector('.her .outgoing-text');const a=el.getAnimations()[0];a.pause();return [0,80,159].map(t=>{a.currentTime=t;return {time:t,opacity:getComputedStyle(el).opacity,retained:el.isConnected}});} """)
|
||||
assert float(exit_frames[0]['opacity']) > float(exit_frames[-1]['opacity'])
|
||||
report['motion']['exit_frames'] = exit_frames
|
||||
page.evaluate("document.querySelector('.her .outgoing-text').getAnimations().forEach(a=>a.play())")
|
||||
expect(outgoing).to_have_count(0)
|
||||
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
|
||||
passed('Old sentence stays mounted during a visible 160ms fade-out, then leaves')
|
||||
|
||||
for width, height in [(1440, 900), (360, 640), (390, 700), (390, 844), (390, 420), (844, 390)]:
|
||||
page.set_viewport_size({'width': width, 'height': height})
|
||||
for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]:
|
||||
click(page, label)
|
||||
expect(page.locator('html')).to_have_attribute('data-theme', theme)
|
||||
page.get_by_role('textbox', name='想和她说的话').focus()
|
||||
metrics = page.evaluate("""() => {
|
||||
const box=s=>{const b=document.querySelector(s).getBoundingClientRect();return {top:b.top,bottom:b.bottom,height:b.height}};
|
||||
const scroll=document.querySelector('.dialog-scroll');return {dialog:box('.dialog'),composer:box('.composer'),nav:box('.nav'),stage:box('.portal'),scrollHeight:scroll.scrollHeight,clientHeight:scroll.clientHeight,width:innerWidth,overflow:document.documentElement.scrollWidth>innerWidth};
|
||||
}""")
|
||||
assert not metrics['overflow'], metrics
|
||||
assert metrics['stage']['bottom'] <= metrics['dialog']['top'] + 1, metrics
|
||||
assert metrics['dialog']['bottom'] <= metrics['composer']['top'] + 1, metrics
|
||||
assert metrics['composer']['bottom'] < metrics['nav']['top'], metrics
|
||||
assert metrics['clientHeight'] >= 60, metrics
|
||||
assert metrics['scrollHeight'] > metrics['clientHeight'], metrics
|
||||
report['viewports'].append({'width': width, 'height': height, 'theme': theme, **metrics})
|
||||
page.locator('.dialog-scroll').evaluate('el=>el.scrollTop=0')
|
||||
expect(page.locator('.you .current-text')).to_have_text('再讲一次')
|
||||
if width in (360, 390) and height in (640, 420, 844): shot(page, f'long-start-{width}-{height}-{theme}')
|
||||
page.locator('.dialog-scroll').evaluate('el=>el.scrollTop=el.scrollHeight')
|
||||
if width in (360, 390) and height in (640, 420, 844): shot(page, f'long-end-{width}-{height}-{theme}')
|
||||
click(page, '完整记录')
|
||||
expect(page.locator('.log-panel')).to_contain_text('长句开头')
|
||||
expect(page.locator('.log-panel')).to_contain_text('故事没有被截掉')
|
||||
if width == 390 and height == 844: shot(page, f'full-history-{theme}')
|
||||
click(page, '关闭记录')
|
||||
passed('12 viewport/theme combinations: long text scrolls, stage/dialog/composer/navigation never overlap; full history reachable')
|
||||
|
||||
page.set_viewport_size({'width': 390, 'height': 700})
|
||||
provider.mode = 'slow'; send(page, '取消并重发')
|
||||
expect(page.locator('.her .current-text')).to_have_text('答')
|
||||
click(page, '取消本轮回复')
|
||||
provider.mode = 'normal'; send(page, '新轮')
|
||||
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
|
||||
expect(page.locator('.her .current-text')).to_have_text('答复')
|
||||
provider.mode = 'error'; send(page, '失败再试')
|
||||
expect(page.locator('.dialog .failed')).to_be_visible()
|
||||
expect(page.get_by_role('textbox', name='想和她说的话')).to_have_value('失败再试')
|
||||
count = app.state.store.query_one('SELECT count(*) n FROM chat_message')['n']
|
||||
provider.mode = 'normal'; page.locator('.dialog .retry').click()
|
||||
expect(page.locator('.dialog .failed')).not_to_be_visible()
|
||||
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
|
||||
assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] == count + 1
|
||||
passed('Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages')
|
||||
|
||||
nav(page, '设置'); click(page, '她')
|
||||
expect(page.locator('.color-sections section')).to_have_count(6)
|
||||
expect(page.locator('.calendar')).to_contain_text('闰四月初一')
|
||||
shot(page, 'existing-birth-fog')
|
||||
page.locator('.color-sections section').last.scroll_into_view_if_needed()
|
||||
shot(page, 'existing-growth-fog')
|
||||
click(page, '设置')
|
||||
for label in ('城市', '外观素材', '备份', '外接服务'):
|
||||
page.get_by_role('navigation', name='设置分区').get_by_role('button', name=label, exact=True).click()
|
||||
shot(page, {'城市':'city','外观素材':'appearance','备份':'backup','外接服务':'services'}[label] + '-fog')
|
||||
if label == '城市':
|
||||
page.get_by_label('搜索城市').fill('不存在')
|
||||
expect(page.get_by_text('当前目录没有匹配的城市。')).to_be_visible()
|
||||
if label == '外接服务':
|
||||
click(page, '重新检查连接')
|
||||
expect(page.get_by_role('status')).to_contain_text('文字服务可达')
|
||||
click(page, '设置')
|
||||
click(page, '关系')
|
||||
page.get_by_label('相处边界').fill('未保存的边界')
|
||||
nav(page, '日子')
|
||||
expect(page.get_by_role('dialog')).to_contain_text('有未保存的修改')
|
||||
click(page, '取消')
|
||||
expect(page.locator('.pane-manage')).to_be_visible()
|
||||
nav(page, '日子'); click(page, '放弃修改并离开')
|
||||
expect(page.get_by_text('还没有可展示的生活记录')).to_be_visible()
|
||||
shot(page, 'life-empty-fog')
|
||||
page.get_by_role('tab', name='我们的回忆').click()
|
||||
expect(page.locator('.pane .list .grp')).to_have_count(4)
|
||||
shot(page, 'memory-groups-empty-fog')
|
||||
assert app.state.store.query_one('SELECT count(*) n FROM life_event')['n'] == 0
|
||||
passed('Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard')
|
||||
|
||||
nav(page, '交谈'); page.keyboard.press('Escape')
|
||||
expect(page.get_by_role('navigation', name='主导航').get_by_role('button', name='此刻', exact=True)).to_have_attribute('aria-current', 'page')
|
||||
nav(page, '设置'); click(page, '显示')
|
||||
page.get_by_role('checkbox').check(); click(page, '保存')
|
||||
expect(page.get_by_role('status')).to_contain_text('此浏览器')
|
||||
page.reload(); nav(page, '交谈')
|
||||
assert page.locator('html').evaluate("el=>el.classList.contains('reduced')")
|
||||
provider.mode = 'normal'; send(page, '减少动效检查')
|
||||
expect(page.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
|
||||
assert page.evaluate('motionStarts.at(-1).duration') == 120
|
||||
reduced = browser.new_context(viewport={'width': 390, 'height': 700}, reduced_motion='reduce')
|
||||
rp = reduced.new_page(); rp.goto(url); nav(rp, '交谈'); send(rp, '系统减少动效')
|
||||
expect(rp.get_by_role('button', name='取消本轮回复')).not_to_be_visible()
|
||||
reduced.close()
|
||||
passed('Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity')
|
||||
|
||||
# Demonstration mode must never request APIs or borrow a real profile.
|
||||
demo = browser.new_context(viewport={'width': 390, 'height': 844})
|
||||
dp = demo.new_page(); requests = []
|
||||
dp.on('request', lambda r: requests.append(r.url) if '/api/' in r.url else None)
|
||||
dp.goto(url + '/?demo=1')
|
||||
expect(dp.get_by_text('1 / 4 · 出生资料')).to_be_visible()
|
||||
dp.get_by_label('她的名字', exact=True).fill('小满')
|
||||
click(dp, '下一步'); click(dp, '下一步'); click(dp, '朋友'); click(dp, '下一步')
|
||||
dp.get_by_role('checkbox').check(); click(dp, '确认创建'); click(dp, '去看看她')
|
||||
nav(dp, '交谈'); click(dp, '开始模拟语音'); click(dp, '开始模拟')
|
||||
expect(dp.get_by_role('button', name='结束语音')).to_be_visible()
|
||||
assert dp.locator('.voicebar').bounding_box()['y'] > dp.locator('.dialog').bounding_box()['y']
|
||||
shot(dp, 'demo-voice-390')
|
||||
click(dp, '结束语音')
|
||||
dp.locator('.demo > button').click(); click(dp, '载入三天记录')
|
||||
nav(dp, '日子'); dp.locator('.ev').first.click(); click(dp, '看那段聊天')
|
||||
expect(dp.locator('.log-panel')).to_be_visible(); click(dp, '关闭记录'); click(dp, '返回日子')
|
||||
expect(dp.locator('.pane[aria-label="日子"]')).to_have_class('pane glass read open has-detail')
|
||||
assert not requests
|
||||
passed('Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests')
|
||||
demo.close(); browser.close()
|
||||
assert not report['console_errors'], report['console_errors']
|
||||
finally:
|
||||
(OUT / 'browser-results.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
|
||||