Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71660155cb | ||
|
|
2d97050c7e | ||
|
|
e32a1ac0d2 | ||
|
|
0bc4a9e68e | ||
|
|
cef8ae5581 | ||
|
|
4321aebfdc | ||
|
|
e5fe01bfd1 | ||
|
|
7db6677234 | ||
|
|
003d8c2cae | ||
|
|
db571ba204 | ||
|
|
f6614d4bf9 |
@@ -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/
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
不是聊天机器人。区别在于:**它在你不说话的时候也在运行**。
|
||||
|
||||
设计、施工与审核先读 [完整需求入口](docs/requirements/README.md) 和 [项目必读规则](AGENTS.md)。后续逐项确认的需求优先于本仓库早期构想;演示通过不代表整个项目完成。
|
||||
|
||||
---
|
||||
|
||||
## 先看一眼
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"kind": "mechanical source bundle, not a Git merge or release",
|
||||
"product_base": "db571ba204c8372989f96e1ed9b5cd00d3a306c7",
|
||||
"requirements_and_hel799_source": "63799f02474ed94fd793de9e2360d185c878165d",
|
||||
"approval_comment": "01a169a8-5884-7c26-a00f-c23a5f5399a9",
|
||||
"scope": "A1/A2 frontend continuation; do not replace 8300/8310 or production data",
|
||||
"hel815_source_attachment": "01a16845-b50a-7335-a87e-61bf11c44703",
|
||||
"unmodified_product_sources": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,8 +15,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": 88
|
||||
"requests": 54
|
||||
}
|
||||
@@ -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,34 @@
|
||||
{
|
||||
"task": "HEL-821 视觉总监:A1+A2 把对坐场景(hel-799 scene/ambient/room)和完整对话呈现接入正式 React 前端;本文件为视觉总监交接(额度中断后恢复续交)",
|
||||
"baseline_version": "1.10",
|
||||
"read_commit": "63799f02474ed94fd793de9e2360d185c878165d",
|
||||
"read_notes": [
|
||||
"无 git 环境:需求与 prototypes/hel-799 读自总管机械包《CyberGril-A批施工基底-PR4与需求v1.10.zip》(SHA256 0c5b1665048960b3102216a834e26791dee2f665b467d49e62bfb50319f3c138),其 SOURCE-BUNDLE-PROVENANCE.json 记 requirements_and_hel799_source=63799f0、product_base=db571ba(PR#4 head)。非独立 checkout;origin/main 仍 3a986726(v1.8)。",
|
||||
"需求文件随包置于 docs/requirements(v1.10,59项)。原运行开工阅读范围以其执行记录为准;本次恢复复读 README.md、task-handoff-template.md、handoff-HEL-816.json、sources/shared-space-static-acceptance-20261023.md,并对照 issue 原文逐条核对本卡七项施工要求。current/coverage/changes 全文须审核时对照原话复核;附件 emil 动效参考(animate/MAIN、RECIPES、review-animations/MAIN、STANDARDS)仅作参考,未安装或改动运行时技能。包内无仓库根 AGENTS.md 与 scripts/,未能读取/运行,交总工在仓库内补。",
|
||||
"施工授权:主卡 HEL-797 白栖知评论 01a169a8-5884-7c26-a00f-c23a5f5399a9『继续吧,现在没什么主要的内容需要我确认的』,回应总管提案 01a16851-302d-75ef-9e8f-cf1719998076。这是本轮前端施工授权,不是真实能力验收或旧产品规则修改。"
|
||||
],
|
||||
"requirement_ids": ["CG-002", "CG-004", "CG-006", "CG-007", "CG-010", "CG-012", "CG-013", "CG-014", "CG-015", "CG-018", "CG-024", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
|
||||
"deliverable": "正式 React 源码(基于 PR#4 db571ba,未合并、未改 main):A1 新增 web/src/stage/camera.ts(同脸对齐的单相机、对数缩放、可打断临界阻尼弹簧)、roomScene.ts(逐帧只写 transform/opacity,推近/退回、反向保留速度、减少动效改为静置交叉淡入、自动降级档、页面隐藏暂停)、ambient.ts(窗内雨、城市灯、近景桌面,两主题材质光线不同),Presenter.tsx 改为共享空间三层;shared/space.ts 把此刻/交谈/记录/面板映射到空间并管理焦点;shared/motion.ts 键盘快照(即时落位);tokens.css/components.css 落 hel-799 room/talk/nav/pane 样式。A2 新增 web/src/chat/reading.ts(按轮分组、一两句分页、字素安全、无标点长句也断、流式最新一句先压住、阅读位置用文本锚点)、lineStage.ts(已显示的句子不重渲染不重播,墨入每句只播一次);ChatPanel.tsx 在原 RealChatAdapter/TurnGate/epoch/AbortController 数据流上接自动续读、暂停/上一句/下一句/回到读到的地方、全文、完整记录、身份标签、取消保留未完片段并标『未完成 · 已取消』。App.tsx/RealApp.tsx 接空间、Esc 逐层、导航直接切换;删除不再使用的 shared/SoftText.tsx;浏览器检查脚本迁到新选择器。",
|
||||
"acceptance": [
|
||||
"相处↔交流同一空间:点她或『和她说话』推近,『结束交谈』/Esc 退回;Esc 即时落位,鼠标/点击走约0.85s弹簧,中途反向不跳(camera-in-reverse-esc-1600-moon.webm)。",
|
||||
"两主题同结构:月光/雾光切换只换材质、光线、雨色与玻璃,不改布局(desk-1600-moon/fog-*)。",
|
||||
"交流:一次一两句,长回复自动完整续读;暂停后 ←/→/按钮即时回看无动画,『回到读到的地方』回到进度;全文与完整记录可读全部内容;『你』与实际名字区分;停止回复保留已生成部分并标未完成,未存入记录。",
|
||||
"流式增长不重置阅读位置、不改已显示句子(reading.test.ts 流式只追加用例);历史恢复按 sessionStorage 轮次锚点;快速取消后新轮与旧轮迟到事件由 TurnGate/epoch 过滤。",
|
||||
"演示 ?demo=1 独立 localStorage、零网络;真实模式不读演示数据(rework_browser_check.py 断言);未改 API 协议、SQLite、后端。",
|
||||
"浮层打开时导航可见可直接切换(.nav z25 高于 .record z20/21);Esc 顺序:模态→全文→记录→退回空间;全文关闭焦点回『全文』,记录关闭焦点回记录按钮;普通面板 240ms 进入,不沿用空间长转场。",
|
||||
"布局:1600×1000、1024、390×844、320×568、390×430 键盘等效下长中文与无标点长句不溢出,舞台/阅读条/输入/导航不重叠(check/shots/*.json 几何报告)。"
|
||||
],
|
||||
"deferred": [
|
||||
"构建与测试未运行:本机无 Node/npm/git/Python。须总工在既有 Linux 环境执行 npm ci、npm run typecheck、npm run build、npm run lint、npm test(含新 reading.test.ts)、Playwright 三份浏览器检查、python3 scripts/check_requirements.py --base-ref origin/main 及 --handoff、git diff --check。",
|
||||
"check/ 证据为静态 harness:产品 CSS + 剥类型后的控制器 + 静态 DOM,用 Edge CDP 截图/录屏;不是正式 React 运行,也未跑真实 SSE;录屏帧率不代表产品 GPU 性能。",
|
||||
"rework_browser_check.py 本次恢复后才迁移(原运行未迁),未执行;lineStage 墨入每句只播一次为本次恢复时新增修正,未进 harness 录屏。",
|
||||
"A3 其余页面(日子/回忆/创建/设置按 HEL-815 稿)未改,保持原功能与入口。",
|
||||
"人物仍为旧参考静帧,推近是镜头不是人物动作;新日常视频、首尾衔接、真实口型、语音、天气、生活、记忆、备份、视觉观察均未接入(R1 人格深度、R2–R7 继续承接);手机 4:5 候选默认关闭;真机/Safari/Firefox/读屏未测。"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"未执行:tsc/vite build/vitest/eslint/Playwright/check_requirements/git diff --check(缺工具,如实交总工)。",
|
||||
"人工静态复核(本次恢复):base 对 orig 逐文件 SHA256 比对得 13 改、8 增(另加本交接 JSON)、1 删,逐文件哈希见包内 CHANGES.json;全部相对 import 可解析,无残留 SoftText 引用;tsconfig ES2022 覆盖 Array.at 与 Intl.Segmenter。",
|
||||
"原运行 harness 证据:check/shots 桌面两主题与 390/320/390×430 截图及几何 JSON;check/rec 三段 webm(进出、快速反向+Esc、手机进出)。"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"task": "HEL-822 总工:HEL-821 A1+A2 源码机械落库、Linux 构建、四份浏览器检查、真实SSE独立审核;因发现必须修复项未部署",
|
||||
"baseline_version": "1.10",
|
||||
"read_commit": "63799f02474ed94fd793de9e2360d185c878165d",
|
||||
"read_notes": "需求/治理文件与 63799f0 字节核对一致;产品基底 db571ba(PR#4 head,refs/pull/4/head 复核仍指向 db571ba);origin/main 3a986726(v1.8)。",
|
||||
"requirement_ids": ["CG-002", "CG-004", "CG-018", "CG-024", "CG-031", "VIS-003", "VIS-004", "VIS-007", "VIS-011", "VIS-013", "UX-001", "UX-002", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
|
||||
"deliverable": "候选分支 hel-822-candidate(自 db571ba):核验作者包 SHA256 后按 CHANGES.json 落库 13改/8增(另交接JSON)/1删,全部哈希一致;docs/requirements v1.10 与 prototypes/hel-799 字节等于治理提交 63799f0;补齐根 AGENTS.md 与 scripts/check_requirements.py。审核侧最小机械检查修正 3 处(browser_check 视觉观察(模拟)选择器、import_check 脏表单离开确认处理、rework_browser_check 长用户句预算60s/条件续读/紧凑打字沉降600ms)并附本机全部运行证据。",
|
||||
"acceptance": [
|
||||
"机械落库核验:ZIP 250条反斜杠路径规范化解包零越界;CHANGES 23条前后哈希全部复算一致;git status 恰为23条变更+治理增补。",
|
||||
"构建:npm ci/typecheck/lint/test/build 全过(lint 1条既有模式警告);vitest 22/22 含新 reading.test.ts 8条。",
|
||||
"后端:Python 3.12 下 142/142 通过;本机默认 3.10 缺 asyncio.timeout(pyproject 要求≥3.11)致 8 条流式相关测试失败,基底 db571ba 同样失败,属环境非回归。",
|
||||
"浏览器检查:browser_check 16/16、import_check 7/7、real_browser_check 全过(真实网关3轮真实SSE、取消、重启epoch 1→2、移动端、受控错误重试,隔离临时库);rework_browser_check 前3节过后被下方问题1阻塞(本地绕过后12视口节通过)。",
|
||||
"check_requirements 对 origin/main 与实际候选目标 origin/agent/agent/de8874a3803b 均通过(59项);git diff --check 干净。",
|
||||
"代码卫生:无硬编码密钥/console残留/debugger;reduced-motion 全局处理;hover 动效均以 (hover:hover) and (pointer:fine) 门控。"
|
||||
],
|
||||
"deferred": [
|
||||
"未部署:审核发现2项必须修复问题(见评论),按卡交总管退回原作者返工后复审一次。",
|
||||
"候选 PR 未开:Gitea ssh 远端无 CLI/API 凭据,分支 hel-822-candidate 已推送,开 PR 需总管经 Web/API 以 base=agent/agent/de8874a3803b 操作。",
|
||||
"真机/Safari/Firefox/读屏未测;录屏fps不代表GPU流畅度;人物仍为参考静帧;R1人格深度与R2–R7、A3页面(HEL-815稿)继续承接。"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"真实SSE证据:real_browser_check 全部PASS输出(含 REAL_REPLY 蓝色风铃 两轮上下文召回、取消后无完整assistant行、epoch重启、390×844移动端)。",
|
||||
"问题1证据:390×420 紧凑高度下聚焦输入后点击发送,click 事件落点 SECTION.conversation(trusted click 捕获),草稿未发出;先blur再点击则命中。发送失败连锁导致 rework 检查 390×420 节全文本按钮永不出现。",
|
||||
"问题2证据:受控慢流取消→立即新轮后,阅读停在用户句约10–15秒(dwell 多次被重置,进度条读数 0.06→0.93 证据),随后自行恢复推进到『答复』。",
|
||||
"本审核机环境:uv 安装 Python 3.12.14 于 .venv312(项目依赖+playwright);系统 Chromium 经 LD_LIBRARY_PATH=~/pwlibs 与 FONTCONFIG_FILE 启动。"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"task": "HEL-823 视觉总监:把 HEL-815 的日子、已分享生活详情、共同回忆、首次创建、设置管理完整页面接到正式 React 产品(A3)",
|
||||
"baseline_version": "1.10",
|
||||
"read_commit": "cef8ae5581f68ae81991533698ef05abcb82e659",
|
||||
"read_notes": "唯一施工基底为远端 hel-822-candidate 提交 cef8ae5(总管 git archive 附件 HEL-823-A3-base-cef8ae5.zip,SHA256 245dab3f124c0c987a322c4dd788dbb7e7f5e24c4fb96f9eb50f4ec6c61a3894,本机复算一致)。本机实际阅读该包内根 AGENTS.md、docs/requirements/README.md、current.md(v1.10,59项)、coverage.md、changes.md、task-handoff-template.md、handoff-HEL-824.json,并按编号核对 CG-006/007/012–016/018/024/029–031、VIS-001–013、AM-001–003、UX-001–006、GOV-001–005、DEL-001 原文与边界。页面设计依据为本人 HEL-815 原稿附件 01a16845-b50a-7335-a87e-61bf11c44703(README、src/pages.js、boards/01)。授权原话:主卡评论 01a169a8-5884-7c26-a00f-c23a5f5399a9「继续吧,现在没什么主要的内容需要我确认的」。模板里的旧 1.7 字样不覆盖当前入口 v1.10。",
|
||||
"requirement_ids": ["CG-006", "CG-007", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-024", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "AM-001", "AM-002", "AM-003", "UX-001", "UX-002", "UX-003", "UX-004", "UX-005", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
|
||||
"deliverable": "在 cef8ae5 上改 web 下 15 个文件(10 个源码、5 个测试)、新增本交接,无删除(清单与前后哈希见 CHANGES-HEL-823.json)。(1) web/src/life/LifePanel.tsx 重写为 HEL-815 日子页:标题区「她的日子 / 我们的回忆」+ 宽屏「日子 · 演示数据」/ ≤1100「她此刻 · …」;页签行附说明;列表按「她说过的打算」置顶、其余按日分组,行=时间·状态点(实心已发生/呼吸点正在/虚线打算)·标题·一句摘要·少量标签;分页「看更早的日子」→ 加载中 → 到底「再往前,就是她刚住进来的那天了」;演示失败开关下的读取失败卡与「再试一次」(加载骨架);空状态(首晚 / 真实后台未接)。详情:类别·时间·地点、何时告诉你、带身份的她的原话、关联回忆卡、那时候的聊天(只取已通过 publicView 的消息)、「在交谈里看这段」、演示说明。回忆四组带计数,详情「她是这样记得的」、成长注明不改写底色并可跳到设置·她、来自这些日子的反链或说明。任何解析不到的选择(过期、删除或从未讲过)统一显示「这一条现在打不开」,不暗示记录存在。每个页签保留自己的滚动位置,面板隐藏再打开(交谈返回日子)恢复;手机 Esc 先关推入详情。(2) web/src/manage/CreationWizard.tsx:左侧叙事随步骤与创建中/失败/完成变化;名字为空并有占位,错误时焦点落到出问题的字段(名字/生日/关系);生日「随机生成 / 我来指定」(默认随机一个满 19–35 岁的生日,可再随机;手动改日期或时辰即为指定);关系改为两张说明卡且不预选;新增可选相处边界(后端 CreationInput 已接受 boundary,未改 API);汇总含边界;创建中提示、失败「返回修改 / 再试一次」资料保留;真实 409「已经有一位她」时禁用再次创建并提供「重新打开,恢复已有的她」(只 reload,不覆盖);完成页「第一晚」。(3) web/src/manage/ManagePanel.tsx:分区列表加能力说明与手机推入箭头、底注「首版只有一位她 · 没有重建或清空」;分区标题旁能力标签(只读/可以调整/演示数据/未接入/文字已接通/本机生效);「她」改为名字只读+生辰只读、底色(沿用 BaseColor 真实排盘)、「她怎么理解自己」诚实未接卡(不放固定段落)、最初就有的/后来慢慢有的分栏(成长只取已分享来源的回忆);关系改为分段按钮,确认框写明「把关系从「X」改成「Y」?」并保留记忆与信任说明;真实城市/外观/备份/语音视觉开头加「还没有接上」说明卡(接上后做什么、现在什么都不会发生),原有待接入细节保留;减少动效改为开关样式。(4) web/src/stage/Presenter.tsx 新增「她此刻」浮签:面板打开且宽度 >1100 时贴在她下方,随相机落定与窗口变化跟随命中区;≤1100 改写在面板标题上方。(5) App.tsx / RealApp.tsx 传入此刻文字、演示失败开关与「在设置里看底色与成长」跳转;domain/model.ts LifeEvent 增可选 told;demo/fixtures.ts 演示条目补 told;adapters/storage.ts 恢复时只为已分享条目保留 told。(6) styles/components.css 追加 A3 区块(两主题共用 token,状态色只用于点、细线与短字;≤1100、≤720、≤720×620、≤360 断点)。(7) tests:domain.test.ts 新增 told 只保留在已分享记录的单测;browser_check / real_browser_check / import_check / rework_browser_check 只按本次有意的界面变化改选择器(关系下拉→分段按钮,「看那段聊天」→「在交谈里看这段」,私有深链断言改为中性「这一条现在打不开」),其余断言不动。",
|
||||
"acceptance": [
|
||||
"日子(演示 ?demo=1,载入三天记录):打算置顶并标「计划可能会变」,正在为呼吸点;列表与详情、回忆、搜索不出现未分享条目;#event=secret 深链只显示「这一条现在打不开。」,页面不含「秘密散步/私密地点」(browser_check 已改断言)。",
|
||||
"长记录:首屏 18 条,看更早的日子后 30 条;选第 11 条 → 在交谈里看这段 → 结束交谈 · 回到日子,列表滚动位置与选中项不变(import_check 原断言)。",
|
||||
"我们的回忆:真实入口四组各显示「暂无可核实的回忆」(.pane .list .grp = 4,rework 原断言);演示入口四组带计数,成长条目可跳到设置·她。",
|
||||
"首建:名字为空、关系不预选(两张卡 aria-pressed=false)、未满 18 岁与未选关系被拦并聚焦对应字段;勾选确认前「确认创建」禁用;演示失败后资料保留、再试一次成功;真实入口 409 不覆盖已有档案。",
|
||||
"设置:名字与生辰只读,无改名/新建/切换/清空/重建入口;改关系类型须确认,取消恢复,保存失败修改保留;离开有未保存修改须确认(原守卫未改);未接入能力全部标「未接入」且按钮不可用,演示能力标「演示数据」。",
|
||||
"两主题结构一致(同一 DOM,只换 token);1600×1000、1024、390×844、320×568、390×430 键盘等效下无关键遮挡——需总工以浏览器检查与截图实测,本机未能运行。",
|
||||
"未触碰 A1/A2:ChatPanel、reading.ts、lineStage、Viewport、compact/typing 规则、真实适配器/TurnGate/epoch 均未修改。"
|
||||
],
|
||||
"deferred": [
|
||||
"本机(Windows 运行时)没有 node/npm、python、git,也找不到可用的 TypeScript:未执行 npm ci/typecheck/lint/test/build、四份浏览器检查、check_requirements.py、git diff --check,也没有正式 React 页面截图或录屏;全部交总工机械执行,未运行即未通过,不以静态阅读或 HEL-815 静态样图代替。",
|
||||
"未提交、未开 PR:按 CHANGES-HEL-823.json 落到 hel-822-candidate(cef8ae5)之上的独立分支,产品目标仍为 agent/agent/de8874a3803b,不指 main;不合并、不部署 8320,8300/8310 未触碰。",
|
||||
"HEL-815 中「看接入后的设计稿」只作施工参照,按原稿施工须知未放进正式产品;城市、外观、备份、语音、视觉观察、生活、长期记忆、主动消息的真实能力仍未接通(R2–R7)。",
|
||||
"「她怎么理解自己」与最初小习惯在真实入口显示未接说明,由 R1 人格深度承接(CG-013/014/015、UX-004);不以固定模板填充。",
|
||||
"真机软键盘、Safari/Firefox、读屏未测;等效视口不冒充真机(UX-001)。人物仍为参考静帧,真实日常视频/首尾衔接/语音口型由主卡 HEL-797 与 HEL-802/803 承接(DEL-001)。"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"附件 SHA256 复算 245dab3f124c0c987a322c4dd788dbb7e7f5e24c4fb96f9eb50f4ec6c61a3894,与卡片一致;解包为只读 before 与可写 work 两份,修改只在 work,before 全部 250 个文件哈希另存。",
|
||||
"修改前后文件哈希与改/增/删清单见交付包 CHANGES-HEL-823.json。",
|
||||
"静态自查:逐文件回读 JSX 结构与类型(新增 props 均为可选、noUnusedLocals 下无多余变量),核对测试所依赖的可访问名称(她的名字、出生日期(公历)、1 / 4 · 出生资料、朋友/恋人 aria-pressed、确认创建、再试一次、去看看她、看更早的日子、结束交谈 · 回到日子、设置分区各按钮名、她怎么称呼你、相处边界、取消修改/保存/重试保存、.kv/.ev/.list/.grp/.city-list/.svc、关闭日子/关闭设置、has-detail 类串)保持不变。静态自查不等于构建或测试通过。",
|
||||
"待总工执行:cd web && npm ci && npm run typecheck && npm run lint && npm test && npm run build;PYTHONPATH=src python3.12 web/tests/{browser_check,import_check,real_browser_check,rework_browser_check}.py;python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-823.json 及实际目标引用;git diff --check;并补 1600×1000、1024、390×844、320×568、390×430 两主题截图。"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"task": "HEL-824 视觉总监:HEL-822 审核两项必须修复缺陷返工(紧凑高度聚焦后首击失灵;停止回复后立即新轮续读迟滞),并修正 rework 检查错误断言、核对 Viewport 无效逻辑",
|
||||
"baseline_version": "1.10",
|
||||
"read_commit": "4321aebfdc491993e0648f68a0375047c3eb6359",
|
||||
"read_notes": "唯一返工基底为远端 hel-822-candidate 提交 4321aeb(总管 git archive 附件 HEL-822-candidate-4321aeb.zip,SHA256 d18d54891f365f4b7999b4defb8cc4f92dace3e296b96707c617ac08f3b3e870,本机复算一致)。已读根 AGENTS.md、docs/requirements/README.md、current.md(59项)、coverage.md、changes.md、task-handoff-template.md、sources/dynamic-sample-effect-acceptance-20261023.md,并按编号核对 CG-018、VIS-003/004/007/011/013、UX-001/002/005、GOV-001–005、DEL-001 原文与边界;模板中的旧1.7字样不覆盖当前入口 v1.10。审核原文:HEL-822 评论 01a16afe-0cb1-7146-b561-c348c9fc8ed8。",
|
||||
"requirement_ids": ["CG-018", "VIS-003", "VIS-004", "VIS-007", "VIS-011", "VIS-013", "UX-001", "UX-002", "UX-005", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
|
||||
"deliverable": "在 4321aeb 上修改 6 个文件、新增本交接:(1) web/src/styles/components.css 删除 html.compact.typing{--nav-space:6px}:聚焦输入只让导航淡出让位,对话区几何不随焦点变化,按下与松开落在同一按钮;交流视觉、两主题与导航淡出保留。(2) web/src/chat/reading.ts 新增 ReadingClock,阅读停留按真实时间计(每帧最多计 STALL=1000ms,页面隐藏后的间隔不计),同一句 key 不变即不重置;web/src/chat/ChatPanel.tsx 改用它(goTo→forget、setFlow→restart、visibilitychange→pause),自动续读、暂停、回看、回到进度、墨入只播一次的逻辑不变。(3) web/src/shared/Viewport.tsx 删除没有任何 CSS 对应的 viewport-resizing 类及其定时器,注释改为实际行为。(4) web/tests/reading.test.ts 新增 3 项时钟回归;web/tests/rework_browser_check.py 三处整行 to_have_text('答复') 改为只对正文 <p> 精确断言,新增 390×420/390×430 双主题聚焦后首击回归与 700ms 慢帧下停止→立即新轮回归。总工在 browser_check / import_check 的机械修正未触碰。",
|
||||
"root_causes": [
|
||||
"缺陷1:compact 下输入聚焦时 --nav-space 由 54px 变 6px;按下按钮使输入失焦,对话区在按下与松开之间下移 48px,click 落到两者共同祖先 SECTION.conversation。修法:对话区几何不随焦点改变(只保留导航淡出),未用先 blur、强制点击、脚本调 handler 或 pointerdown 绕过。",
|
||||
"缺陷2:阅读时钟每帧把 dt 截到 100ms(ChatPanel.tsx 原 113 行 Math.min(100, now - last)),停留时长随帧率缩放。停止→立即新轮时交叉淡化、模糊、墨入与房间画面同时进行,审核环境帧间隔约 1 秒,『新轮』1400ms 的停留被拉长为 14 帧≈14 秒;审核读到的 0.06…0.93 正是 1/14…13/14 的逐帧步进,满格后进入下一句再次从 0 起,看起来像反复重置。逐行核对:同一轮内 key 只由轮次与位置决定,流式增长、落库替换、旧轮迟到事件(TurnGate 拒收)都不会改变当前句 key,也没有其他自动调用 forget/restart 的路径。修法:按真实时间计时,不缩短任何设计停留、不取消自动阅读、不放宽测试等待(8s 超时保持)。"
|
||||
],
|
||||
"acceptance": [
|
||||
"正式 React 月光/雾光,390×420 与 390×430:输入框聚焦后直接点发送、上一句、暂停/继续、全文,均首击生效;草稿真正发出并落库(用户+她各1条)、全文浮层真正打开;按下与松开时对话区 top 相同、click 送达该按钮(rework_browser_check 新节,真实指针点击,脚本不 blur 不派发)。",
|
||||
"停止回复→立即新轮:取消即时、旧轮迟到不串入(当前句状态为空),新轮先显示你的一句(设计停留1.4s)后她的回复上台;在 rAF 每 700ms 一帧的慢帧下同样在原 8s 断言内完成(旧实现需约 9.8s 必然失败)。",
|
||||
"正常 60fps 下各句停留与修改前一致(每帧 dt≈16ms 远小于原 100ms 上限,行为不变);暂停/恢复、前后回看、回到读到的地方、全文、完整记录、同句墨入只播一次、历史恢复锚点逻辑未改动。",
|
||||
"reading.test.ts:任意帧间隔(16/100/250/700/1000ms)下你的一句在 1400ms 至 1400ms+一帧之间到期;流式增长与落库不回退进度;新句、回看、恢复从零开始,暂停/悬停与隐藏期间不计,单帧停顿最多计 1 秒。",
|
||||
"320×568、390×844、1024、1600×1000 布局:本次只删除 compact+typing 时的位移规则,非 compact 尺寸 CSS 无变化;仍需总工以 browser_check / rework 12 组视口复核。"
|
||||
],
|
||||
"deferred": [
|
||||
"本机(Windows 运行时)没有 node/npm、python、git,未能执行 typecheck/lint/test/build、四份浏览器检查、check_requirements.py 与 git diff --check;全部交总工机械执行,未运行即未通过,不以静态阅读代替。",
|
||||
"修前修后实测计时未在本机取得:修前数据沿用总工 HEL-822 观测(约10–15秒、0.06→0.93),修后应由总工以 rework 慢帧节的 slow_frames_new_turn_to_reply_s 及受控慢流原复现记录。",
|
||||
"未开 PR、未提交:本机无 git;按 changes 清单落到 hel-822-candidate 之上,PR 目标仍为 PR#4 产品线 agent/agent/de8874a3803b(原产品基底 db571ba),不指 main;远端引用名称须由总工用 git 实查。",
|
||||
"真机键盘、Safari/Firefox、读屏未测;等效视口不冒充真机(UX-001)。人物仍为参考静帧,真实视频/语音/口型/生活/记忆未接入(DEL-001)。A3 日子/回忆/创建/设置由 HEL-823 承接;R1 人格深度与 R2–R7 在主卡 HEL-797 追踪。"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"附件 SHA256 复算 d18d54891f365f4b7999b4defb8cc4f92dace3e296b96707c617ac08f3b3e870,与卡片一致;解包为只读 before 与可写 work 两份,修改只在 work。",
|
||||
"修改前后文件哈希与新增/删除清单见交付包 CHANGES-HEL-824.json(6 改、1 增、0 删)。",
|
||||
"未执行任何构建、测试或浏览器检查(工具缺失);待总工执行:cd web && npm ci && npm run typecheck && npm run lint && npm test && npm run build;PYTHONPATH=src python3.12 web/tests/{browser_check,import_check,real_browser_check,rework_browser_check}.py;python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-824.json 及实际 PR 目标引用;git diff --check。",
|
||||
"真实网关 SSE 证据仍为 HEL-822 已有记录(本次未改后端、协议、SQLite、RealChatAdapter/TurnGate/epoch/AbortController);rework 新节使用隔离临时库与受控模型,二者分别标明。"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"task": "HEL-825 总工:HEL-823(日子/回忆/首建/设置 A3)功能+界面独立审核,通过后更新 8320 隔离测试版",
|
||||
"baseline_version": "1.10",
|
||||
"read_commit": "cef8ae5581f68ae81991533698ef05abcb82e659",
|
||||
"read_notes": "独立审核分支 hel-825-review 自 cef8ae5(hel-822-candidate 远端头,本机核验一致)。实际阅读根 AGENTS.md、docs/requirements/README.md、current.md(v1.10,59项)、coverage.md、changes.md、task-handoff-template.md,及 sources/design-acceptance-v1.md、visual-confirmation-v2.md、integration-plan-v1.md、dynamic-sample-effect-acceptance-20261023.md;核对 CG-006/007/012–016/018/024/029–031、VIS-001–013、AM-001–003、UX-001–006、GOV-001–005、DEL-001 原文与边界。交付包 SHA256 3a8e1720f347f7b5449c2ac5af116553a41a28982046d4d19eadbb1f4e2f2b27 本机独立复算一致;15改1增的 before 对 git cef8ae5、after 对源码字节逐项独立核验一致,无未列明改动、无删除、无路径越界。",
|
||||
"requirement_ids": ["CG-006", "CG-007", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-024", "CG-029", "CG-030", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "AM-001", "AM-002", "AM-003", "UX-001", "UX-002", "UX-003", "UX-004", "UX-005", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
|
||||
"deliverable": "审核通过并部署。分支 hel-825-review(cef8ae5 之上三提交:0bc4a9e 作者交付原字节、e32a1ac 审核接线修正与证据、本 handoff 提交)。审核接线修正仅一处:web/tests/browser_check.py 关联回忆卡点击改 exact=False——新版 link-card 可访问名称由「标题+来源」拼接,作者改版时漏改该选择器,断言强度不变(heading 断言保留),不放宽隐私或回归断言。另新增 web/tests/hel825_shot.py、hel825_tag_probe.py 两个审核证据脚本。8320 更新为新 release 并存切 current,数据目录与档案不动。",
|
||||
"acceptance": [
|
||||
"机械构建:npm ci/typecheck/lint/test/build 全通过;lint 0 errors 2 warnings(ChatPanel:156 为基线已有,ManagePanel:123 unused-directive 为本次引入的非阻塞建议项)。前端 vitest 26/26;后端回归 pytest 142/142(与上批基线一致,本轮实际数字)。",
|
||||
"四份浏览器脚本全部通过:browser_check 16 PASS(1440/1280/390 三视口:创建校验/失败重试/重开、两主题日子/交谈/设置往返、私密深链中性文案、取消隔离、IME/XSS、发送失败重试、离开摘要过滤);import_check 7 PASS(分页18→12滚动保持、损坏持久化不覆盖首建等);real_browser_check 8 PASS(真实网关流式/取消/epoch 重启/SSE 错误/手机关系恢复);rework_browser_check 9 PASS(12 视口主题组合无遮挡、390×430 首击命中、reading 真实时钟、演示零 API)。",
|
||||
"需求检查:check_requirements.py --base-ref origin/main 与 origin/agent/agent/de8874a3803b(均带 --handoff handoff-HEL-823.json)通过,59项;git diff --check 干净。",
|
||||
"隐私与深链:#event=secret 只显示「这一条现在打不开。」,页面无「秘密散步/私密地点」;列表/详情/回忆/摘要无未分享条目(脚本断言+人工核对 LifePanel publicView 路径)。",
|
||||
"首建:名字空拦、未成年拦、关系不预选(朋友/恋人 aria-pressed=false)、未勾选确认创建禁用、失败资料保留、409 只提供恢复已有她(源码 74-75 行核对+脚本验证)。",
|
||||
"设置:名字/生辰只读;关系确认框含「记忆与信任都保留」;未保存离开确认守卫(beforeunload+cg:leave-settings 双路径,ManagePanel:43-49 核对);未接入能力如实标注。",
|
||||
"她此刻浮签几何实测:1600/1440/1101 now-chip 显示于角色下方;1100/1024/390 面板标题上方 .k-near「她此刻 · 在窗边待着」(hel825_tag_probe.py 六档全过)。",
|
||||
"截图证据:两主题 × 1600×1000/1024/390×844/320×568/390×430 键盘等效共 46 张(docs/frontend/screenshots/hel825/)+ 宽屏/手机两段录屏(docs/frontend/recordings/)。"
|
||||
],
|
||||
"deferred": [
|
||||
"真机软键盘、Safari、Firefox、读屏未测(等效视口不冒充真机,UX-001);本机模型不支持图像目检,截图以 DOM 几何断言+脚本覆盖代替人工目检,全部截图已附卡供人工复核。",
|
||||
"ManagePanel.tsx:123 unused eslint-disable directive(本次引入,非阻塞)与 ChatPanel.tsx:156 ref cleanup warning(基线已有)留作后续小优化,不阻塞本轮。",
|
||||
"生活、长期记忆、主动消息、语音口型、天气素材备份、视觉观察真实能力仍未接通(R1–R7 主卡承接);人物仍为参考静帧;本卡不宣称完整产品完成。",
|
||||
"阶段工程审核通过不等于白栖知体验验收;小样与正式界面替换边界不变。"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"独立复算:交付 zip SHA256、逐文件 before/after 哈希、清单外无差异、无删除;commit 0bc4a9e 逐字节等于作者交付(16 文件哈希复验全 OK)。",
|
||||
"机械执行记录:npm ci 176 包、typecheck 0 错、lint 0/2、vitest 26/26、build 514ms 产物 263.64 kB js + 81.28 kB css、pytest 142 passed 6.75s、四浏览器脚本 exit=0、双 base-ref 需求检查通过、git diff --check 通过。",
|
||||
"审核环境:uv 管理 Python 3.12.14 独立 venv(.venv-hel825,项目 requirements.lock + pytest/playwright),Chromium 156.0.8078.4,系统库复用既有 .browser-libs 用户态副本;未改全局环境、未动队员运行时/模型/认证。",
|
||||
"部署验证见本卡成果评论:8300/8310 前后可用、8320 服务健康、?demo=1 与普通真实入口实测、epoch 历史保留、回滚方案(切回旧 release current 符号链接)。"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"task": "HEL-826 总工:核清 R2/R3 数据接缝与 R1 人格缺口,交可直接派施工的小范围接续说明(只读核对,不改产品源码)",
|
||||
"baseline_version": "1.10",
|
||||
"read_commit": "2d97050c7e86f909590ffdec3e8bc1aa5627cade",
|
||||
"read_notes": "读取提交为 origin/hel-825-review 头 2d97050(59项 v1.10)。实际阅读根 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、changes.md、task-handoff-template.md,及 sources/integration-plan-v1.md、design-acceptance-v1.md、visual-confirmation-v2.md、dynamic-sample-effect-acceptance-20261023.md、comment-evidence.json 中 CG-013/014/015 原话(01a15763-1fc5、01a15763-e729、01a15765-2fa8、01a15771-3a93、01a15772-3101、01a15774-ddc6)与 UX-004 来源(01a162eb-1eab);另读本卡附件 HEL-816-接续方案.md 第4–6节。源码核对 src/cybergril/memory/schema.sql、src/cybergril/api/app.py、src/cybergril/core/bazi.py、src/cybergril/core/birth_view.py、src/cybergril/think/scenes.py、config/{character,models}.yaml、web/src/{adapters/real.ts,app/RealApp.tsx,life/LifePanel.tsx,manage/ManagePanel.tsx,domain/privacy.ts}。服务核查仅 8310/8320 GET /api/health(两者 capabilities 如实仅 text=true;8310 epoch 3、8320 epoch 4),未读档案与聊天正文,未做任何变更。",
|
||||
"requirement_ids": ["CG-007", "CG-008", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-021", "CG-023", "CG-031", "AM-001", "AM-002", "AM-003", "UX-004", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
|
||||
"deliverable": "一份接续说明(本卡附件 HEL-826-接续说明.md):R2 最小生活接入的可审核批次设计(服务端循环、事件来源/时区/幂等键、重启不补造、故障预算停止条件、列表/详情/摘要/搜索/关联回忆/聊天引用的响应前裁剪、told 提交时机与可执行反例),R1 人格深度在 R2/R3 的落点(八字原始事实只读每轮独立、模型诠释/初始习惯/后天成长三层分离、已有档案诚实补建、失败幂等、关系变更不重建人格),含数据表/迁移草案、端点与事件契约、前端绑定点、测试与隔离部署建议、下一张施工卡草稿及 R3/R4 依赖;另加本 handoff。产品源码零改动。",
|
||||
"acceptance": [
|
||||
"接续说明标明实际读取版本 v1.10 / 提交 2d97050、关联要求原意与原话来源、已有与缺失的实证(逐条 file:line)、下一批可观察验收项、仍待用户决定的问题。",
|
||||
"已交付事实与缺失事实分清:R1 真实档案/流式/近20轮在 8310(epoch 3)运行、A1/A2/A3 已过工程审核并部署 8320(epoch 4);生活循环零代码(life_event 表无生产者)、记忆仅测试路径、近20轮上下文不冒充 R3 长期记忆、六段固定模板即 chat 系统提示的底色来源(app.py:385-395、birth_view.py:41-80)、capabilities 前端未消费(real.ts 仅读 epoch/llm.ok)。",
|
||||
"told 机制定义完整:生成即 told=0;仅在 chat.final 完整送达并经引用判定后才置 1,取消/失败/部分流不置;provenance 记录(telling_log UNIQUE message);方案附可执行反例清单。",
|
||||
"施工卡草稿可独立执行:覆盖服务重启幂等、流式取消不误标、伪造ID/搜索/摘要/关联引用零泄漏、底色指纹不变、关系变更不重建人格、演示零API、真实聊天回归;压缩时钟测试与真实三天运行分开表述。",
|
||||
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref origin/agent/agent/de8874a3803b(均带本 handoff)通过;git diff --check 干净。"
|
||||
],
|
||||
"deferred": [
|
||||
"本卡不写产品代码、不部署、不生成真实生活事件、不调用模型生成人格、不做迁移;一切实现进下一张施工卡。",
|
||||
"R3 长期记忆、R4 主动联系、R5 语音、R6 天气/素材/备份、R7 视觉观察按 DEL-001 与 integration-plan-v1 依赖顺序由主卡承接;真实视频首尾衔接、口型由 HEL-802/803 承接。",
|
||||
"仍待用户决定(经总管):R2 真实三天运行的启用位置(推荐 8310,R2 已获批,带回滚);生活时区默认 Asia/Shanghai 至 R6 城市选择(工程默认,可后续随城市调整)。",
|
||||
"需求变更:无。"
|
||||
],
|
||||
"requirement_changes": [],
|
||||
"verification": [
|
||||
"只读源码核对(file:line 证据见接续说明第一节);8310/8320 health 实测 2026-10-24(本机 curl,capabilities 全部如实 false 除 text)。",
|
||||
"git 状态干净(本分支仅新增本 handoff 与接续说明不落仓库、以附件交付);未动 8300/8310/8320 任何数据与服务;未访问 4080;未合并任何 PR。",
|
||||
"结构检查不能代替原话核对:CG-013/014/015 与 UX-004 原话已逐条对照 comment-evidence.json;本卡不声称任何产品功能已通过用户验收。"
|
||||
]
|
||||
}
|
||||
@@ -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: 150 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,760 @@
|
||||
{
|
||||
"checks": [
|
||||
"Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows",
|
||||
"Long Chinese stream: shown lines never change or re-key, ink plays once per line; pause/keyboard review is instant; record keeps everything",
|
||||
"12 viewport/theme combinations: lines stay inside the stage, stage/reader/composer/navigation never overlap; Esc closes full text with focus back; full history reachable",
|
||||
"390x420 and 390x430, both themes: with the input focused, the first press on send, review, pause/continue and full text acts; the dialogue does not move between press and release",
|
||||
"Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages",
|
||||
"Stop then new turn with busy (700 ms) frames: your line keeps its own time and her reply follows; the reading clock counts real time",
|
||||
"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",
|
||||
"stage": {
|
||||
"top": 605.609375,
|
||||
"bottom": 733.609375,
|
||||
"left": 250,
|
||||
"right": 1190,
|
||||
"height": 128
|
||||
},
|
||||
"reader": {
|
||||
"top": 762,
|
||||
"bottom": 792,
|
||||
"left": 596.484375,
|
||||
"right": 843.515625,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 798,
|
||||
"bottom": 848,
|
||||
"left": 400,
|
||||
"right": 1040,
|
||||
"height": 50
|
||||
},
|
||||
"nav": {
|
||||
"top": 860,
|
||||
"bottom": 894,
|
||||
"left": 582,
|
||||
"right": 858,
|
||||
"height": 34
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 643.21875,
|
||||
"bottom": 665.71875,
|
||||
"left": 596.625,
|
||||
"right": 887.328125,
|
||||
"height": 22.5
|
||||
},
|
||||
{
|
||||
"top": 699.109375,
|
||||
"bottom": 733.609375,
|
||||
"left": 555.765625,
|
||||
"right": 884.21875,
|
||||
"height": 34.5
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "fog",
|
||||
"stage": {
|
||||
"top": 605.609375,
|
||||
"bottom": 733.609375,
|
||||
"left": 250,
|
||||
"right": 1190,
|
||||
"height": 128
|
||||
},
|
||||
"reader": {
|
||||
"top": 762,
|
||||
"bottom": 792,
|
||||
"left": 596.484375,
|
||||
"right": 843.515625,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 798,
|
||||
"bottom": 848,
|
||||
"left": 400,
|
||||
"right": 1040,
|
||||
"height": 50
|
||||
},
|
||||
"nav": {
|
||||
"top": 860,
|
||||
"bottom": 894,
|
||||
"left": 582,
|
||||
"right": 858,
|
||||
"height": 34
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 643.21875,
|
||||
"bottom": 665.71875,
|
||||
"left": 596.625,
|
||||
"right": 887.328125,
|
||||
"height": 22.5
|
||||
},
|
||||
{
|
||||
"top": 699.109375,
|
||||
"bottom": 733.609375,
|
||||
"left": 555.765625,
|
||||
"right": 884.21875,
|
||||
"height": 34.5
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 640,
|
||||
"theme": "moon",
|
||||
"stage": {
|
||||
"top": 362,
|
||||
"bottom": 494,
|
||||
"left": 10,
|
||||
"right": 350,
|
||||
"height": 132
|
||||
},
|
||||
"reader": {
|
||||
"top": 496,
|
||||
"bottom": 526,
|
||||
"left": 72.5,
|
||||
"right": 287.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 532,
|
||||
"bottom": 580,
|
||||
"left": 10,
|
||||
"right": 350,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 586,
|
||||
"bottom": 634,
|
||||
"left": 30,
|
||||
"right": 330,
|
||||
"height": 48
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 412.453125,
|
||||
"bottom": 432.59375,
|
||||
"left": 155.5625,
|
||||
"right": 248.390625,
|
||||
"height": 20.140625
|
||||
},
|
||||
{
|
||||
"top": 466.109375,
|
||||
"bottom": 494,
|
||||
"left": 51.46875,
|
||||
"right": 308.515625,
|
||||
"height": 27.890625
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 640,
|
||||
"theme": "fog",
|
||||
"stage": {
|
||||
"top": 362,
|
||||
"bottom": 494,
|
||||
"left": 10,
|
||||
"right": 350,
|
||||
"height": 132
|
||||
},
|
||||
"reader": {
|
||||
"top": 496,
|
||||
"bottom": 526,
|
||||
"left": 72.5,
|
||||
"right": 287.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 532,
|
||||
"bottom": 580,
|
||||
"left": 10,
|
||||
"right": 350,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 586,
|
||||
"bottom": 634,
|
||||
"left": 30,
|
||||
"right": 330,
|
||||
"height": 48
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 412.453125,
|
||||
"bottom": 432.59375,
|
||||
"left": 155.5625,
|
||||
"right": 248.390625,
|
||||
"height": 20.140625
|
||||
},
|
||||
{
|
||||
"top": 466.109375,
|
||||
"bottom": 494,
|
||||
"left": 51.46875,
|
||||
"right": 308.515625,
|
||||
"height": 27.890625
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 700,
|
||||
"theme": "moon",
|
||||
"stage": {
|
||||
"top": 422,
|
||||
"bottom": 554,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 132
|
||||
},
|
||||
"reader": {
|
||||
"top": 556,
|
||||
"bottom": 586,
|
||||
"left": 87.5,
|
||||
"right": 302.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 592,
|
||||
"bottom": 640,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 646,
|
||||
"bottom": 694,
|
||||
"left": 45,
|
||||
"right": 345,
|
||||
"height": 48
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 472.453125,
|
||||
"bottom": 492.59375,
|
||||
"left": 91,
|
||||
"right": 342.953125,
|
||||
"height": 20.140625
|
||||
},
|
||||
{
|
||||
"top": 526.109375,
|
||||
"bottom": 554,
|
||||
"left": 66.46875,
|
||||
"right": 323.515625,
|
||||
"height": 27.890625
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 700,
|
||||
"theme": "fog",
|
||||
"stage": {
|
||||
"top": 422,
|
||||
"bottom": 554,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 132
|
||||
},
|
||||
"reader": {
|
||||
"top": 556,
|
||||
"bottom": 586,
|
||||
"left": 87.5,
|
||||
"right": 302.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 592,
|
||||
"bottom": 640,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 646,
|
||||
"bottom": 694,
|
||||
"left": 45,
|
||||
"right": 345,
|
||||
"height": 48
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 472.453125,
|
||||
"bottom": 492.59375,
|
||||
"left": 91,
|
||||
"right": 342.953125,
|
||||
"height": 20.140625
|
||||
},
|
||||
{
|
||||
"top": 526.109375,
|
||||
"bottom": 554,
|
||||
"left": 66.46875,
|
||||
"right": 323.515625,
|
||||
"height": 27.890625
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"theme": "moon",
|
||||
"stage": {
|
||||
"top": 566,
|
||||
"bottom": 698,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 132
|
||||
},
|
||||
"reader": {
|
||||
"top": 700,
|
||||
"bottom": 730,
|
||||
"left": 87.5,
|
||||
"right": 302.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 736,
|
||||
"bottom": 784,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 790,
|
||||
"bottom": 838,
|
||||
"left": 45,
|
||||
"right": 345,
|
||||
"height": 48
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 616.453125,
|
||||
"bottom": 636.59375,
|
||||
"left": 91,
|
||||
"right": 342.953125,
|
||||
"height": 20.140625
|
||||
},
|
||||
{
|
||||
"top": 670.109375,
|
||||
"bottom": 698,
|
||||
"left": 66.46875,
|
||||
"right": 323.515625,
|
||||
"height": 27.890625
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"theme": "fog",
|
||||
"stage": {
|
||||
"top": 566,
|
||||
"bottom": 698,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 132
|
||||
},
|
||||
"reader": {
|
||||
"top": 700,
|
||||
"bottom": 730,
|
||||
"left": 87.5,
|
||||
"right": 302.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 736,
|
||||
"bottom": 784,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 790,
|
||||
"bottom": 838,
|
||||
"left": 45,
|
||||
"right": 345,
|
||||
"height": 48
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 616.453125,
|
||||
"bottom": 636.59375,
|
||||
"left": 91,
|
||||
"right": 342.953125,
|
||||
"height": 20.140625
|
||||
},
|
||||
{
|
||||
"top": 670.109375,
|
||||
"bottom": 698,
|
||||
"left": 66.46875,
|
||||
"right": 323.515625,
|
||||
"height": 27.890625
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 420,
|
||||
"theme": "moon",
|
||||
"stage": {
|
||||
"top": 206,
|
||||
"bottom": 284,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 78
|
||||
},
|
||||
"reader": {
|
||||
"top": 284,
|
||||
"bottom": 314,
|
||||
"left": 87.5,
|
||||
"right": 302.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 318,
|
||||
"bottom": 366,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 436,
|
||||
"bottom": 480,
|
||||
"left": 45,
|
||||
"right": 345,
|
||||
"height": 44
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 259.203125,
|
||||
"bottom": 284,
|
||||
"left": 80.75,
|
||||
"right": 309.234375,
|
||||
"height": 24.796875
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 420,
|
||||
"theme": "fog",
|
||||
"stage": {
|
||||
"top": 206,
|
||||
"bottom": 284,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 78
|
||||
},
|
||||
"reader": {
|
||||
"top": 284,
|
||||
"bottom": 314,
|
||||
"left": 87.5,
|
||||
"right": 302.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 318,
|
||||
"bottom": 366,
|
||||
"left": 10,
|
||||
"right": 380,
|
||||
"height": 48
|
||||
},
|
||||
"nav": {
|
||||
"top": 436,
|
||||
"bottom": 480,
|
||||
"left": 45,
|
||||
"right": 345,
|
||||
"height": 44
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 259.203125,
|
||||
"bottom": 284,
|
||||
"left": 80.75,
|
||||
"right": 309.234375,
|
||||
"height": 24.796875
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 844,
|
||||
"height": 390,
|
||||
"theme": "moon",
|
||||
"stage": {
|
||||
"top": 174,
|
||||
"bottom": 252,
|
||||
"left": 50.640625,
|
||||
"right": 793.359375,
|
||||
"height": 78
|
||||
},
|
||||
"reader": {
|
||||
"top": 252,
|
||||
"bottom": 282,
|
||||
"left": 359.484375,
|
||||
"right": 484.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 286,
|
||||
"bottom": 336,
|
||||
"left": 102,
|
||||
"right": 742,
|
||||
"height": 50
|
||||
},
|
||||
"nav": {
|
||||
"top": 406,
|
||||
"bottom": 450,
|
||||
"left": 284,
|
||||
"right": 560,
|
||||
"height": 44
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 227.203125,
|
||||
"bottom": 252,
|
||||
"left": 307.75,
|
||||
"right": 536.234375,
|
||||
"height": 24.796875
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
},
|
||||
{
|
||||
"width": 844,
|
||||
"height": 390,
|
||||
"theme": "fog",
|
||||
"stage": {
|
||||
"top": 174,
|
||||
"bottom": 252,
|
||||
"left": 50.640625,
|
||||
"right": 793.359375,
|
||||
"height": 78
|
||||
},
|
||||
"reader": {
|
||||
"top": 252,
|
||||
"bottom": 282,
|
||||
"left": 359.484375,
|
||||
"right": 484.5,
|
||||
"height": 30
|
||||
},
|
||||
"composer": {
|
||||
"top": 286,
|
||||
"bottom": 336,
|
||||
"left": 102,
|
||||
"right": 742,
|
||||
"height": 50
|
||||
},
|
||||
"nav": {
|
||||
"top": 406,
|
||||
"bottom": 450,
|
||||
"left": 284,
|
||||
"right": 560,
|
||||
"height": 44
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"top": 227.203125,
|
||||
"bottom": 252,
|
||||
"left": 307.75,
|
||||
"right": 536.234375,
|
||||
"height": 24.796875
|
||||
}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
],
|
||||
"motion": {
|
||||
"inked_lines": {
|
||||
"6fc4f8ff-218a-4c0a-933c-7982fef596b6:1:0": 1
|
||||
},
|
||||
"slow_frames_new_turn_to_reply_s": 2.31
|
||||
},
|
||||
"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."
|
||||
],
|
||||
"compact_first_press": [
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "moon",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "发送"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "moon",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "上一句"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "moon",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "暂停"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "moon",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "继续"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "moon",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "全文"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "fog",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "发送"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "fog",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "上一句"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "fog",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "暂停"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "fog",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "继续"
|
||||
},
|
||||
{
|
||||
"size": "390x420",
|
||||
"theme": "fog",
|
||||
"down": 182,
|
||||
"up": 182,
|
||||
"typing": false,
|
||||
"target": "全文"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "moon",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "发送"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "moon",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "上一句"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "moon",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "暂停"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "moon",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "继续"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "moon",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "全文"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "fog",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "发送"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "fog",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "上一句"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "fog",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "暂停"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "fog",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "继续"
|
||||
},
|
||||
{
|
||||
"size": "390x430",
|
||||
"theme": "fog",
|
||||
"down": 192,
|
||||
"up": 192,
|
||||
"typing": false,
|
||||
"target": "全文"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 276 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: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
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: 302 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 399 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: 283 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: 136 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
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="zh-CN" data-theme="moon"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark"><title>CyberGril · 一扇亮着的窗</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
<!doctype html><html lang="zh-CN" data-theme="moon"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content"><meta name="color-scheme" content="dark"><title>CyberGril · 一扇亮着的窗</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
|
||||
@@ -25,4 +25,7 @@
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 15a4.5 4.5 0 0 1 .6-9 5.5 5.5 0 0 1 10.3 2.1A3.5 3.5 0 0 1 17.5 15z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M9 18l-1 2.5M13 18l-1 2.5M17 18l-1 2.5" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="M4 12l16-8-6 16-2.5-6.5z" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M9 6v12M15 6v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.5v13l10-6.5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M4 7h16M4 12h10M4 17h13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -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); } });
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function parseState(text: string, sameIdentity?: Identity): DemoState {
|
||||
if (!messages.every(m => object(m) && str(m.id, 100) && ['user', 'companion'].includes(String(m.role)) && str(m.text) && strings(m.lifeIds) && (!m.status || ['pending', 'failed', 'sent', 'cancelled'].includes(String(m.status)))))
|
||||
throw new Error('聊天记录不完整。');
|
||||
// Reconstruct the schema: do not preserve arbitrary imported object keys/prototypes.
|
||||
return { version: 1, identity: { id: i.id, name: i.name, birth: i.birth, hour: i.hour, relation: i.relation as Identity['relation'], nickname: i.nickname, boundary: i.boundary }, settings: { city: s.city, appearance: s.appearance, reduced: s.reduced, services: Object.fromEntries(Object.keys(defaultSettings.services).map(k => [k, String((s.services as Record<string, unknown>)[k] || '')])) }, events: events.map(e => { const v = e as DemoState['events'][number]; return { id: v.id, title: v.title, text: v.text, time: v.time, place: v.place, kind: v.kind, shared: v.shared === true, memoryIds: [...v.memoryIds] }; }), memories: memories.map(m => { const v = m as DemoState['memories'][number]; return { id: v.id, title: v.title, text: v.text, group: v.group, source: v.source, lifeIds: [...v.lifeIds] }; }), messages: messages.map(m => { const v = m as DemoState['messages'][number]; return { id: v.id, role: v.role, text: v.text, lifeIds: [...v.lifeIds], status: v.status === 'pending' ? 'cancelled' : v.status, turnId: typeof v.turnId === 'string' ? v.turnId : undefined, proactive: v.proactive === true }; }), lastVisit: typeof x.lastVisit === 'number' ? x.lastVisit : Date.now() };
|
||||
return { version: 1, identity: { id: i.id, name: i.name, birth: i.birth, hour: i.hour, relation: i.relation as Identity['relation'], nickname: i.nickname, boundary: i.boundary }, settings: { city: s.city, appearance: s.appearance, reduced: s.reduced, services: Object.fromEntries(Object.keys(defaultSettings.services).map(k => [k, String((s.services as Record<string, unknown>)[k] || '')])) }, events: events.map(e => { const v = e as DemoState['events'][number]; return { id: v.id, title: v.title, text: v.text, time: v.time, place: v.place, kind: v.kind, shared: v.shared === true, ...(v.shared === true && str(v.told, 100) ? { told: v.told } : {}), memoryIds: [...v.memoryIds] }; }), memories: memories.map(m => { const v = m as DemoState['memories'][number]; return { id: v.id, title: v.title, text: v.text, group: v.group, source: v.source, lifeIds: [...v.lifeIds] }; }), messages: messages.map(m => { const v = m as DemoState['messages'][number]; return { id: v.id, role: v.role, text: v.text, lifeIds: [...v.lifeIds], status: v.status === 'pending' ? 'cancelled' : v.status, turnId: typeof v.turnId === 'string' ? v.turnId : undefined, proactive: v.proactive === true }; }), lastVisit: typeof x.lastVisit === 'number' ? x.lastVisit : Date.now() };
|
||||
}
|
||||
export function loadState(): {
|
||||
state: DemoState | null;
|
||||
|
||||
@@ -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';
|
||||
@@ -7,9 +8,10 @@ import { CreationWizard } from '../manage/CreationWizard';
|
||||
import { ManagePanel } from '../manage/ManagePanel';
|
||||
import { LifePanel } from '../life/LifePanel';
|
||||
import { ChatPanel } from '../chat/ChatPanel';
|
||||
import { Background } from '../stage/Background';
|
||||
import { FallbackPresenter } from '../stage/Presenter';
|
||||
import { Presenter } from '../stage/Presenter';
|
||||
import { Button, Icon, Status } from '../shared/ui';
|
||||
import { snap } from '../shared/motion';
|
||||
import { spaceFor, useSpace } from '../shared/space';
|
||||
const demo = new URLSearchParams(location.search).get('demo') === '1';
|
||||
export function App() {
|
||||
const [initial] = useState(loadState), [state, setState] = useState<DemoState | null>(initial.state), [creating, setCreating] = useState(!initial.state), [area, setArea] = useState('now'), [theme, setThemeValue] = useState<Theme>(() => { try {
|
||||
@@ -17,7 +19,8 @@ export function App() {
|
||||
}
|
||||
catch {
|
||||
return 'moon';
|
||||
} }), [toast, setToast] = useState(''), [controls, setControls] = useState(false), [fail, setFail] = useState(false), [offline, setOffline] = useState(false), [reconnecting, setReconnecting] = useState(false), [welcome, setWelcome] = useState(!!initial.state && Date.now() - initial.state.lastVisit > 86400000), [reach, setReach] = useState(false), [unread, setUnread] = useState(false), [log, setLog] = useState(false), [focusLife, setFocusLife] = useState(''), [returnTo, setReturnTo] = useState('now'), [presence, setPresence] = useState('在你面前'), [voice, setVoice] = useState(false), [observation, setObservation] = useState('');
|
||||
} }), [toast, setToast] = useState(''), [controls, setControls] = useState(false), [fail, setFail] = useState(false), [offline, setOffline] = useState(false), [reconnecting, setReconnecting] = useState(false), [welcome, setWelcome] = useState(!!initial.state && Date.now() - initial.state.lastVisit > 86400000), [reach, setReach] = useState(false), [unread, setUnread] = useState(false), [log, setLog] = useState(false), [focusLife, setFocusLife] = useState(''), [returnTo, setReturnTo] = useState('now'), [presence, setPresence] = useState('在你面前'), [voice, setVoice] = useState(false), [observation, setObservation] = useState(''), [record, setRecord] = useState(false), [instant, setInstant] = useState(0);
|
||||
const [herFocus, setHerFocus] = useState(0);
|
||||
const latest = useRef(state);
|
||||
latest.current = state;
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null), reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -38,18 +41,20 @@ 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);
|
||||
} }, []);
|
||||
} }); }, []);
|
||||
const space = spaceFor(area, record || log, creating);
|
||||
useSpace(area, space);
|
||||
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]);
|
||||
useEffect(() => { const visibility = () => { document.documentElement.classList.toggle('paused', document.hidden); if (document.hidden && latest.current) {
|
||||
useEffect(() => { const visibility = () => { if (document.hidden && latest.current) {
|
||||
try {
|
||||
persistState({ ...latest.current, lastVisit: Date.now() });
|
||||
}
|
||||
@@ -57,6 +62,9 @@ export function App() {
|
||||
} }; document.addEventListener('visibilitychange', visibility); return () => { document.removeEventListener('visibilitychange', visibility); if (toastTimer.current)
|
||||
clearTimeout(toastTimer.current); if (reconnectTimer.current)
|
||||
clearTimeout(reconnectTimer.current); }; }, []);
|
||||
function openEventChat(id: string) { setFocusLife(id); setReturnTo('days'); setLog(true); setArea('talk'); }
|
||||
const closeTalk = useCallback(() => { setArea(returnTo); setLog(false); setVoice(false); }, [returnTo]);
|
||||
// Esc peels one layer at a time: demo menu, welcome, record; then you are back across the room at once.
|
||||
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating)
|
||||
return; if (controls) {
|
||||
setControls(false);
|
||||
@@ -67,9 +75,12 @@ export function App() {
|
||||
} if (log) {
|
||||
setLog(false);
|
||||
return;
|
||||
} navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [controls, welcome, log, creating, navigate]);
|
||||
function openEventChat(id: string) { setFocusLife(id); setReturnTo('days'); setLog(true); setArea('talk'); }
|
||||
function closeTalk() { setArea(returnTo); setLog(false); setVoice(false); }
|
||||
} if (area === 'talk') {
|
||||
snap();
|
||||
setInstant(i => i + 1);
|
||||
closeTalk();
|
||||
return;
|
||||
} if (area !== 'now') navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [controls, welcome, log, creating, navigate, area, closeTalk]);
|
||||
function loadSamples(long = false) { if (!state)
|
||||
return; try {
|
||||
save(sampleDays(state, long));
|
||||
@@ -94,15 +105,18 @@ export function App() {
|
||||
notify('暂时还没连上,再试一次。');
|
||||
return;
|
||||
} setOffline(false); notify('模拟恢复完成,已重新同步。草稿仍在,可自行发送。'); }, 700); }
|
||||
const scene = { mode: area === 'talk' ? 'talk' as const : 'daily' as const, activity: view?.events.find(e => e.kind === 'now') ? '在窗边读书' : '刚搬进来,安静待着', time_band: 'evening', weather: 'demo', outfit: 'home' };
|
||||
return <><Background /><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{demo ? '本机演示 · 无真实后台' : '服务尚未接通'}</span></div><div className="env"><span className="chip glass place"><Icon name="pin"/>{state?.settings.city || '杭州'}</span><span className="chip glass weather"><Icon name="cloud"/>雨后 · 模拟</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>
|
||||
// The reference still is not her new daily life (reading is no longer the default, 01a166aa): say only where she is.
|
||||
const scene = { mode: area === 'talk' ? 'talk' as const : 'daily' as const, activity: view?.events.length ? '在窗边待着' : '刚搬进来,安静待着', time_band: 'evening', weather: 'demo', outfit: 'home' };
|
||||
const talking = area === 'talk';
|
||||
return <><Presenter scene={scene} space={space} theme={theme} instant={instant} observation={observation} now={state && !creating ? scene.activity : ''} onApproach={() => navigate('talk')}/>
|
||||
<header className="topbar"><div className="who"><i className="dot"/><span className="name" title={state?.identity.name}>{state?.identity.name || 'CyberGril'}</span>{state && !creating && <span className="presence">{talking ? presence : scene.activity}</span>}<span className="state">{demo ? '本机演示 · 无真实后台' : '服务尚未接通'}</span></div><div className="env"><span className="chip glass place"><Icon name="pin"/>{state?.settings.city || '杭州'}</span><span className="chip glass weather"><Icon name="cloud"/>雨后 · 模拟</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>{talking && state && <button className="end-talk" onClick={closeTalk}><Icon name="x"/><span>{returnTo === 'days' ? '结束交谈 · 回到日子' : '结束交谈'}</span></button>}</div></header>
|
||||
{!demo ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><p>真实服务还在准备,当前没有连接对话、生活、记忆或备份后台。你可以先体验完整的本机演示。</p><a className="btn-primary" href="?demo=1">进入本机演示</a></div></main> : initial.error ? <main className="entry"><div className="card"><h1>资料暂时读不出来</h1><Status text={initial.error} error/><p>请先检查浏览器存储权限,或备份浏览器中的原始资料后再处理。不会自动清空或覆盖。</p></div></main> : <>
|
||||
{creating && <CreationWizard fail={fail} onCreate={save} onEnter={() => setCreating(false)}/>}
|
||||
{!creating && state && view && <><FallbackPresenter scene={scene} presence={presence} observation={observation} onClose={area === 'talk' ? closeTalk : () => navigate('now')} closeLabel={returnTo === 'days' ? '返回日子' : '回到此刻'}/>
|
||||
{!creating && state && view && <>
|
||||
{area === 'now' && !welcome && <main className="home"><div className="eyebrow">夜色里 · 一扇亮着的窗</div><h1 className="whisper">{state.events.length ? '夜深了,慢慢说。' : '刚住进来,一切都慢慢来。'}</h1><div className="meta"><span><Icon name="moon"/>心情平静 · 演示</span><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div>{view.messages.filter(m => m.role === 'companion').length > 0 && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>她留给你的</b><span>演示留言</span></span><p>{[...view.messages].reverse().find(m => m.role === 'companion')?.text}</p></button>}<button className="away" onClick={() => navigate('days')}>你不在时 · 看她已经分享的日子 <Icon name="next"/></button><div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button><button className="btn-round glass" aria-label="进入模拟语音" onClick={() => { navigate('talk'); notify('点输入框右侧麦克风,可开启无收音的语音模拟。'); }}><Icon name="mic"/></button></div><p className="first-note">{state.identity.name}今晚刚住进来。这里还没有共同的日子。</p><p className="demo-label">本机演示 · 没有真实后台生活</p></main>}
|
||||
{welcome && area === 'now' && <section className="welcome glass"><div className="eyebrow">回来坐一会儿</div><h2>你不在的这两天</h2><p className="sub">固定演示摘要 · 只展示已分享记录</p><ul>{view.events.filter(e => e.kind !== 'plan').slice(0, 3).map(e => <li key={e.id}><time>{e.time}</time><span>{e.title}</span></li>)}</ul><p className="note">演示中网页关闭不会产生新的生活,服务器后台尚未接通。</p><div className="acts"><Button primary onClick={() => { navigate('talk'); setLog(true); }}>看她留的话</Button><Button onClick={() => navigate('days')}>看她的日子</Button><button className="btn-text" onClick={() => setWelcome(false)}>先待一会儿</button></div></section>}
|
||||
<LifePanel state={state} open={area === 'days'} onClose={() => navigate('now')} onChat={openEventChat}/><ManagePanel state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={save} fail={fail} theme={theme} setTheme={setTheme}/>
|
||||
{area === 'talk' && <ChatPanel state={state} onChange={changeMessages} onPresence={setPresence} onVoice={setVoice} onObservation={setObservation} onClose={closeTalk} offline={offline} fail={fail} log={log} setLog={setLog} focusLife={focusLife}/>}
|
||||
<LifePanel state={state} open={area === 'days'} now={scene.activity} fail={fail} onClose={() => navigate('now')} onChat={openEventChat} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/><ManagePanel state={state} open={area === 'settings'} now={scene.activity} focus={herFocus} onClose={() => navigate('now')} onSave={save} fail={fail} theme={theme} setTheme={setTheme}/>
|
||||
{talking && <ChatPanel state={state} onChange={changeMessages} onPresence={setPresence} onVoice={setVoice} onObservation={setObservation} onRecord={setRecord} offline={offline} fail={fail} log={log} setLog={setLog} focusLife={focusLife}/>}
|
||||
{offline && <div className={`netbar show glass ${reconnecting ? '' : 'failed'}`} role="status"><span>{reconnecting ? '信号不太好,正在重新连上…' : '模拟断线,草稿还在'}</span><button className="btn-ghost" onClick={reconnect}>再试一次</button></div>}
|
||||
{reach && <aside className="reach glass show"><div className="rh"><b>{state.identity.name}</b>主动说的 · 模拟</div><p>窗外的雨停了,想跟你说一声。</p><div className="basis">来自已分享的「听了一下午的雨」</div><div className="acts"><Button primary onClick={() => { setReach(false); setUnread(false); setReturnTo(area); setArea('talk'); setFocusLife('rain'); setLog(true); }}>回她</Button><Button onClick={() => setReach(false)}>稍后再看</Button></div></aside>}
|
||||
<nav className="nav glass" aria-label="主导航">{[['now', 'moon', '此刻'], ['talk', 'chat', '交谈'], ['days', 'book', '日子'], ['settings', 'gear', '设置']].map(([id, icon, label]) => <button className={`nav-item ${id === 'talk' && unread ? 'unread' : ''}`} key={id} aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}<i className="badge"/></button>)}</nav></>}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, loadReal, type Health } from '../adapters/real';
|
||||
import type { DemoState, Message, Theme } from '../domain/model';
|
||||
import { Presenter } 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';
|
||||
import { snap } from '../shared/motion';
|
||||
import { spaceFor, useSpace } from '../shared/space';
|
||||
|
||||
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('文字已接通'), [record, setRecord] = useState(false), [instant, setInstant] = useState(0);
|
||||
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
|
||||
const [herFocus, setHerFocus] = useState(0);
|
||||
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]);
|
||||
const space = spaceFor(area, record || log, creating || !ready);
|
||||
useSpace(area, space);
|
||||
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); }); }, []);
|
||||
// Esc peels one layer at a time: the record first, then you are back across the room at once.
|
||||
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) { setLog(false); return; } if (area === 'talk') { snap(); setInstant(i => i + 1); setArea('now'); return; } if (area !== 'now') navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate, area]);
|
||||
const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined;
|
||||
const talking = area === 'talk' && !!state && !creating;
|
||||
return <><Presenter real scene={{ mode: area === 'talk' ? 'talk' : 'daily', activity: '文字已接通', time_band: '', weather: '', outfit: '' }} space={space} theme={theme} instant={instant} observation="" now={state && !creating ? '生活尚未接通' : ''} onApproach={() => navigate('talk')}/>
|
||||
<header className="topbar"><div className="who"><i className="dot"/><span className="name" title={state?.identity.name}>{state?.identity.name || 'CyberGril'}</span>{talking && <span className="presence">{presence}</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>{talking && <button className="end-talk" onClick={() => navigate('now')}><Icon name="x"/><span>结束交谈</span></button>}</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 && <>
|
||||
{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} onRecord={setRecord} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
|
||||
<LifePanel real state={state} open={area === 'days'} now="生活尚未接通" onClose={() => navigate('now')} onChat={() => { navigate('talk'); setLog(true); }} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/>
|
||||
<ManagePanel real state={state} open={area === 'settings'} now="生活尚未接通" focus={herFocus} 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,111 +1,241 @@
|
||||
import { newId } from '../domain/id';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, 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';
|
||||
import { snap } from '../shared/motion';
|
||||
import { motionOff } from '../stage/roomScene';
|
||||
import { LineStage, type PresentHow } from './lineStage';
|
||||
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
|
||||
|
||||
const START: Anchor = { mi: 0, offset: 0 };
|
||||
const metricsFor = (width: number): Metrics => { const compact = document.documentElement.classList.contains('compact'); return { width, font: compact ? 16 : innerWidth <= 720 ? 18 : 23, compact }; };
|
||||
const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]'));
|
||||
|
||||
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, real = false }: {
|
||||
real?: boolean;
|
||||
state: DemoState;
|
||||
onChange: (messages: Message[]) => void;
|
||||
onPresence: (p: string) => void;
|
||||
onVoice: (v: boolean) => void;
|
||||
onObservation: (s: string) => void;
|
||||
onClose: () => void;
|
||||
// A reading sheet (full text or the whole record) is open: she stays across the table, the room dims a little.
|
||||
onRecord: (open: boolean) => void;
|
||||
offline: boolean;
|
||||
fail: boolean;
|
||||
log: boolean;
|
||||
setLog: (b: boolean) => void;
|
||||
focusLife: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => { try {
|
||||
return sessionStorage.getItem('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 draftKey = real ? 'cg.real.draft' : 'cg.draft', readKey = real ? 'cg.real.reading' : 'cg.reading';
|
||||
const [draft, setDraft] = useState(() => { try { return sessionStorage.getItem(draftKey) || ''; } catch { return ''; } }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [flash, setFlash] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
|
||||
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(''), sentTurn = useRef('');
|
||||
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 name = state.identity.name;
|
||||
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)); }
|
||||
|
||||
// ── What the stage shows: the newest turn of the (already privacy-filtered) record, plus the live reply. ──
|
||||
const view = useMemo(() => publicView(state), [state]);
|
||||
// The cut-off part of a stopped reply stays with its own turn (it is shown, never stored).
|
||||
const shown = useMemo<ShownMessage[]>(() => {
|
||||
const list: ShownMessage[] = [...view.messages];
|
||||
if (reply) list.push({ id: 'live-reply', role: 'companion', text: reply, lifeIds: [], turnId: activeTurn, live: 'generating' });
|
||||
else if (interrupted) {
|
||||
const at = list.map(m => m.turnId).lastIndexOf(interrupted.turn);
|
||||
list.splice(at < 0 ? list.length : at + 1, 0, { id: 'cut-reply', role: 'companion', text: interrupted.text, lifeIds: [], turnId: interrupted.turn, live: 'cancelled' });
|
||||
}
|
||||
return list;
|
||||
}, [view.messages, reply, activeTurn, interrupted]);
|
||||
const turn = useMemo(() => groupTurns(shown).at(-1), [shown]);
|
||||
const stageRef = useRef<HTMLDivElement>(null), lines = useRef<LineStage | null>(null), meter = useRef<HTMLElement>(null);
|
||||
const [metrics, setMetrics] = useState<Metrics>(() => metricsFor(Math.min(900, innerWidth * .88)));
|
||||
const pages = useMemo(() => pagesOf(turn, metrics), [turn, metrics]);
|
||||
// Reading position is kept as a place in the text (not a line number), so re-chunking never loses it.
|
||||
const restored = useRef<{ turn: string; anchor: Anchor; flow: boolean } | null>((() => { try { return JSON.parse(sessionStorage.getItem(readKey) || 'null'); } catch { return null; } })());
|
||||
const resume = restored.current && turn && restored.current.turn === turn.key ? restored.current : null;
|
||||
const [anchor, setAnchor] = useState<Anchor>(resume?.anchor || START), [flow, setFlowState] = useState(resume ? resume.flow : true), [full, setFull] = useState(false);
|
||||
const head = useRef<Anchor>(resume?.anchor || START), how = useRef<PresentHow>('jump'), turnKey = useRef(turn?.key || '');
|
||||
const cursor = cursorAt(pages, anchor), page = pages[cursor], atEnd = cursor >= pages.length - 1;
|
||||
const headIndex = cursorAt(pages, head.current);
|
||||
if (cursor > headIndex) head.current = anchor;
|
||||
const held = useRef(new Set<string>()), clock = useRef(new ReadingClock());
|
||||
const reading = useRef({ pages, cursor, flow }); reading.current = { pages, cursor, flow };
|
||||
|
||||
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(() => { try {
|
||||
sessionStorage.setItem('cg.draft', draft);
|
||||
}
|
||||
catch { /* Draft remains in memory. */ } }, [draft]);
|
||||
useEffect(() => { if (offline) {
|
||||
gate.current.resync();
|
||||
setBusy(false);
|
||||
setReply('');
|
||||
setVoice(false);
|
||||
onVoice(false);
|
||||
voiceController.current?.abort();
|
||||
} }, [offline, onVoice]);
|
||||
useEffect(() => { if (!vision)
|
||||
return; onObservation('模拟 · 刚刚看到,未采集影像'); const t = setTimeout(() => onObservation('模拟 · 已过期,不再当作眼前'), 30000); return () => clearTimeout(t); }, [vision, onObservation]);
|
||||
useEffect(() => { if (log) {
|
||||
const el = focusLife ? logRef.current?.querySelector(`[data-life="${CSS.escape(focusLife)}"]`) : null;
|
||||
if (el)
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
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 {
|
||||
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
|
||||
return; if (event.t === 'chat.delta') {
|
||||
setReply(x => x + event.delta);
|
||||
onPresence('在说话');
|
||||
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(draftKey, draft); } catch { /* Draft remains in memory. */ } }, [draft, draftKey]);
|
||||
useEffect(() => { try { if (turn) sessionStorage.setItem(readKey, JSON.stringify({ turn: turn.key, anchor, flow })); } catch { /* Position remains in memory. */ } }, [turn, anchor, flow, readKey]);
|
||||
useEffect(() => { if (offline) { gate.current.resync(); setBusy(false); setReply(''); setVoice(false); onVoice(false); voiceController.current?.abort(); } }, [offline, onVoice]);
|
||||
useEffect(() => { if (!vision) return; onObservation('模拟 · 刚刚看到,未采集影像'); const t = setTimeout(() => onObservation('模拟 · 已过期,不再当作眼前'), 30000); return () => clearTimeout(t); }, [vision, onObservation]);
|
||||
useEffect(() => { if (!flash) return; const t = setTimeout(() => setFlash(''), 2600); return () => clearTimeout(t); }, [flash]);
|
||||
useEffect(() => { onRecord(log || full); }, [log, full, onRecord]);
|
||||
useEffect(() => () => onRecord(false), [onRecord]);
|
||||
useEffect(() => { if (log) { const el = focusLife ? logRef.current?.querySelector(`[data-life="${CSS.escape(focusLife)}"]`) : null; if (el) el.scrollIntoView({ block: 'center' }); else if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log, focusLife, view.messages.length]);
|
||||
|
||||
// ── Stage ──
|
||||
useLayoutEffect(() => { if (stageRef.current) lines.current = new LineStage(stageRef.current); return () => { lines.current = null; }; }, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = stageRef.current; if (!el) return;
|
||||
const measure = () => setMetrics(m => { const next = metricsFor(el.clientWidth || m.width); if (next.width === m.width && next.font === m.font && next.compact === m.compact) return m; how.current = 'jump'; return next; });
|
||||
const ro = new ResizeObserver(measure), vv = window.visualViewport; ro.observe(el); addEventListener('resize', measure); vv?.addEventListener('resize', measure); measure();
|
||||
return () => { ro.disconnect(); removeEventListener('resize', measure); vv?.removeEventListener('resize', measure); };
|
||||
}, []);
|
||||
// A new turn (you sent something, or she spoke on her own) starts at its first line.
|
||||
useLayoutEffect(() => {
|
||||
const key = turn?.key || '';
|
||||
if (key === turnKey.current) return;
|
||||
turnKey.current = key; how.current = 'jump'; head.current = START; setAnchor(START);
|
||||
if (key && key === sentTurn.current) setFlowState(true);
|
||||
}, [turn?.key]);
|
||||
const signature = pages.map(p => p.key).join('|');
|
||||
useLayoutEffect(() => {
|
||||
lines.current?.present(pages, cursor, how.current, name, !motionOff());
|
||||
how.current = 'advance';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-present only when the visible lines change
|
||||
}, [signature, cursor]);
|
||||
useLayoutEffect(() => {
|
||||
if (!page) return;
|
||||
const mine = page.message, cancelled = mine.live === 'cancelled' || (mine.status === 'cancelled' && mine.role === 'companion');
|
||||
const text = cancelled && page.last ? '未完成 · 已取消' : mine.role === 'user' && mine.status === 'failed' ? '没发出去' : mine.role === 'user' && mine.status === 'cancelled' && atEnd ? '本轮已取消' : atEnd && busy && mine.role === 'companion' ? '还在说' : '';
|
||||
lines.current?.mark({ text, waiting: atEnd && busy && mine.role === 'companion' });
|
||||
});
|
||||
useEffect(() => { lines.current?.rename(name); }, [name]);
|
||||
|
||||
// ── Reading clock. Holds (hover, selection, open sheets, hidden page) freeze it without changing your choice. ──
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const c = clock.current, away = () => c.pause();
|
||||
const tick = (now: number) => {
|
||||
const { pages: list, cursor: at, flow: on } = reading.current;
|
||||
if (c.tick(now, list[at], on && !held.current.size && !document.hidden) && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); }
|
||||
if (meter.current) meter.current.style.transform = `scaleX(${c.progress})`;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
document.addEventListener('visibilitychange', away);
|
||||
return () => { cancelAnimationFrame(raf); document.removeEventListener('visibilitychange', away); };
|
||||
}, []);
|
||||
const [holding, setHolding] = useState(false);
|
||||
const hold = useCallback((why: string, on: boolean) => { if (on) held.current.add(why); else held.current.delete(why); setHolding(held.current.size > 0); }, []);
|
||||
useEffect(() => { hold('sheet', log || full); }, [log, full, hold]);
|
||||
useEffect(() => {
|
||||
const selection = () => { const s = getSelection(); hold('select', !!(s && String(s).length && stageRef.current?.contains(s.anchorNode))); };
|
||||
document.addEventListener('selectionchange', selection);
|
||||
return () => document.removeEventListener('selectionchange', selection);
|
||||
}, [hold]);
|
||||
|
||||
const goTo = (index: number, mode: PresentHow) => { const to = Math.max(0, Math.min(index, pages.length - 1)); if (to === cursor) return; how.current = mode; clock.current.forget(); setAnchor(anchorOf(pages, to) || START); };
|
||||
const setFlow = (on: boolean, quiet = false) => { setFlowState(on); clock.current.restart(); if (!quiet) setFlash(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看'); };
|
||||
const back = (instant = false) => { if (flow) setFlow(false, true); goTo(cursor - 1, instant ? 'instant' : 'jump'); };
|
||||
const forward = (instant = false) => goTo(cursor + 1, instant ? 'instant' : 'advance');
|
||||
const toLatest = () => { how.current = 'jump'; setAnchor(head.current); setFlow(true, true); };
|
||||
// Keyboard review lands at once (no slide, no replayed ink). Sheets and dialogs own their keys.
|
||||
const keys = useRef({ back, forward, toggle: () => setFlow(!flow) }); keys.current = { back, forward, toggle: () => setFlow(!flow) };
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || typingTarget(document.activeElement) || document.querySelector('.modal.open') || document.documentElement.dataset.mode !== 'talk') return;
|
||||
if (e.key === 'ArrowLeft') { snap(); keys.current.back(true); }
|
||||
else if (e.key === 'ArrowRight') { snap(); keys.current.forward(true); }
|
||||
else if (e.key === ' ' && (document.activeElement === document.body || !document.activeElement)) { e.preventDefault(); keys.current.toggle(); }
|
||||
};
|
||||
addEventListener('keydown', key);
|
||||
return () => removeEventListener('keydown', key);
|
||||
}, []);
|
||||
// Esc closes the full-text sheet first (one layer at a time) and returns focus to the button that opened it.
|
||||
const fullButton = useRef<HTMLButtonElement>(null), historyButton = useRef<HTMLButtonElement>(null), fullClose = useRef<HTMLButtonElement>(null), logClose = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!full) return;
|
||||
fullClose.current?.focus({ preventScroll: true });
|
||||
const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || document.querySelector('.modal.open')) return; e.stopImmediatePropagation(); e.preventDefault(); setFull(false); };
|
||||
document.addEventListener('keydown', key, true);
|
||||
return () => { document.removeEventListener('keydown', key, true); const back = fullButton.current; if (back && !back.closest('[inert]')) back.focus({ preventScroll: true }); };
|
||||
}, [full]);
|
||||
const logWasOpen = useRef(log);
|
||||
useEffect(() => {
|
||||
if (log) logClose.current?.focus({ preventScroll: true });
|
||||
else if (logWasOpen.current && document.activeElement && (document.activeElement === document.body || document.activeElement.closest('.record'))) historyButton.current?.focus({ preventScroll: true });
|
||||
logWasOpen.current = log;
|
||||
}, [log]);
|
||||
|
||||
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' };
|
||||
sentTurn.current = turnId;
|
||||
change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]);
|
||||
if (!retry) setDraft('');
|
||||
setActiveTurn(turnId); turnLive.current = turnId; setBusy(true); onPresence('在想'); setNotice('');
|
||||
if (!flow) setFlash('新的一轮会自动呈现,随时可以暂停');
|
||||
try {
|
||||
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => {
|
||||
if (!gate.current.accepts(event)) return;
|
||||
if (event.t === 'chat.delta') { replyLive.current += event.delta; setInterrupted(null); setReply(replyLive.current); onPresence('在说话'); }
|
||||
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 }]);
|
||||
replyLive.current = ''; setInterrupted(null); setReply('');
|
||||
if (real) setDraft(current => current === text ? '' : current);
|
||||
setBusy(false);
|
||||
onPresence(voice ? '在听你说(模拟)' : '在你面前');
|
||||
}
|
||||
}, fail);
|
||||
}
|
||||
else {
|
||||
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('');
|
||||
catch (e) {
|
||||
if (signal.aborted) return;
|
||||
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
|
||||
setBusy(false);
|
||||
onPresence(voice ? '在听你说(模拟)' : '在你面前');
|
||||
} }, fail);
|
||||
}
|
||||
catch (e) {
|
||||
if (signal.aborted)
|
||||
return;
|
||||
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
|
||||
setBusy(false);
|
||||
setNotice((e as Error).message);
|
||||
onPresence('在你面前');
|
||||
} }
|
||||
const setVoiceMode = () => { 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>
|
||||
{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) {
|
||||
setVision(false);
|
||||
onObservation('窗户关上了');
|
||||
}
|
||||
else
|
||||
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>}
|
||||
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
|
||||
setDenied(false);
|
||||
setVoice(true);
|
||||
onVoice(true);
|
||||
voiceController.current = new AbortController();
|
||||
onPresence('在听你说(模拟)');
|
||||
setNotice('模拟语音没有收音,可继续用文字输入。');
|
||||
}
|
||||
else {
|
||||
if (fail) {
|
||||
onObservation('模拟 · 看不清,视觉服务未接通');
|
||||
setNotice('模拟观察失败,文字仍可使用。');
|
||||
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('在你面前');
|
||||
}
|
||||
else
|
||||
setVision(true);
|
||||
} setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。</Modal>}
|
||||
<button hidden className="sr" onClick={onClose}>退出交流</button></>;
|
||||
}
|
||||
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) { endVoice(); return; } setPermission('mic'); };
|
||||
const lastUser = [...view.messages].reverse().find(m => m.role === 'user');
|
||||
const status = notice || flash || (busy ? (reply ? `${name}正在回应…` : `${name}在想…`) : !pages.length ? '你们还没有说过话。' : real ? '' : '演示回复 · 未连接真实模型');
|
||||
const unread = pages.length - 1 - cursor, single = pages.length <= 1 && !busy, fullMessage = page?.message;
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
useLayoutEffect(() => { const t = textarea.current; if (!t) return; t.style.height = 'auto'; t.style.height = Math.min(t.scrollHeight, 88) + 'px'; }, [draft]);
|
||||
const typing = (on: boolean) => document.documentElement.classList.toggle('typing', on);
|
||||
useEffect(() => () => { document.documentElement.classList.remove('typing'); }, []);
|
||||
const record = reply ? shown.filter(m => !m.live) : shown;
|
||||
// While a reading sheet is open the table words are out of reach (the sheet holds them all); layout effect so the
|
||||
// focus hand-back after closing never lands on an inert button.
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => { conversation.current?.toggleAttribute('inert', log || full); }, [log, full]);
|
||||
|
||||
return <><section ref={conversation} className={`conversation ${single ? 'single-page' : ''} ${flow ? '' : 'reading-paused'} ${holding && flow ? 'reading-held' : ''}`} aria-label="当前对话">
|
||||
{voice && <div className="voice-row"><span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span><span>{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button onClick={() => { stop(); setNotice(fail ? '刚才没听清,你再说一遍?(模拟识别失败)' : '她停下来了,在等你。'); }}>打断她</button><button onClick={endVoice}>结束语音</button></div>}
|
||||
<div className="stage" ref={stageRef} aria-hidden="true" onPointerEnter={e => { if (e.pointerType === 'mouse') hold('hover', true); }} onPointerLeave={() => hold('hover', false)}/>
|
||||
<p className="sr" aria-live="polite" aria-atomic="true">{page ? `${page.message.role === 'user' ? '你' : name}:${page.text}` : ''}</p>
|
||||
{status && <div className="talk-status" role="status"><span>{status}</span>{lastUser?.status === 'failed' && !busy && <button className="retry" onClick={() => void send(lastUser)}>重试</button>}</div>}
|
||||
<div className="reader-bar">
|
||||
<button className="flow" aria-pressed={flow} onClick={() => setFlow(!flow)}><Icon name={flow ? 'pause' : 'play'}/><span>{flow ? '暂停' : '继续'}</span></button>
|
||||
<button className="icon" aria-label="上一句" disabled={cursor <= 0} onClick={() => back()}><Icon name="back"/></button>
|
||||
<span className="count" aria-label={pages.length ? `第 ${cursor + 1} 句,共 ${pages.length} 句${busy ? ',还在说' : ''}` : '还没有对话'}><span>{pages.length ? `${cursor + 1} / ${pages.length}${busy ? '…' : ''}` : '0 / 0'}</span><i ref={meter}/></span>
|
||||
<button className="icon" aria-label="下一句" disabled={atEnd} onClick={() => forward()}><Icon name="next"/></button>
|
||||
<button className="read-all" ref={fullButton} disabled={!page} onClick={() => setFull(true)}>全文</button>
|
||||
{busy && <button className="stop" onClick={stop}>停止回复</button>}
|
||||
{!flow && cursor < cursorAt(pages, head.current) && <button className="latest" onClick={toLatest}><Icon name="down"/><span>{unread > 0 ? `回到读到的地方 · 后面还有 ${unread} 句` : '回到读到的地方'}</span></button>}
|
||||
</div>
|
||||
<div className="composer"><button className={`round log-btn ${log ? 'on' : ''}`} ref={historyButton} aria-label="完整记录" aria-expanded={log} onClick={() => setLog(!log)}><Icon name="lines"/></button>
|
||||
<textarea ref={textarea} aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onFocus={() => typing(true)} onBlur={() => typing(false)} 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={`round cam ${vision ? 'on' : ''}`} aria-label={real ? '视觉观察尚未接通' : '视觉观察(模拟)'} onClick={() => { if (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) { setVision(false); onObservation('窗户关上了'); } else setPermission('vision'); }}><Icon name="eye"/></button>
|
||||
<button className="round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button>
|
||||
<button className={`voice-button ${denied ? 'denied' : ''}`} aria-label={real ? '语音尚未接通' : voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="record log-panel glass read" hidden={!log} role="dialog" aria-modal="false" aria-labelledby="record-title"><header><div><p className="eyebrow">{real ? '服务器聊天记录' : '完整记录 · 本机演示'}</p><h2 id="record-title">你们说过的话</h2></div><button className="btn-round sm" ref={logClose} aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header>
|
||||
<div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds[0]} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}</div></aside>
|
||||
{full && fullMessage && <aside className="record full glass read" role="dialog" aria-modal="false" aria-labelledby="full-title"><header><div><p className="eyebrow">{fullMessage.role === 'user' ? '你' : name} · 全文</p><h2 id="full-title">这一段,慢慢看</h2></div><button className="btn-round sm" ref={fullClose} aria-label="关闭全文" onClick={() => setFull(false)}><Icon name="x"/></button></header>
|
||||
<div className="scroll"><p className="guide">整段完整可读。关闭后回到刚才那一句。</p><article className="message" data-role={fullMessage.role}><div className="author"><i/>{fullMessage.role === 'user' ? '你' : name}</div><p>{fullMessage.text}</p>{fullMessage.live === 'generating' && <span className="incomplete">还在说…</span>}{fullMessage.live === 'cancelled' && <span className="incomplete">这段回复已停止,内容未完成,未存入聊天记录。</span>}</article></div></aside>}
|
||||
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') { setDenied(false); setVoice(true); onVoice(true); voiceController.current = new AbortController(); onPresence('在听你说(模拟)'); setNotice('模拟语音没有收音,可继续用文字输入。'); } else { if (fail) { onObservation('模拟 · 看不清,视觉服务未接通'); setNotice('模拟观察失败,文字仍可使用。'); } else setVision(true); } setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。</Modal>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { graphemes, type Page } from './reading';
|
||||
// The lines on your side of the table (HEL-799 talk.js present/pin/leave). React owns the element; this class owns
|
||||
// its children so finished lines are never re-rendered, re-typeset or re-animated. Text is always textContent.
|
||||
const EASE = 'cubic-bezier(.22,1,.36,1)';
|
||||
export type PresentHow = 'advance' | 'jump' | 'instant';
|
||||
export interface LineState { text: string; waiting: boolean }
|
||||
|
||||
export class LineStage {
|
||||
constructor(private stage: HTMLElement) {}
|
||||
// A line inks in only the first time it is read; review, returns and resizes show it as it is.
|
||||
private inked = new Set<string>();
|
||||
private line(page: Page, current: boolean, name: string, ink: boolean) {
|
||||
if (ink) this.inked.add(page.key);
|
||||
const el = document.createElement('div');
|
||||
el.className = 'line' + (current ? ' is-current' : ' is-prev');
|
||||
el.dataset.role = page.message.role; el.dataset.key = page.key;
|
||||
const who = document.createElement('span'); who.className = 'by';
|
||||
const dot = document.createElement('i'), label = document.createElement('b'), state = document.createElement('em');
|
||||
label.textContent = page.message.role === 'user' ? '你' : name; label.title = label.textContent; state.className = 'state';
|
||||
who.append(dot, label, state);
|
||||
const p = document.createElement('p');
|
||||
if (ink) {
|
||||
// Her current line inks in once, character by character (capped so long lines don't drag).
|
||||
p.className = 'ink';
|
||||
graphemes(page.text).forEach((c, i) => { const s = document.createElement('span'); s.style.setProperty('--i', String(Math.min(i, 48))); s.textContent = c; p.append(s); });
|
||||
} else p.textContent = page.text;
|
||||
el.append(who, p);
|
||||
return el;
|
||||
}
|
||||
// Take a line out of the flow at its current place, so the remaining lines lay out once at their final size.
|
||||
private pin(node: HTMLElement, rect: DOMRect, stageRect: DOMRect) {
|
||||
node.classList.add('leaving');
|
||||
Object.assign(node.style, { position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px', transformOrigin: '50% 0' });
|
||||
}
|
||||
private leave(node: HTMLElement, rect: DOMRect, stageRect: DOMRect, quick = false, lift = quick ? -4 : -14, shrink = 1) {
|
||||
this.pin(node, rect, stageRect);
|
||||
node.animate([{ opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)' }, { opacity: 0, transform: `translateY(${lift}px) scale(${shrink})`, filter: 'blur(5px)' }],
|
||||
{ duration: quick ? 240 : 520, easing: EASE, fill: 'forwards' }).finished.then(() => node.remove()).catch(() => node.remove());
|
||||
}
|
||||
clear() { this.stage.replaceChildren(); }
|
||||
// Shows the current page with the one before it. Returns early when the same lines are already on screen, so
|
||||
// a growing stream, a status change or a re-render never replays anything.
|
||||
present(pages: Page[], cursor: number, how: PresentHow, name: string, animate: boolean) {
|
||||
const stage = this.stage, page = pages[cursor];
|
||||
if (!page) { stage.replaceChildren(); return; }
|
||||
const prev = pages[cursor - 1], want = [prev, page].filter((p): p is Page => !!p);
|
||||
const have = [...stage.querySelectorAll<HTMLElement>('.line:not(.leaving)')], keys = have.map(n => n.dataset.key);
|
||||
const inkFor = (p: Page) => p === page && animate && p.message.role === 'companion' && !this.inked.has(p.key);
|
||||
if (how === 'instant' || !animate) {
|
||||
// Keyboard review and reduced motion: show the target lines as they are — no slide, no re-played ink.
|
||||
if (keys.join() === want.map(p => p.key).join() && how !== 'instant') return;
|
||||
for (const n of stage.querySelectorAll('.line.leaving')) n.remove();
|
||||
stage.replaceChildren(...want.map(p => this.line(p, p === page, name, false)));
|
||||
return;
|
||||
}
|
||||
if (keys.join() === want.map(p => p.key).join()) return;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
// Forward by one: the finished line lifts toward the "previous" slot while a small copy settles there; the
|
||||
// oldest drifts away. Both copies keep a fixed font size; only transform/opacity/filter move.
|
||||
if (how === 'advance' && prev && keys[keys.length - 1] === prev.key) {
|
||||
const first = new Map(have.map(n => [n, n.getBoundingClientRect()]));
|
||||
for (const n of have) if (n.dataset.key !== prev.key) this.leave(n, first.get(n)!, stageRect);
|
||||
const keep = have.find(n => n.dataset.key === prev.key)!, was = first.get(keep)!;
|
||||
const settled = this.line(prev, false, name, false), fresh = this.line(page, true, name, inkFor(page));
|
||||
this.pin(keep, was, stageRect);
|
||||
stage.append(settled, fresh);
|
||||
const to = settled.getBoundingClientRect();
|
||||
if (to.height) {
|
||||
const shrink = parseFloat(getComputedStyle(settled.querySelector('p')!).fontSize) / parseFloat(getComputedStyle(keep.querySelector('p')!).fontSize);
|
||||
this.leave(keep, was, stageRect, false, to.top - was.top, shrink);
|
||||
settled.animate([{ opacity: 0, transform: `translateY(${Math.round((was.top - to.top) * .35)}px)` }, { opacity: getComputedStyle(settled).opacity, transform: 'none' }], { duration: 620, easing: EASE });
|
||||
} else this.leave(keep, was, stageRect); // compact screens hide the previous line
|
||||
fresh.animate([{ opacity: 0, transform: 'translateY(16px)', filter: 'blur(8px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 680, easing: EASE });
|
||||
return;
|
||||
}
|
||||
// Jumps (review, new turn, resize) crossfade instead of sliding, so direction never lies.
|
||||
for (const n of have) this.leave(n, n.getBoundingClientRect(), stageRect, true);
|
||||
for (const p of want) {
|
||||
const el = this.line(p, p === page, name, inkFor(p)); stage.append(el);
|
||||
el.animate([{ opacity: 0, filter: 'blur(6px)' }, { opacity: 1, filter: 'blur(0)' }], { duration: 520, delay: 90, easing: EASE, fill: 'backwards' });
|
||||
}
|
||||
}
|
||||
// Status words beside the current speaker ("还在说", "未完成 · 已取消"); changing them never touches the text.
|
||||
mark(state: LineState) {
|
||||
const cur = this.stage.querySelector('.line.is-current:not(.leaving)'); if (!cur) return;
|
||||
const s = cur.querySelector('.state'); if (s && s.textContent !== state.text) s.textContent = state.text;
|
||||
cur.classList.toggle('waiting', state.waiting);
|
||||
}
|
||||
rename(name: string) {
|
||||
for (const n of this.stage.querySelectorAll<HTMLElement>('.line[data-role="companion"] .by b')) { n.textContent = name; n.title = name; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Message } from '../domain/model';
|
||||
// Reading logic carried over from the verified HEL-793/795 prototype (PR#8) and HEL-799 (approved 01a16810), moved
|
||||
// onto the real message flow: replies are shown one or two lines at a time and flow on by themselves.
|
||||
|
||||
// A message as the stage sees it: stored messages plus, at most, the reply still being written or cut off.
|
||||
export interface ShownMessage extends Message { live?: 'generating' | 'cancelled' }
|
||||
export interface Turn { key: string; items: ShownMessage[] }
|
||||
export interface Page { key: string; text: string; index: number; last: boolean; mi: number; message: ShownMessage }
|
||||
export interface Metrics { width: number; font: number; compact: boolean }
|
||||
|
||||
export const graphemes = (text: string): string[] => typeof Intl.Segmenter === 'function' ? [...new Intl.Segmenter('zh', { granularity: 'grapheme' }).segment(text)].map(s => s.segment) : Array.from(text);
|
||||
|
||||
// A turn starts with your message (or with something she says on her own). Server turn ids keep a reply with the
|
||||
// message it answers; older local records without ids fall back to their order.
|
||||
export function groupTurns(messages: ShownMessage[]): Turn[] {
|
||||
const turns: Turn[] = [];
|
||||
for (const m of messages) {
|
||||
const cur = turns[turns.length - 1], head = cur?.items[0];
|
||||
const fresh = !cur || m.role === 'user' || !!m.proactive || (!!m.turnId && !!head?.turnId && m.turnId !== head.turnId);
|
||||
if (fresh || !cur) turns.push({ key: m.turnId || m.id, items: [m] }); else cur.items.push(m);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
export function chunks(text: string, { width, font, compact }: Metrics): string[] {
|
||||
const perLine = Math.floor(Math.max(220, Math.min(width, 900) - 24) / font), max = Math.max(14, Math.min(60, Math.floor(perLine * (compact ? 1.6 : 2))));
|
||||
const out: string[] = []; let line = '', n = 0;
|
||||
for (const ch of graphemes(text)) {
|
||||
if (ch === '\n') { if (line.trim()) { out.push(line); line = ''; n = 0; } continue; }
|
||||
// Keep closing punctuation with its sentence; never produce an empty line.
|
||||
if (!line && out.length && /[。!?!?,、;:,;:」』))”]/u.test(ch)) { out[out.length - 1] += ch; continue; }
|
||||
line += ch; n++;
|
||||
// Short sentences travel together (as in the approved still); a line ends at a sentence once it has some weight.
|
||||
if (/[。!?!?]/u.test(ch) && n >= Math.min(22, max * .45)) { out.push(line); line = ''; n = 0; }
|
||||
else if (n >= max) {
|
||||
// Long text without punctuation still breaks, so nothing ever overflows the stage.
|
||||
const cut = Math.max(line.lastIndexOf(','), line.lastIndexOf('、'), line.lastIndexOf(';'));
|
||||
if (cut > max * .55) { out.push(line.slice(0, cut + 1)); line = line.slice(cut + 1); n = graphemes(line).length; } else { out.push(line); line = ''; n = 0; }
|
||||
}
|
||||
}
|
||||
if (line.trim()) out.push(line);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pagesOf(turn: Turn | undefined, metrics: Metrics): Page[] {
|
||||
const list: Page[] = [];
|
||||
if (!turn) return list;
|
||||
turn.items.forEach((message, mi) => {
|
||||
const parts = chunks(message.text, metrics);
|
||||
// The newest chunk of a reply still being written is held back (it may still grow or merge with the next
|
||||
// sentence), so a line never changes or re-animates after it has appeared.
|
||||
const held = message.live === 'generating' && parts.length ? 1 : 0;
|
||||
// Keys follow the turn and position, not the message id, so a streamed reply keeps its lines when it is stored.
|
||||
for (let index = 0; index < parts.length - held; index++) list.push({ key: `${turn.key}:${mi}:${index}`, text: parts[index], index, last: index === parts.length - 1, mi, message });
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
// Each line stays on screen in proportion to its length (about 2.4–8 s for her, shorter for yours).
|
||||
export function dwellFor(page: Page) {
|
||||
const n = graphemes(page.text).length;
|
||||
return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150));
|
||||
}
|
||||
|
||||
// How long the current line has stayed. It counts real time, not frames: busy frames (a new turn's crossfade,
|
||||
// ink, the room) never stretch a stay. One frame counts at most STALL, so a stall (sleep, debugger) is not
|
||||
// reading; a hidden page restarts the count from its return.
|
||||
export const STALL = 1000;
|
||||
export class ReadingClock {
|
||||
key = ''; dwell = 0; total = 0;
|
||||
private last = 0;
|
||||
// True once the line on screen has stayed its time while reading runs. Same key = same line: a growing stream
|
||||
// or a re-render never restarts it.
|
||||
tick(now: number, page: Page | undefined, running: boolean): boolean {
|
||||
const dt = this.last ? Math.min(STALL, Math.max(0, now - this.last)) : 0; this.last = now;
|
||||
if (!page) return false;
|
||||
if (page.key !== this.key) { this.key = page.key; this.dwell = 0; this.total = dwellFor(page); }
|
||||
if (running && this.dwell < this.total) this.dwell = Math.min(this.total, this.dwell + dt);
|
||||
return running && this.dwell >= this.total;
|
||||
}
|
||||
get progress() { return this.total ? this.dwell / this.total : 0; }
|
||||
// You moved or resumed: the line you land on gets its full time.
|
||||
restart() { this.dwell = 0; }
|
||||
forget() { this.key = ''; }
|
||||
// Page hidden: the gap until it is seen again is not reading time.
|
||||
pause() { this.last = 0; }
|
||||
}
|
||||
|
||||
// Reading position as a character offset inside one message, so a re-chunk (resize, keyboard) keeps your place.
|
||||
export interface Anchor { mi: number; offset: number }
|
||||
export function anchorOf(pages: Page[], cursor: number): Anchor | null {
|
||||
const page = pages[cursor];
|
||||
if (!page) return null;
|
||||
let offset = 0;
|
||||
for (const p of pages) { if (p === page) break; if (p.mi === page.mi) offset += p.text.length; }
|
||||
return { mi: page.mi, offset };
|
||||
}
|
||||
export function cursorAt(pages: Page[], anchor: Anchor | null): number {
|
||||
if (!anchor) return 0;
|
||||
let used = 0, best = -1;
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (pages[i].mi < anchor.mi) { best = i; continue; }
|
||||
if (pages[i].mi > anchor.mi) break;
|
||||
best = i;
|
||||
if (used + pages[i].text.length > anchor.offset) return i;
|
||||
used += pages[i].text.length;
|
||||
}
|
||||
return Math.max(0, best);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ export function freshState(identity: Identity): DemoState {
|
||||
// Loaded only through an explicit demo action; never a fabricated background life loop.
|
||||
export function sampleDays(state: DemoState, long = false): DemoState {
|
||||
const events: DemoState['events'] = [
|
||||
{ id: 'book', title: '读到了书里的那片海', text: '翻到讲海的那一页,忽然想起你说过喜欢安静的地方。', time: '10月21日 19:40', place: '卧室 · 窗边', kind: 'now', shared: true, memoryIds: ['habit'] },
|
||||
{ id: 'tea', title: '给自己泡了一杯热茶', text: '杯子捧在手里,夜晚就慢了下来。', time: '10月21日 16:20', place: '卧室', kind: 'done', shared: true, memoryIds: [] },
|
||||
{ id: 'rain', title: '听了一下午的雨', text: '雨滴在玻璃上跑得一快一慢,很像一段没有歌词的曲子。', time: '10月20日 15:30', place: '卧室 · 窗边', kind: 'done', shared: true, memoryIds: ['rain-memory'] },
|
||||
{ id: 'book', title: '读到了书里的那片海', text: '翻到讲海的那一页,忽然想起你说过喜欢安静的地方。', time: '10月21日 19:40', place: '卧室 · 窗边', kind: 'now', shared: true, told: '19:46 告诉你', memoryIds: ['habit'] },
|
||||
{ id: 'tea', title: '给自己泡了一杯热茶', text: '杯子捧在手里,夜晚就慢了下来。', time: '10月21日 16:20', place: '卧室', kind: 'done', shared: true, told: '16:40 告诉你', memoryIds: [] },
|
||||
{ id: 'rain', title: '听了一下午的雨', text: '雨滴在玻璃上跑得一快一慢,很像一段没有歌词的曲子。', time: '10月20日 15:30', place: '卧室 · 窗边', kind: 'done', shared: true, told: '17:05 留言给你', memoryIds: ['rain-memory'] },
|
||||
{ id: 'secret', title: '未分享的秘密散步', text: '这段未分享的生活不能出现在任何视图里。', time: '10月21日 13:00', place: '私密地点', kind: 'done', shared: false, memoryIds: ['secret-memory'] },
|
||||
{ id: 'plan', title: '明天想读完这一章', text: '如果精神好,就再读一会儿。计划也可能会变。', time: '10月22日 20:00', place: '卧室', kind: 'plan', shared: true, memoryIds: [] }
|
||||
{ id: 'plan', title: '明天想读完这一章', text: '如果精神好,就再读一会儿。计划也可能会变。', time: '10月22日 20:00', place: '卧室', kind: 'plan', shared: true, told: '10月21日 21:10 告诉你', memoryIds: [] }
|
||||
];
|
||||
if (long)
|
||||
for (let i = 0; i < 40; i++)
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface LifeEvent {
|
||||
place: string;
|
||||
kind: 'done' | 'now' | 'plan';
|
||||
shared: boolean;
|
||||
// When and how she told you ("19:34 告诉你", "你们一起经历的"); only ever present on records she has shared.
|
||||
told?: string;
|
||||
memoryIds: string[];
|
||||
}
|
||||
export interface Memory {
|
||||
@@ -59,6 +61,8 @@ export interface SceneDesc {
|
||||
outfit: string;
|
||||
}
|
||||
export type ServerEvent = {
|
||||
t: 'chat.error'; epoch: number; turn_id: string; phi: string; retryable: boolean;
|
||||
} | {
|
||||
t: 'chat.delta';
|
||||
epoch: number;
|
||||
turn_id: string;
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DemoState } from '../domain/model';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { DemoState, LifeEvent, Memory } from '../domain/model';
|
||||
import { publicView } from '../domain/privacy';
|
||||
import { Button, Icon } from '../shared/ui';
|
||||
export function LifePanel({ state, open, onClose, onChat }: {
|
||||
import { delay } from '../adapters/demo';
|
||||
// HEL-815 page design: a page lying on your side of the table while the room steps back. Only what she has already
|
||||
// told you is ever rendered (AM-001), so there is no "not told yet" label, filter or switch anywhere; any link the
|
||||
// client cannot resolve opens one neutral "can't open" state that reveals nothing about whether the record exists.
|
||||
const KIND = { plan: '打算', now: '正在', done: '已经发生' } as const;
|
||||
const MEMORY_GROUPS = ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'];
|
||||
const phone = () => matchMedia('(max-width:720px)').matches;
|
||||
type Load = 'ok' | 'loading' | 'error';
|
||||
export function LifePanel({ state, open, onClose, onChat, onSettings, now = '', fail = false, real = false }: {
|
||||
real?: boolean;
|
||||
state: DemoState;
|
||||
open: boolean;
|
||||
now?: string;
|
||||
fail?: boolean;
|
||||
onClose: () => void;
|
||||
onChat: (id: string) => void;
|
||||
onSettings?: () => void;
|
||||
}) {
|
||||
const view = publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
const view = publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18), [load, setLoad] = useState<Load>('ok'), [more, setMore] = useState(false);
|
||||
const list = useRef<HTMLDivElement>(null), detail = useRef<HTMLElement>(null), scroll = useRef<Record<string, number>>({}), wasOpen = useRef(false), request = useRef<AbortController | null>(null);
|
||||
useEffect(() => { if (open) {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
const id = params.get('event');
|
||||
@@ -18,9 +30,73 @@ export function LifePanel({ state, open, onClose, onChat }: {
|
||||
setSelection(id);
|
||||
}
|
||||
} }, [open]);
|
||||
// Demo only: the failure switch shows how a list that could not be read looks; nothing real is requested.
|
||||
useEffect(() => { if (open && !wasOpen.current && !real) setLoad(fail ? 'error' : 'ok'); wasOpen.current = open; if (!open) { request.current?.abort(); setMore(false); } }, [open, fail, real]);
|
||||
useEffect(() => () => request.current?.abort(), []);
|
||||
async function retry() { request.current?.abort(); const c = new AbortController(); request.current = c; setLoad('loading'); try { await delay(600, c.signal); setLoad(fail ? 'error' : 'ok'); } catch { /* Closed or replaced. */ } }
|
||||
async function earlier() { request.current?.abort(); const c = new AbortController(); request.current = c; setMore(true); try { await delay(real ? 0 : 450, c.signal); setLimit(x => x + 12); } catch { /* Closed. */ } finally { if (!c.signal.aborted) setMore(false); } }
|
||||
// Each tab keeps its own reading position; switching back lands where you left it.
|
||||
// A hidden panel is display:none, which drops its scroll offset; reopening (e.g. "结束交谈 · 回到日子") puts it back.
|
||||
useLayoutEffect(() => { if (list.current && open) list.current.scrollTop = scroll.current[tab] || 0; }, [tab, open]);
|
||||
useLayoutEffect(() => { if (detail.current) detail.current.scrollTop = 0; }, [selection, tab]);
|
||||
const switchTab = (t: string) => { if (list.current) scroll.current[tab] = list.current.scrollTop; setTab(t); setSelection(''); };
|
||||
const select = (id: string, t = tab) => { if (t !== tab) switchTab(t); setSelection(id); };
|
||||
// Esc on a phone closes the pushed detail first; the app then closes the panel on the next press.
|
||||
useEffect(() => { if (!open || !selection) return; const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || !phone() || document.querySelector('.modal.open')) return; e.stopImmediatePropagation(); setSelection(''); }; window.addEventListener('keydown', key, true); return () => window.removeEventListener('keydown', key, true); }, [open, selection]);
|
||||
const ordered = [...view.events.filter(e => e.kind === 'plan'), ...view.events.filter(e => e.kind !== 'plan')], shown = ordered.slice(0, limit);
|
||||
const day = (e: LifeEvent) => e.kind === 'plan' ? '她说过的打算' : e.time.split(' ')[0];
|
||||
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>;
|
||||
const name = view.identity.name, demoTag = !real && <span className="tag-demo">演示数据</span>;
|
||||
const lines = (e: LifeEvent) => view.messages.filter(m => m.lifeIds.includes(e.id));
|
||||
const said = (who: string, text: string) => <blockquote className="quote"><span className="who"><i/>{who}</span>{text}</blockquote>;
|
||||
const linkCard = (key: string, title: string, sub: string, go: () => void) => <button className="link-card" key={key} aria-describedby={`lc-${key}`} onClick={go}><span><b>{title}</b><small id={`lc-${key}`}>{sub}</small></span><Icon name="next"/></button>;
|
||||
function eventView(e: LifeEvent) {
|
||||
const mems = e.memoryIds.map(id => view.memories.find(m => m.id === id)).filter((m): m is Memory => !!m), chat = lines(e);
|
||||
return <>
|
||||
<div className="d-kicker"><i className={`k ${e.kind}`}/>{KIND[e.kind]} · {e.time} · {e.place}</div>
|
||||
<h3 className="d-title">{e.title}</h3>
|
||||
<p className="d-told">{e.told ? `她在 ${e.told}` : '她讲给你听过的事'}{e.kind === 'plan' ? ' · 计划可能会变' : ''}</p>
|
||||
{said(name, e.text)}
|
||||
{mems.length > 0 && <><div className="d-sec">和这件事有关的回忆</div><div className="link-list">{mems.map(m => linkCard(m.id, m.title, m.source, () => select(m.id, 'memory')))}</div></>}
|
||||
<div className="d-sec">那时候的聊天</div>
|
||||
{chat.length ? <div className="said-then">{chat.map(m => <p key={m.id} data-role={m.role}><span className="who"><i/>{m.role === 'user' ? '你' : name}</span>{m.text}</p>)}</div> : <p className="d-dim">那时候没有留下聊天记录。</p>}
|
||||
<Button onClick={() => onChat(e.id)}>在交谈里看这段<Icon name="next"/></Button>
|
||||
<div className="d-foot">{demoTag}这是模拟生活里的一条记录,不是真实发生的事。画面只按记录选片,不会反过来改写她的生活。</div>
|
||||
</>;
|
||||
}
|
||||
function memoryView(m: Memory) {
|
||||
const from = m.lifeIds.map(id => view.events.find(e => e.id === id)).filter((e): e is LifeEvent => !!e);
|
||||
return <>
|
||||
<div className="d-kicker"><i className={`k ${m.group === '她的成长' ? 'grow' : 'mem'}`}/>{m.group} · {m.source}</div>
|
||||
<h3 className="d-title">{m.title}</h3>
|
||||
<p className="d-told">她是这样记得的</p>
|
||||
{said(name, m.text)}
|
||||
{m.group === '她的成长' && <p className="d-note">这是后来慢慢有的。成长单独记录,不改写她最初的底色。{onSettings && <button className="btn-text" onClick={onSettings}>在设置里看底色与成长<Icon name="next"/></button>}</p>}
|
||||
<div className="d-sec">来自这些日子</div>
|
||||
{from.length ? <div className="link-list">{from.map(e => linkCard(e.id, e.title, e.time, () => select(e.id, 'life')))}</div> : <p className="d-dim">这条回忆来自你们的日常聊天,没有对应的某一天。</p>}
|
||||
<div className="d-foot">{demoTag}回忆由她在相处中整理,只引用她讲过的日子。</div>
|
||||
</>;
|
||||
}
|
||||
const failed = <div className="state-card" role="alert"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这几天的事一时没翻出来。</p><p>不是你的问题,记录也没有丢。</p><Button onClick={() => void retry()}>再试一次</Button></div>;
|
||||
const skeleton = (n: number) => <div className="skel" aria-hidden="true">{Array.from({ length: n }, (_, i) => <i key={i} style={{ width: `${[78, 62, 70, 54, 66][i % 5]}%` }}/>)}</div>;
|
||||
function lifeList() {
|
||||
if (load === 'loading') return <><p className="grp" role="status">正在翻她的日子…</p>{skeleton(6)}</>;
|
||||
if (load === 'error') return failed;
|
||||
if (!view.events.length) return <div className="empty">{real ? '还没有可展示的生活记录' : '她今晚刚住进来,还没有发生什么。'}<small>{real ? '生活后台尚未接通,不用示例经历填充。你们说过的话可在交谈的完整记录中查看。' : '等她跟你讲起,日子会一条条留在这里。演示资料可以从「演示场景」载入。'}</small></div>;
|
||||
return <>{shown.map((e, i) => <div key={e.id}>{(i === 0 || day(shown[i - 1]) !== day(e)) && <div className="grp">{day(e)}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.kind === 'plan' ? e.time : e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sn">{e.text}</span>{(e.kind !== 'done' || e.memoryIds.length > 0) && <span className="sub">{e.kind === 'plan' && <span className="tag">计划可能会变</span>}{e.kind === 'now' && <span className="tag accent">正在</span>}{e.memoryIds.length > 0 && <span className="tag">和你有关</span>}</span>}</span></button></div>)}
|
||||
{more ? <p className="more-busy" role="status"><i className="spin"/>正在翻更早的日子…</p> : limit < ordered.length ? <div className="more"><Button onClick={() => void earlier()}>看更早的日子</Button></div> : <p className="list-end">再往前,就是她刚住进来的那天了。</p>}</>;
|
||||
}
|
||||
function memoryList() {
|
||||
if (load === 'loading') return skeleton(5);
|
||||
if (load === 'error') return failed;
|
||||
if (!real && !view.memories.length) return <div className="empty">还没有共同的回忆。<small>聊着聊着就会有了。</small></div>;
|
||||
return MEMORY_GROUPS.map(group => { const items = view.memories.filter(m => m.group === group); return <div key={group}><div className="grp">{group}{!real && <span>{items.length}</span>}</div>{real && <div className="empty">暂无可核实的回忆<small>长期记忆尚未接通;文字聊天记录独立保留。</small></div>}{items.map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}><i className={`k ${group === '她的成长' ? 'grow' : 'mem'}`}/><span><small className="src">{m.source}</small><b>{m.title}</b><span className="sn">{m.text}</span></span></button>)}</div>; });
|
||||
}
|
||||
const blank = load !== 'ok' || (tab === 'life' ? !view.events.length : !view.memories.length);
|
||||
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><div className="ttl"><small className="kicker"><span className="k-wide">日子 · {real ? '后台待接入' : '演示数据'}</span><span className="k-near"><i/>她此刻 · {now || '安静待着'}</span></small><h2>{tab === 'life' ? '她的日子' : '我们的回忆'}</h2></div><span className="grow"/>{real ? <span className="tag-demo">后台待接入</span> : demoTag}<button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header>
|
||||
<div className="pane-tabs"><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => switchTab(t)}>{l}</button>)}</div><span className="tabnote">{tab === 'life' ? '只有她讲给你听过的事,才会出现在这里' : '她记得的你们,和她后来慢慢有的'}</span></div>
|
||||
<div className="cols"><div className="list" ref={list} onScroll={e => { scroll.current[tab] = e.currentTarget.scrollTop; }}>{tab === 'life' ? lifeList() : memoryList()}</div>
|
||||
<article className="detail" ref={detail}><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>
|
||||
{event ? <div className="d-in" key={`e-${event.id}`}>{eventView(event)}</div> : memory ? <div className="d-in" key={`m-${memory.id}`}>{memoryView(memory)}</div> : selection ? <div className="state-card"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这一条现在打不开。</p><p>可能是链接已经过期。</p><Button onClick={() => setSelection('')}>回到{tab === 'life' ? '她的日子' : '我们的回忆'}</Button></div> : load === 'loading' ? skeleton(5) : <div className="d-empty"><div><Icon name="book"/>{real ? '后台接通后,她讲给你听过的事会出现在左侧;这里保留日子详情、回忆与聊天的关联。' : blank ? (tab === 'life' ? '选一天,看她那天跟你讲过的事。' : '选一条,看她怎么记得。') : tab === 'life' ? '选一件小事,慢慢读。' : '选一条,看她怎么记得。'}</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>);
|
||||
|
||||