Compare commits

..
2 Commits
Author SHA1 Message Date
白栖知andmultica-agent db571ba204 HEL-783: repair readable dialogue, birth explanations and complete real page layouts
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 17:55:05 +08:00
白栖知andmultica-agent f6614d4bf9 HEL-780: connect persistent companion and real streaming text chat
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 16:25:35 +08:00
108 changed files with 2789 additions and 5920 deletions
+8 -11
View File
@@ -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
-2
View File
@@ -11,8 +11,6 @@
# 二进制
*.wav binary
*.mp3 binary
*.mp4 binary
*.webp binary
*.png binary
*.jpg binary
*.onnx binary
+2
View File
@@ -48,3 +48,5 @@ models/
*.swp
.DS_Store
Thumbs.db
.venv311/
-37
View File
@@ -1,37 +0,0 @@
# CyberGril 项目必读规则
白栖知已于主卡 HEL-697 评论 `01a1635b-b47c-79ed-ac66-9408c7905af9` 批准需求继承办法。
## 开始任何工作前
1. 读取 `docs/requirements/README.md`、`current.md`、`coverage.md`,以及本次相关的原始来源。完整需求不能被任务卡摘录替代。记录所用版本和读取时的仓库提交。
2. 项目现行需求入口优先于旧 `docs/SPEC.md`、历史设计稿、样机、代码现状与任务卡中的旧指令。明确的用户新决定才能覆盖旧决定,并须更新需求变更记录。遇到冲突先核对原话,无法解决才向总管报告具体问题,不擅自降低要求。
3. 所有 CyberGril 任务应归属 CyberGril 项目,并包含需求入口、版本、关联要求编号、交付与验收、未完成项及后续承接、需求变更来源。现有历史卡的迁移不授权重启已停止的试验或重新执行已交付工作。
## 施工和交接
- 白栖知于主卡评论 `01a163a5-e2a7-7ac8-afdb-991868e721bc` 明确:本项目整体视觉由视觉总监接手设计与实际落地,职责不止审核把关。施工员无法实现对应效果时,由视觉总监直接编写相关代码。本轮对话及沉浸界面交由视觉总监亲自负责;总管负责协调、核对需求和交付,不再以自己的视觉原型替代它接手。此项是CyberGril项目分工,不修改队员在其他项目的全局职责、模型或认证。
- 使用 `docs/requirements/task-handoff-template.md`,写出本次交付及完整需求的关联;完整当前需求始终有效。阶段不做的内容列明承接,不删除需求。
- 需求、技术方案、阶段范围、实现状态、用户验收分开记录。不得把智能体建议、技术困难、模型登录失败或某阶段通过当作修改需求的授权。
- 不用“当前一两句”以外的长消息滚动主界面替代已确认的交流体验。长回复需完整可读,具体分句等方案先通过动态小样验证。
- 不用固定模板长文冒充 CG-014 的模型诠释自由、CG-015 的自主初始习惯与成长。出生底色不变;后天成长另记,不得因 UI 修复重算已有伴侣。
- 演示数据与真实能力严格区分。未分享生活不得借列表、搜索、摘要或旧链接泄露;真实服务须在响应前裁剪,前端过滤不是权限控制。
- 保留已有伴侣、名字、生日、聊天与记忆;写入测试使用隔离数据。用户认可界面小样前不替换现有正式界面。当前产品 PR#4 未获合并授权。
## 修改需求和审核
修改 `baseline.json` 中的要求时,须在 `changes.json` 和 `changes.md` 中记录白栖知明确批准的原话来源、旧要求、新要求及影响;原始来源只增补,不篡改。先核实来源作者和语义,不能用智能体确认代替。
交付前执行:
```bash
python3 scripts/check_requirements.py --base-ref origin/main
python3 scripts/check_requirements.py --base-ref origin/main --handoff <本次交接JSON文件>
```
检查脚本只发现结构缺项、来源损坏和编号失配。审核人仍须逐项核对原意、实际画面及动态过程;未实际验证真机或 Safari 就明确注明。前端演示通过、工程测试通过、用户阶段验收和整项目完成是不同事实。
比较前先获取真实目标分支;若本卡目标不是 main,使用实际目标引用。初次引入基准时目标没有基准文件,由独立审核核对整包来源;以后需求有变必须升版、追加新的用户授权,历史来源和变更记录只增不改。项目尚未配置自动CI合并门禁,当前由交付与审核执行本检查,不能声称平台已经强制拦截合并。
主任务 HEL-697 持续追踪完整功能,不因局部通过而归档。平台交付路由遵守当次运行指令,总管负责向白栖知汇报。
+25 -3
View File
@@ -4,13 +4,35 @@
不是聊天机器人。区别在于:**它在你不说话的时候也在运行**。
设计、施工与审核先读 [完整需求入口](docs/requirements/README.md) 和 [项目必读规则](AGENTS.md)。后续逐项确认的需求优先于本仓库早期构想;演示通过不代表整个项目完成。
---
## 先看一眼
<video src="docs/promo.mp4" controls width="100%"></video>
![晚晚 · 介绍动画](docs/preview.webp)
[**▶ 打开完整动画**](docs/intro.html) — 22 秒,纯代码生成,无任何外部资源
```
docs/intro.html 单文件动画,双击即播
docs/preview.webp 渲染好的预览(800×450 / 10fps / 22s / 0.5MB)
tools/render_preview.py 重新渲染、换分辨率、导出静帧
```
动画里有一条贯穿的主线:**强调色 = 当前效价 `v`**。
情绪低落时画面整体转冷,而不是靠字幕告诉你"她现在很难过"。
| 幕 | 时间 | 讲什么 |
|---|---|---|
| 膜 | 0–6s | 物质撞在膜上弹回,信息穿过去 |
| 心跳 | 6–12s | 没有输入时它也在跳;难过恢复得慢 |
| 记忆 | 12–17s | 记得的会沉下去,但沉不到底 |
| 名字 | 17–22s | — |
四张静帧:`docs/01-membrane.png` `02-heartbeat.png` `03-memory.png` `04-title.png`
动画本身的技术取舍:时间轴是纯函数 `render(t)`,不做 RAF 累加。
所以任意一帧都能独立渲染——这也是 `render_preview.py` 能逐帧截图、
而不需要真实录屏的原因。
---
+8 -8
View File
@@ -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
+28
View File
@@ -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
+12
View File
@@ -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
}
+2 -2
View File
@@ -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
}
BIN
View File
Binary file not shown.
+15
View File
@@ -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是当前约束,后续扩展需独立设计。
+64
View File
@@ -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;它不是部署依赖。
+7
View File
@@ -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和共享模型服务,交总工独立审核。
+20
View File
@@ -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。当前单用户内网、单进程;近轮上下文与进程内预算不是长期记忆或持久费用账本。
+35
View File
@@ -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": []
}
+35
View File
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

-36
View File
@@ -1,36 +0,0 @@
# CyberGril 需求入口
当前完整基准:**v1.8**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
| 文件 | 用途 |
| --- | --- |
| [current.md](current.md) | 59条完整有效要求:原31条产品决定、13条视觉、3条补充、6条用户整改、5条执行约束、1条交付边界;另列未定项 |
| [baseline.json](baseline.json) | 同一份要求的结构化编号、边界和来源 |
| [coverage.md](coverage.md) / [coverage.json](coverage.json) | 实现与验收状态,未完成项目继续保留 |
| [changes.md](changes.md) / [changes.json](changes.json) | 后续修改的用户授权及影响 |
| [task-handoff-template.md](task-handoff-template.md) | 每张卡的必填交接项 |
| [sources/](sources/) | 原始v1.6、完整初轮和视觉访谈、后续裁定、获批设计及分批方案 |
| [source-manifest.json](source-manifest.json) | 来源文件校验值,防止历史来源被静默改写 |
| [implementation-verification.md](implementation-verification.md) | 本次实际写入、旧卡迁移与正反向验证记录 |
`sources/comment-evidence.json` 保存初版125条来源评论(保留原样)的身份、时间、原话和父评论;正文中的编号可追溯回主卡。原始初轮访谈与完整视觉访谈均用分页取齐,后续材料按关联决定保存。其余来源文档保留原样;其中旧进度、技术推荐、旧待定项不能自动成为当前事实。已经被后续明确决定覆盖的内容,见 current.md 和 changes.md。
原仓库 `docs/SPEC.md` 是初始构想,不能盖过后续逐项确认:例如旧VRM路线、允许经历改写出生核心等,均不能用于覆盖当前要求。
## 当前返工与交付边界
用户对当前前端的**对话显示效果**和**整体沉浸界面**不满意。本轮先完善需求约束,再制作独立样图及可操作动态小样;两主题、电脑/手机、长句与身份、历史展开、交流开合和空间过渡都须能查看。小样未认可前不替换正式界面。
完整产品仍需按获批批次接入生活、记忆、主动联系、语音、天气/素材/备份及有条件的视觉观察。R1已有档案与文字能力不能视为全部要求完成。嘴型研究独立,不阻塞完整界面设计。
## 检查与人工审核
先获取实际目标分支,再运行 `python3 scripts/check_requirements.py --base-ref origin/main`(实际目标不是 main 时替换引用);有交接JSON时加 `--handoff <文件>`。检查会对照目标阻止原地改需求却不升版、不追加新用户授权,且保留历史来源和变更记录。初次引入基准时由独立审核核对整包来源。此检查不证明画面好看、评论语义构成授权或用户已经满意。人工审核必须阅读原话,体验验收仍由白栖知决定。
当前没有配置仓库自动CI合并门禁;上述命令作为交付和独立审核的必做项,不声称平台已强制拦截所有修改。
v1.8新增来源见 `sources/comment-evidence-visual-role-20261022.json`,最新分工见GOV-005。
后续以该入口所记录的最新版本为准,不把v1.7永久钉死为永不可新增的文档。每次新版本必须记录用户明确裁定,保留旧版来源与覆盖关系。
-751
View File
@@ -1,751 +0,0 @@
{
"schema_version": 1,
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.8按用户明确指令新增视觉总监接手及必要代码职责,原58项和已批准边界继续有效,不证明功能已实现或已验收。",
"requirements": [
{
"id": "CG-001",
"category": "产品",
"topic": "需求对齐与开工边界",
"conclusion": "先逐项问清需求,形成统一记录和方案;对单项建议的同意,仅用于确认该项,不能扩大成全部功能的开工授权。",
"boundary": "",
"source_comment_ids": [
"01a15722-86fc-7c70-9409-d2e9b8cb041f",
"01a157ab-209c-7c8f-956c-40ebb3340f4e"
]
},
{
"id": "CG-002",
"category": "产品",
"topic": "写实视觉风格",
"conclusion": "人物采用照片、电影中真人的写实质感。首版日常画面采用CG-029的人物与房间成品视频,按活动、天气和时段选片;不再要求浏览器每帧用独立人物透明层与房间合成。",
"boundary": "原仓库的VRM/建模默认路线和Alina候选,不作为当前已认可的外观方向。 真人照片般的效果仍为首选;新增条件备选见CG-027,不把讨论二维视为立即放弃真人路线。 独立控制的产品目标由统一状态和对应素材组合实现;物理分层实现要求已由CG-029覆盖。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a15717-b34f-72ac-939f-d81c90f58920",
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a158c5-a25f-742b-975d-6af1ce8ff6b6",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-003",
"category": "产品",
"topic": "动作与交谈的混合路线",
"conclusion": "预生成场景和同一人物的日常动作、服装素材;根据实际生活状态选择、衔接。交谈实时产生内容与语音,口型和局部反应需实际验证;后台可逐步补充素材。",
"boundary": "首版不承诺即时生成任意新全身动作。现场自由生成仍是用户的理想方向,属于后续增强目标。重生成任务应给交谈让出资源。 当前首版主验证路线明确为CG-029;具体交谈驱动器和真实表现仍待测试。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-004",
"category": "产品",
"topic": "交流镜头",
"conclusion": "日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。",
"boundary": "具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730"
]
},
{
"id": "CG-005",
"category": "产品",
"topic": "工程外观与自备素材",
"conclusion": "工程阶段由总管安排选择人物外观;后续支持用户按标准自行生成图片或视频,替换人物外观。该能力从首版设计时预留。",
"boundary": "具体素材包规范和导入流程尚未定稿;一张图片本身不等于已有全部动态动作。最终外观仍经过样图验收。 工程外观须遵循CG-025的新要求;素材替换能力与仅换外观、不重建伴侣的CG-006保持不变。 首轮工程素材由小队制作,不以用户先备齐视频为前提,见CG-030。",
"source_comment_ids": [
"01a1572c-18c8-7872-94b0-c05c1b24b5c7",
"01a1572d-2c55-777c-a63f-a7c74c31c070",
"01a1573f-aedf-7d08-91a7-482b2a94f782",
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-006",
"category": "产品",
"topic": "换外观保留同一个她",
"conclusion": "换素材仅改变外观,保留原有八字底色、性格积累、记忆和关系;创建新人物与换外观是不同概念。",
"boundary": "首版随后收缩为仅一个伴侣;这里不授权实现多个伴侣或并行运行。",
"source_comment_ids": [
"01a1573f-aedf-7d08-91a7-482b2a94f782",
"01a15749-7530-71b4-9157-b9fa7011254b"
]
},
{
"id": "CG-007",
"category": "产品",
"topic": "首版仅一个伴侣",
"conclusion": "首版只能创建、使用一个伴侣,暂不做多个伴侣及切换;支持数据备份。",
"boundary": "覆盖此前已讨论并同意的多伴侣保留、切换方向;单伴侣下的清空或重新创建规则未定。",
"source_comment_ids": [
"01a15749-7530-71b4-9157-b9fa7011254b"
]
},
{
"id": "CG-008",
"category": "产品",
"topic": "后台生活与连续性",
"conclusion": "用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续。画面、对话、记忆应对应同一份生活状态。",
"boundary": "没有承诺服务器关机仍能运行;停机后的恢复、补算规则另待设计。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a15749-7530-71b4-9157-b9fa7011254b",
"01a15751-ded1-7328-80ae-39967d2b3821"
]
},
{
"id": "CG-009",
"category": "产品",
"topic": "备份与恢复",
"conclusion": "每天自动备份;支持随时手动完整导出到自己的电脑,并从备份恢复同一位伴侣。覆盖八字、性格资料、对话、记忆、关系、生活进度;手动完整导出带上所用人物、场景素材及恢复所需设定。",
"boundary": "自动副本留在服务器,首版暂不增加云盘接入;保留份数、空间策略、恢复校验等工程细节尚未定稿。",
"source_comment_ids": [
"01a15749-fc88-7295-8350-dccfb0168b70",
"01a1574f-9e0e-7bf4-b158-3f88a4c1988a"
]
},
{
"id": "CG-010",
"category": "产品",
"topic": "连续语音与文字",
"conclusion": "用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。",
"source_comment_ids": [
"01a15750-2cf9-7f23-9269-0f150d2c5e61",
"01a15750-e061-733b-9d80-38459d306b97"
]
},
{
"id": "CG-011",
"category": "产品",
"topic": "主动联系范围",
"conclusion": "首版只在网页内主动联系:语音开启时可以主动开口,未开启时留文字消息;关闭网页后保留消息,回来再看。主动分享应有自己的生活经历作为依据。",
"boundary": "首版不接手机推送或其他聊天软件;具体频率可在方案中结合原仓库参数制定。",
"source_comment_ids": [
"01a15751-670e-7187-b94a-cea70154df3b",
"01a15751-ded1-7328-80ae-39967d2b3821"
]
},
{
"id": "CG-012",
"category": "产品",
"topic": "生辰创建方式",
"conclusion": "使用她自己的出生年月日和时辰;创建时可自动生成,也允许用户在确认前手动指定。确认后作为人格底色依据,换外观不会改变。",
"boundary": "",
"source_comment_ids": [
"01a15752-4cfe-7cd7-a4a3-8a8f8143f943",
"01a15754-b879-73b6-b367-b105898d7107"
]
},
{
"id": "CG-013",
"category": "产品",
"topic": "八字底色不可编辑",
"conclusion": "八字带来的性格底色是系统级资料,创建后只读;每轮重新提供给模型并预留上下文空间。可用结构化指标配合简短解释表达,关系提示词不得覆盖底色。",
"boundary": "程序保证资料不被改写;模型能否长期遵守仍需验证,不能把写成数字或系统提示词等同于已解决漂移。",
"source_comment_ids": [
"01a15771-3a93-7518-9b23-489509520d77",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-014",
"category": "产品",
"topic": "性格诠释空间",
"conclusion": "性格描述保留适度抽象和解释空间,让模型形成有延续性的个性化诠释;同样底色允许不同表现与成长,不给每次回答规定唯一标准答案。",
"boundary": "不能将本项误记成‘用户可修改八字底色’,也不能将随机、无缘由地反复变换人设当作个性。",
"source_comment_ids": [
"01a15763-1fc5-7f36-bf43-722e8755703c",
"01a15763-e729-7aa2-a752-7b95fb69372c",
"01a15771-3a93-7518-9b23-489509520d77",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-015",
"category": "产品",
"topic": "初始习惯与后天成长",
"conclusion": "创建时带少量模型自主形成的喜好和小习惯;以后通过生活、相处逐渐丰富。成长经历单独记录,保留最初的八字底色。",
"boundary": "原仓库重大经历可微调核心的旧例外,按此讨论改为另存成长层,不据旧文直接修改出生底色。",
"source_comment_ids": [
"01a15763-e729-7aa2-a752-7b95fb69372c",
"01a15765-2fa8-7494-b0b8-7a0054500556",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-016",
"category": "产品",
"topic": "关系可配置、可调整",
"conclusion": "朋友或恋人、称呼、相处边界等关系设定通过独立提示词表达,相处过程中也可明确修改;保留原有记忆和互动形成的信任,不改写过去。",
"boundary": "没有确定默认必须初识或必须情侣;通用人物提示词编辑器并未获确认,允许修改的范围是关系设定。",
"source_comment_ids": [
"01a1576a-5ffe-73fb-b6f1-2aeee98a5782",
"01a15771-3a93-7518-9b23-489509520d77",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-017",
"category": "产品",
"topic": "首版场景范围",
"conclusion": "先集中做好一个高层卧室,以成品视频表现对应天气、昼夜、光线以及少量服装和日常动作;由统一状态按条件选片,保留后续扩展入口。",
"boundary": "其他房间和外出场景后续扩展;素材数量先结合试片结果定,不将早期示例数量作为现行承诺。 当前唯一房间明确为CG-026所述高层卧室。 浏览器人物/环境物理分层要求由CG-029覆盖;活动、天气等逻辑状态仍独立管理,不用选片篡改生活记录。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a15775-89f4-7ec8-89eb-b3554fb05d19",
"01a1577a-e852-7551-ace8-0b2028db9ebb",
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-018",
"category": "产品",
"topic": "电脑优先、兼顾手机",
"conclusion": "主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。",
"boundary": "",
"source_comment_ids": [
"01a1577b-b65c-787a-b436-b0a74c38fea4",
"01a1577c-d10f-7513-8f58-62b3b387240a"
]
},
{
"id": "CG-019",
"category": "产品",
"topic": "素材工具与新增费用",
"conclusion": "首版先使用已有工具和本地机器制作图片、视频素材,暂不新增付费云端生成接口;若实测不够,再列明方案效果和费用,由用户决定。",
"boundary": "此项是素材制作的费用边界,不能误解为禁止使用已提供的外接LLM或用户自行部署的小模型服务。",
"source_comment_ids": [
"01a1577d-7203-7b2b-bf24-f880f56750cf",
"01a1577e-96f6-72c7-b66b-6c43779fc5f0"
]
},
{
"id": "CG-020",
"category": "产品",
"topic": "首版内网访问",
"conclusion": "首版在部署服务器所在内网使用,电脑和手机在该内网访问。",
"boundary": "外出访问后续扩展。摄像头和麦克风所需可信访问方式仍要在工程方案中落实。",
"source_comment_ids": [
"01a1577f-0707-7ac6-9c0f-aef2e73ba108",
"01a15780-0fd0-7438-beee-4947f4c3c928"
]
},
{
"id": "CG-021",
"category": "产品",
"topic": "按任务选模型、视觉外接",
"conclusion": "该用小模型的地方用小模型;状态和时间推进用程序,记忆等后台任务可用小模型,主模型负责主对话和较复杂理解。摄像头用小视觉模型前处理,部署由用户在其他设备负责,项目通过接口接入;用户另有4080显卡。",
"boundary": "平时把带时间和不确定项的观察摘要交给主模型,必要时保留原图复核途径。具体小模型、部署参数、接口地址和性能尚未定版。",
"source_comment_ids": [
"01a157a5-1668-723d-af23-732a612b30b9",
"01a157a6-b158-7ba5-8a3a-1d19920890ac",
"01a157a7-c9f2-7133-8340-9b497f12f7e7"
]
},
{
"id": "CG-022",
"category": "产品",
"topic": "首版摄像头范围",
"conclusion": "加入可开关的视觉观察,先聚焦人在不在、明显物品、房间环境变化;快速挥手、点头等连续动作放到后续。",
"boundary": "观察有时效,不能把旧画面继续当作眼前事实。刷新间隔和延迟以小视觉服务实测为准,5—10秒只是先前建议,不是固定性能保证。",
"source_comment_ids": [
"01a157a6-b158-7ba5-8a3a-1d19920890ac",
"01a157a7-c9f2-7133-8340-9b497f12f7e7"
]
},
{
"id": "CG-023",
"category": "产品",
"topic": "天气、昼夜与作息",
"conclusion": "用户手动选择一个真实城市,房间天气、昼夜和生活作息跟随当地节奏;城市可自行更换,无需每次打开重新定位。",
"boundary": "具体城市、天气来源及跨城市切换细节仍待设置或工程设计。",
"source_comment_ids": [
"01a157a8-510f-7d3d-9880-670dc9ae4d22",
"01a157a8-aeab-7611-9695-74afbc2dec02"
]
},
{
"id": "CG-024",
"category": "产品",
"topic": "第一道验收及后续顺序",
"conclusion": "先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。",
"boundary": "小样通过仅表示该阶段通过,不等于记忆、后台生活、摄像头或备份已全部实现和通过验收。 用户本次确认v2静态方向并授权继续最小动态验证;不等于动态或完整功能已通过,也不扩大为全量施工及任意模型安装授权。 本次同意CG-029/030后进入已确认方案的关键技术验证;旧小样通过不等于新实时交谈、衔接或完整功能通过。 CG-031明确完整前端设计不以嘴型技术验证通过为前置条件;设计样图、交互原型与真实功能接通须分别标明,不能把设计推进等同于全部后台施工获准。",
"source_comment_ids": [
"01a157a9-bd18-7533-9b21-e5f4c3a90d7c",
"01a157ab-209c-7c8f-956c-40ebb3340f4e",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-025",
"category": "产品",
"topic": "当前工程人物外观",
"conclusion": "成年女性,年龄观感在20岁左右,冷白皮,圆鹅蛋脸,中短发,气质清纯干净。用户指出上一版人物太老、太路人,要求重新设计。",
"boundary": "用户已于2026年10月20日15:17(北京时间)认可v2人物静态方向。CG-002的写实风格、CG-006外观与伴侣身份分离保持;不代表分层和跨视频身份连续性已验证。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-026",
"category": "产品",
"topic": "当前房间设定",
"conclusion": "高层卧室,设置慵懒沙发和女性化装饰元素,整体温暖温馨。",
"boundary": "用户已认可v2高层卧室静态方向,四张v2图为当前视觉基准。CG-017的一间房、人物与环境独立、天气昼夜变化范围保持;分层几何、光照切换效果仍需实测。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-027",
"category": "产品",
"topic": "真人优先与二维备用",
"conclusion": "优先争取真人照片般的效果;如果真人路线无法达到所需效果、损害沉浸感,再考虑二维动画。",
"boundary": "真人优先、二维为条件备用保持。首版具体主验证路线现由CG-029确认;交谈身体范围按CG-028。真人转二维的具体判据和二维风格尚未定稿,不自动切换。",
"source_comment_ids": [
"01a158c5-a25f-742b-975d-6af1ce8ff6b6",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-028",
"category": "产品",
"topic": "首版交谈的实时表现与预设身体动作",
"conclusion": "首版优先让口型、表情和目光自然;身体主要使用少数预设动作,不要求肩膀、手势根据每句话实时生成新的变化。",
"boundary": "这是首版范围选择,不是禁止预设肩部/手部动作或要求人物身体完全静止。具体模型能支持哪些表情、目光和动作仍待实测;没有测试证明现有硬件绝对无法实时驱动肩部或手势。此项同意不代表选定成品视频架构、具体模型、安装、付费API或新施工。 后续用户另于01a1599b-f137-71cc-8af2-a265499b4b6e同意CG-029/030方案,不把这两次确认混成同一授权。",
"source_comment_ids": [
"01a158c7-f16f-7b98-844e-77271f16d6d8",
"01a158d7-1d0c-764c-8a4c-35359b4f5d8a",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-029",
"category": "产品",
"topic": "成品日常视频与本地实时交谈主路线",
"conclusion": "首版优先验证:人物与房间一起制作成品日常视频,按真实时间、天气及她的实际活动选片;交流时自然转入同一人物的实时半身交谈画面,结束后回到日常活动。人格、记忆、关系与后台生活由本地核心系统管理。",
"boundary": "替代首版浏览器每帧独立人物抠像合成的实施要求;天气、服装或活动变化需要对应视频与自然过渡,保持同脸、服装和光线连续。只确认架构方向,LiveTalking/MuseTalk/Ditto等具体驱动器仍需实际选型;不承诺任意新身体动作,不默认采用云端收费服务,也不代表全部功能已完成。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a158d9-2f3a-72f2-a911-5f58adf074c7",
"01a1597e-ef9e-7cfa-9456-1a0d4e248430",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-030",
"category": "产品",
"topic": "首批视频制作与后续用户自备素材",
"conclusion": "首轮工程素材由总管安排小队使用现有本地资源制作,以已确认的人物与高层卧室为基准;用户不需要先自行准备整套视频。后续提供模板、制作规范与导入检查,用户可自行生成替换外观。",
"boundary": "先验证少量日常、过渡和交谈素材的完整流程,再扩充服装/天气/动作;具体数量和格式待测试。生成有筛选、重试和处理成本,不承诺一张照片自动得到全套合格素材;换素材不改变八字、记忆和关系。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1597d-f505-7481-a9ee-62e025fbfa2f",
"01a1597e-ef9e-7cfa-9456-1a0d4e248430",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-031",
"category": "产品",
"topic": "完整前端以产品功能和使用流程为设计依据,人物与卧室样图只作视觉参考",
"conclusion": "完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。",
"boundary": "完整界面的布局、导航、内容层级、交互与视觉语言应围绕产品使用流程独立设计,不能被旧样图构图锁定,也不把成品视频策略扩张为所有界面必须全屏视频。嘴型同步作为独立技术研究,不作为完整前端设计开工的前置验收。此项确认设计依据与范围,不代表已认可任何完整页面稿,不授权直接实现所有后台功能或自动改换模型;此前素材仍可作参考,不能误记成需要删除旧素材或推翻全部既有需求。",
"source_comment_ids": [
"01a15e8d-91ab-739e-84f9-fc4026bfcd70",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "VIS-001",
"category": "视觉",
"topic": "两人的数字空间",
"conclusion": "整体像只属于两人的数字空间,人物、交流、回忆融在其中。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-002",
"category": "视觉",
"topic": "整体气质",
"conclusion": "静谧梦幻、安静亲密,有空间深度和柔和微光。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-003",
"category": "视觉",
"topic": "多主题的一致结构",
"conclusion": "切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-004",
"category": "视觉",
"topic": "两套首轮主题",
"conclusion": "靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-005",
"category": "视觉",
"topic": "空间优先的首页",
"conclusion": "首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-006",
"category": "视觉",
"topic": "背景层次",
"conclusion": "光雾、景深与若隐若现的景物融合;具体画面通过样图验证。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-007",
"category": "视觉",
"topic": "空间动效",
"conclusion": "平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-008",
"category": "视觉",
"topic": "透光浮层",
"conclusion": "内容采用透光浮层,背后保留空间光影,文字清晰可读。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-009",
"category": "视觉",
"topic": "轻盈文字",
"conclusion": "现代轻盈、字形简洁、标题克制,正文易读。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-010",
"category": "视觉",
"topic": "微光强调",
"conclusion": "少量微光集中于关键操作和选中状态,两主题各有强调色。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-011",
"category": "视觉",
"topic": "人物独立视窗",
"conclusion": "人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-012",
"category": "视觉",
"topic": "悬浮导航",
"conclusion": "底部小巧透光导航,图标配简短文字,选中亮起微光。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-013",
"category": "视觉",
"topic": "交流呈现",
"conclusion": "交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "AM-001",
"category": "补充裁定",
"topic": "未分享生活不可见",
"conclusion": "等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "AM-002",
"category": "补充裁定",
"topic": "关系由用户选择",
"conclusion": "创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "AM-003",
"category": "补充裁定",
"topic": "名字由用户起",
"conclusion": "首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "UX-001",
"category": "用户整改",
"topic": "手机对话可用",
"conclusion": "手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-002",
"category": "用户整改",
"topic": "对话文字动效",
"conclusion": "文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-003",
"category": "用户整改",
"topic": "滚动条主题一致",
"conclusion": "记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-004",
"category": "用户整改",
"topic": "出生资料与底色深度",
"conclusion": "创建与资料显示公历、农历、时辰及四柱说明;性格不能只用话多、直接、焦虑等浅标签概括。深度不能变成固定模板人设,仍须遵守 CG-013/014/015 的诠释自由、初始个性、成长分离。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-005",
"category": "用户整改",
"topic": "交流身份清楚",
"conclusion": "用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-006",
"category": "用户整改",
"topic": "完整界面功能保留",
"conclusion": "按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "GOV-001",
"category": "执行约束",
"topic": "完整需求跨任务继承",
"conclusion": "所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-002",
"category": "执行约束",
"topic": "只有用户能改需求",
"conclusion": "只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-003",
"category": "执行约束",
"topic": "对照和验收",
"conclusion": "每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-004",
"category": "执行约束",
"topic": "本轮返工范围",
"conclusion": "先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "DEL-001",
"category": "交付批次",
"topic": "演示与真实接入边界",
"conclusion": "前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。",
"boundary": "接入方案中的候选服务、声音、摄像头和硬件操作仍须满足各自前置与授权,不能从批次批准推断全部候选已经批准。",
"source_comment_ids": [
"01a161a4-842f-7c26-9be0-1812b720976a",
"01a1629c-732f-7559-8417-6f2ea05c7151"
],
"source_document": "sources/integration-plan-v1.md"
},
{
"id": "GOV-005",
"category": "执行分工",
"topic": "视觉总监接手整体视觉及必要代码",
"conclusion": "CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。",
"boundary": "总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。",
"source_comment_ids": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc"
]
}
],
"open_items": [
{
"id": "T-01",
"topic": "八字映射细则",
"detail": "具体规则表、权重、命理解释覆盖深度与验证样例尚未定稿。用户没有直接确认‘只保留明确量化项、排除所有抽象描述’;后续已明确保留性格诠释空间。"
},
{
"id": "T-03",
"topic": "素材包标准与具体数量",
"detail": "导入模板、尺寸、帧率、背景处理、动作节点、口型预处理、预览与回退方式等需结合方案、小样定稿。未承诺固定服装/动作数量或一张参考图自动生成整套合格视频。"
},
{
"id": "T-04",
"topic": "模型与速度",
"detail": "Qwen3-VL-4B、2B和此前提到的视频/口型方案都是候选;4080部署、真实摄像头、语音与人物同时运行尚未实测。既有接口单图测试的1.5秒/7秒不代表4080速度或端到端语音延迟。"
},
{
"id": "T-05",
"topic": "外接服务接入",
"detail": "4080视觉服务地址、具体模型、输入输出能力,以及现有ASR/TTS的流式和打断能力,需后续核实或由用户提供。部署视觉小模型由用户负责。"
},
{
"id": "T-06",
"topic": "运行与恢复细节",
"detail": "单伴侣重新创建/清空、服务器停机后的状态恢复、备份保留份数和空间策略尚未定稿;不能用多伴侣旧结论补全。"
},
{
"id": "T-07",
"topic": "天气和访问细节",
"detail": "具体城市、天气数据来源、缺失天气素材时的呈现方式、内网可信HTTPS方案等留待工程设计。"
},
{
"id": "T-08",
"topic": "实现完成情况",
"detail": "本记录是需求确认台账,不证明功能已实现。旧演示的部署和测试结果不能作为新写实动态路线的验收结果。"
},
{
"id": "T-09",
"topic": "真人呈现路线与交谈动态边界",
"detail": "CG-029已选定成品日常视频配本地实时半身交谈,CG-030确定首批由小队制作。待验证具体驱动器、同脸/服装/光线连续、音频/口型/头部表情实际能力、打断、日常与交谈往返、素材制作成本及本机并发/恢复;真人转二维的条件仍待明确。云端收费服务未选定。"
},
{
"id": "T-02",
"topic": "关系界面余项",
"detail": "初始关系由用户选择已由 AM-002 确定,旧待定结论被覆盖;自由提示词不能被用于修改出生底色。"
},
{
"id": "T-10",
"topic": "当前一两句的长回复呈现",
"detail": "分句、停留和前后查看细节在独立动态小样中验证,不冒充用户已选具体方案。"
}
],
"precedence": "用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。"
}
-82
View File
@@ -1,82 +0,0 @@
{
"entries": [
{
"version": "1.7",
"approval_comment_id": "01a1635b-b47c-79ed-ac66-9408c7905af9",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"previous_record": "sources/product-decisions-v1.6.json",
"summary": "整合原31项、已确认视觉与后续裁定,增加跨卡继承约束;只落实用户同意的方案,不把实现现状写成需求。",
"affected_ids": [
"AM-001",
"AM-002",
"AM-003",
"CG-001",
"CG-002",
"CG-003",
"CG-004",
"CG-005",
"CG-006",
"CG-007",
"CG-008",
"CG-009",
"CG-010",
"CG-011",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-017",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-022",
"CG-023",
"CG-024",
"CG-025",
"CG-026",
"CG-027",
"CG-028",
"CG-029",
"CG-030",
"CG-031",
"DEL-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"UX-001",
"UX-002",
"UX-003",
"UX-004",
"UX-005",
"UX-006",
"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"
],
"impact": "约束新旧卡及独立界面小样;不改产品数据、模型与现有服务。"
},
{
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "新增GOV-005:整体视觉交给视觉总监接手,不止把关;施工员不能实现时由视觉总监直接写代码。",
"affected_ids": [
"GOV-005"
],
"impact": "本项目本轮视觉设计、动效与相应代码责任转给视觉总监;不改现网、数据、全局模型或认证。"
}
]
}
-13
View File
@@ -1,13 +0,0 @@
# 需求变更记录
v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入口,新增白栖知批准的跨任务继承办法。批准评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`;所批方案原文在 `sources/governance-proposal-approved.md`。
原始 v1.6 原样保留。后续生活披露、初始关系和名字的覆盖见 current.md 的 AM-001–003 及对应来源,不回写历史。旧文档中的历史进度不作为现在的事实。
以后每次改需求,记录:原要求、新要求、白栖知明确原话及评论编号、涉及编号、影响范围。智能体建议、实现困难、阶段通过均不能替代用户授权。检查脚本只能检查材料完整性,审核者仍必须核对原话。
## v1.8:视觉总监接手
白栖知明确裁定:整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。旧分工为总管因环境失败接手制作、后续视觉总监只把关;现按GOV-005交视觉总监亲自设计与落地,必要时直接写代码。本次不改变产品功能与原验收边界。
-363
View File
@@ -1,363 +0,0 @@
{
"baseline_version": "1.8",
"items": [
{
"requirement_id": "CG-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-002",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-003",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-004",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-005",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-006",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-007",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-008",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-009",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-010",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-011",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-012",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-013",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-014",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-015",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-016",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-017",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-018",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-019",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-020",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-021",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-022",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-023",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-024",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-025",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-026",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-027",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-028",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-029",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-030",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-031",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "VIS-001",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-002",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-003",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-004",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-005",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-006",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-007",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-008",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-009",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-010",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-011",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-012",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-013",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "AM-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "AM-002",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "AM-003",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-001",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-002",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-003",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-004",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-005",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-006",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-001",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-002",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-003",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-004",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "DEL-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-005",
"status": "用户恢复原运行时后,视觉总监已在HEL-793真实运行;设计与代码由其亲自负责",
"evidence": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc",
"01a163be-0bbc-7a74-8dc1-a3bcee89cc58",
"running:01a163bc-9134-75e4-aa28-71c6371ff692"
],
"next_step": "视觉总监交实际样图和动态代码;总管按完整需求核对后交用户验收,保持用户设定的运行时"
}
]
}
-67
View File
@@ -1,67 +0,0 @@
# 完整需求对照表
此表不使用“代码存在”代替用户验收;当前所有未闭合要求继续有效。
| 要求 | 当前状态 | 后续承接 |
| --- | --- | --- |
| CG-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-002 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-004 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-005 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-007 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-008 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-009 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-010 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-011 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-012 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-013 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-014 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-015 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-016 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-017 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-018 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-019 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-020 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-021 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-022 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-023 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-024 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-025 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-026 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-027 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-028 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| 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 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| AM-003 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-001 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-002 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-004 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-005 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-001 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-002 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-003 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-004 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| DEL-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-005 | 用户恢复原运行时,视觉总监已在HEL-793真实运行 | 由其交实际设计与代码,保持用户配置 |
详细证据位于 `coverage.json`;空证据表示尚无本基准下整项合格证据,不表示需求取消。
-562
View File
@@ -1,562 +0,0 @@
# CyberGril 完整当前需求 · v1.8
完整要求;版本1.8保留原完整要求并新增用户明确的视觉总监接手分工,不证明已实现或已验收。
## 阅读与效力
用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。
已确认的产品、视觉与补充要求全文如下;实现情况见 `coverage.md`,未定事项不视为已确认。旧方案的阶段状态、技术推荐和旧样机结论仅作历史证据。
## CG-001 · 需求对齐与开工边界
先逐项问清需求,形成统一记录和方案;对单项建议的同意,仅用于确认该项,不能扩大成全部功能的开工授权。
来源评论:`01a15722-86fc-7c70-9409-d2e9b8cb041f`、`01a157ab-209c-7c8f-956c-40ebb3340f4e`。
## CG-002 · 写实视觉风格
人物采用照片、电影中真人的写实质感。首版日常画面采用CG-029的人物与房间成品视频,按活动、天气和时段选片;不再要求浏览器每帧用独立人物透明层与房间合成。
边界:原仓库的VRM/建模默认路线和Alina候选,不作为当前已认可的外观方向。 真人照片般的效果仍为首选;新增条件备选见CG-027,不把讨论二维视为立即放弃真人路线。 独立控制的产品目标由统一状态和对应素材组合实现;物理分层实现要求已由CG-029覆盖。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a15717-b34f-72ac-939f-d81c90f58920`、`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a158c5-a25f-742b-975d-6af1ce8ff6b6`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-003 · 动作与交谈的混合路线
预生成场景和同一人物的日常动作、服装素材;根据实际生活状态选择、衔接。交谈实时产生内容与语音,口型和局部反应需实际验证;后台可逐步补充素材。
边界:首版不承诺即时生成任意新全身动作。现场自由生成仍是用户的理想方向,属于后续增强目标。重生成任务应给交谈让出资源。 当前首版主验证路线明确为CG-029;具体交谈驱动器和真实表现仍待测试。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-004 · 交流镜头
日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。
边界:具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`。
## CG-005 · 工程外观与自备素材
工程阶段由总管安排选择人物外观;后续支持用户按标准自行生成图片或视频,替换人物外观。该能力从首版设计时预留。
边界:具体素材包规范和导入流程尚未定稿;一张图片本身不等于已有全部动态动作。最终外观仍经过样图验收。 工程外观须遵循CG-025的新要求;素材替换能力与仅换外观、不重建伴侣的CG-006保持不变。 首轮工程素材由小队制作,不以用户先备齐视频为前提,见CG-030。
来源评论:`01a1572c-18c8-7872-94b0-c05c1b24b5c7`、`01a1572d-2c55-777c-a63f-a7c74c31c070`、`01a1573f-aedf-7d08-91a7-482b2a94f782`、`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-006 · 换外观保留同一个她
换素材仅改变外观,保留原有八字底色、性格积累、记忆和关系;创建新人物与换外观是不同概念。
边界:首版随后收缩为仅一个伴侣;这里不授权实现多个伴侣或并行运行。
来源评论:`01a1573f-aedf-7d08-91a7-482b2a94f782`、`01a15749-7530-71b4-9157-b9fa7011254b`。
## CG-007 · 首版仅一个伴侣
首版只能创建、使用一个伴侣,暂不做多个伴侣及切换;支持数据备份。
边界:覆盖此前已讨论并同意的多伴侣保留、切换方向;单伴侣下的清空或重新创建规则未定。
来源评论:`01a15749-7530-71b4-9157-b9fa7011254b`。
## CG-008 · 后台生活与连续性
用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续。画面、对话、记忆应对应同一份生活状态。
边界:没有承诺服务器关机仍能运行;停机后的恢复、补算规则另待设计。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a15749-7530-71b4-9157-b9fa7011254b`、`01a15751-ded1-7328-80ae-39967d2b3821`。
## CG-009 · 备份与恢复
每天自动备份;支持随时手动完整导出到自己的电脑,并从备份恢复同一位伴侣。覆盖八字、性格资料、对话、记忆、关系、生活进度;手动完整导出带上所用人物、场景素材及恢复所需设定。
边界:自动副本留在服务器,首版暂不增加云盘接入;保留份数、空间策略、恢复校验等工程细节尚未定稿。
来源评论:`01a15749-fc88-7295-8350-dccfb0168b70`、`01a1574f-9e0e-7bf4-b158-3f88a4c1988a`。
## CG-010 · 连续语音与文字
用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`。
## CG-011 · 主动联系范围
首版只在网页内主动联系:语音开启时可以主动开口,未开启时留文字消息;关闭网页后保留消息,回来再看。主动分享应有自己的生活经历作为依据。
边界:首版不接手机推送或其他聊天软件;具体频率可在方案中结合原仓库参数制定。
来源评论:`01a15751-670e-7187-b94a-cea70154df3b`、`01a15751-ded1-7328-80ae-39967d2b3821`。
## CG-012 · 生辰创建方式
使用她自己的出生年月日和时辰;创建时可自动生成,也允许用户在确认前手动指定。确认后作为人格底色依据,换外观不会改变。
来源评论:`01a15752-4cfe-7cd7-a4a3-8a8f8143f943`、`01a15754-b879-73b6-b367-b105898d7107`。
## CG-013 · 八字底色不可编辑
八字带来的性格底色是系统级资料,创建后只读;每轮重新提供给模型并预留上下文空间。可用结构化指标配合简短解释表达,关系提示词不得覆盖底色。
边界:程序保证资料不被改写;模型能否长期遵守仍需验证,不能把写成数字或系统提示词等同于已解决漂移。
来源评论:`01a15771-3a93-7518-9b23-489509520d77`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-014 · 性格诠释空间
性格描述保留适度抽象和解释空间,让模型形成有延续性的个性化诠释;同样底色允许不同表现与成长,不给每次回答规定唯一标准答案。
边界:不能将本项误记成‘用户可修改八字底色’,也不能将随机、无缘由地反复变换人设当作个性。
来源评论:`01a15763-1fc5-7f36-bf43-722e8755703c`、`01a15763-e729-7aa2-a752-7b95fb69372c`、`01a15771-3a93-7518-9b23-489509520d77`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-015 · 初始习惯与后天成长
创建时带少量模型自主形成的喜好和小习惯;以后通过生活、相处逐渐丰富。成长经历单独记录,保留最初的八字底色。
边界:原仓库重大经历可微调核心的旧例外,按此讨论改为另存成长层,不据旧文直接修改出生底色。
来源评论:`01a15763-e729-7aa2-a752-7b95fb69372c`、`01a15765-2fa8-7494-b0b8-7a0054500556`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-016 · 关系可配置、可调整
朋友或恋人、称呼、相处边界等关系设定通过独立提示词表达,相处过程中也可明确修改;保留原有记忆和互动形成的信任,不改写过去。
边界:没有确定默认必须初识或必须情侣;通用人物提示词编辑器并未获确认,允许修改的范围是关系设定。
来源评论:`01a1576a-5ffe-73fb-b6f1-2aeee98a5782`、`01a15771-3a93-7518-9b23-489509520d77`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-017 · 首版场景范围
先集中做好一个高层卧室,以成品视频表现对应天气、昼夜、光线以及少量服装和日常动作;由统一状态按条件选片,保留后续扩展入口。
边界:其他房间和外出场景后续扩展;素材数量先结合试片结果定,不将早期示例数量作为现行承诺。 当前唯一房间明确为CG-026所述高层卧室。 浏览器人物/环境物理分层要求由CG-029覆盖;活动、天气等逻辑状态仍独立管理,不用选片篡改生活记录。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a15775-89f4-7ec8-89eb-b3554fb05d19`、`01a1577a-e852-7551-ace8-0b2028db9ebb`、`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-018 · 电脑优先、兼顾手机
主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。
来源评论:`01a1577b-b65c-787a-b436-b0a74c38fea4`、`01a1577c-d10f-7513-8f58-62b3b387240a`。
## CG-019 · 素材工具与新增费用
首版先使用已有工具和本地机器制作图片、视频素材,暂不新增付费云端生成接口;若实测不够,再列明方案效果和费用,由用户决定。
边界:此项是素材制作的费用边界,不能误解为禁止使用已提供的外接LLM或用户自行部署的小模型服务。
来源评论:`01a1577d-7203-7b2b-bf24-f880f56750cf`、`01a1577e-96f6-72c7-b66b-6c43779fc5f0`。
## CG-020 · 首版内网访问
首版在部署服务器所在内网使用,电脑和手机在该内网访问。
边界:外出访问后续扩展。摄像头和麦克风所需可信访问方式仍要在工程方案中落实。
来源评论:`01a1577f-0707-7ac6-9c0f-aef2e73ba108`、`01a15780-0fd0-7438-beee-4947f4c3c928`。
## CG-021 · 按任务选模型、视觉外接
该用小模型的地方用小模型;状态和时间推进用程序,记忆等后台任务可用小模型,主模型负责主对话和较复杂理解。摄像头用小视觉模型前处理,部署由用户在其他设备负责,项目通过接口接入;用户另有4080显卡。
边界:平时把带时间和不确定项的观察摘要交给主模型,必要时保留原图复核途径。具体小模型、部署参数、接口地址和性能尚未定版。
来源评论:`01a157a5-1668-723d-af23-732a612b30b9`、`01a157a6-b158-7ba5-8a3a-1d19920890ac`、`01a157a7-c9f2-7133-8340-9b497f12f7e7`。
## CG-022 · 首版摄像头范围
加入可开关的视觉观察,先聚焦人在不在、明显物品、房间环境变化;快速挥手、点头等连续动作放到后续。
边界:观察有时效,不能把旧画面继续当作眼前事实。刷新间隔和延迟以小视觉服务实测为准,5—10秒只是先前建议,不是固定性能保证。
来源评论:`01a157a6-b158-7ba5-8a3a-1d19920890ac`、`01a157a7-c9f2-7133-8340-9b497f12f7e7`。
## CG-023 · 天气、昼夜与作息
用户手动选择一个真实城市,房间天气、昼夜和生活作息跟随当地节奏;城市可自行更换,无需每次打开重新定位。
边界:具体城市、天气来源及跨城市切换细节仍待设置或工程设计。
来源评论:`01a157a8-510f-7d3d-9880-670dc9ae4d22`、`01a157a8-aeab-7611-9695-74afbc2dec02`。
## CG-024 · 第一道验收及后续顺序
先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。
边界:小样通过仅表示该阶段通过,不等于记忆、后台生活、摄像头或备份已全部实现和通过验收。 用户本次确认v2静态方向并授权继续最小动态验证;不等于动态或完整功能已通过,也不扩大为全量施工及任意模型安装授权。 本次同意CG-029/030后进入已确认方案的关键技术验证;旧小样通过不等于新实时交谈、衔接或完整功能通过。 CG-031明确完整前端设计不以嘴型技术验证通过为前置条件;设计样图、交互原型与真实功能接通须分别标明,不能把设计推进等同于全部后台施工获准。
来源评论:`01a157a9-bd18-7533-9b21-e5f4c3a90d7c`、`01a157ab-209c-7c8f-956c-40ebb3340f4e`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-025 · 当前工程人物外观
成年女性,年龄观感在20岁左右,冷白皮,圆鹅蛋脸,中短发,气质清纯干净。用户指出上一版人物太老、太路人,要求重新设计。
边界:用户已于2026年10月20日15:17(北京时间)认可v2人物静态方向。CG-002的写实风格、CG-006外观与伴侣身份分离保持;不代表分层和跨视频身份连续性已验证。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-026 · 当前房间设定
高层卧室,设置慵懒沙发和女性化装饰元素,整体温暖温馨。
边界:用户已认可v2高层卧室静态方向,四张v2图为当前视觉基准。CG-017的一间房、人物与环境独立、天气昼夜变化范围保持;分层几何、光照切换效果仍需实测。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-027 · 真人优先与二维备用
优先争取真人照片般的效果;如果真人路线无法达到所需效果、损害沉浸感,再考虑二维动画。
边界:真人优先、二维为条件备用保持。首版具体主验证路线现由CG-029确认;交谈身体范围按CG-028。真人转二维的具体判据和二维风格尚未定稿,不自动切换。
来源评论:`01a158c5-a25f-742b-975d-6af1ce8ff6b6`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-028 · 首版交谈的实时表现与预设身体动作
首版优先让口型、表情和目光自然;身体主要使用少数预设动作,不要求肩膀、手势根据每句话实时生成新的变化。
边界:这是首版范围选择,不是禁止预设肩部/手部动作或要求人物身体完全静止。具体模型能支持哪些表情、目光和动作仍待实测;没有测试证明现有硬件绝对无法实时驱动肩部或手势。此项同意不代表选定成品视频架构、具体模型、安装、付费API或新施工。 后续用户另于01a1599b-f137-71cc-8af2-a265499b4b6e同意CG-029/030方案,不把这两次确认混成同一授权。
来源评论:`01a158c7-f16f-7b98-844e-77271f16d6d8`、`01a158d7-1d0c-764c-8a4c-35359b4f5d8a`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-029 · 成品日常视频与本地实时交谈主路线
首版优先验证:人物与房间一起制作成品日常视频,按真实时间、天气及她的实际活动选片;交流时自然转入同一人物的实时半身交谈画面,结束后回到日常活动。人格、记忆、关系与后台生活由本地核心系统管理。
边界:替代首版浏览器每帧独立人物抠像合成的实施要求;天气、服装或活动变化需要对应视频与自然过渡,保持同脸、服装和光线连续。只确认架构方向,LiveTalking/MuseTalk/Ditto等具体驱动器仍需实际选型;不承诺任意新身体动作,不默认采用云端收费服务,也不代表全部功能已完成。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a158d9-2f3a-72f2-a911-5f58adf074c7`、`01a1597e-ef9e-7cfa-9456-1a0d4e248430`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-030 · 首批视频制作与后续用户自备素材
首轮工程素材由总管安排小队使用现有本地资源制作,以已确认的人物与高层卧室为基准;用户不需要先自行准备整套视频。后续提供模板、制作规范与导入检查,用户可自行生成替换外观。
边界:先验证少量日常、过渡和交谈素材的完整流程,再扩充服装/天气/动作;具体数量和格式待测试。生成有筛选、重试和处理成本,不承诺一张照片自动得到全套合格素材;换素材不改变八字、记忆和关系。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1597d-f505-7481-a9ee-62e025fbfa2f`、`01a1597e-ef9e-7cfa-9456-1a0d4e248430`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-031 · 完整前端以产品功能和使用流程为设计依据,人物与卧室样图只作视觉参考
完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。
边界:完整界面的布局、导航、内容层级、交互与视觉语言应围绕产品使用流程独立设计,不能被旧样图构图锁定,也不把成品视频策略扩张为所有界面必须全屏视频。嘴型同步作为独立技术研究,不作为完整前端设计开工的前置验收。此项确认设计依据与范围,不代表已认可任何完整页面稿,不授权直接实现所有后台功能或自动改换模型;此前素材仍可作参考,不能误记成需要删除旧素材或推翻全部既有需求。
来源评论:`01a15e8d-91ab-739e-84f9-fc4026bfcd70`、`01a15e92-e689-754a-aad3-4566100962b3`。
## VIS-001 · 两人的数字空间
整体像只属于两人的数字空间,人物、交流、回忆融在其中。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-002 · 整体气质
静谧梦幻、安静亲密,有空间深度和柔和微光。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-003 · 多主题的一致结构
切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-004 · 两套首轮主题
靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-005 · 空间优先的首页
首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-006 · 背景层次
光雾、景深与若隐若现的景物融合;具体画面通过样图验证。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-007 · 空间动效
平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-008 · 透光浮层
内容采用透光浮层,背后保留空间光影,文字清晰可读。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-009 · 轻盈文字
现代轻盈、字形简洁、标题克制,正文易读。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-010 · 微光强调
少量微光集中于关键操作和选中状态,两主题各有强调色。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-011 · 人物独立视窗
人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-012 · 悬浮导航
底部小巧透光导航,图标配简短文字,选中亮起微光。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-013 · 交流呈现
交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## AM-001 · 未分享生活不可见
等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## AM-002 · 关系由用户选择
创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## AM-003 · 名字由用户起
首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## UX-001 · 手机对话可用
手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-002 · 对话文字动效
文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-003 · 滚动条主题一致
记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-004 · 出生资料与底色深度
创建与资料显示公历、农历、时辰及四柱说明;性格不能只用话多、直接、焦虑等浅标签概括。深度不能变成固定模板人设,仍须遵守 CG-013/014/015 的诠释自由、初始个性、成长分离。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-005 · 交流身份清楚
用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-006 · 完整界面功能保留
按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## GOV-001 · 完整需求跨任务继承
所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-002 · 只有用户能改需求
只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-003 · 对照和验收
每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-004 · 本轮返工范围
先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## DEL-001 · 演示与真实接入边界
前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。
边界:接入方案中的候选服务、声音、摄像头和硬件操作仍须满足各自前置与授权,不能从批次批准推断全部候选已经批准。
来源评论:`01a161a4-842f-7c26-9be0-1812b720976a`、`01a1629c-732f-7559-8417-6f2ea05c7151`。
关联全文:`sources/integration-plan-v1.md`。
## GOV-005 · 视觉总监接手整体视觉及必要代码
CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。
边界:总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。
## 尚未确定或尚未验证
### T-01 · 八字映射细则
具体规则表、权重、命理解释覆盖深度与验证样例尚未定稿。用户没有直接确认‘只保留明确量化项、排除所有抽象描述’;后续已明确保留性格诠释空间。
### T-03 · 素材包标准与具体数量
导入模板、尺寸、帧率、背景处理、动作节点、口型预处理、预览与回退方式等需结合方案、小样定稿。未承诺固定服装/动作数量或一张参考图自动生成整套合格视频。
### T-04 · 模型与速度
Qwen3-VL-4B、2B和此前提到的视频/口型方案都是候选;4080部署、真实摄像头、语音与人物同时运行尚未实测。既有接口单图测试的1.5秒/7秒不代表4080速度或端到端语音延迟。
### T-05 · 外接服务接入
4080视觉服务地址、具体模型、输入输出能力,以及现有ASR/TTS的流式和打断能力,需后续核实或由用户提供。部署视觉小模型由用户负责。
### T-06 · 运行与恢复细节
单伴侣重新创建/清空、服务器停机后的状态恢复、备份保留份数和空间策略尚未定稿;不能用多伴侣旧结论补全。
### T-07 · 天气和访问细节
具体城市、天气数据来源、缺失天气素材时的呈现方式、内网可信HTTPS方案等留待工程设计。
### T-08 · 实现完成情况
本记录是需求确认台账,不证明功能已实现。旧演示的部署和测试结果不能作为新写实动态路线的验收结果。
### T-09 · 真人呈现路线与交谈动态边界
CG-029已选定成品日常视频配本地实时半身交谈,CG-030确定首批由小队制作。待验证具体驱动器、同脸/服装/光线连续、音频/口型/头部表情实际能力、打断、日常与交谈往返、素材制作成本及本机并发/恢复;真人转二维的条件仍待明确。云端收费服务未选定。
### T-02 · 关系界面余项
初始关系由用户选择已由 AM-002 确定,旧待定结论被覆盖;自由提示词不能被用于修改出生底色。
### T-10 · 当前一两句的长回复呈现
分句、停留和前后查看细节在独立动态小样中验证,不冒充用户已选具体方案。
## 历史覆盖记录
原始 v1.6 的 `superseded` 字段保留全部早期路线变化。另有以下后续覆盖:
- 私人手账视觉初稿未获认可,按完整视觉访谈和后续认可稿重做。
- 初始关系“尚未定”由 AM-002 覆盖;生活“可先显示并标注未分享”由 AM-001 否决;名字由 AM-003 确定。
- 最新用户对对话与整体沉浸界面不满意,不得沿用此前阶段通过作为本轮通过。
- 以上不授权重置出生底色、伴侣身份、聊天、记忆或直接合并产品分支。
-35
View File
@@ -1,35 +0,0 @@
{
"baseline_version": "1.8",
"read_commit": "4242f8377e350e5f17e836d275c15b0f099bf109",
"requirement_ids": [
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005"
],
"deliverable": "按用户明确指令记录视觉总监接手设计与相应代码职责,并交接本轮返工",
"acceptance": [
"58项原意、边界、来源完整;初轮及视觉对话取齐",
"版本变更、来源、覆盖、交接缺项检查可运行",
"项目资源与说明及旧卡回读核对;独立审核后仅治理分支允许合入"
],
"deferred": [
"用户界面小样及其体验验收另卡",
"R1修正与R2–R7真实接入继续保留",
"CI自动合并门禁尚未配置,当前检查由交付与审核执行"
],
"requirement_changes": [
{
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"requirement_id": "GOV-005"
}
],
"verification": [
"59项需求及交接检查通过;原始来源和变更历史保留",
"项目规则、主卡与返工卡已明确视觉总监亲自接手设计与相应代码",
"用户手动恢复原运行时后,HEL-793运行01a163bc-9134-75e4-aa28-71c6371ff692真实开始;已读旧稿与样图并检查浏览器;没有重复派工",
"追溯确认此前2026-10-22T09:31:24Z的运行时变更来自总管执行01a162f2-61a5-7c6a-9704-44c8e15219ff,未经用户事先批准;已向用户如实说明",
"主卡恢复in_progress;用户设定的运行时、模型与认证未再更改,正式界面及用户数据保持原状"
]
}
@@ -1,12 +0,0 @@
# 本次约束落地与验证
需求基准v1.7由白栖知主卡评论 `01a1635b-b47c-79ed-ac66-9408c7905af9` 批准。
- 已归档58项完整有效要求和125条来源评论;旧原始记录、获批设计及接入方案保留原样。
- 项目说明已加入必读路径、只有用户可改需求、每卡对照、阶段与全项目验收边界。项目仓库资源已绑定,回读URL正确;新卡将实际验证收到的项目上下文。
- 原39张尚未关闭子卡逐张补充入口及交接约束,回读确认状态和负责人不变;主卡同样补充且仍in_progress。迁移清单见 `task-migration.json`。旧试验未因迁移重新启动。
- 正常基准和交接通过;对覆盖漏项、来源改动、无升版/新批准的需求修改、过期交接共4种反向输入均失败,详见 `verification.json`。验证在隔离临时副本进行,未修改服务或用户库。
- `git diff --check` 通过。未改产品行为,未重跑无关后端与浏览器测试;本次没有新增界面画面可验收。
- 仓库治理请求为PR#5,仅含规则、需求、检查与说明;独立审核和合入main尚未完成。此前产品PR#4保持open未合并,8300/8310及用户数据保持原状。
此记录证明本轮写入与有限验证,不证明后续每位智能体都会理解正确。结构检查须配合原话审核与实际体验验收;当前没有配置CI强制合并门禁。
-11
View File
@@ -1,11 +0,0 @@
{
"governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
"design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
"approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
"frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797"
}
@@ -1,267 +0,0 @@
## CyberGril 完整产品设计 · 可施工规范(HEL-746 / R2)
延续 HEL-744 R1 已认可的方向「夜色里一扇亮着的窗」。本轮没有改首页的比例、字号和人物窗位置,也没有改交流时放大人物窗的方式。在这个基础上补齐了生活与共同回忆、首建、管理、交流的完整状态、离开后返回、主动消息,以及手机上的完整路径。
依据:需求记录 v1.6(CG-001~031、T-01~09),HEL-738 总工方案(F1~F9 流程、组件边界、需要视觉总监覆盖的状态清单)。本稿只是设计原型,不是正式产品。
---
### 1. 交付物与打开方式
| 文件 | 说明 |
|---|---|
| `index.html` | 完整原型,包含两套主题和电脑/手机两种布局。不需要联网,也不需要服务 |
| `phone.html` | 在 390×844 的手机框里打开同一份 index.html |
| `assets/` | 沿用 R1 的两张参考静帧(房间、近景) |
| `shots/` | 55 张实际截图(42 张电脑 1440×900,13 张手机 390×844@2x) |
| 本文件 | 可施工规范 |
- **演示控制**:电脑在左下角,手机在顶部中间。可以切换数据(正常/空/很长),以及「未讲述的事」的两种呈现方式,还能模拟两天后回来、她主动说话、断线(恢复/失败)、发送失败、麦克风被拒、语音识别出错、视觉服务失败、重新演示首建、创建失败和保存失败。**这个面板只属于原型,不是产品界面。**
- 截图参数:`?shot=<场景名>&theme=moon|fog`,场景名见第 14 节。自检参数:`?selftest=1`,结果写在 body 的 `data-selftest` 属性里。
---
### 2. 已认可、本轮不改动的部分
- 靛蓝月光和蓝紫雾光两套主题整体切换,DOM 和布局完全相同。用户没有指定默认主题,原型以月光打开只是临时设定,不代表用户已经决定。
- 首页:左侧一句话(30px/300)、心情与关系、她的留言、「你不在时」入口和两个按钮;右侧拱形人物窗(`clamp(300px,27vw,430px)`,比例 1:1.3,距右边缘 14vw)。
- 交流:人物窗展开为宽幅圆角窗,画面只留当前一两句;输入框常驻,麦克风是唯一实心的强调按钮,完整记录从右侧滑入。
- 底部导航保持小巧、透光:此刻 / 交谈 / 日子 / 设置。
- 人物和卧室仍用静帧占位,标注「示意画面 · 参考静帧」。没有新做嘴型实验。
---
### 3. 信息架构与导航、返回关系
```mermaid
title 区域与返回关系
flowchart LR
NOW[此刻 首页] -->|和她说话/麦克风/导航交谈| TALK[交谈]
NOW -->|你不在时/导航日子| DAYS[日子: 她的日子 / 我们的回忆]
NOW -->|导航设置| MANAGE[设置: 她/关系/城市/外观/备份/外接服务/显示]
NOW -->|她留给你的| TALK
DAYS -->|事件详情→看那段聊天| TALK
TALK -->|返回日子 原滚动位置与选中项| DAYS
TALK -->|回到此刻/Esc| NOW
DAYS -->|回忆↔事件 互链| DAYS
DAYS -->|× / Esc / 回到她身边| NOW
MANAGE -->|× / Esc| NOW
FIRST[首建 仅无伴侣时] -->|完成→去看看她| NOW
BACK[离开后返回] -->|你不在的这两天| NOW
REACH[主动消息] -->|回她: 记录定位到该条| TALK
```
- **离开日子以后还能回到原来的位置**:从事件详情打开聊天时,关闭按钮会改成「返回日子」。日子列表的 DOM 不重建,所以滚动位置和选中的条目都保留。自检里有专门的验证项。
- **Esc 逐层退出**:弹层 → 演示面板 → 返回欢迎层 → 聊天记录 → 交谈 → 手机详情 → 区域。首建过程中按 Esc 不会退出。
- **手机**:列表和详情、设置分区列表和分区内容都改成「推入」。左上角的「‹ 她的日子」「‹ 设置」负责返回。
- 设置里有没保存的修改时,关闭网页会触发浏览器离开提示(`beforeunload`);对应分区导航上会显示一个暖色小点。
---
### 4. 需求 → 页面 / 状态映射
| 需求 | 落点(原型中可以操作) |
|---|---|
| CG-007 单伴侣 | 首建只出现一次;设置→她,注明「首版只有一位,不提供新建、切换或清空」;恢复别人的备份会被阻止 |
| CG-008 后台生活 | 日子时间线;「你不在的这两天」;文案注明「网页关着她照常生活(服务器停机期间除外)」 |
| CG-009 备份恢复 | 设置→备份:每日自动备份列表、完整导出(进度→完成)、恢复(三项检查→要求输入「恢复」两个字的双重确认→恢复前先自动导出) |
| CG-010 连续语音 | 语音条:计时、状态(倾听/思考/她在说/停下来了/没听清),「打断她」和常驻的「结束语音」;输入框不会消失 |
| CG-011 主动联系 | 首页上从人物窗里浮出的轻提醒,注明「由她今天 15:30 的生活经历引起」;交谈导航上的未读点;聊天记录里标「她主动说的」;语音开着时她会直接开口 |
| CG-012/013/014 生辰与底色 | 首建 4 步:出生(随机生成/手动修改,未满 18 岁会被拦下)→ 底色(四柱、倾向词和白话说明,说明是「倾向不是台词」)→ 关系 → 确认(必须勾选「我明白」)。设置→她:底色只读,带锁标识 |
| CG-015 成长分层 | 回忆→她的成长;设置→她:「最初就有的」和「后来慢慢有的」分两栏 |
| CG-016 关系可调 | 设置→关系:关系、称呼、相处边界;改关系类型需要二次确认,确认框说明「记忆和信任都保留,底色不变」 |
| CG-005/006 换外观 | 设置→外观素材:选择素材包→五项检查(通过/不合格)→新旧对比→确认「她还是她」 |
| CG-022 视觉观察 | 交谈里的「窗」按钮:首次开启要说明并确认;状态分为正在看、刚刚看到 N 秒前、已过期(不再当作眼前)、失败;设置→外接服务里有总开关 |
| CG-023 城市 | 设置→城市:搜索、选择、保存(成功或失败),顶栏城市同步变化 |
| CG-018 电脑优先、兼顾手机 | 全部区域在手机上都能走完关键路径,见第 8 节 |
| CG-031 设计依据 | 布局以使用流程组织;人物窗只是一个区域,不铺满全屏 |
| HEL-738 F6 断线 | 顶部横幅「信号不太好,正在重新连上…」,人物窗变灰,发送暂停但草稿保留,语音安全结束;重连成功会提示补了几条;重连失败显示「再试一次」 |
| HEL-738 §5.5 φ 文案 | 所有错误都用她的话或生活化的话表达(「我没听清」「看不清」),不出现技术报错 |
| SPEC §8.5 不画仪表盘 | 关系、心情都用一句话描述,不出现数值、进度条或雷达图 |
---
### 5. 各区域规范
#### 5.1 日子(她的生活与共同回忆)
- **空间关系**:打开后,首页内容向左后退并模糊;人物窗缩小到右侧(`--mini-pw: clamp(170px,14vw,230px)`),留在原来的空间里;窗下写「她此刻 · …」,并注明「来自她的生活记录 · 画面为示意静帧」,把生活状态和素材播放区分开;背景推近到 1.03 倍并压暗。
- **面板**:`left 5vw / top 84px / bottom 104px / width min(1000px,66vw)`,圆角 28,使用阅读底 `--glass-read`。电脑上分两栏:列表 400px,详情占剩余宽度。
- **三种事件**:已发生(实心点,`--text-2`)、正在(强调色点,带呼吸光)、打算(虚线空心点),分组为「之后的打算」和按天分组。打算类注明「计划可能会变」。
- **标签**:「还没跟你说过」(强调色)、「和你有关」(有关联回忆)。已经说过的事标题会弱化成 `--text-2`。
- **详情**:类型、时间、地点、是否说过、她的话(引用块)、和这件事有关的回忆(可以点)、那时候的聊天(可以点,看完返回)。底部固定说明「这是模拟生活里的一条记录,不是真实发生的事;画面只按记录选片,不会反过来改写生活」。
- **回忆**分为四组:我们一起的 / 你们的小习惯 / 她知道的你 / 她的成长。每条显示时间与来源(如「你 10 月 8 日说的」)、她怎么记得,以及来自哪些日子(反向链接)。
- **空记录**:「她今晚刚住进来,还没有发生什么」;回忆为「聊着聊着就会有了」。
- **长记录**:先显示前 18 条,再按「看更早的日子」每次加载 12 条,加载时保持滚动位置。施工时按天分页。
- **不做**:情绪或信任的数值仪表盘;任何「你好久没来」之类带负罪感的叙述。
#### 5.2 首建
- 全屏,人物窗和导航隐藏,左侧是叙事一句话(跟随步骤变化),右侧是向导卡 `minmax(460px,560px)`。
- 步骤条只覆盖 4 个步骤;进行中和完成不计入步骤数。
- 校验:名字必填;出生日期必须满 18 年;关系必须选(原型不预选,T-02);确认页没勾选时「创建」按钮不可用。
- 创建过程分三段:排好底色 → 形成最初的小习惯 → 布置房间。失败时停在失败的那一段,并提示「资料都还在」,可以选「返回修改」(回到第 1 步,数据保留)或「再试一次」。
- 完成页写「她住进来了」,按「去看看她」进入首页的「刚搬进来」状态:生活为空,首页一句话换成首晚的文案。
- 创建之后,界面上任何地方都没有重建、清空或切换伴侣的入口。
#### 5.3 设置(创建与管理)
- 面板几何与日子相同;左侧是 220px 的分区导航,右侧是内容。
- **表单通用规则**:没有修改时「保存」不可用,状态写「尚未修改」;有修改后,文字变成暖色「有未保存的修改」,导航上出现暖色点;「取消」把内容恢复到上次保存的值。保存时显示进行中(转圈和说明,按钮锁定)→ 成功(`--ok`,一句话说明影响)或失败(`--danger`,「你的修改还在」,可以重试)。操作栏吸附在底部。
- **重要确认**:改关系类型、应用新外观用普通确认;从备份恢复用危险样式,并要求输入「恢复」两个字。
- **外接服务**:对话模型、ASR、TTS、视觉观察服务四项,各有地址输入框和「测试连接」。原型不会发出任何请求;测试结果明确写「界面演示 / 未真实检测」。地址为空或测试失败时显示「相关功能会显示为不可用,其余照常」。
#### 5.4 交流的完整状态
| 状态 | 人物窗左上标签 | 语音条 | 台词区 / 提示 |
|---|---|---|---|
| 文字待机 | 在你面前 | — | 上一句和她当前这一句 |
| 她在想 | 在想 | 她在想 | 三点跳动 |
| 她在打字或说话 | 在说话(声波) | 她在说 · 可以打断,并出现「打断她」 | 逐字显示加光标 |
| 发送失败 | — | — | 「没发出去 · 重试」,记录里虚线边框;重试不会重复发送 |
| 麦克风授权 | — | — | 说明弹层:允许 / 不允许(模拟) |
| 倾听 | 在听你说 | 红点计时、在听你说、结束语音 | 你说的话实时显示(灰色) |
| 被打断 | 停下来了 | 她停下来了,在等你 | 她的那句话半透明,后面加省略号 |
| 识别出错 | 没听清 | 刚才没听清 | 「刚才没听清,你再说一遍?」,然后自动继续听 |
| 麦克风被拒 | — | —(会话不开始) | 麦克风变灰并加斜线;φ 文案;再点一次会给出处理指引 |
| 断线 | 画面变灰 | 语音自动结束 | 顶部横幅;发送暂停,草稿保留 |
| 视觉观察 | 绿点:刚刚看到… N 秒前 | — | 过期后变黄并标「已过期,不再当作眼前」;失败为红点 |
- 语音会话期间,「结束语音」一直显示在语音条上,麦克风按钮也同时变成停止键,两处都能结束。
- 语音开着时,人物窗高度从 `100vh-360` 降到 `100vh-412`,给语音条留出位置,三者不会重叠。
#### 5.5 返回与主动消息
- **离开后返回**:首页左侧换成欢迎层「你不在的这两天」,列出时间范围、3 件小事和留言提示,用中性语气,按钮是「看她留的话 / 看她的日子 / 先待一会儿」。
- **主动消息**:在首页时,她的话从人物窗下部浮出(`width = 窗宽-32`);在日子或设置里时,提示出现在右下角,不打断当前操作。提示上注明这句话来自哪段生活经历。点「回她」会打开聊天记录并定位、高亮这一条,关闭后回到原处。点「稍后再看」会留在「交谈」的未读里。
---
### 6. 设计 Token(R1 的值照旧,以下只列 R2 新增)
| Token | 月光 | 雾光 | 用途 |
|---|---|---|---|
| `--glass-read` | rgba(10,18,46,.62) | rgba(34,20,66,.60) | 日子和设置这类长内容的阅读底 |
| `--surface` | #0b1330 | #24154a | 手机上推入的详情层和吸底操作栏(不透明,避免下层透字) |
| `--field` / `--field-line` | rgba(255,255,255,.05) / rgba(205,222,255,.16) | rgba(255,240,255,.06) / rgba(236,218,255,.16) | 输入框 |
| `--ok` | #9fe8cf | #a8f0d8 | 成功、视觉观察「新鲜」 |
| `--warn` | #ffd79c | #ffdcae | 未保存、待定、观察已过期 |
| `--danger` | #ff9fb3 | #ffa8c8 | 失败、危险操作、录音红点 |
| `--radius-field` | 14px | | 输入框、链接卡片 |
| `--pane-l / -w / -t / -b` | 5vw / min(1000px,66vw) / 84px / 104px | | 区域面板几何 |
| `--mini-pw` | clamp(170px,14vw,230px) | | 区域页里缩小后的人物窗 |
- 字体、字号层级(30/20/15/13/11.5)、间距(4/8/12/16/24/32/48)、圆角(999/20/28)、曲线、时长与 R1 一致。新增:详情标题 24px/300,分区标题 22px/300,表单标签 13px,分组标题 12px,字间距 .14em。
- 状态色只用在小点、细边框和短文字上,不做大面积色块,以免破坏夜色的整体气氛。
---
### 7. 组件与状态
| 组件 | 状态 |
|---|---|
| `.btn-primary` | 默认(微光)/ hover(光晕变大)/ 按下(缩到 .97)/ 不可用(.42 透明,去掉光) |
| `.btn-ghost` / `.btn-danger` / `.btn-text` | 同上;危险按钮只用描边加文字色 |
| `.input/.select/.textarea` | 默认 / 获得焦点(强调色描边并加 4px 柔光圈)/ 错误(`--danger` 描边加下方说明)/ 只读(虚线描边) |
| `.seg` | 选中项为强调色实底 |
| `.status` | busy / ok / fail |
| `.checklist` | 待检查 / 检查中(转圈)/ 通过 ✓ / 失败 ! |
| `.ev` 事件行 | 默认 / hover / 当前选中(强调色浅底)/ 已讲述(弱化)/ 打算 |
| `.tag` | 中性 / accent / ok / warn / danger;`.tag-demo` 用虚线表示「演示/模拟」 |
| `.modal` | 普通 / 危险;需要输入确认字时,主按钮会先锁住,输入正确后才能点 |
| `.netbar` | 重连中(转圈)/ 失败(红点,加「再试一次」) |
| `.observe` | asking / fresh / stale / fail |
| `.nav-item` | 默认 / 当前项 / 未读(小点) |
---
### 8. 响应式
| 断点 | 布局 |
|---|---|
| ≥1101px | 如上;区域页右侧显示缩小的人物窗和说明 |
| 721–1100px | 区域面板宽 90vw,隐藏缩小的人物窗,在面板标题上改用「她此刻在…」一行字;日子列表 340px,设置导航 200px |
| ≤720px | 首页和交流沿用 R1 已认可的手机布局。区域面板改为全屏层(`top 54 / bottom 84`),底部导航始终可见,不被遮挡。列表和详情、设置分区都改为推入方式,详情层和吸底操作栏使用不透明的 `--surface`。首建为纵向可滚动布局,「上一步 / 下一步」吸在底部。语音时人物窗高 `min(42vh, 1.1w)`,自上而下依次是台词、语音条(底部 162px 处)、输入框(98px 处)、导航(16px 处),互不重叠。弹层宽度为 `100vw-32px` |
---
### 9. 动效
| 动效 | 触发 | 时长 / 曲线 | 能否打断 |
|---|---|---|---|
| 进入日子或设置:首页后退并模糊,人物窗缩小移到右侧,面板上升 24px 并淡入,背景缩放到 1.03 | 导航、入口 | 人物窗 720ms、面板 420ms(延迟 80ms),ease-out `(.22,1,.36,1)` | 可以,全部用 transition,中途反向会从当前位置回去 |
| 列表 → 详情(手机推入) | 点条目 | 420ms ease-out | 可以 |
| 首建换步 | 下一步 / 上一步 | 420ms,前进时从右侧淡入 16px,后退时从左侧 | — |
| 弹层 | 确认、授权 | 280ms,缩放 .97→1 并淡入 | Esc 或点遮罩取消 |
| 主动消息浮出 | 推送 | 420ms,上移 10px 并淡入 | 可以 |
| 断线横幅 | 断开 | 280ms | — |
| 语音条 | 开始语音 | 280ms | — |
| 「正在」事件的点、录音红点、倾听外圈、声波 | 常驻或状态 | 1–2.4s 循环 | — |
| 主题切换 | 开关 | 1100ms `(.65,0,.35,1)` | 可以 |
- **减少动效**(系统设置或设置→显示):所有循环停止;空间不再推近;过渡统一为 200ms、无延迟;粒子只画一帧。
- **页面隐藏时**:粒子停止绘制,CSS 动画暂停(`html.paused`)。
- 施工建议(与 R1 相同):人物窗缩放改用 FLIP(transform),雾层模糊预先烘焙。**本轮没有做性能实测,所以不给帧率数字。**
---
### 10. 模拟与真实能力的边界
- 原型里所有数据、回复、语音、摄像头、生活、备份、导出、导入、服务测试都是模拟,没有连接任何服务,也不会写入或下载文件。界面上用「演示数据 / 模拟生活 / 模拟 / 原型 · 不连接服务、不写数据」等标签逐处标明。
- 施工时按 HEL-738 §7 的能力标签处理:`mock` 打「演示数据」角标;`unavailable` 用 φ 占位文案;不能为了显示「在线」而伪装。
- 人物画面始终标注「示意画面 · 参考静帧」。正式版通过 Presenter 替换成视频,无论有没有口型驱动,页面结构都不变。
- 文案不暗示「关网页会停机」,也不承诺「服务器停机时她还在生活」。
---
### 11. 施工验收清单(与 S1 切片对齐)
1. 两套主题下,每个区域的 DOM 和布局完全一致;切换主题不改变滚动位置,也不改变当前视图。
2. 首页比例、字号、人物窗位置和 R1 一致(拿 D01 与 R1 截图对照)。
3. 从日子打开聊天再返回,列表的滚动位置和选中项都不变。
4. 事件和回忆可以互相跳转;空记录和长记录(分页)都正常。
5. 首建:未成年、未选关系、未勾选确认三种情况都会被拦下;创建失败时资料保留并能返回修改;完成后进入首页的空生活状态;之后任何地方都没有重建入口。
6. 设置:有修改才能保存,取消能恢复,保存过程中按钮锁定,失败时修改保留,改关系要确认,恢复要输入两个字确认,并且会拦下别人的备份。
7. 交流:发送失败能重试且不重复;语音的倾听、思考、她在说、打断、出错、结束都能走通;语音中「结束语音」一直可见;麦克风被拒后文字照常可用;断线时草稿保留、语音安全结束,重连后补齐消息。
8. 视觉观察:标注时效;30 秒后显示为过期(演示用的阈值,真实值以小视觉服务的实测为准);失败时不影响交谈。
9. 主动消息注明依据;点开后定位到那条,关闭后回到原处;不展示负罪叙事。
10. 手机 390×844:输入框、语音条、导航、吸底操作栏和弹层互不遮挡;手机上的详情层不透出下面的文字。
11. 减少动效、隐藏页面暂停都生效;Esc 能逐层退出。
12. 所有模拟能力都带标签。
---
### 12. 待定项(不阻塞其他设计,都已做成可替换的呈现)
| 编号 | 事项 | 本稿处理 |
|---|---|---|
| A | 她没亲口分享过的事对你是否可见(用户未拍板) | 默认显示并标注「还没跟你说过」;演示控制里可以切到「讲之前不显示」。详情页用黄色虚线框标为待定 |
| B | 默认主题 | 两套都保留,原型以月光打开只是临时设定 |
| C | 关系的默认值和编辑形式(T-02) | 首建时不预选;设置里用表单呈现,可以换成引导式对话 |
| D | 单伴侣下的重新创建或清空(T-06) | 不设入口 |
| E | 城市清单和天气来源(T-07) | 搜索列表为演示 |
| F | 素材包规范(T-03) | 五项检查只是示意 |
| G | 自动备份的保留份数(T-06) | 列表只显示 3 份并注明待定 |
| H | 内网 HTTPS(T-07) | 麦克风授权弹层里注明;不可用时按「麦克风被拒或不可用」的状态显示 |
| I | 观察过期的阈值、刷新间隔(CG-022) | 原型用 30 秒演示,真实值以实测为准 |
| J | 她的名字是否在首建时由用户填写 | 原型放了名字输入框;如果要改成别的方式,只影响第 1 步 |
---
### 13. 原型检查记录
- **自动自检**(`?selftest=1`,用真实点击走关键流程,共 43 项):在 Edge 无头模式下,1440×900、1280×800、窄窗(手机布局生效)三种尺寸全部通过。覆盖:主题、日子的打开/详情/互链/看聊天后返回原位、空记录与长记录、未讲述切换、设置的修改/取消/确认/保存成功/保存失败、恢复拦截、素材检查、文字失败重试、麦克风拒绝与授权、语音的思考/说话/打断/结束、视觉观察开关、断线与重连、Esc、欢迎层、主动消息定位、首建的三项拦截/失败返回/成功进入首页。
- **截图检查**:电脑截图是 1440×900 下实际 DOM 的截图;手机截图在 390×844 的 iframe 里按 2 倍像素密度截取并裁切(无头窗口有最小宽度,所以不直接截窗口)。
- **本轮发现并已修正的问题**:手机详情层半透明,下面的列表文字透出来(改为不透明的 `--surface`);手机语音时人物窗没有铺满宽度;语音条文字换行(手机上隐藏声波图标,文字过长时截断);静帧截图里声波条高度为 0;吸底操作栏的偏移。
- **限制**:
- 没有录屏工具,动效请直接打开原型查看。
- 没有做性能实测。
- 没有在真实手机或 Safari 上测试,只用了 Edge(Chromium)。
- 截图时关闭了过渡,所以截图里看不到「你不在的这两天」卡片的闪亮反馈。
- 本运行环境里没有可用的设计或动效类技能(只有平台类技能),动效由本规范和自检把关,没有使用其他技能。
---
### 14. 截图索引
D01 首页 · D02/D03 事件详情(两套主题)· D04 你不在时摘要 · D05 打算中的事 · D06 还没跟你说过(待定呈现)· D07 长记录 · D08 空记录 · D09 回忆详情与关联日子 · D10 成长 · D11 返回欢迎层 · D12 主动消息(首页)· D13 主动消息定位 · D14~D21 首建(出生、校验错误、底色、关系、确认、失败、完成、首建后首页)· D22~D30 设置(她、关系确认、城市成功/失败、外观通过/失败、备份、恢复双重确认、外接服务)· D31~D41 交流(发送失败、授权、倾听、她在说、打断、麦克风被拒、断线、重连失败、观察新鲜/过期/失败)· D42 演示控制台 · P01~P13 手机(日子列表、详情、回忆、首建出生/确认/失败、设置列表、关系表单、恢复确认、语音、断线、返回、主动消息)。
@@ -1,10 +0,0 @@
[
{
"id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码",
"created_at": "2026-10-22T12:46:18Z",
"parent_id": "01a1639b-8416-7b0b-8dbf-0de16c257cb5"
}
]
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
主卡 HEL-697 的整套界面设计已经白栖知验收。批准评论:01a15f42-2781-700e-b75a-3ae4b023f46c,原文:“整套认可,1选等她讲过才显示,2创建时由我选择,3首次创建时由我起名”。对应验收说明评论01a15f3b-ec7e-7bfb-bf86-9005b81b095c。
## 本次定稿
1. 她未亲口分享的生活经历,等她讲过才显示。覆盖生活列表、详情、离开摘要、关联回忆与消息中的生活引用;不能通过旧链接、搜索结果或摘要绕开。未确认已分享的记录不能当已分享展示。已有原型默认“显示并标注还没跟你说过”的分支被本条覆盖,正式前端不保留切回该分支的用户开关。
2. 首次创建时,由白栖知选择关系,不预选、不替用户决定;未选择不能完成。以后设置中修改关系按已认可设计处理:改变关系类型再次确认,保留已有记忆与信任,底色不变。
3. 首次创建时,由白栖知给她起名字,要求有效输入;主页、聊天、首建确认和设置统一使用该名字,不把“晚晚”等参考名硬编码为真实身份。不额外新增改名流程。
4. HEL-746 R2整套页面和流程认可;HEL-744定稿视觉延续。两套主题均保留;首页人物窗大小位置、首页文字分量和交流放大效果均沿定稿。不再请用户确认同一设计。
## 执行范围
启动HEL-738方案的S1:在项目仓库落地整套可构建前端、结构化状态/数据适配边界和完整演示数据,覆盖相处、交流、生活回忆、首建管理及电脑/手机。完成写码、构建、自测后交总工统一审核;审核合格部署独立测试版,再由白栖知验收。
本轮不是重做设计,也不是补齐全部后端:生活循环、真实记忆/备份、ASR/TTS、视觉观察与口型等依HEL-738后续切片接入,不以这些能力作为前端交付前置。真实能力与演示能力必须区分,尚未接通不能假称在运行。演示不能自动连接现有服务、申请麦克风或摄像头。
## 依据顺序
本次用户裁定优先于R2规范中的相关待定项和旧原型分支;其余业务以需求确认记录v1.6、最新主卡决定、HEL-738流程边界为依据。视觉以获批HEL-746 R2为依据,HEL-737私人手账稿已经否决。原型的临时初始主题不视为用户指定默认主题,保留两主题切换和用户选择。
真实后端未来提供数据时,未分享生活信息必须在服务器响应前裁剪;前端过滤不能冒充服务器权限控制。本轮只实现本地演示和适配边界,并在接线说明中明确该前置约束。
其余待定的天气来源、素材格式、HTTPS和观察时效等使用明确演示或可替换适配边界,不擅自接服务、买接口、装模型或新增产品规则。原型内演示控制只放演示模式,不混入正常产品流程。
@@ -1,486 +0,0 @@
# CyberGril 完整前端结构与可实施边界方案(HEL-738 · v1)
> 依据:HEL-697 首版需求确认记录 v1.6(31 项 CG + 9 项未定稿 T)、原仓库 CyberGril(commit `1e9b08a`,2026-10-19)实际代码、P21 源码阅读包。
> 性质:**只读分析 + 设计方案**。不写产品代码、不部署、不开施工卡。
> 状态标记(大白话区分):
> - 【已实现·有测试】仓库里真有,且测试守着
> - 【已实现·未接线】代码在,但没接真实后端/没人调用
> - 【仅规格】SPEC.md 里写了,代码没有
> - 【完全缺失】规格都没写或明确没有
> - 【拟议】本方案新提的接口,需要后续评审定稿
---
## 0. 一页结论(大白话)
**现有代码是一个"地基",不是一个产品。** 仓库里有:数据契约(7 个契约文件)、情绪动力学、八字编译、投影 φ、记忆存储、关系账本、模型路由——全部带测试。仓库里**没有**:服务器(api/ 目录不存在)、前端(一行都没有)、四个后台循环(tick/生活/整合/主动联系)、记忆检索与整合、表达管道、备份。所以"完整前端"不能假设任何后台已经在线,方案必须让前端在**纯 mock** 下就能完整设计、完整演示,接真后台是逐片替换的事。
**本方案的核心判断(对应 CG-031):**
1. 前端 = 一个"观看 + 交流 + 管理"的界面层,**它是服务器生活的视图,不是生活本身**。关掉网页她的生活继续(CG-008),回来做增量同步。
2. 画面(视频)是**生活状态的下游**:后端发"她在读书(晚上/晴/家居服)",前端据此选片播放;**前端永远不因为"手头有哪段视频"而反编生活事件**。
3. 视频/口型只是**一个可替换的呈现组件**(Presenter)。缺口型驱动器时,其余全部页面照常设计、照常演示;也不允许用全屏播放器冒充产品(CG-031)。
4. 所有界面数据带来源标签(mock / 契约已有 / 拟议),演示模式打"演示数据"角标,**不伪装已接通语音或记忆后台**。
---
## 1. 现有代码事实盘点(证据位置)
### 1.1 已有(【已实现·有测试】)
| 模块 | 文件 | 内容 | 对前端的意义 |
|---|---|---|---|
| 数据契约 | `src/cybergril/contracts/`:`state.py`(State 四维 v/a/d/e、Presence 枚举、StateBlock)、`percept.py`(Percept:speech_start/end、asr_partial/final、para、vision、env、system、silence)、`render.py`(RenderCmd:speak/expression/posture/idle/presence;visemes)、`context.py`(ContextBundle 六块)、`utterance.py`(text/speech_tags/withheld)、`audio.py`(AudioChunk、采样率)、`capabilities.py`(Capabilities/Degradation,"能力必须诚实、降级必须显式") | 全项目共同语言 | **WS 协议可以直接投影这些契约**,前端事件模型不用发明新概念 |
| 情绪动力学 | `core/mood.py`(209 行) | 衰减方程、负向偏置、麻木 | 状态是慢变量,前端展示"行为层"(她的样子),不展示数值 |
| 八字编译 | `core/bazi.py`(332 行) | 排盘→十神→derived 数值向量 | 创建向导的"生辰→底色预览"数据源 |
| 投影 φ | `core/projection.py`(131 行) | 系统事件→世界内陈述("刚才走神了") | **前端错误提示直接复用这套叙事**,不弹技术报错 |
| 记忆存储 | `memory/store.py`(200 行)+ `schema.sql` | SQLite 全套表(bazi_core、life_event、mem_episodic/semantic/relational、dyad_*、projection_gap)写入/衰减/FTS 查询 | 生活页、回忆页的表结构已定;`mem_vec` 向量表在 schema 里是注释(`schema.sql:183`),**向量检索未实现** |
| 关系账本 | `dyad/ledger.py`(108 行) | trust/intimacy/debt、未修复事件→行为偏移 | 关系设定页数据源 |
| 模型路由 | `think/router.py`、`scenes.py`、`budget.py`、`wiring.py` | 四档(none/low/standard/high)、场景→档位、预算降级 | 前端不用管;能力接口可透出"当前哪些档位可用" |
| Provider 抽象 | `providers/`:llm(echo/openai_compat)、embedding(openai_compat)、asr(scripted)、tts(silence)、turn(heuristic)、vad(energy)、avatar(**只有基类**) | 离线可跑通接口形状 | avatar provider 是空壳——**浏览器形象端从未实现** |
测试:`tests/` 共 7 个测试文件(contracts/providers/mood/bazi/projection/store/router)。
### 1.2 没有(对前端最关键)
| 缺失 | 证据 | 影响 |
|---|---|---|
| **服务器 api/** | `src/cybergril/` 下无 api 目录;SPEC 附录 A 的 `api/ws.py`、`session.py` 【仅规格】 | **没有任何 WS/HTTP 接口存在**,本方案第 5 节全部接口均为【拟议】 |
| **前端 web/** | SPEC 附录 A 设想 `web/src/Avatar.tsx` 等 【仅规格】,仓库无一行前端代码 | 前端从零开始,技术选型自由(见 5.1) |
| **四个常驻循环** | `core/` 下无 tick.py/life.py/reachout.py(README"下一步"明列未做) | "她在生活"目前没有运行时——前端演示必须 mock |
| **记忆检索/整合** | 无 retrieval.py/consolidation.py;store.py 只有 FTS 搜索 | 回忆页接真是后期切片 |
| **表达管道 express/** | 不存在;TTS 只有 silence 占位 | 语音会话依赖已有 ASR/TTS 服务另行接线(T-05) |
| **城市/天气** | 无任何代码(T-07 未定稿) | 环境条数据源待定 |
| **备份** | SPEC §11.3 【仅规格】 | 管理页的导出/恢复是拟议接口 |
### 1.3 与旧仓库冲突处,按 v1.6 用户最新决定执行
- 旧 SPEC §8.2 / `config/models.yaml:130-134` 的 VRM+three.js 形象路线 → 由 CG-002/029 覆盖为"成品视频为主、呈现器可替换";`RenderCmd` 契约保留作为指令语言。
- 旧仓库"重大经历微调出生核心"(README 三层表里 derived 可被整合器 ±0.02 微调)→ 按 CG-015 收缩为"成长层另存,八字底色只读";前端身份卡不提供任何 derived 编辑入口。
- 旧样图/卧室构图 → 只作视觉参考(CG-031),布局、导航、信息层级围绕使用流程重新设计。
---
## 2. 产品形态与用户流程(CG → 流程映射)
### 2.1 产品一句话
单一伴侣(CG-007)、电脑优先兼顾手机(CG-018)、首版内网访问(CG-020);她有后台生活(CG-008),写实成品视频呈现日常(CG-029),交谈时切到同一人物的实时半身画面(CG-004/028);文字常驻、语音可开可打断(CG-010);网页内主动联系(CG-011);生活与共同回忆可见(CG-008/015);创建一次、八字锁定(CG-012/013);外观可换人不换(CG-005/006);每日自动备份 + 手动全量导出恢复(CG-009)。
### 2.2 九条关键流程(每条:触发 → 界面行为 → 数据来源)
**F1 首次创建(CG-012/013/007)**
空库 → 创建向导:① 输入她的出生年月日+时辰(可"自动生成一组"按钮,可手动改)→ ② 实时预览排盘结果与底色倾向(结构化指标+一句白话解释,CG-013/014:展示"倾向",不展示死板台词)→ ③ 设定初始关系(朋友/恋人、称呼;默认值待用户定,T-02)→ ④ 确认。确认即锁定:八字部分永久只读,界面上不出现任何"编辑底色"入口。已有伴侣时创建入口直接禁用(单伴侣守卫),不提供切换 UI。
数据:排盘预览来自 `core/bazi.py`【已实现】;"创建后进入她已在过自己的生活"首屏叙事由生活事件回填(mock 阶段用演示数据)。
**F2 正常打开(CG-008/029)**
进入 → 首次交互门(浏览器自动播放策略需要一次点击,正好用作"推门进屋"的仪式感)→ WS hello/sync → 显示:她当前的活动(视频舞台)、环境条(城市时段/天气)、在场状态、未读的她的话。视频按 scene_key 从素材清单选片,选不到就走降级链(见 6.2),**不黑屏**。
**F3 离开后返回(CG-008/011)**
网页关闭期间服务器继续生活(前提:服务器没停机,CG-008 边界)。返回时 sync 带 `since` 游标 → 增量拉取:期间的生活事件时间线、她留的离线消息("你不在的时候"列表)、关系/状态变化。**缺席可被提及、不可被武器化**(SPEC §10.2 红线)——离线消息展示语气遵循投影 φ,不显示"我等了你好久"式内容(后端约束,前端只展示收到的内容)。
**F4 主动消息(CG-011)**
在线时:页面内非侵入提醒(角落轻提示 + 舞台她转向镜头的呈现,不打断用户当前操作)。离线后回来:并入 F3 的"你不在的时候"。首版无推送(CG-011 边界)。
**F5 语音会话:开始/结束/打断(CG-010/028/022)**
- 开始:点麦克风 → 授权提示(需 HTTPS 安全上下文,T-07)→ 会话建立 → 舞台切到交谈近景(过渡素材)→ 连续收音。
- 进行中:字幕流(asr_partial 实时显示,contracts `percept.py` 的事件形状)→ 她回复(chat.delta + 音频流 + 可选口型驱动)。
- **打断**:用户开口 → 服务端/客户端 VAD 判定 → 她 150ms 内停(SPEC 目标值,实测待定)→ 界面给"她停下来了"的轻反馈。
- 结束:点停止或闲置超时 → 停止收音 → 舞台回到日常活动 → 回到文字模式。
- 全程文字输入框不消失(CG-010:语音与文字并存)。
**F6 断线恢复**
WS 断开 → 界面进入"连接中"(她的画面停在最后一帧或低动效待机,不显示技术错误,用 φ 文案如"信号不太好")→ 指数退避重连 → 重连后带 epoch+游标重同步,期间的消息与事件按序补齐;语音会话中断则安全收尾(停收音、释放麦克风)。**旧轮结果隔离见 5.4。**
**F7 城市改变(CG-023)**
管理页 → 选择城市 → 保存 → 环境条与作息节奏切换到新城市;下次选片按新天气/时段走。**无需重建伴侣、不动记忆**。城市清单与天气源【拟议,待 T-07】。
**F8 素材替换(CG-005/006/030)**
管理页 → 素材包导入:上传素材包(含 manifest)→ 校验(格式、时长、命名、人脸一致性标记)→ 预览对比 → 应用。**只换外观:manifest 的 appearance_id 变,身份/八字/记忆/关系全部不动**(CG-006)。素材包规范 v1【拟议】(T-03),首批素材由小队制作(CG-030),导入器首版只收本地文件,无云端付费接口(CG-019)。
**F9 备份恢复(CG-009)**
管理页 → 备份区:显示每日自动备份状态(份数/空间策略【T-06 待定】);"完整导出"=下载数据库+素材包引用+设定到用户电脑;"从备份恢复"=上传包 → 校验(完整性、版本兼容、八字指纹比对:恢复的必须是同一位她)→ 确认恢复(恢复前自动再做一次当前状态导出,可反悔)。
### 2.3 明确不做(首版界面不出现)
多伴侣切换/并列(CG-007);创建后编辑八字底色(CG-013);通用人物提示词编辑器(CG-016 边界:可改的只有关系设定);云端付费生成接口入口(CG-019);手机推送(CG-011);外出场景/多房间(CG-017);把视频全屏铺满当产品(CG-031)。
---
## 3. 页面/区域结构(四类要素 → 页面模型)
### 3.1 布局模型(电脑优先,CG-018)
```
┌────────────────────────────────────────────────┬──────────────┐
│ │ 侧栏(可收起) │
│ 舞台 StageArea │ · 对话流 │
│ (可替换呈现器:视频/交谈/待机/降级图) │ · 文字输入 │
│ │ · 语音控制 │
│ 底部:环境条(时段·天气·城市)+ 她的状态(行为层) │ · 快捷入口 │
└────────────────────────────────────────────────┴──────────────┘
导航(左上/顶栏):相处(主界面)|她的生活|回忆|管理
```
- 舞台**不是**全屏播放器:它是产品的一个区域组件,四周永远有产品界面(导航、状态、输入),CG-031 明确禁止"先铺满场景再叠界面"。
- 手机端:舞台置顶 + 对话全屏切换(标签式),保画面、文字聊天和连续语音(CG-018),管理/回忆功能折叠进"更多"。
### 3.2 四类要素的落位(CG-031)
**P1 相处主界面**(默认页):舞台 + 环境条 + 在场状态 + 对话侧栏。承担 F2/F3/F4/F5 的日常界面。
**P2 交流**(与 P1 融合,不单独成页):对话流(文字气泡,她的消息支持副语言标签的可视化由视觉总监定)、语音会话控制器、字幕条、打断反馈。交谈时舞台自动切近景(CG-004)。
**P3 她的生活与共同回忆**(一页两栏或两标签):
- 她的日子:生活事件时间线(按天分组,`life_event` 表已有 told 标记→"已经跟你说过的"弱化显示);
- 共同回忆:mem_episodic(记得的片段)、mem_relational(你们的习惯/暗语/仪式)、mem_semantic(她知道的事)分层展示;成长记录(CG-015:初始喜好 vs 后天成长分开两段)。
**P4 创建与管理**:创建向导(仅首次);日常为管理面板:关系设定(CG-016)、城市(CG-023)、素材包(F8)、备份恢复(F9)、视觉观察开关(CG-022:"一扇窗",开/关状态与最近观察摘要,时效性标注)。
### 3.3 状态流转(mermaid)
```mermaid
title 用户会话与舞台状态流转
stateDiagram-v2
[*] --> ColdStart: 打开页面
ColdStart --> Connecting: WS hello/sync
Connecting --> Syncing: 已连接
ColdStart --> FirstCreate: 无伴侣(仅一次)
FirstCreate --> Syncing: 创建完成
Syncing --> DailyLive: 同步完成
DailyLive --> Talking: 语音开始/用户发文字
Talking --> DailyLive: 会话结束/她答完
DailyLive --> Offline: 断线
Talking --> Offline: 断线
Offline --> Syncing: 重连(带游标)
DailyLive --> AwayView: 用户闲置(她继续活动)
AwayView --> DailyLive: 用户回来
```
舞台呈现器内部状态(与上正交):`daily_clip → transition → talk(可带口型驱动) → transition → daily_clip`;任一态可降级为 `static_image`。
---
## 4. 统一生活状态与数据来源表
### 4.1 生活状态 LifeScene【拟议——契约已有部分投影】
后端是唯一事实源。前端收到的是一个"场景描述",而不是"播放某视频"的指令:
```json
{
"epoch": 41,
"presence": "idle_daily", // 投影 contracts/state.py Presence + 日常态
"scene": {
"mode": "daily", // daily | talk | away | asleep
"activity": "reading", // 来自 life_event/schedule
"time_band": "evening", // 由城市时区计算
"weather": "clear", // 城市天气源(T-07)
"outfit": "home"
},
"visible": "平静", // StateBlock.visible:一个词,不展示 v/a/d/e 数值
"since": "2026-10-21T19:40:00+08:00"
}
```
**铁律三条:**
1. **媒体是下游**:`scene.change` 事件驱动前端选片;前端/选片器**不产生** life_event,也不因素材缺失请求后端改生活事件(素材缺失走降级链,见 6.2)。
2. **前端是视图**:关网页不等于停机(CG-008);前端本地不保存权威状态,重连必重新 sync。
3. **状态展示走行为层**:SPEC §8.5"不要把状态画成仪表盘"——展示 visible 词、姿态、回复快慢、活动内容;数值只进调试模式(工程开关,默认隐藏)。
### 4.2 各区域数据来源一览
| 界面区域 | 数据 | 来源现状 |
|---|---|---|
| 舞台-日常 | scene + 素材 manifest | manifest【拟议】;选片【拟议】;scene 数据 life_event 表【已实现·表结构】但生成循环【完全缺失】 |
| 舞台-交谈 | 音频流 + visemes(可选) | RenderCmd.speak(ts, audio_ref, visemes) 契约【已实现】;驱动器【HEL-739 独立研究,非门禁】 |
| 对话流 | 文字 + speech_tags | Utterance 契约【已实现】;生成管道(think/context+generate)【仅规格】 |
| 字幕条 | asr_partial/final | Percept 契约【已实现】;真实 ASR 接线【T-05】 |
| 她的日子 | life_event 列表 | 表【已实现】;每日生成循环【完全缺失】 |
| 共同回忆 | mem_episodic/relational/semantic | 表与写入【已实现】;检索/整合【完全缺失】(FTS 查询有,向量检索注释着) |
| 关系卡 | dyad_state | 表与账本【已实现】;循环推进【缺失】 |
| 身份卡(只读) | bazi_core + derived 白话化 | bazi.py【已实现·有测试】 |
| 环境条 | 城市/时段/天气 | 【完全缺失】拟议 GET /api/env |
| 能力徽章 | mock/real 标签 | 投影 contracts/capabilities.py 哲学【拟议端点】 |
---
## 5. 前端技术选型、组件边界与接口示例
### 5.1 技术选型(够用且不过度设计)
- **React 18 + TypeScript + Vite**。理由:SPEC 附录 A 原设想即为 React 组件;单一桌面+移动 Web 应用,无需 SSR/Next;Vite 开发热更新快,内网部署就是静态文件 + 反代。扩展:将来要 3D/二维呈现器时按 Presenter 插件加库,不预先引入。
- **状态管理:zustand(或 React 内置)**。单伴侣单用户应用,全局状态很小(连接、场景、会话、UI 偏好),不需要 redux 级别机械。
- **样式:视觉总监定的 Token 体系落地为 CSS Variables + 常规 CSS(或 CSS Modules)**。风格不由本方案锁定。
- **不引入**:Electron(Web 已够)、大型 UI 库全家桶(组件不多,可控成本内手写 + 少量无头组件)、测试框架之外的重型设施(Vitest + Testing Library 足够)。
- 部署形态:静态构建产物由部署服务器(192.168.200.12)反代承载,与后端 WS 同源,避免跨域与混合内容问题【拟议,部署细节后续定】。
### 5.2 组件清单与职责边界
```
web/src/
app/ 布局、路由、导航、首交互门(自动播放策略)
connection/ WSClient(重连/退避/epoch)、SyncStore、useLiveState
stage/ StageArea(容器,唯一决定"用哪个呈现器")
stage/presenters/
DailyVideoPresenter 成品视频选片+双缓冲切换+过渡素材(CG-029)
TalkPresenter 交谈呈现:音频播放+可插接口型驱动器(CG-028/031)
FallbackPresenter 静态图/低动效待机(降级链末端,永不黑屏)
chat/ ChatPanel、MessageList、Composer(文字常驻)、SubtitleBar
voice/ VoiceSessionController(开始/结束/打断/权限)、
BargeInIndicator、MicPermissionGate
life/ LifeTimeline(她的日子)、GrowthPanel(初始喜好vs成长)
memory/ MemoryView(三层回忆)
manage/
CreationWizard 生辰→排盘预览→关系初值→锁定(F1)
RelationSettings 关系设定(CG-016 范围内)
CityPicker 城市(F7)
MaterialImporter 素材包导入/校验/预览(F8)
BackupPanel 备份/导出/恢复(F9)
VisionWindowToggle 视觉观察开关+摘要(CG-022)
shared/ CapabilityBadge(mock/real 角标)、PhiToast(φ 叙事化提示)、
StatusStrip、EnvBar、Skeletons、EmptyStates
```
**Presenter 接口(TS,本方案关键边界):**
```ts
interface StagePresenter {
mount(el: HTMLElement): void;
applyScene(scene: SceneDesc): Promise<void>; // 幂等;内部处理过渡
degrade(reason: string): void; // 显式降级,触发 FallbackPresenter
destroy(): void;
}
interface TalkPresenter extends StagePresenter {
attachSession(s: VoiceSession): void;
feedAudio(chunk: AudioPcmChunk): void; // 播放她的话
feedVisemes(v: number[]): void; // 有驱动器才被调用;无则忽略
interrupt(): void; // 目标 <150ms 停声停像
}
// 口型驱动器是 TalkPresenter 的注入依赖,可替换:
createTalkPresenter({ lipsync: null | 'local-w4w' | 'future-x' })
// lipsync=null 时:交谈画面退为"预设交谈近景素材/静态图 + 音频",
// 全部其他页面与流程不受影响(CG-031:不等待、不伪装)。
```
### 5.3 接口事件示例【全部拟议——api/ 完全缺失,形状投影现有契约】
一条 WebSocket(JSON 控制消息 + 二进制音频帧交错),加少量 REST。
```jsonc
// C→S
{"t":"hello","proto":1,"epoch":40,"caps":{"audio":"opus","video":false}}
{"t":"chat.send","turn_id":"c7-12","text":"今天过得怎么样"}
{"t":"voice.begin","session_id":"v3"} // 请求开麦(授权在浏览器层)
{"t":"voice.end","session_id":"v3"}
{"t":"sync","since":"2026-10-21T08:00:00Z"} // 断线/返回后增量
// S→C
{"t":"sync.done","epoch":41,"companion":{...},"scene":{...},"unread":[{"kind":"reach_out",...}]}
{"t":"scene.change","scene":{"mode":"talk","activity":"talking","time_band":"evening","weather":"clear","outfit":"home"},"reason":"voice_session"}
{"t":"presence.update","presence":"speaking"}
{"t":"chat.delta","turn_id":"c7-12","delta":"今天"}
{"t":"chat.final","turn_id":"c7-12","utterance":{"text":"今天挺好的,下午看了一会儿书[laughter]","speech_tags":["laughter"],"emotion_intent":"warm"}}
{"t":"asr.partial","session_id":"v3","text":"那你还想"}
{"t":"asr.final","session_id":"v3","text":"那你还想不想看电影"}
{"t":"interrupt.ok","session_id":"v3"} // 她已停
{"t":"reach_out","message":{"id":"r9","text":"刚看到窗外的晚霞,想跟你说一声","based_on_life_event":1234}}
{"t":"degraded","cap":"tts.speech_tags","reason":"provider_silent","phi":"(这句她的声音少了点语气)"}
{"t":"error","scope":"chat","retryable":true,"phi":"我没听清,你再说一遍?"}
```
音频帧:二进制帧头 8 字节(session_id 哈希 + seq + 方向),opus/pcm16k;细节施工时定稿。
REST【拟议】:`GET /api/companion`(只读档案)、`POST /api/companion`(创建,已存在则 409)、`PATCH /api/relation`、`GET|PUT /api/env/city`、`GET /api/media/manifest`、`POST /api/media/import`(multipart 素材包)、`GET /api/capabilities`(各能力 mock/real 标签,前端据此打角标)、`POST /api/backup/export`(下载 zip)、`POST /api/backup/restore`(上传 zip + 校验报告)。
### 5.4 取消与旧轮结果隔离(防"过期回答覆盖新问题")
1. **turn_id 配对**:客户端每个 chat.send 生成 `客户端id-自增`;所有 chat.delta/final 必须回带同 turn_id。用户放弃输入/新问题时丢弃不匹配的流;渲染层只认"当前活跃 turn_id"。
2. **epoch 门槛**:sync.done 携带 epoch(后端重启/状态纪元变化时 +1);收到旧 epoch 消息一律丢弃并触发一次 resync。
3. **语音会话隔离**:一切 asr/tts/interrupt 事件带 session_id;会话结束后的迟到帧全部丢弃;interrupt 后同 session 内更早 seq 的音频块不再播放。
4. **HTTP**:管理页所有请求走 AbortController,页面切换/重复点击即取消;导出/导入等长任务用任务句柄轮询,不悬挂按钮。
5. **重连风暴保护**:指数退避 + 抖动;重连成功先 sync 再恢复输入框可用。
### 5.5 错误与降级展示(φ 叙事化,不用技术黑话)
- 原则来自 `contracts/capabilities.py`:**降级必须显式**;但显式给的是"世界内"表达,不是堆栈。展示分层:φ 文案("她好像走神了,稍等")为主,次要系统徽章("语音暂不可用")为辅,详细原因收进"工程信息"抽屉。
- 视频素材缺失:FallbackPresenter 静态图 + 轻动效,环境条照常,绝不黑屏、绝不停摆其他区域。
- 语音不可用(无 HTTPS/拒授权/服务离线):麦克风按钮置灰 + φ 文案,文字聊天完全可用。
- 后台不可达:连接态横幅(弱提示),本地保留未发出的文字草稿,重连后由用户重发(不自动重发,避免陈词滥调式重复)。
---
## 6. 媒体适配边界
### 6.1 素材清单 manifest【拟议,规范 T-03 待定稿】
```jsonc
{
"manifest_version": 1,
"appearance_id": "her-v1", // 换外观=换这个 id(CG-006)
"clips": [
{"id":"daily__reading__evening__clear__home",
"src":"clips/daily_reading_evening_clear.mp4",
"scene":{"mode":"daily","activity":"reading","time_band":"evening","weather":"clear","outfit":"home"},
"loop":true,"duration_s":12.5,"in_point":0.0,"out_point":12.5}
],
"transitions":[{"id":"daily_to_talk","from":"daily","to":"talk","src":"clips/tr_daily_talk.mp4"}],
"talk_base":{"src":"clips/talk_base.mp4","loop":true} // 无口型驱动器时的交谈底
}
```
- 键 =(mode, activity, time_band, weather, outfit)五元组;**逻辑状态与素材解耦**(CG-017:活动/天气是逻辑状态,不用选片篡改生活记录)。
- 首版范围:一间高层卧室(CG-026 只作视觉参考)、少量服装/动作(CG-017/030),素材数量随试片扩充。
### 6.2 选片与降级链(顺序固定)
`精确匹配 → 去掉 weather → 去掉 outfit → 换近似 activity → FallbackPresenter 静态图`。每级降级在工程抽屉记录(对齐 projection_gap 的"泄漏可统计"思想),**不向生活状态倒灌任何东西**。
### 6.3 口型/交谈驱动器(可替换,非门禁)
- 驱动器是 TalkPresenter 的注入实现,候选(LiveTalking/MuseTalk/Ditto 等)由 HEL-739 独立研究选型【候选·未测】,本方案不预设结论。
- 无驱动器时:交谈呈现 = talk_base 素材(预设少量身体动作,CG-028:口型/表情/目光优先,身体预设)+ 音频 + 字幕;**产品完整可用**。
- 驱动器上线时只改注入参数,不动任何页面结构(CG-031:视频/口型仅一种可替换呈现组件)。
- 真人→二维的条件判据未定(CG-027),Presenter 抽象同时兼容二维素材包,不需要结构变更。
### 6.4 播放工程
双 `<video>` 缓冲交替切素材实现无黑帧;transition 素材垫在模式切换处(日常↔交谈,CG-004 要求自然切镜、不突兀跳切);移动端降码率副本可选(不做首版承诺);自动播放策略由首交互门解决。
---
## 7. Mock 与真实:能力标签、最小演示数据
### 7.1 能力标签(诚实三档)
每个数据区域绑定标签,来自 `GET /api/capabilities`(mock 模式下由演示脚本静态给出):
- `mock`:演示数据,UI 打"演示数据"角标(如生活时间线右上角小字);
- `real:<tier>`:真实后端(标注来源档位,如 real:echo / real:high);
- `unavailable`:能力缺失,显示 φ 化占位("这部分还在准备"),**绝不假装在线**。
演示模式全局开关:`?demo=1`。演示脚本(一个定时器驱动的假 WS 层)按剧本推 scene.change / chat.final / reach_out,让**全部九条流程(F1–F9)在没有后端的情况下走通**——这是"界面和原型阶段与真实功能施工分开"的机制保障。
### 7.2 最小演示数据约定(demo pack)
```
demo/
capabilities.json # 全部能力标 mock
companion.json # 一位已创建的她(含八字只读档案、白话底色)
life_events.json # 3 天 × 每天 1–4 条(含 1 条高显著未讲述→主动消息素材)
chat_backlog.json # 6 轮对话(含 1 条带 [laughter])
reach_out.json # 1 条主动消息
manifest.json # 4 段占位素材(早/昼/晚/夜 reading)+ 1 段过渡 + talk_base
clips/*.mp4|*.png # 占位片可用静态图代替(降级链自动接管)
timeline_script.json # 演示剧本:事件发生顺序与时刻
```
### 7.3 阶段划分(设计/原型 ≠ 真实施工)
| 阶段 | 内容 | 用什么数据 |
|---|---|---|
| A 设计 | 视觉总监产出完整界面稿与交互原型(HEL-737) | demo pack(本方案 7.2) |
| B 原型验证 | 前端壳 + mock WS 跑通 F1–F9 | demo pack |
| C 真实施工 | 按第 8 节切片逐片接真 | 逐片换 real 标签 |
---
## 8. 施工切片与验收清单(供总管排期,不在此开卡)
> 前端能立的切片尽量先立;每片验收要点独立,对齐 CG-024 的"分阶段验收"。
| # | 切片 | 依赖的后端(现状) | 验收要点 |
|---|---|---|---|
| S1 | 前端壳+路由+demo pack | 无 | F1–F9 全流程在 ?demo=1 走通;降级链全程无黑屏;手机布局不崩 |
| B1 | API 骨架:WS hello/sync/chat + REST | api/【完全缺失】→ 新建 | echo 级应答;epoch/游标正确;断线重连补齐 |
| B2 | tick+life 循环 | core/tick.py【缺失】 | 离线数小时后回来,时间线有新事件且与状态一致 |
| S2 | 文字聊天接真 | think 上下文组装+生成【仅规格】 | chat 流式;turn_id 隔离(旧回答不串台);φ 化错误 |
| S3 | 语音会话接真 | ASR/TTS 接线【T-05 待核实】 | 连续对话;打断停得下来(目标值待实测);结束即停收音 |
| B3 | 记忆检索+整合 | retrieval/consolidation【缺失】 | 回忆页三层数据真实;"她记得"与对话一致 |
| S4 | 生活/回忆页接真 | B2/B3 | 时间线、told 标记、成长分层展示正确 |
| S5 | 管理:城市/素材/备份 | env、media、backup 端点【拟议】 | F7/F8/F9 全走通;换素材后身份卡不变 |
| S6 | 视觉窗 | 4080 小视觉服务【用户部署,T-04/05】 | 开关生效;摘要带时效;关闭显示"窗户关上了" |
---
## 9. 与视觉总监(HEL-737)的对齐输入
**我方提供(本方案已定,视觉总监可直接取用)**:第 3 节页面模型、3.3 状态图、5.2 组件清单、7.2 demo pack、第 4 节各区域数据字段。视觉风格、Token、构图**由视觉总监决定**,不受旧样图锁定(CG-031)。
**需要视觉总监设计时覆盖的状态清单**(每态都需要可识别的视觉表达):
- 在场/呈现:日常活动、交谈中、待机(away)、睡眠(asleep)、降级占位
- 连接:正常、连接中、离线(φ 语气)
- 语音会话:未开启、授权中、聆听中、她说话中、被打断、已结束、不可用
- 对话:她正在输入(流式)、已说完、发送失败(可重试)
- 场景维度:早/昼/晚/夜 × 晴/阴/雨 × 居家/外出服(素材命名的视觉对照)
- 流程态:创建向导各步、导入校验中/成功/失败、备份进行中、恢复确认(高危操作的双确认样式)
- 空态:无回忆、无生活记录(新生的她)、无未读
- 角标体系:mock"演示数据"、降级徽章、视觉观察"窗"状态
**需要视觉总监回传给我方**:Token(色/字/圆角/间距/动效曲线)、桌面与手机两版布局定稿、过渡动效规范(含打断的即时反馈)、素材安全框(视频区域内 UI 避让区)——这些直接决定 5.1 样式层的实现参数。
---
## 10. 风险与尚待决定项
**风险(前端相关):**
| 风险 | 影响 | 缓解 |
|---|---|---|
| 内网 HTTPS 方案未定(T-07) | 麦克风/摄像头不可用,语音/视觉窗全灭 | S1 阶段就出"HTTP 降级态"设计;证书方案尽早定(自签+设备安装或内网 CA) |
| 素材制作跟不上切片 | 演示/施工期素材缺口 | 降级链 + 占位图规范进 demo pack;素材数量按 CG-030 试片后再定 |
| 视频自动播放/移动端功耗 | 首屏黑屏、手机发烫 | 首交互门;移动端默认低动效可选 |
| 口型研究(HEL-739)失败或延期 | 交谈近观感打折 | Presenter 已解耦:talk_base+音频先行,不阻塞任何页面(CG-031) |
| WS 重连边界(epoch/游标设计错) | 状态错乱、串台 | 5.4 五条隔离规则 + B1 验收专测 |
| 素材 manifest 版本漂移 | 升级后选片失配 | manifest 带版本,导入校验含兼容性检查 |
| 长时间线数据量 | 生活页变慢 | 分页 + 按天聚合(对齐 state_snapshot 90 天聚合思想) |
**尚待用户/总管决定(不在本卡解决,汇总供决策):**
1. 关系初始值与编辑形态(T-02):默认初识还是情侣、表单还是引导式对话设定;
2. 城市清单与天气数据源(T-07);缺失天气素材时的默认呈现;
3. 内网可信访问(HTTPS)具体方案;
4. 素材包规范 v1 定稿(T-03):格式/时长/命名/人脸一致性检查项;
5. 单伴侣"重新创建/清空"规则(T-06)——影响创建守卫与高危操作设计;
6. 语音服务(ASR/TTS)流式与打断能力的实测参数(T-05),决定 F5 的实际体验目标;
7. 主动消息频率参数化取值(CG-011:原仓库 reach_out 参数可作起点:间隔≥6h、8–23 点)。
---
## 11. 附录:CG-001~031 → 界面/流程映射总表
| CG | 落点 |
|---|---|
| 001 需求边界 | 治理原则,无界面 |
| 002 写实风格 | 舞台呈现器路线(视觉风格归 HEL-737) |
| 003 混合路线 | manifest 选片器 + TalkPresenter |
| 004 交流镜头 | 日常↔交谈转场与过渡素材 |
| 005 工程外观/自备素材 | MaterialImporter + 校验预览 |
| 006 换外观不换人 | appearance_id 机制;身份卡不可变 |
| 007 单伴侣 | 创建守卫;无切换 UI |
| 008 后台生活 | LifeScene 唯一事实源 + 返回增量同步 |
| 009 备份恢复 | BackupPanel(F9) |
| 010 连续语音+文字 | VoiceSessionController + Composer 常驻 |
| 011 主动联系 | 页内轻提醒 + 离线消息列表(F3/F4) |
| 012 生辰创建 | CreationWizard 步骤①② |
| 013 底色只读 | 身份卡只读 + 无编辑入口 |
| 014 诠释空间 | 底色展示为倾向词+白话解释 |
| 015 初始习惯与成长 | GrowthPanel 两段分层 |
| 016 关系可调 | RelationSettings(仅关系范围) |
| 017 场景范围 | scene_key 维度约束(一间卧室) |
| 018 电脑优先 | 布局模型 + 手机适配 |
| 019 素材费用 | 仅本地素材包导入,无付费入口 |
| 020 内网访问 | 部署约束 + HTTPS 待定项 |
| 021 按任务选模型 | 能力接口透出,前端不感知 |
| 022 摄像头范围 | VisionWindowToggle + 时效摘要 |
| 023 城市作息 | CityPicker + EnvBar |
| 024 验收顺序 | 第 8 节切片验收对齐 |
| 025 人物样图 | 视觉参考(CG-031),不锁布局 |
| 026 卧室样图 | 同上 |
| 027 真人优先二维备用 | Presenter 抽象兼容两种素材包 |
| 028 交谈动作范围 | TalkPresenter 动作范围=预设;口型表情优先 |
| 029 成品视频主路线 | DailyVideoPresenter 默认实现 |
| 030 首批素材分工 | manifest v1 由小队产出 + 导入检查 |
| 031 设计依据 | 本方案组织原则:流程先行、样图仅参考、嘴型独立 |
---
*HEL-738 · 总工 · 2026-10-21 · 本卡只交方案,未改任何业务代码/配置/PR,未启动 GPU/模型/服务,未访问 4080,未建施工卡。*
@@ -1,95 +0,0 @@
本稿供白栖知确认执行办法;不重新表决已经确认的需求,不代表界面已经验收,也不把任何未完成能力写成已实现。
## 要解决的问题
完整需求已经经过多轮逐项确认,但项目说明目前只有简介,项目没有绑定资源。需求记录、后续裁定和设计规范散在旧卡、附件中。新任务卡容易只继承最近一条反馈,施工和审核也容易把局部完成误当成整体满足需求。
本次建立统一、可追溯的需求入口。只有白栖知明确修改的要求才能覆盖原要求;缩小某阶段施工范围、技术做不到、模型登录失效、演示通过,都不能成为改需求的理由。
## 一、把完整需求留在项目中
建议在正式仓库建立以下材料,并以同一个入口链接它们:
| 材料 | 保存什么 | 如何使用 |
| --- | --- | --- |
| `docs/requirements/README.md` | 当前有效版本、完整需求入口、来源、未定项、验收状态 | 所有任务开工前先读 |
| `docs/requirements/current.md` | 所有有效产品要求、视觉要求、后续明确裁定,每条有编号和来源 | 按主题完整阅读,不能只读本卡摘录 |
| `docs/requirements/sources/` | 原始确认记录、已认可设计和主卡裁定 | 核对含义和解决冲突,保留历史 |
| `docs/requirements/changes.md` | 用户原话、来源评论、旧要求、新要求、影响范围 | 只有用户明确改变要求时才更新结论 |
| `docs/requirements/coverage.md` | 每项要求对应的实现、验证证据和未完成部分 | 每次派工、审核、交付都更新 |
整理依据包括需求确认记录 v1.6 中的 31 项产品决定、后续完整视觉访谈与获批设计、整套设计验收的三项补充,以及真实功能接入批次。不能把 v1.6 的旧待定项原封不动当成当前状态,例如首次关系已明确由用户选择。
完整当前文档必须保留以下层次:已确认要求、用户明确覆盖的旧要求、未确认提议、技术待验证项、实现与验收状态。后两项不能冒充产品决定。
原始对话来源用主卡评论链接或评论编号逐条保留。整理发现意思不一致时先核对原话;只有原话仍无法解决的真实歧义才问白栖知,不重问已经回答的选择题。
## 二、让新卡也必须读到它
### 项目级说明草案
保留原项目简介、仓库和部署信息,补充以下约束,并把仓库绑定到项目资源:
> 本项目的产品约定以正式仓库 `docs/requirements/README.md` 指向的完整当前需求为准。每张新卡、续办卡、设计卡、施工卡和审核卡,开始前必须获取并阅读该入口及相关原始来源。只有白栖知明确的新决定可以修改需求,必须记录来源与变更;阶段范围不能覆盖完整需求。任务必须列出基准版本、对应需求编号、交付内容和暂未实现项。阶段或演示通过不代表项目完成。发现本卡与完整需求冲突,先指出冲突,不擅自降低要求。
项目说明随项目任务提供,仓库中的规则负责后续设计、施工与审核。两处同时保留入口,避免只依靠某个会话的记忆或旧卡附件。CyberGril 的后续任务均应归属这个项目;归属错误也应在派工前纠正。
### 仓库必读规则草案
在仓库自有的 `AGENTS.md` 中加入以下要求,不改运行环境自动管理的指令区:
1. 开始前阅读需求入口、完整当前需求与本次关联的原始来源,记录所用版本及提交位置。
2. 本卡的摘录是施工指引,完整需求仍有效。不得因本卡没写某条就把它删掉。
3. 需求、实现方案、阶段范围分别记录;施工者不能把自己的方案改写成用户决定。
4. 实现和审核都提交需求对照表。发现旧代码偏离要求,记为欠缺,不把现状倒推成需求。
5. 用户要求的体验不能用另一种较容易实现的体验替换,例如不能把“当前一两句、历史按需展开”换成长消息滚动窗。
6. 修改需求文档须附白栖知明确批准的来源和影响说明;只有智能体提议或认可不能通过审核。
### 每张卡的固定交接项
- 基准:当前需求版本与仓库提交位置。
- 本次关联要求:编号、原意、相关来源。
- 本次交付:做到什么程度、怎样验收。
- 暂未实现:对应要求仍有效,后续由哪张卡承接。
- 需求变更:没有则写“无”;有则附白栖知明确裁定,不能只写“方案调整”。
- 审核证据:功能验证、真实画面、必要的动态过程,以及实际未验证的设备与场景。
现有未完工卡也补上这些交接项,不只约束新卡。
## 三、把约束带进验收
在仓库检查中核对需求编号、版本、必要交接项和需求变更来源是否缺失;审核人再对照原文判断实现是否符合。自动检查只能发现缺项和失配,不能判断设计是否有沉浸感,也不能代替白栖知验收。
每次交付分别写明“做了什么”“验证了什么”“还差什么”。演示验收、某批接入验收、整项目验收分开记录。全部功能未完成前,不再建议把主卡当成已完成归档。
## 四、当前前端返工聚焦两件事
### 对话显示效果
| 已确认要求 | 小样与验收必须看见的效果 |
| --- | --- |
| 展开交流后以人物为主 | 人物自然放大,与相处状态之间有连续的空间关系,退出后回到原处 |
| 默认只呈现当前一两句 | 长回复也有可读完的呈现方案;不裁掉内容,不强迫主界面堆成长聊天记录;完整记录按需展开 |
| 双方身份清楚 | 实际创建名字和用户身份可辨,不能只靠左右位置猜;完整记录同样清楚 |
| 文字柔和出现和退场 | 展示短句、长句、连续来句与发送过程的真实动态,能读清,不因每次更新反复闪烁 |
| 手机能完整交流 | 小屏、长句、弹出键盘时文字和输入操作不被人物、导航或底部区域挡住 |
长回复如何分句、停留多久、能否提前切换等属于待验证的交互方案,先用可操作的小样展示,不在此稿冒充用户已选规则。
### 整体沉浸界面
继续沿用已确认的两套主题、轻盈文字、透光浮层、底部小巧导航和空间优先的层次。空间应有光雾、景深、若隐若现的景物与舒缓变化;操作时有连续的空间过渡。人物是其中的独立视窗,完整界面覆盖相处、交流、生活回忆、创建管理。
验收看整个使用过程:进入空间、安静停留、展开交流、查看记录、进入生活回忆或管理、返回空间,以及两主题切换。滚动条、弹层、加载、失败和未接通状态也要融入同一视觉。不能只交一张好看的首页或只改聊天框。
涉及未接通能力,小样明确使用演示状态;其真实功能继续按既定批次接入。不能为了沉浸效果伪造她已经发生的生活、记忆或服务运行。
先交电脑与手机的相处、交流样图,并附能操作的动态小样,展示两主题、长句、身份区分、历史展开、交流开合及空间过渡。由白栖知判断是否达到原先想要的体验,认可后才替换正式界面;不重新表决已经认可的视觉选择。
## 五、本次拟修改范围及影响
确认后先落地项目说明、项目仓库资源、仓库需求入口和必读规则、已有任务的需求对照项,再安排上述界面小样。当前 PR 保持未合并,未获认可的界面不作为最终定稿。
这次机制建设不修改已有伴侣、名字、生日、记忆和聊天记录,不重置服务器数据,不改变底色与成长规则,不切换队员的全局模型,不合并或覆盖正式服务。界面小样使用独立预览,真实功能接入工作仍受完整需求约束。
本稿是待确认的执行办法;上述项目与仓库约束尚未写入生效,动态小样尚未制作。
@@ -1,208 +0,0 @@
# CyberGril 真实服务核查与功能接入批次方案(HEL-778 · v1)
> 依据:main 合并提交 `498429399812a0776dadd4b56662d31b455a9ea5`(第二父提交为已审核前端 `6c3b09e6936881fb3e0681ce40bf6069d2c250ac`,已核对无误);HEL-738 结构方案(本卡附件,不重复其内容,冲突处以本文为准);《整套设计验收与施工裁定-v1》;需求确认记录 v1.6。
> 性质:**只读核查 + 实施方案**。本轮未写产品代码、未改任何服务、未访问 4080、未输出任何凭据。所有尚未实施的接口均标【拟议】。
> 核查手段:main 源码全量阅读;Python 测试复跑(**78 passed**);对 .11 LLM 网关只读 GET;经既有授权 SSH 对 .12 做 systemctl / ss / docker / 进程 / 本机 HTTP 只读探测;对 8300/8090 只读 HTTP 探测。
---
## 0. 一页结论
1. **第一批该干什么已经清楚了**:把 B1 与 S2 合并为一批(本方案称 **R1**),让白栖知用普通入口在服务器上创建唯一真实档案,并与 .11 的真实模型流式文字对话。服务器是唯一事实源,重开/换设备都是同一位她。不动语音、不做生活循环、不做口型。
2. **比 HEL-738 写方案时多出一个重大事实**:.12 上已有一套**端到端验证过的全双工语音底座** `/opt/voice-agent`(FunASR 流式 ASR + CosyVoice3 流式 TTS + LiveKit + silero VAD 打断,2026-10-16 实测可用,含可用 HTTPS 自签通话页 8090)。语音批次(R5)从"从零接线"变成"复用已验证底座换上她的人格与记忆",风险大幅下降。
3. **LLM 网关 .11:8731 实测可用**:OpenAI 兼容,无需鉴权,单模型 `halogen-qwen3.8-flash-next`(262K 上下文)。现有 `providers/llm/openai_compat.py` + `think/wiring.py` 只改 `config/models.yaml` 即可接上,代码零改动。
4. **S1 前端把接缝留对了**:`ChatAdapter` 接口 + `TurnGate`(turn_id/epoch/abort 三重门槛)就是为换真实传输准备的;真实模式只需要新增一个适配器和普通入口路由,演示模式代码路径完全不动。
---
## 1. 核查证据表
### 1.1 前端(S1,`6c3b09e`,已合并 main)
| 事实 | 证据 | 结论 |
|---|---|---|
| 聊天传输是可替换接口:`send(text, turnId, epoch, signal, emit, fail?)` | `web/src/adapters/demo.ts:2-3` | 【已有可复用】R1 新增 `RealChatAdapter` 实现同接口即可,`ChatPanel.tsx:26` 只改 `useRef(new DemoAdapter())` 一处注入 |
| 旧轮隔离门槛:事件必须同时匹配 turn_id + epoch 且未 abort,非协作适配器的迟到事件同样被拒 | `web/src/adapters/demo.ts:25-33`(TurnGate),`ChatPanel.tsx:57` 每事件过 `gate.accepts` | 【已有可复用】真实 SSE 流直接受益,无需重写 |
| 服务端事件形状 `chat.delta` / `chat.final`(带 epoch + turn_id) | `web/src/domain/model.ts:61-74` | 【已有可复用】R1 服务端 SSE 事件与它一字不差,前端模型层零改动 |
| 隐私 fail-closed 裁剪:未分享事件及其关联回忆、消息全部过滤,未知引用默认拒绝 | `web/src/domain/privacy.ts:4-11` | 【已有可复用·仅前端】按裁定,真实后端必须在响应前裁剪(R2 起服务端强制),前端过滤不构成权限控制 |
| 创建守卫:名字 1–20 字无尖括号/控制符、日期合法且满 18 岁、关系必选不预选 | `web/src/domain/model.ts:76-86`(validateCreation),`CreationWizard.tsx:43` 关系无预选 | 【已有可复用】R1 服务端必须同规则复验(不信任客户端) |
| 演示档案存 `localStorage` key `cybergril.demo.v1`,导入走严格 schema 重建 | `web/src/adapters/storage.ts:2,6-32` | 【已有可复用】真实模式使用独立数据源(服务器),key 不冲突 |
| 普通入口(非 `?demo=1`)显示"服务尚未接通"卡 + 演示入口链接,不读档案 | `web/src/app/App.tsx:13,99` | 【需改】R1 在此分岔:可达且有档案→主界面;可达无档案→创建向导;不可达→维持现状卡 |
| S1 全程零网络请求、不碰 getUserMedia | `docs/frontend/IMPLEMENTATION.md` + 源码通读(无 fetch/XHR/WS) | 【已验证】演示隔离的基线成立 |
| 演示模式全局开关 `?demo=1`,演示剧本与页面分离 | `App.tsx:13`、`web/src/demo/fixtures.ts` | 【已有可复用】真实模式上线后演示路径原样保留 |
### 1.2 Python 核心(自 `1e9b08a` 起未变:`git diff 1e9b08a..main -- src/ config/ tests/` 为空;78 测试通过)
| 模块 | 关键接口(证据) | 结论 |
|---|---|---|
| 八字编译 | `pillars_from_lunar()`、`compile_pillars()→Derived`、`Derived.traits_text()` 白话倾向(`core/bazi.py:271,310,159`) | 【已有可复用】R1 创建时服务端排盘 + 底色白话直接产出 |
| LLM 适配 | `OpenAICompatLLM.stream()`(SSE 流式)、`health()`(GET /models 探活)(`providers/llm/openai_compat.py:71-76,78-134`) | 【已有可复用】对 .11:8731 协议完全匹配 |
| 模型路由 | `build_llm()` / `build_router_from_config()`:配置→provider→router 唯一接缝(`think/wiring.py:30,100`;`config/models.yaml`) | 【已有可复用】换模型只改 yaml,业务代码不碰 |
| 记忆存储 | `Store`:`add_episodic`/`decay_accessibility`/`reinforce`/`search_fts`(FTS5 已建,向量表在 `schema.sql:183` 注释)(`memory/store.py`) | 表结构可用;R1 需**增量补一张 `chat_message` 表**(schema 无聊天记录表);检索/整合【需补实现】(R3) |
| 关系账本 | `dyad/ledger.py`:`Dyad.apply`/`tick_daily_regression`/`behavior_hints` | 【已有可复用】R4 起用 |
| 情绪/投影 | `core/mood.py` 全套衰减方程;`core/projection.py` Projector | 【已有可复用】R2/R4 起用 |
| 服务器 api/ | 不存在(`src/cybergril/` 下无 api 目录) | 【需补实现】R1 新建 |
| tick/生活/reach_out 循环、检索/整合、表达管道、天气、备份 | 无任何代码(HEL-738 §1.2 结论仍成立,本轮复核确认) | 【需补实现】R2 起分批 |
### 1.3 授权服务实测结果(本轮只读探测)
| 服务 | 地址/端口 | 实测结果 | 证据 |
|---|---|---|---|
| LLM 网关(halogen) | `http://192.168.200.11:8731` | `GET /v1/models` → 200,**无鉴权头也 200**;单模型 `halogen-qwen3.8-flash-next`,上下文 262144,max_tokens 上限 65536/默认 32768 | 本轮 curl 实测;且 `/opt/voice-agent/README.md` 记载语音栈已于 2026-10-16 用它端到端验证(OpenAI 兼容) |
| ASR(FunASR 2pass 流式) | .12 `:10095`(docker `voice-funasr`,funasr-runtime-sdk-online-cpu-0.1.13,0.0.0.0) | WS 协议,流式 ~600ms + 离线纠错;已知问题:长句偶发被切成两次 final(两次触发回复),可切 mode=online 或调 VAD 尾点 | `docker ps`、`ss -tlnp`、`/opt/voice-agent/agent/stt_funasr.py:29`、README"已知限制" |
| TTS(CosyVoice3 流式) | .12 `:9880`(`/opt/voice-agent/cosyvoice/server.py`,GPU ~5.6GB 显存) | FastAPI:`/health` + `POST /tts` → 24k int16 PCM 字节流;首包 1.7–2.5s(官方 150ms 需 vLLM 后端,未开);`voices.json` 零样本音色注册 + 热加载 + 网页音色管理 | `curl /openapi.json` → `['/health','/tts']`、`tts_cosyvoice.py:22`、README |
| LiveKit SFU | .12 `:7880/:7881`(docker `voice-livekit`,host 网络) | 运行中;配套 livekit-agents 1.8.8 worker(.12:8081)内置 silero VAD,打断判定 min_silence 0.55s 已含在链路 | `docker ps`、`ps`(agent.py start)、README |
| HTTPS 通话页(先例) | `https://192.168.200.12:8090`(/opt/voice-agent/web) | 自签证书,TLS 200;浏览器"高级→继续"后麦克风可用;token 由服务端签发,密钥不进浏览器 | `ss`、`curl -k` 实测、README"浏览器通话" |
| S1 演示站 | .12 `:8300`(systemd `cybergril-s1-web.service`,`/opt/cybergril-s1/current` 指向 6c3b09e 构建,releases/current 结构) | 200 正常;静态 http.server;回滚=切符号链接 | `systemctl cat`、curl 实测。**本轮未做任何变更** |
| 口型试验(遗产) | .12 `:8010`(systemd `cybergril-musetalk-test.service`:MuseTalk + WebRTC + cosyvoice TTS) | 运行中,为口型独立线的既有验证件 | `systemctl list-units`。按边界不动 |
| 旧原型页 | .12 `:18080/:18081`(含 HEL-711 卧室分层校准等) | 存在且运行 | 按边界保留不动 |
| 摄像头/视觉 4080 | 未访问 | 按本轮边界继续禁止访问,仅列 R7 独立决策 | — |
### 1.4 普通入口如何进入真实模式;演示如何隔离;演示档案不会被当成真实伴侣
- **普通入口(R1 后)**:非 `?demo=1` 打开 → 前端 `GET /api/health`:可达 → 按 `GET /api/companion` 结果分岔(404→创建向导;200→主界面+服务器历史消息);不可达 → 维持现在的"服务尚未接通"卡。真实档案创建只写服务器 SQLite,浏览器不再持有权威状态。
- **演示隔离**:`?demo=1` 路径、localStorage key `cybergril.demo.v1`、演示剧本、演示角标全部原样保留;演示模式继续零网络请求。
- **不自动上传**:真实模式代码从不读取 `cybergril.demo.v1`,也不提供"演示档案转真实"入口;R1 验收含 Network 面板核验。浏览器里的演示数据与服务器上的她是两个世界,互不可见。
---
## 2. 第一批施工卡正文(R1:真实档案 + 真实文字对话,可直接交施工员)
> 即 HEL-738 切片 B1+S2 的合并最小版。目标一句话:**白栖知用普通入口创建唯一真实档案,和真实模型流式文字聊天,关掉浏览器再开(换设备也一样)还是同一位她。**
### 2.1 做(范围)
**A. 后端 `src/cybergril/api/`(拟议,FastAPI + uvicorn,单进程 systemd 服务)**
| 端点【拟议】 | 行为 | 错误语义 |
|---|---|---|
| `GET /api/health` | `{ok, version, epoch, companion_created, llm:{ok, model}}`;llm 探活用 `OpenAICompatLLM.health()`(GET /models,5s 超时,结果缓存 30s) | 永远 200(状态在体里) |
| `GET /api/companion` | 档案:名字、生辰+时辰、关系、称呼、八字只读白话(`compile_pillars().traits_text()` 输出);不含内部数值向量 | 404 未创建 |
| `POST /api/companion` | body `{name, birth, hour, relation, nickname?}`。服务端复验(规则与前端 `validateCreation` 一致:名字 1–20 字不含尖括号/控制符、日期合法且已满 18 岁、≥1900、relation ∈ {朋友,恋人} 且必选——不设默认值);排盘入 `bazi_core`(单行表);写档案 | 409 已有一位(单伴侣守卫);422 字段级中文错误;birth/derived **不提供任何修改端点**(八字只读守卫) |
| `PATCH /api/relation` | body `{relation}`;只改关系字段,其余不动;"再次确认"交互由前端承担 | 404 / 422 |
| `POST /api/chat` | body `{text, turn_id}` → **SSE**(`text/event-stream`)。事件复用前端 `ServerEvent` 形状:`chat.delta {epoch, turn_id, delta}`、`chat.final {epoch, turn_id, utterance{text, speech_tags}}`,另加 `chat.error {epoch, turn_id, phi, retryable}`。客户端断开(AbortController)即关闭上游 LLM 流 | 503 映射为 chat.error(phi:"她这会儿没接上话,稍后再试试",retryable:true) |
| `GET /api/messages?limit=200` | 重开/换设备恢复历史(升序返回) | — |
**B. 服务端状态与持久化**
- SQLite WAL:`/opt/cybergril-data/cybergril.db`【拟议路径】。复用 `Store`;**增量新增 `chat_message` 表**(id, ts, role, text, turn_id, status;`CREATE TABLE IF NOT EXISTS` 迁移,不动既有表)。`bazi_core` 存排盘;`meta` 表存 `epoch`(服务每次启动 +1 并持久)。
- chat 上下文组装(最小版):系统提示 = 她的名字 + 关系 + 称呼 + 八字白话 traits_text + 世界红线(不出戏、不假装有物理身体、不把"等了你很久"当武器)+ 最近 20 轮消息 + 用户文本。经 `ModelRouter` 的 `dialogue_main` 场景(high 档)。
- `config/models.yaml`【拟议改法】:三档先全指 `http://192.168.200.11:8731/v1` + `halogen-qwen3.8-flash-next`(api_key_env: null;high temperature 沿 0.85,standard 0.7,low 0.4,timeout 沿用);budget 沿用(200 万 token/天,0.7 警告,0.95 硬停)。
**C. 前端(真实模式)**
- 新增 `web/src/adapters/real.ts`【拟议】:`RealChatAdapter implements ChatAdapter`——`fetch POST /api/chat` + ReadableStream 解析 SSE,`signal` 直通 TurnGate 的 AbortController;`fail` 参数忽略。
- 普通入口路由按 1.4 节分岔;创建向导复用四步流程,去掉"本机演示"文案,第 2 步"底色"展示服务端真实排盘白话(替换现在的"示例底色"),确认步的三段动画对应真实排盘/建档。
- 关系修改接 `PATCH /api/relation`;管理页其余(城市/素材/备份)保持演示/占位态不变。
### 2.2 不做(R1 边界)
语音、生活循环、记忆检索、主动消息、天气、素材、备份、口型、视觉、多用户、鉴权体系(内网 + 单伴侣 409 守卫先行)、WebSocket(文字轮次 SSE 足够,语音批次再上 WS/RTC)。不以口型或生活引擎作为前置。
### 2.3 验收清单(总工复核用)
1. 全新浏览器普通入口 → 四步创建(关系不选无法下一步)→ 服务器 DB 出现 `bazi_core` + 档案;重复创建 → 409。
2. 发送文字:**真实流式回复**(非演示固定句"我听见了…");中途"取消本轮回复"立即停止;马上提新问题,旧流残句不串台。
3. 关浏览器重开 / 换另一台设备打开:同一位她、历史消息完整恢复(服务器为唯一事实源)。
4. `systemctl restart cybergril-api` → epoch +1,前端后续请求自动重同步,无旧事件混入。
5. LLM 不可达(验收时用 health 面板状态 + 一次真实 503 路径验证)→ φ 文案、草稿保留、重试可成功。
6. `?demo=1` 演示功能与现在完全一致;Network 面板确认演示模式零外发请求、真实模式从不读取 `cybergril.demo.v1`。
7. `curl :8310/api/health` 返回可读服务状态(含 llm.ok 与模型名)。
8. web build 通过;Python 78 测试不回归;新增端点有对应测试。
### 2.4 停止条件
若 halogen 模型对话质量明显不可用(连续乱码/系统性拒答/超时 >30s 成为常态):停止验收,记录请求与响应证据,回报总管,**不自行换模型、不开调参循环**。
### 2.5 部署与回滚(总工执行)
- 部署:`/opt/cybergril-api/releases/<ts>/` + `current` 符号链接;systemd `cybergril-api.service`;端口 **8310**(当前空闲,部署时复核);前端构建由同一服务以 StaticFiles 同源挂载(API 走 `/api` 前缀)——同源避免 CORS 与混合内容;SSE 需关闭代理缓冲(uvicorn 直连即可)。
- 回滚:`systemctl stop cybergril-api` + 符号链接切回上一 release + start;数据目录 `/opt/cybergril-data/` 永不随回滚删除。**8300 演示服务与浏览器演示档案全程不动。**
- 用户验收通过后,是否把 8300 切到新构建或以 8310 为正式入口,由总管与白栖知确认后执行。
---
## 3. 后续有限批次
依赖关系:`R1 → {R2, R3}`;`R2+R3 → R4`;`R5 依赖 R1(可并行于 R2–R4)`;`R6 依赖 R1`;`R7 依赖 R5 的 HTTPS 路径 + 用户明确授权`。
### R2 生活循环(最小版)+ 未分享裁剪 + 日子页接真
- **输入**:R1 档案与 DB;`character.yaml`(day_start_hour=7、每天 1–4 条、reach_out 参数)。
- **做**:API 进程内 asyncio 定时任务(或独立 worker 服务)每日生成 `life_event`(low 档生成、过投影 φ 成世界内陈述、记 salience、told=0);"她讲过"最小机制:对话中引用该事件即置 told=1;`GET /api/life?since=` 分页,**只返回 told=1 的事件,且响应前裁剪其一切关联引用**(回忆 lifeIds、消息 lifeIds 一并剥离,覆盖列表/详情/摘要/搜索路径——服务器侧强制,对应前端 `publicView` 的服务端版);日子页数据源换 real 标签。
- **不做**:记忆整合、主动消息、素材视频(仍静帧+文字)、生活事件编辑/删除。
- **验收**:连续 3 天每天 1–4 条;curl 逐端点核验未 told 事件及引用零泄漏;**网页关闭期间照常生成**(服务端循环,非浏览器假数据);LLM 故障时循环降级跳过或模板句,不伪造生活。
- **停止条件**:事件质量持续荒谬或预算异常 → 停循环回报。
### R3 记忆最小版 + 对话记忆
- **做**:每轮对话后写 `mem_episodic`(raw + low 档 gist 压缩 + affect 粗估);chat 上下文加 FTS top-k 检索("她记得");`reinforce`/`decay_accessibility` 定期跑批;回忆页 episodic 层接真。
- **不做**:向量检索(mem_vec 仍注释)、语义/关系层整合、identity promotion。
- **验收**:隔天提及前日话题,回复体现记忆;回忆页展示与对话一致;FTS 命中有日志可查。
- **停止条件**:检索噪声导致人设漂移 → 关检索开关回报(可配置降级为纯近轮上下文)。
### R4 主动消息 + 返回同步
- **做**:reach_out 规则引擎(间隔≥6h、8–23 点、salience≥0.6,参数照 `character.yaml`);`GET /api/sync?since=` 统一游标(life + messages + reach_out 增量);页内轻提醒 + "你不在的时候"列表;措辞过 φ(缺席可被提及、不可被武器化)。
- **不做**:手机推送、危机通道。
- **验收**:构造高显著已分享事件后,规则窗口内收到主动消息且来源可点回;离线消息语气符合红线。
### R5 语音连续对话(复用 /opt/voice-agent 底座)
- **前置**:HTTPS 方案落地(见第 5 节)+ 用户选音色(见第 4 节 D1)。
- **做**:新增一个 CyberGril 专属 agent worker(参照现有 agent.py 组装:FunASR STT + halogen LLM + CosyVoice TTS + silero VAD),system prompt 换成她的人格 + R1/R3 的档案与记忆上下文;前端聊天面板语音模式接 LiveKit client(HTTPS 页面内);字幕走 asr partial;打断沿用 VAD(0.55s 判定 + 清流);结束即停麦释放;文字模式全程并存。
- **不做**:口型(MuseTalk 8010 试验件为独立可替换线,不阻塞)、手机推送、副语言标签。
- **已知限制内置缓解**:TTS 首包 1.7–2.5s 期间显示"在想"+字幕先行;FunASR 双 final 出现频率记录,影响体验时切 mode=online。
- **验收**:连续对话 ≥3 轮;用户开口她 ~1s 内停声;结束即停收音;打断后无旧音频残留。
- **停止条件**:打断常态 >2s 或首包常态 >5s → 记录参数与日志回报总管,**不重开旧无限调参流程**。
### R6 城市/天气 + 素材包 + 真备份
- **做**:城市存档 + `GET /api/env`(时段/天气);天气源按 D2 决定(推荐 open-meteo,先实测 .12 出网连通);素材包 manifest v1 导入校验(结构/时长/命名),只换 `appearance_id`,身份卡不变;备份:`POST /api/backup/export`(zip:DB+设定+素材引用)与 restore(上传→版本兼容+**八字指纹比对=同一位她**→恢复前自动先导出当前→确认执行);systemd timer 每日自动备份(7 份滚动,拟议)。
- **验收**:F7/F8/F9 全走通;恢复后记忆与关系保留、身份唯一;换素材档案不变;备份空间受控。
### R7 视觉观察(独立决策,默认关)
- **前置**:R5 的 HTTPS 路径已跑通 + 白栖知明确授权摄像头 + 4080 使用决策(D4)。
- **做**:观察摘要带时效(前端 30s 过期演示逻辑的真实版);关闭显示"窗户关上了";隐私边界写入服务端。
- **验收**:开关生效、摘要时效、关闭后无任何采集。
---
## 4. 必须白栖知拍板的问题(均已给推荐答案,其余技术取舍由小队自行决定不升级)
| # | 问题 | 影响批次 | 推荐 |
|---|---|---|---|
| D1 | 她的声音用哪支音色 | R5 前 | 在 8090 音色管理页试听现有中文女声;都不满意就录一段 5–20s 中文参考音频做零样本克隆(页面已支持,这是"像不像"的关键) |
| D2 | 天气数据源 | R6 前 | open-meteo(免费、无 key);若 .12 出网不可达则暂用固定显示,不买付费接口 |
| D3 | 手机/电脑麦克风的可信访问方式 | R5 前 | 自签证书 + 每台设备首次"高级→继续"信任一次(8090 页已验证此路可行);不建内网 CA、不买域名公网证书、不改防火墙 |
| D4 | 是否开启视觉观察(摄像头"一扇窗"),及 4080 小视觉服务的使用方式 | R7 前 | 默认关闭;确认开启后再单独决策时段/帧率 |
---
## 5. 可信访问(HTTPS)核查结论与最少影响方案
- 事实:浏览器麦克风/摄像头要求安全上下文;**8090 通话页已用自签证书在局域网内验证过麦克风可用**(浏览器手动信任一次)。
- 推荐:R1 纯文字阶段无需 HTTPS;R5 前为正式入口(8310 或正式端口)按同一流程签一张覆盖 `192.168.200.12` 的自签证书即可。**不改证书体系、不动防火墙、不影响任何现有服务**;若未来要消除手动信任,再评估内网 CA(首版不做)。
---
## 6. 风险表
| 风险 | 影响 | 缓解 |
|---|---|---|
| halogen 单模型、无 SLA、质量未实测 | R1 回复质量不达标 | R1 验收实测 + 明确停止条件;路由/预算框架已支持随时换模型(只改 yaml) |
| .11:8731 无鉴权、内网明文 | 同网段任何人可调用 | 保持内网边界;产品侧不内置 key;后续可加访问口令(建议项,不阻塞) |
| 单用户 API 无鉴权,LAN 内他人可创建/聊天 | 档案被抢先创建 | 单伴侣 409 守卫 + 内网边界;访问口令列为 R2 建议项 |
| .12 GPU 显存共享(CosyVoice ~5.6G + musetalk 试验 + 未来视觉) | R5/R7 显存不足 | R5/R7 开工前 `nvidia-smi` 核算;口型试验件可停(有启停记录) |
| FunASR 双 final、TTS 首包 1.7–2.5s | 语音体验打折 | 已知限制,R5 方案内置缓解;vLLM TTS 后端列为后续优化 |
| SSE 被代理缓冲 | 流式变一次性 | uvicorn 直连;如加反代显式关缓冲 |
| epoch/游标边界设计错误 | 状态串台 | TurnGate 已守客户端;R1 验收专测重启/换设备场景 |
| 演示与真实模式混淆 | 用户误以为已接通 | 存储 key 分离、无自动上传、普通入口显式状态、演示角标保留 |
| 生活循环用假数据冒充 | 违反"不伪装运行"红线 | R2 验收明确服务端生成证据(DB 记录 + LLM 调用日志) |
---
## 7. 测试部署与回滚总安排
- 每批均走 HEL-749 已验证的模式:`/opt/cybergril-*/releases/<ts> + current` 符号链接 + 独立 systemd 服务 + 独立端口 + 数据目录与代码分离;8300 演示站在白栖知明确验收前不动。
- 回滚通式:`systemctl stop <svc> && ln -sfn releases/<prev> current && systemctl start <svc>`;数据目录永不删除;Git 侧对应回退 + 重新部署。
- 各批验收含"服务状态可验证"(/api/health + 日志位置 + 启停命令写入部署记录)。
---
*HEL-778 · 总工 · 2026-10-22 · 本轮只读核查 + 方案;未写产品代码、未改 .11/.12 任何服务与配置、未访问 4080、未输出凭据、未创建施工卡。*
File diff suppressed because one or more lines are too long
@@ -1,48 +0,0 @@
本稿汇总 HEL-697 主卡本轮逐项确认的视觉方向,供确认重做范围。确认后交视觉总监用于重新出样图;本稿不代表页面样图已验收。
## 产品与范围
CyberGril 是有自己生活的单伴侣。完整前端覆盖相处、文字和语音交流、她的生活与共同回忆、创建与管理。人物视频是独立展示模块,整体界面需要自己的空间、氛围与交互体系。
既有产品需求继续有效;本轮只调整视觉方向和呈现方式,不重定业务规则。HEL-738 的页面流程和实现边界可继续作为输入;HEL-737 的私人手账视觉初稿未获认可,需要按本稿重做。
## 已确认的视觉决定
1. 整体像只属于两人的数字空间,人物、交流、回忆融在其中。
2. 气质静谧梦幻、安静亲密,有空间深度和柔和微光。
3. 支持多主题。主题切换同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。
4. 首轮主题为「靛蓝月光」与「蓝紫雾光」。墨绿萤光与浅雾晨光未选入首轮;最终主题数量和默认主题尚未确定。
5. 首页空间优先,只留少量当前状态与入口,其余内容按需展开。
6. 背景由光雾、景深和若隐若现的景物融合构成。具体景物由设计师提出,并在样图中验证。
7. 平时能看到舒缓流动与景物微动;操作时有明显的景深变化和空间过渡。
8. 主要内容采用透光浮层,背后保留空间光影;文字区域保证清晰可读。
9. 文字现代轻盈、字形简洁、标题克制,正文易读。字体和字号由样图验证。
10. 强调色使用少量微光,集中于关键操作和选中状态;每套主题有自己的强调色。
11. 人物平时作为空间中的独立视窗,能看清她正在做什么;交流时自然放大。具体位置、比例与形状由样图验证。
12. 使用底部小巧透光的悬浮导航,图标配简短文字,选中时亮起微光。
13. 交流展开后以人物为主,只呈现当前一两句对话,完整聊天记录按需展开;文字输入和语音操作均保留。
## 建议的重做交付顺序(待本次确认)
先验证视觉最关键的部分,再展开整套设计:
第一步:提交同一布局下两套主题的电脑首页与交流展开样图,共四张;补手机首页与交流展开样图,共两张。样图必须展示完整界面、真实可读文字与操作入口,能比较人物分量、空间留白、浮层材质和两套主题的区别。
同时提交一份轻量动效原型,展示背景的舒缓变化、交流开合和主题切换。模拟数据与未接通能力须说明,不用真实口型或后台接入作为交付前置。
第二步:首轮样图经白栖知认可后,沿定稿方向补齐生活与回忆、创建与管理、完整聊天记录等页面及其状态、响应式与可施工规范。继续覆盖完整产品,不停留在人物展示页。
正式产品施工另在完整设计确认后启动。
## 样图验收要点
- 第一眼能感到静谧、梦幻、有空间深度;布局、文字与入口符合本稿已确认方向。
- 两主题沿用相同布局,背景、光影和材质有可见差异;两套都保持清晰可读。
- 首页能看到她的生活,仍有充足空间留白;展开交流后人物成为重心。
- 默认与展开状态之间有连续的空间关系,关闭后回到原来的位置。
- 电脑和手机的关键入口容易找到,人物和文字不会互相遮挡。
- 动效有实际可看的原型;具体速度、曲线、效果强度由设计师提出,允许在样图与原型验收时调整。
## 本轮边界
只重做设计样图与设计原型;不修改正式产品代码、不部署、不合并分支、不更改现有服务,也不重新启动嘴型研究。现有功能流程、已确认的产品规则和服务保持原有边界。
@@ -1,26 +0,0 @@
# 任务需求交接模板
- 基准版本:从当前入口读取,当前为1.7;记录读取时的仓库提交。
- 完整需求阅读:`docs/requirements/README.md`、`current.md`、`coverage.md` 与相关原始来源。
- 本次关联要求:编号、原意、出处;不能只写“参见需求”。
- 交付与验收:具体行为、可观察结果、实际验证方法。
- 未完成项及承接:本次暂不做的要求仍有效,说明下一批或卡片;未知写明,不能假装已完成。
- 需求变更:无;若有,附白栖知明确原话、评论编号及影响,不以方案调整代替。
- 审核证据:功能验证、电脑/手机画面、必要动态过程、未验证设备和场景。
结构化交接JSON必填键:
```json
{
"baseline_version": "1.7",
"read_commit": "实际读到的40位仓库提交",
"requirement_ids": ["VIS-013", "CG-018"],
"deliverable": "本卡实际交付",
"acceptance": ["可观察的验收项"],
"deferred": ["本卡不做但仍须后续承接的内容"],
"requirement_changes": [],
"verification": ["实际证据或清楚写明待验证"]
}
```
交付前运行 `python3 scripts/check_requirements.py --handoff <文件>`。填写占位文字不算真正交接;审核人须核对原话和实际证据。
-275
View File
@@ -1,275 +0,0 @@
[
{
"id": "01a1548a-1744-780f-9972-48b0fa078eb9",
"identifier": "HEL-698",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a1548a-55be-77eb-8b01-71ed0a37424d",
"identifier": "HEL-699",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a1548a-55f4-726d-8624-154ce928f319",
"identifier": "HEL-700",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a1548a-5631-7321-94d1-a88053ffcda9",
"identifier": "HEL-701",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a156e0-2ac3-7274-a9d2-d39f10d19581",
"identifier": "HEL-702",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a156e0-2f1d-7c55-b2e5-b169b8f6f00d",
"identifier": "HEL-703",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a15700-8422-785a-93c3-fff3351a2aa1",
"identifier": "HEL-706",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15700-add2-7bf6-a488-0e64062139f8",
"identifier": "HEL-707",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a156e0-2f57-7286-8721-7a9371ff3474",
"identifier": "HEL-704",
"status": "backlog",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a156e0-2fa0-7589-a62e-b9aa985fba95",
"identifier": "HEL-705",
"status": "backlog",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a157b9-33d0-7324-98f3-44c23ecaa171",
"identifier": "HEL-708",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a157b9-3839-733a-b363-82d7716bc7dc",
"identifier": "HEL-709",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a15830-293e-79c7-a284-20f0c526f0dd",
"identifier": "HEL-711",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15851-3750-762e-a0c3-3de0d4d50f34",
"identifier": "HEL-712",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15830-1c80-7326-8951-2176f08ba180",
"identifier": "HEL-710",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15863-1869-725f-8dae-4c8170e4efe5",
"identifier": "HEL-713",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a159a1-030a-793b-8c30-4ef799ee9941",
"identifier": "HEL-715",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a159a0-f64c-780b-8bf4-85fec219dcf0",
"identifier": "HEL-714",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a22-6fc6-7c94-b662-40599cbf73a9",
"identifier": "HEL-717",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a43-e2c2-76c7-8e4b-18330ad0fa3e",
"identifier": "HEL-718",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a57-0de5-7eaa-aecc-bdc21efb5f56",
"identifier": "HEL-719",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a69-07f6-786d-a51e-d2057b7a9a3a",
"identifier": "HEL-720",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a7f-3292-7d81-8de1-0d9628ca6c1b",
"identifier": "HEL-721",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a97-342a-7e91-b65e-8e4b7b415765",
"identifier": "HEL-722",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15aa7-f766-71a8-ad17-7da10dcd4bf7",
"identifier": "HEL-723",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15abf-566c-770a-a0a4-c1db4941c12b",
"identifier": "HEL-724",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15ad2-7eae-7b48-b4e6-e11597d4fe3e",
"identifier": "HEL-725",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15e9b-e35f-72f3-926f-23997c8e4e42",
"identifier": "HEL-737",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15e9b-e3c5-7dfe-98b8-5acf067075cb",
"identifier": "HEL-738",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15f0b-6915-7d4b-bd2f-788ff1ebd063",
"identifier": "HEL-744",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15f21-25c6-7e95-a55f-bd24a3128838",
"identifier": "HEL-746",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15f44-ac39-7b90-a4af-328fbdbfd973",
"identifier": "HEL-748",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15f45-40cf-7479-a9af-fb4199b82928",
"identifier": "HEL-749",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a161a9-13b3-7af2-8694-a28ced583f85",
"identifier": "HEL-778",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a1629e-4eff-780f-94b1-d126027b3136",
"identifier": "HEL-780",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a1629e-f348-7aff-80e5-ef64b5067501",
"identifier": "HEL-781",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a162f1-0942-7b0d-b1ba-af821ddc1594",
"identifier": "HEL-783",
"status": "in_review",
"assignee_id": "bbcc1179-3a55-48e7-a72e-c84cf79f41cb",
"verified": true
},
{
"id": "01a162f2-3fc7-7e01-9744-ef54cae2591e",
"identifier": "HEL-784",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15e9b-e42b-7c92-a925-b84802c25ab3",
"identifier": "HEL-739",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
}
]
-37
View File
@@ -1,37 +0,0 @@
[
{
"case": "valid",
"passed": true,
"observed": []
},
{
"case": "missing-coverage",
"passed": true,
"observed": [
"对照表遗漏、重复或包含不存在的要求"
]
},
{
"case": "changed-source",
"passed": true,
"observed": [
"历史来源已改变:visual-confirmation-v2.md"
]
},
{
"case": "unversioned-change",
"passed": true,
"observed": [
"需求有变但版本未更新",
"需求有变但没有追加用户授权记录",
"新需求变更不能重复使用旧批准评论"
]
},
{
"case": "stale-handoff",
"passed": true,
"observed": [
"交接使用过期基准"
]
}
]
+77
View File
@@ -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. 本批用户尚未验收,不合并、不归档、不推进到收尾。
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

+391
View File
@@ -0,0 +1,391 @@
{
"checks": [
"Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows",
"Long Chinese stream: entire user/reply retained; final keeps same DOM; one entrance across all chunks and final",
"Old sentence stays mounted during a visible 160ms fade-out, then leaves",
"12 viewport/theme combinations: long text scrolls, stage/dialog/composer/navigation never overlap; full history reachable",
"Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages",
"Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard",
"Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity",
"Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests"
],
"viewports": [
{
"width": 1440,
"height": 900,
"theme": "moon",
"dialog": {
"top": 564,
"bottom": 734,
"height": 170
},
"composer": {
"top": 746,
"bottom": 806,
"height": 60
},
"nav": {
"top": 818,
"bottom": 878,
"height": 60
},
"stage": {
"top": 72,
"bottom": 552,
"height": 480
},
"scrollHeight": 1848,
"clientHeight": 142,
"overflow": false
},
{
"width": 1440,
"height": 900,
"theme": "fog",
"dialog": {
"top": 564,
"bottom": 734,
"height": 170
},
"composer": {
"top": 746,
"bottom": 806,
"height": 60
},
"nav": {
"top": 818,
"bottom": 878,
"height": 60
},
"stage": {
"top": 72,
"bottom": 552,
"height": 480
},
"scrollHeight": 1848,
"clientHeight": 142,
"overflow": false
},
{
"width": 360,
"height": 640,
"theme": "moon",
"dialog": {
"top": 220,
"bottom": 492,
"height": 272
},
"composer": {
"top": 500,
"bottom": 556,
"height": 56
},
"nav": {
"top": 566,
"bottom": 624,
"height": 58
},
"stage": {
"top": 58,
"bottom": 212,
"height": 154
},
"scrollHeight": 2876,
"clientHeight": 244,
"overflow": false
},
{
"width": 360,
"height": 640,
"theme": "fog",
"dialog": {
"top": 220,
"bottom": 492,
"height": 272
},
"composer": {
"top": 500,
"bottom": 556,
"height": 56
},
"nav": {
"top": 566,
"bottom": 624,
"height": 58
},
"stage": {
"top": 58,
"bottom": 212,
"height": 154
},
"scrollHeight": 2876,
"clientHeight": 244,
"overflow": false
},
{
"width": 390,
"height": 700,
"theme": "moon",
"dialog": {
"top": 253,
"bottom": 552,
"height": 299
},
"composer": {
"top": 560,
"bottom": 616,
"height": 56
},
"nav": {
"top": 626,
"bottom": 684,
"height": 58
},
"stage": {
"top": 58,
"bottom": 245,
"height": 187
},
"scrollHeight": 2735,
"clientHeight": 271,
"overflow": false
},
{
"width": 390,
"height": 700,
"theme": "fog",
"dialog": {
"top": 253,
"bottom": 552,
"height": 299
},
"composer": {
"top": 560,
"bottom": 616,
"height": 56
},
"nav": {
"top": 626,
"bottom": 684,
"height": 58
},
"stage": {
"top": 58,
"bottom": 245,
"height": 187
},
"scrollHeight": 2735,
"clientHeight": 271,
"overflow": false
},
{
"width": 390,
"height": 844,
"theme": "moon",
"dialog": {
"top": 332.1875,
"bottom": 696,
"height": 363.8125
},
"composer": {
"top": 704,
"bottom": 760,
"height": 56
},
"nav": {
"top": 770,
"bottom": 828,
"height": 58
},
"stage": {
"top": 58,
"bottom": 324.1875,
"height": 266.1875
},
"scrollHeight": 2735,
"clientHeight": 335,
"overflow": false
},
{
"width": 390,
"height": 844,
"theme": "fog",
"dialog": {
"top": 332.1875,
"bottom": 696,
"height": 363.8125
},
"composer": {
"top": 704,
"bottom": 760,
"height": 56
},
"nav": {
"top": 770,
"bottom": 828,
"height": 58
},
"stage": {
"top": 58,
"bottom": 324.1875,
"height": 266.1875
},
"scrollHeight": 2735,
"clientHeight": 335,
"overflow": false
},
{
"width": 390,
"height": 420,
"theme": "moon",
"dialog": {
"top": 106,
"bottom": 272,
"height": 166
},
"composer": {
"top": 280,
"bottom": 336,
"height": 56
},
"nav": {
"top": 346,
"bottom": 404,
"height": 58
},
"stage": {
"top": 58,
"bottom": 98,
"height": 40
},
"scrollHeight": 2735,
"clientHeight": 138,
"overflow": false
},
{
"width": 390,
"height": 420,
"theme": "fog",
"dialog": {
"top": 106,
"bottom": 272,
"height": 166
},
"composer": {
"top": 280,
"bottom": 336,
"height": 56
},
"nav": {
"top": 346,
"bottom": 404,
"height": 58
},
"stage": {
"top": 58,
"bottom": 98,
"height": 40
},
"scrollHeight": 2735,
"clientHeight": 138,
"overflow": false
},
{
"width": 844,
"height": 390,
"theme": "moon",
"dialog": {
"top": 124,
"bottom": 224,
"height": 100
},
"composer": {
"top": 236,
"bottom": 296,
"height": 60
},
"nav": {
"top": 308,
"bottom": 368,
"height": 60
},
"stage": {
"top": 72,
"bottom": 112,
"height": 40
},
"scrollHeight": 2178,
"clientHeight": 72,
"overflow": false
},
{
"width": 844,
"height": 390,
"theme": "fog",
"dialog": {
"top": 124,
"bottom": 224,
"height": 100
},
"composer": {
"top": 236,
"bottom": 296,
"height": 60
},
"nav": {
"top": 308,
"bottom": 368,
"height": 60
},
"stage": {
"top": 72,
"bottom": 112,
"height": 40
},
"scrollHeight": 2178,
"clientHeight": 72,
"overflow": false
}
],
"motion": {
"entry_frames": [
{
"time": 0,
"opacity": "0",
"transform": "matrix(1, 0, 0, 1, 0, 3)"
},
{
"time": 80,
"opacity": "0.917147",
"transform": "matrix(1, 0, 0, 1, 0, 0.248559)"
},
{
"time": 200,
"opacity": "1",
"transform": "none"
}
],
"exit_frames": [
{
"time": 0,
"opacity": "1",
"retained": true
},
{
"time": 80,
"opacity": "0.0386175",
"retained": true
},
{
"time": 159,
"opacity": "3.47566e-08",
"retained": true
}
]
},
"console_errors": [],
"limits": [
"Chromium only; physical phone keyboard, Safari and screen reader not tested.",
"LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

@@ -0,0 +1,35 @@
{
"checks": [
"desktop: four-step real creation, side-effect-free lunar preview, mandatory relationship",
"real gateway: complete streamed reply and second turn recalls 蓝色风铃 from server context",
"real gateway: cancel after first delta, upstream request closed, no complete assistant row",
"independent Chromium context 390×844: same companion/history; both themes; IME and Shift+Enter",
"server restart on same origin: epoch 1→2, existing page sends automatically, data retained",
"controlled SSE model error: Chinese notice, draft retained, same-turn retry without duplicate user row",
"controlled blocked upstream: immediate cancellation/new question isolation",
"mobile: relation cancel/confirm/save restores from server; unfinished life has no demo records/console"
],
"real_dialogue": [
{
"input": "请记住我们这次聊天的暗号是蓝色风铃。简短回应一句就好。",
"reply": "好的,暗号是蓝色风铃。",
"source": "real gateway"
},
{
"input": "刚才我们的暗号是什么?请直接回答。",
"reply": "蓝色风铃。",
"source": "real gateway"
},
{
"input": "重启后继续",
"reply": "答复",
"source": "controlled provider"
},
{
"input": "新轮不会串台",
"reply": "答复",
"source": "controlled provider"
}
],
"console_errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+4
View File
@@ -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]
+60
View File
@@ -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)
-141
View File
@@ -1,141 +0,0 @@
"""Check requirement provenance and handoff completeness; no service or network access."""
import argparse
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / 'docs/requirements'
def read(path):
return json.loads(path.read_text(encoding='utf-8'))
def check(handoff=None, base_ref=None):
errors = []
baseline = read(DOCS / 'baseline.json')
rows = baseline['requirements']
ids = [r['id'] for r in rows]
known = set(ids)
evidence = {}
for path in sorted((DOCS / 'sources').glob('comment-evidence*.json')):
for comment in read(path):
old = evidence.get(comment['id'])
if old and old != comment:
errors.append(f'同一来源评论出现不同记录:{comment["id"]}')
evidence[comment['id']] = comment
if len(ids) != len(known):
errors.append('重复的需求编号')
text = (DOCS / 'current.md').read_text(encoding='utf-8')
headings = re.findall(r'^## ((?:CG|VIS|AM|UX|GOV|DEL)-\d{3}) · ', text, re.M)
if headings != ids:
errors.append('完整正文与结构化需求编号不一致')
for row in rows:
key = row['id']
if not re.fullmatch(r'(?:CG|VIS|AM|UX|GOV|DEL)-\d{3}', key):
errors.append(f'非法编号:{key}')
if not row.get('topic') or not row.get('conclusion') or not row.get('source_comment_ids'):
errors.append(f'缺少要求或来源:{key}')
if row.get('conclusion','') not in text or row.get('boundary','') not in text:
errors.append(f'完整正文缺少要求原意或边界:{key}')
for source in row.get('source_comment_ids', []):
if source not in evidence or source not in text:
errors.append(f'来源缺失:{key}/{source}')
if row.get('source_document') and not (DOCS / row['source_document']).is_file():
errors.append(f'来源文档缺失:{key}')
for name, expected in read(DOCS / 'source-manifest.json').items():
path = DOCS / 'sources' / name
if path.parent != DOCS / 'sources' or not path.is_file():
errors.append(f'来源文件缺失或路径异常:{name}')
elif hashlib.sha256(path.read_bytes()).hexdigest() != expected:
errors.append(f'历史来源已改变:{name}')
coverage = read(DOCS / 'coverage.json')
coverage_ids = [r['requirement_id'] for r in coverage['items']]
if coverage.get('baseline_version') != baseline['version']:
errors.append('对照表版本过期')
if len(coverage_ids) != len(set(coverage_ids)) or set(coverage_ids) != known:
errors.append('对照表遗漏、重复或包含不存在的要求')
for row in coverage['items']:
if not row.get('status') or not row.get('next_step') or not isinstance(row.get('evidence'), list):
errors.append(f'对照项缺少状态、证据栏或承接:{row.get("requirement_id")}')
changes = read(DOCS / 'changes.json')['entries']
if not changes or changes[-1].get('version') != baseline['version']:
errors.append('当前版本没有变更记录')
for change in changes:
source = evidence.get(change.get('approval_comment_id'), {})
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
errors.append('需求变更未关联白栖知的原始评论')
if change.get('approved_by') != baseline['owner_id'] or not change.get('summary') or not change.get('impact'):
errors.append('变更记录缺少批准者、说明或影响')
if not change.get('affected_ids') or not set(change['affected_ids']) <= known:
errors.append('变更影响编号缺失或无效')
if base_ref:
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]*', base_ref):
errors.append('非法比较引用')
else:
valid=subprocess.run(['git','rev-parse','--verify',base_ref+'^{commit}'],cwd=ROOT,capture_output=True)
if valid.returncode:
errors.append('比较引用不存在;不能跳过版本检查')
else:
def previous(name):
result=subprocess.run(['git','show',f'{base_ref}:docs/requirements/{name}'],cwd=ROOT,capture_output=True,text=True)
return json.loads(result.stdout) if not result.returncode else None
old=previous('baseline.json')
if old:
old_changes=previous('changes.json')['entries']
if changes[:len(old_changes)] != old_changes:
errors.append('历史变更记录不能改写或删除')
previous_manifest=previous('source-manifest.json')
current_manifest=read(DOCS / 'source-manifest.json')
if any(current_manifest.get(k)!=v for k,v in previous_manifest.items()):
errors.append('历史来源必须保留;后续来源以新文件追加')
if baseline != old:
if baseline['version'] == old['version']:
errors.append('需求有变但版本未更新')
if len(changes)<=len(old_changes):
errors.append('需求有变但没有追加用户授权记录')
old_approvals={c['approval_comment_id'] for c in old_changes}
if changes[-1]['approval_comment_id'] in old_approvals:
errors.append('新需求变更不能重复使用旧批准评论')
if handoff:
data = read(Path(handoff))
for key in ['baseline_version','read_commit','requirement_ids','deliverable','acceptance','deferred','requirement_changes','verification']:
if key not in data:
errors.append(f'交接缺少:{key}')
if data.get('baseline_version') != baseline['version']:
errors.append('交接使用过期基准')
if not re.fullmatch(r'[a-f0-9]{40}', data.get('read_commit','')):
errors.append('交接未记录实际仓库提交')
refs=data.get('requirement_ids', [])
if not refs or not set(refs) <= known or len(refs) != len(set(refs)):
errors.append('交接要求编号缺失、重复或无效')
for key in ['acceptance','deferred','requirement_changes','verification']:
if not isinstance(data.get(key), list):
errors.append(f'交接字段应为列表:{key}')
if not data.get('deliverable') or not data.get('acceptance') or not data.get('verification'):
errors.append('交接缺少交付、验收或验证说明')
for change in data.get('requirement_changes', []):
source = evidence.get(change.get('approval_comment_id'), {})
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
errors.append('交接需求变更缺少用户来源;先更新正式变更记录')
return errors, len(ids)
if __name__ == '__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--handoff', help='JSON交接文件')
parser.add_argument('--base-ref', help='与已获取的目标分支比较版本及历史来源')
args=parser.parse_args()
try:
errors, count=check(args.handoff, args.base_ref)
except (KeyError, TypeError, ValueError, OSError) as exc:
print(f'需求材料无法验证:{exc}', file=sys.stderr)
sys.exit(1)
if errors:
print('\n'.join(errors),file=sys.stderr)
sys.exit(1)
print(f'需求结构检查通过:{count}项;来源和覆盖完整。仍须人工核对授权语义与实际体验。')
+1
View File
@@ -0,0 +1 @@
"""R1 same-origin persistent text service."""
+13
View File
@@ -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,
)
+518
View File
@@ -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
+89
View File
@@ -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": "这是以四柱、五行、十神和已存底色为依据的角色塑造说明,不是对真人性格的测定,也不是已经发生的经历。倾向不等于固定台词或此刻情绪。",
}
+16
View File
@@ -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);
+2 -2
View File
@@ -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(
+381
View File
@@ -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"
+68
View File
@@ -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
+87
View File
@@ -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); } });
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { leaveSettingsThen } from '../shared/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DemoState, Message, Theme } from '../domain/model';
import { publicView } from '../domain/privacy';
@@ -38,14 +39,14 @@ export function App() {
notify('主题已切换,但浏览器没有允许保存偏好。');
} }, [notify]);
const view = state ? publicView(state) : null;
const navigate = useCallback((next: string) => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') {
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setWelcome(false); setControls(false); setArea(next); if (next === 'talk') {
setReturnTo('now');
setFocusLife('');
setUnread(false);
} if (next !== 'talk') {
setLog(false);
setVoice(false);
} }, []);
} }); }, []);
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
useEffect(() => { document.body.className = [creating && demo ? 'creating' : '', area === 'talk' ? 'talk' : '', !creating && ['days', 'settings'].includes(area) ? 'paned' : '', voice ? 'voice' : '', log ? 'log' : '', offline ? 'offline' : '', welcome ? 'welcoming' : '', state?.events.length === 0 ? 'data-empty' : ''].join(' '); }, [area, creating, voice, log, offline, welcome, state]);
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, loadReal, type Health } from '../adapters/real';
import type { DemoState, Message, Theme } from '../domain/model';
import { Background } from '../stage/Background';
import { FallbackPresenter } from '../stage/Presenter';
import { CreationWizard } from '../manage/CreationWizard';
import { LifePanel } from '../life/LifePanel';
import { leaveSettingsThen } from '../shared/navigation';
import { ManagePanel } from '../manage/ManagePanel';
import { ChatPanel } from '../chat/ChatPanel';
import { Button, Icon, Status } from '../shared/ui';
export function RealApp() {
const [state, setState] = useState<DemoState | null>(null), [ready, setReady] = useState(false), [creating, setCreating] = useState(false), [error, setError] = useState(''), [health, setHealth] = useState<Health>(), [area, setArea] = useState('now'), [log, setLog] = useState(false), [presence, setPresence] = useState('文字已接通');
const [theme, setTheme] = useState<Theme>(() => { try { return localStorage.getItem('cg.theme') === 'fog' ? 'fog' : 'moon'; } catch { return 'moon'; } });
const latest = useRef(state); latest.current = state;
const initialize = useCallback(async () => {
setError(''); setReady(false);
try { setHealth(await api<Health>('health')); const next = await loadReal(); setState(next); setCreating(!next); setReady(true); }
catch (e) { setError((e as Error).message || '暂时连不上真实服务。'); }
}, []);
useEffect(() => { void initialize(); }, [initialize]);
useEffect(() => { document.documentElement.dataset.theme = theme; try { localStorage.setItem('cg.theme', theme); } catch { /* Preference stays in memory. */ } }, [theme]);
useEffect(() => { document.body.className = [creating ? 'creating' : '', area === 'talk' ? 'talk' : '', ['days', 'settings'].includes(area) ? 'paned' : '', log ? 'log' : ''].join(' '); }, [area, creating, log]);
useEffect(() => { document.documentElement.classList.toggle('reduced', !!state?.settings.reduced); }, [state?.settings.reduced]);
const changeMessages = useCallback((messages: Message[]) => { if (latest.current) { const next = { ...latest.current, messages }; latest.current = next; setState(next); } }, []);
const ignoreVoice = useCallback(() => {}, []);
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setArea(next); setLog(false); }); }, []);
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) setLog(false); else navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate]);
const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined;
return <><Background/><header className="topbar"><div className="who"><i className="dot"/><span className="name">{state?.identity.name || 'CyberGril'}</span><span className="state">{ready ? '真实档案 · 文字对话' : '连接真实服务'}</span></div><div className="env"><span className="chip glass weather">天气尚未接通</span><span className="theme-switch glass">{(['moon', 'fog'] as Theme[]).map(t => <button key={t} data-t={t} aria-label={t === 'moon' ? '靛蓝月光' : '蓝紫雾光'} aria-pressed={theme === t} onClick={() => setTheme(t)}><i/><span>{t === 'moon' ? '月光' : '雾光'}</span></button>)}</span></div></header>
{!ready ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><Status text={error || '正在读取服务器档案…'} error={!!error}/>{error && <><p>请检查真实服务是否可达。资料仍在服务器保存。</p><Button primary onClick={() => void initialize()}>重新连接</Button></>}</div></main> : <>
{creating && <CreationWizard real fail={false} onCreate={setState} onEnter={() => setCreating(false)}/>}
{!creating && state && <><FallbackPresenter real scene={{ mode: area === 'talk' ? 'talk' : 'daily', activity: '文字已接通 · 生活尚未接通', time_band: '', weather: '', outfit: '' }} presence={presence} observation="" onClose={() => navigate('now')} closeLabel="回到此刻"/>
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><button className="away" onClick={() => navigate('days')}>日子与回忆 · 后台待接入 <Icon name="next"/></button>{lastReply && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onClose={() => navigate('now')} offline={false} fail={false} log={log} setLog={setLog} focusLife=""/>}
<LifePanel real state={state} open={area === 'days'} onClose={() => navigate('now')} onChat={() => { navigate('talk'); setLog(true); }}/>
<ManagePanel real state={state} open={area === 'settings'} onClose={() => navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/>
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className="nav-item" aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}</button>)}</nav></>}
</>}</>;
}
+35 -22
View File
@@ -1,10 +1,13 @@
import { SoftText } from '../shared/SoftText';
import { newId } from '../domain/id';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { DemoState, Message } from '../domain/model';
import { RealChatAdapter } from '../adapters/real';
import { DemoAdapter, TurnGate } from '../adapters/demo';
import { publicView } from '../domain/privacy';
import { Icon, Button, Modal } from '../shared/ui';
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife }: {
import { Icon, Modal } from '../shared/ui';
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onClose, offline, fail, log, setLog, focusLife, real = false }: {
real?: boolean;
state: DemoState;
onChange: (messages: Message[]) => void;
onPresence: (p: string) => void;
@@ -18,22 +21,24 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
focusLife: string;
}) {
const [draft, setDraft] = useState(() => { try {
return sessionStorage.getItem('cg.draft') || '';
return sessionStorage.getItem(real ? 'cg.real.draft' : 'cg.draft') || '';
}
catch {
return '';
} }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
const gate = useRef(new TurnGate()), adapter = useRef(new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
const gate = useRef(new TurnGate()), adapter = useRef(real ? new RealChatAdapter(epoch => { gate.current.epoch = epoch; }) : new DemoAdapter()), live = useRef(state.messages), logRef = useRef<HTMLDivElement>(null), voiceController = useRef<AbortController | null>(null);
const [activeTurn, setActiveTurn] = useState(''), [interrupted, setInterrupted] = useState<{ text: string; turn: string } | null>(null);
const replyLive = useRef(''), turnLive = useRef(''), dialogue = useRef<HTMLDivElement>(null), following = useRef(true);
live.current = state.messages;
const view = publicView(state), lastUser = [...view.messages].reverse().find(m => m.role === 'user'), lastHer = [...view.messages].reverse().find(m => m.role === 'companion');
const change = (messages: Message[]) => { live.current = messages; onChange(messages); };
function stop() { gate.current.cancel(); setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
function stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); }
function endVoice() { stop(); voiceController.current?.abort(); setVoice(false); onVoice(false); onPresence('在你面前'); }
useEffect(() => { const current = gate.current; return () => { current.cancel(); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation]);
useEffect(() => { const current = gate.current; return () => { current.cancel(); if (real) onChange(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); voiceController.current?.abort(); onVoice(false); onObservation(''); }; }, [onVoice, onObservation, onChange, real]);
useEffect(() => { try {
sessionStorage.setItem('cg.draft', draft);
sessionStorage.setItem(real ? 'cg.real.draft' : 'cg.draft', draft);
}
catch { /* Draft remains in memory. */ } }, [draft]);
catch { /* Draft remains in memory. */ } }, [draft, real]);
useEffect(() => { if (offline) {
gate.current.resync();
setBusy(false);
@@ -51,17 +56,19 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
else if (logRef.current)
logRef.current.scrollTop = logRef.current.scrollHeight;
} }, [log, focusLife, view.messages.length]);
async function send(retry?: Message) { const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
setDraft(''); setBusy(true); onPresence('在想'); setNotice(''); try {
useLayoutEffect(() => { if (following.current && dialogue.current) dialogue.current.scrollTop = dialogue.current.scrollHeight; }, [reply, state.messages, interrupted]);
async function send(retry?: Message) { if (!retry && real) retry = [...live.current].reverse().find(m => m.role === 'user' && m.status === 'failed' && m.text === draft.trim()); const text = retry?.text || draft.trim(); if (!text || offline)
return; stop(); const turnId = retry?.turnId || newId(), signal = gate.current.begin(turnId); const msg: Message = { id: retry?.id || newId(), role: 'user', text, lifeIds: [], turnId, status: 'pending' }; change(retry ? live.current.map(m => m.id === retry.id ? msg : m) : [...live.current, msg]); if (!retry)
setDraft(''); setActiveTurn(turnId); turnLive.current = turnId; following.current = true; setBusy(true); onPresence('在想'); setNotice(''); try {
await adapter.current.send(text, turnId, gate.current.epoch, signal, event => { if (!gate.current.accepts(event))
return; if (event.t === 'chat.delta') {
setReply(x => x + event.delta);
replyLive.current += event.delta; setInterrupted(null); setReply(replyLive.current);
onPresence('在说话');
}
else {
else if (event.t === 'chat.final') {
change([...live.current.map(m => m.id === msg.id ? { ...m, status: 'sent' as const } : m), { id: newId(), role: 'companion', text: event.utterance.text, lifeIds: [], turnId }]);
setReply('');
replyLive.current = ''; setInterrupted(null); setReply('');
if (real) setDraft(current => current === text ? '' : current);
setBusy(false);
onPresence(voice ? '在听你说(模拟)' : '在你面前');
} }, fail);
@@ -71,16 +78,23 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
return;
change(live.current.map(m => m.id === msg.id ? { ...m, status: 'failed' } : m));
setBusy(false);
setNotice((e as Error).message);
if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnId }); replyLive.current = ''; setReply('');
setDraft(text);
setNotice(real && (e instanceof TypeError || e instanceof SyntaxError) ? '连接中断了,你写的话还在,可以重试。' : (e as Error).message);
onPresence('在你面前');
} }
const setVoiceMode = () => { if (voice) {
const setVoiceMode = () => { if (real) { setNotice('语音尚未接通,文字仍可使用。'); return; } if (voice) {
endVoice();
return;
} setPermission('mic'); };
return <><div className="dialog"><div className="you">{lastUser && <>你 · {lastUser.text.slice(-48)}{lastUser.status === 'failed' && <><span className="failed">没发出去</span><button className="retry" onClick={() => void send(lastUser)}>重试</button></>}</>}</div><div className="her" aria-live="polite"><em>{state.identity.name}</em>{reply || (busy ? '她在想…' : lastHer?.text.slice(-90) || '我在。你想说什么?')}{busy && <span className="caret"/>}</div><div className="hint">{notice || '演示回复 · 没有连接模型、语音或记忆后台'}</div></div>
const herText = reply || interrupted?.text || lastHer?.text || '我在。你想说什么?';
const herTurn = reply ? activeTurn : interrupted ? interrupted.turn : lastHer?.turnId || lastHer?.id || 'greeting';
return <><div className="chat-layout"><div className="talk-scene-space" aria-hidden="true"/><div className="dialog"><div className="dialog-scroll" ref={dialogue} tabIndex={0} aria-label="当前对话,可滚动阅读全文" onScroll={() => { const el = dialogue.current; if (el) following.current = el.scrollHeight - el.clientHeight - el.scrollTop < 40; }}>
{lastUser && <div className="you"><div className="speaker">你</div><SoftText identity={lastUser.turnId || lastUser.id} text={lastUser.text}/>{lastUser.status === 'failed' && <div className="failed">没发出去 <button className="retry" onClick={() => void send(lastUser)}>重试</button></div>}{lastUser.status === 'cancelled' && <small>本轮已取消</small>}</div>}
<div className={`her ${interrupted ? 'cut' : ''}`}><div className="speaker">{state.identity.name}</div><SoftText identity={herTurn} text={herText}/>{reply && <span className="caret"/>}{interrupted && <small className="partial-note">这段未完成,未存入聊天记录</small>}</div>
</div><div className="hint"><span role="status">{state.identity.name} · {notice || (busy ? reply ? '正在回应…' : `${state.identity.name}在想…` : real ? '文字已接通 · 完整内容可滚动阅读' : '演示回复 · 未连接真实模型')}</span>{busy && <button className="btn-text" onClick={stop}>取消本轮回复</button>}</div></div>
{voice && <div className="voicebar glass"><span className="live"><i /></span><span className="vstate">{busy ? '她在想(模拟)' : '在听你说(模拟,无收音)'}</span><button className="btn-ghost" onClick={() => { stop(); setNotice(fail?'刚才没听清,你再说一遍?(模拟识别失败)':'她停下来了,在等你。'); }}>打断她</button><button className="end" onClick={endVoice}>结束语音</button></div>}
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (vision) {
<div className="composer glass"><button className={`btn-round cam ${vision ? 'on' : ''}`} aria-label="视觉观察" onClick={() => { if (real) { setNotice('视觉尚未接通,不采集影像。'); return; } if (vision) {
setVision(false);
onObservation('窗户关上了');
}
@@ -88,9 +102,8 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
setPermission('vision'); }}><Icon name="eye"/></button><textarea aria-label="想和她说的话" rows={1} maxLength={4000} value={draft} placeholder="想和她说点什么…" onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault();
void send();
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
<aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">本机演示记录</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
{busy && <div className="voicebar glass" style={{ opacity: voice ? 0 : 1, pointerEvents: voice ? 'none' : 'auto' }} hidden={voice}><Button onClick={stop}>取消本轮回复</Button></div>}
} }}/><button className="btn-round send" disabled={!draft.trim() || offline} aria-label="发送" onClick={() => void send()}><Icon name="up"/></button><button className={`btn-round log-btn ${log ? 'on' : ''}`} aria-label="完整记录" onClick={() => setLog(!log)}><Icon name="chat"/></button><button className={`mic ${denied ? 'denied' : ''}`} aria-label={real ? '语音尚未接通' : voice ? '停止语音' : '开始模拟语音'} disabled={offline} onClick={setVoiceMode}><Icon name={voice ? 'stop' : 'mic'}/></button></div>
</div><aside className="log-panel glass read" hidden={!log}><header><h2>你们说过的话</h2><button className="btn-round sm" aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header><div className="scroll" ref={logRef}><div className="day">{real ? '服务器聊天记录' : '本机演示记录'}</div>{view.messages.map(m => <div key={m.id} data-life={m.lifeIds[0]} className={`msg ${m.role === 'user' ? 'me' : ''} ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="who-l">{m.role === 'user' ? '你' : state.identity.name}{m.proactive && <span className="tagm proactive">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <small>已取消</small>}</div>)}</div></aside>
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') {
setDenied(false);
setVoice(true);
+2
View File
@@ -59,6 +59,8 @@ export interface SceneDesc {
outfit: string;
}
export type ServerEvent = {
t: 'chat.error'; epoch: number; turn_id: string; phi: string; retryable: boolean;
} | {
t: 'chat.delta';
epoch: number;
turn_id: string;
+5 -4
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import type { DemoState } from '../domain/model';
import { publicView } from '../domain/privacy';
import { Button, Icon } from '../shared/ui';
export function LifePanel({ state, open, onClose, onChat }: {
export function LifePanel({ state, open, onClose, onChat, real = false }: {
real?: boolean;
state: DemoState;
open: boolean;
onClose: () => void;
@@ -20,7 +21,7 @@ export function LifePanel({ state, open, onClose, onChat }: {
} }, [open]);
const event = tab === 'life' ? view.events.find(e => e.id === selection) : undefined, memory = tab === 'memory' ? view.memories.find(m => m.id === selection) : undefined;
const select = (id: string, t = tab) => { setTab(t); setSelection(id); };
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><h2>日子</h2><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => { setTab(t); setSelection(''); }}>{l}</button>)}</div><span className="grow"/><span className="tag-demo">演示数据</span><button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><div className="list" ref={list}>
{tab === 'life' ? <><div className="away-card"><b>你不在时</b>这里只显示她已经讲给你听的事。演示没有真实后台生活。</div><div className="legend"><span><i className="k done"/>已发生</span><span><i className="k now"/>正在</span><span><i className="k plan"/>打算</span></div>{view.events.length === 0 ? <div className="empty">她今晚刚住进来,还没有发生什么。<small>演示资料可以从「演示场景」载入。</small></div> : view.events.slice(0, limit).map((e, i) => <div key={e.id}>{(i === 0 || view.events[i - 1].time.split(' ')[0] !== e.time.split(' ')[0]) && <div className="grp">{e.kind === 'plan' ? '之后的打算' : e.time.split(' ')[0]}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sub"><span className="tag">已经讲过</span>{e.memoryIds.length > 0 && <span className="tag accent">和你有关</span>}</span></span></button></div>)}{limit < view.events.length && <div className="more"><Button onClick={() => setLimit(x => x + 12)}>看更早的日子</Button></div>}</> : view.memories.length === 0 ? <div className="empty">聊着聊着就会有了。</div> : ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'].map(group => <div key={group}><div className="grp">{group}</div>{view.memories.filter(m => m.group === group).map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}>{m.title}<small>{m.source}</small></button>)}</div>)}
</div><article className="detail"><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>{event ? <><div className="d-kicker"><i className={`k ${event.kind}`}/>{event.kind === 'now' ? '正在' : event.kind === 'plan' ? '打算 · 计划可能会变' : '已经发生'}<span className="tag">已经讲给你听</span></div><h3 className="d-title">{event.title}</h3><div className="d-meta">{event.time} · {event.place}</div><blockquote className="quote">{event.text}<small>{view.identity.name}说过的话 · 演示</small></blockquote><div className="d-sec">和这件事有关的回忆</div><div className="links">{event.memoryIds.map(id => { const m = view.memories.find(x => x.id === id); return m && <button className="link-chip" key={id} onClick={() => select(id, 'memory')}><Icon name="link"/>{m.title}</button>; })}</div><div className="d-sec">那时候的聊天</div><Button onClick={() => onChat(event.id)}>看那段聊天</Button><div className="d-foot">这是模拟生活里的一条记录,不是真实发生的事。画面只按状态展示,不会反过来改写生活。</div></> : memory ? <><div className="d-kicker">{memory.group} · 演示回忆</div><h3 className="d-title">{memory.title}</h3><div className="d-meta">{memory.source}</div><blockquote className="quote">{memory.text}</blockquote>{memory.group === '她的成长' && <div className="growth"><div><h4>最初就有的</h4>细腻、慢热</div><div><h4>后来慢慢有的</h4>{memory.text}</div></div>}<div className="d-sec">来自这些日子</div><div className="links">{memory.lifeIds.map(id => { const e = view.events.find(x => x.id === id); return e && <button className="link-chip" key={id} onClick={() => select(id, 'life')}>{e.title}</button>; })}</div></> : selection ? <div className="empty">这条记录暂时不可见。<small>只会展示她已分享的事。</small></div> : <div className="d-empty"><div><Icon name="book"/>选一件小事,慢慢读。</div></div>}</article></div></section>;
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><h2>日子</h2><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => { setTab(t); setSelection(''); }}>{l}</button>)}</div><span className="grow"/><span className="tag-demo">{real ? '后台待接入' : '演示数据'}</span><button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><div className="list" ref={list}>
{tab === 'life' ? <><div className="away-card"><b>你不在时</b>{real ? '这里只会显示她已经讲给你听的事。生活循环尚未接通,当前没有可核实的生活记录。' : '这里只显示她已经讲给你听的事。演示没有真实后台生活。'}</div><div className="legend"><span><i className="k done"/>已发生</span><span><i className="k now"/>正在</span><span><i className="k plan"/>打算</span></div>{view.events.length === 0 ? <div className="empty">{real ? '还没有可展示的生活记录' : '她今晚刚住进来,还没有发生什么。'}<small>{real ? '后台待接入,不用示例经历填充。你们说过的话可在交谈的完整记录中查看。' : '演示资料可以从「演示场景」载入。'}</small></div> : view.events.slice(0, limit).map((e, i) => <div key={e.id}>{(i === 0 || view.events[i - 1].time.split(' ')[0] !== e.time.split(' ')[0]) && <div className="grp">{e.kind === 'plan' ? '之后的打算' : e.time.split(' ')[0]}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sub"><span className="tag">已经讲过</span>{e.memoryIds.length > 0 && <span className="tag accent">和你有关</span>}</span></span></button></div>)}{limit < view.events.length && <div className="more"><Button onClick={() => setLimit(x => x + 12)}>看更早的日子</Button></div>}</> : view.memories.length === 0 && !real ? <div className="empty">聊着聊着就会有了。</div> : ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'].map(group => <div key={group}><div className="grp">{group}</div>{real && <div className="empty">暂无可核实的回忆<small>长期记忆尚未接通;文字聊天记录独立保留。</small></div>}{view.memories.filter(m => m.group === group).map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}>{m.title}<small>{m.source}</small></button>)}</div>)}
</div><article className="detail"><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>{event ? <><div className="d-kicker"><i className={`k ${event.kind}`}/>{event.kind === 'now' ? '正在' : event.kind === 'plan' ? '打算 · 计划可能会变' : '已经发生'}<span className="tag">已经讲给你听</span></div><h3 className="d-title">{event.title}</h3><div className="d-meta">{event.time} · {event.place}</div><blockquote className="quote">{event.text}<small>{view.identity.name}说过的话 · 演示</small></blockquote><div className="d-sec">和这件事有关的回忆</div><div className="links">{event.memoryIds.map(id => { const m = view.memories.find(x => x.id === id); return m && <button className="link-chip" key={id} onClick={() => select(id, 'memory')}><Icon name="link"/>{m.title}</button>; })}</div><div className="d-sec">那时候的聊天</div><Button onClick={() => onChat(event.id)}>看那段聊天</Button><div className="d-foot">这是模拟生活里的一条记录,不是真实发生的事。画面只按状态展示,不会反过来改写生活。</div></> : memory ? <><div className="d-kicker">{memory.group} · 演示回忆</div><h3 className="d-title">{memory.title}</h3><div className="d-meta">{memory.source}</div><blockquote className="quote">{memory.text}</blockquote>{memory.group === '她的成长' && <div className="growth"><div><h4>最初就有的</h4>细腻、慢热</div><div><h4>后来慢慢有的</h4>{memory.text}</div></div>}<div className="d-sec">来自这些日子</div><div className="links">{memory.lifeIds.map(id => { const e = view.events.find(x => x.id === id); return e && <button className="link-chip" key={id} onClick={() => select(id, 'life')}>{e.title}</button>; })}</div></> : selection ? <div className="empty">这条记录暂时不可见。<small>只会展示她已分享的事。</small></div> : <div className="d-empty"><div><Icon name="book"/>{real ? '这里会保留日子详情、回忆与聊天的关联。后台接通后,已分享的内容会出现在列表中。' : '选一件小事,慢慢读。'}</div></div>}</article></div></section>;
}
+3 -1
View File
@@ -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>);
+16 -8
View File
@@ -1,3 +1,4 @@
import { api, type Color, type RealIdentity } from '../adapters/real';
import { newId } from '../domain/id';
import { useState } from 'react';
import { validateCreation, type Identity } from '../domain/model';
@@ -5,21 +6,28 @@ import { freshState } from '../demo/fixtures';
import { delay } from '../adapters/demo';
import { BaseColor, Button, Field, Status } from '../shared/ui';
import type { DemoState } from '../domain/model';
export function CreationWizard({ onCreate, onEnter, fail }: {
export function CreationWizard({ onCreate, onEnter, fail, real = false }: {
real?: boolean;
onCreate: (s: DemoState) => void;
onEnter: () => void;
fail: boolean;
}) {
const [step, setStep] = useState(0), [name, setName] = useState(''), [birth, setBirth] = useState('2000-10-18'), [hour, setHour] = useState('亥时'), [relation, setRelation] = useState(''), [nickname, setNickname] = useState(''), [agreed, setAgreed] = useState(false), [error, setError] = useState(''), [phase, setPhase] = useState(-1), [busy, setBusy] = useState(false);
const [color, setColor] = useState<Color>();
const labels = ['出生资料', '她的底色', '你们的关系', '最终确认'];
function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
async function next() { const err = validateCreation(name, birth, step === 2 ? relation : '朋友'); if (err) {
setError(err);
return;
} setError(''); setStep(s => s + 1); }
} setError(''); if (real && step === 0) { setBusy(true); try { setColor(await api<Color>('companion/preview', { method: 'POST', body: JSON.stringify({ name, birth, hour }) })); } catch (e) { setError((e as Error).message); return; } finally { setBusy(false); } } setStep(s => s + 1); }
async function create() { const err = validateCreation(name, birth, relation); if (err || !agreed) {
setError(err || '请先确认你已了解。');
return;
} setBusy(true); setError(''); try {
if (real) {
setPhase(0);
const identity = await api<RealIdentity>('companion', { method: 'POST', body: JSON.stringify({ name, birth, hour, relation, nickname }) });
setPhase(2); onCreate({ ...freshState(identity), messages: [], events: [], memories: [] }); setStep(4); return;
}
const c = new AbortController();
for (let p = 0; p < 3; p++) {
setPhase(p);
@@ -37,11 +45,11 @@ export function CreationWizard({ onCreate, onEnter, fail }: {
finally {
setBusy(false);
} }
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
{step === 0 && <><h2>先认识她的开始</h2><p className="lead">名字由你起,生辰也可以在确认前修改。</p><Field label="她的名字"><input className="input" value={name} maxLength={20} onChange={e => setName(e.target.value)} autoComplete="off"/></Field><Field label="出生日期"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰"><select className="select" value={hour} onChange={e => setHour(e.target.value)}>{['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'].map(h => <option key={h}>{h}</option>)}</select></Field><Button onClick={() => { setBirth(`${1990 + Math.floor(Math.random() * 13)}-${String(1 + Math.floor(Math.random() * 12)).padStart(2, '0')}-18`); }}>自动生成一组生辰</Button></>}
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor /></>}
return <main className="create"><div className="story"><div className="eyebrow">一扇窗 · 一个开始</div><h1>{step === 4 ? '她住进来了。' : step === 2 ? '你想和她,怎样相处?' : '在夜色里,留一盏灯。'}</h1><p>{real ? '资料与对话保存在服务器,重开或换设备都能恢复同一位她。最终确认前不会建档。' : '这是本机演示。你的选择只保存在这个浏览器,没有创建服务器上的真实伴侣。'}</p><div className="single">首版只有一位。创建后不提供切换或清空重建。</div></div><section className="wiz glass read"><div className="steps">{labels.map((l, i) => <span className={i <= step ? 'on' : ''} key={l}/>)}</div><div className="steplabel">{step < 4 ? `${step + 1} / 4 · ${labels[step]}` : '创建完成'}</div><div className="wbody"><div className="wstep on" key={step}>
{step === 0 && <><h2>先认识她的开始</h2><p className="lead">名字由你起,生辰也可以在确认前修改。</p><Field label="她的名字"><input className="input" value={name} maxLength={20} onChange={e => setName(e.target.value)} autoComplete="off"/></Field><Field label="出生日期(公历)" hint="下一步会换算真实农历,并显示四柱与说明。"><input className="input" type="date" value={birth} onChange={e => setBirth(e.target.value)}/></Field><Field label="出生时辰" hint="北京时间;子时23:00–00:59,丑时01:00–02:59,之后每两小时一段。排盘取时辰内偶数整点,子时取本日00:00。"><select className="select" value={hour} onChange={e => setHour(e.target.value)}>{['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'].map(h => <option key={h}>{h}</option>)}</select></Field><Button onClick={() => { setBirth(`${1990 + Math.floor(Math.random() * 13)}-${String(1 + Math.floor(Math.random() * 12)).padStart(2, '0')}-18`); }}>自动生成一组生辰</Button></>}
{step === 1 && <><h2>{name.trim()}的底色</h2><p className="lead">底色是倾向,会留下诠释和成长的空间。</p><BaseColor color={real ? color : undefined}/></>}
{step === 2 && <><h2>由你选择关系</h2><p className="lead">没有预选答案。以后可以在设置中确认修改,记忆会保留。</p><div className="seg">{['朋友', '恋人'].map(r => <button aria-pressed={relation === r} key={r} onClick={() => setRelation(r)}>{r}</button>)}</div><Field label="她怎么称呼你(可选)"><input className="input" maxLength={40} value={nickname} onChange={e => setNickname(e.target.value)}/></Field></>}
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>我明白:这是本机演示;创建后底色只读,只保留一位伴侣。</label>{phase >= 0 && <ul className="phases">{['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)'].map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{step === 3 && <><h2>让这次相识,有一个开始</h2><div className="sum">{[['名字', name], ['生辰', `${birth} · ${hour}`], ['关系', relation], ['称呼', nickname || '暂时不设']].map(([k, v]) => <div className="r" key={k}><span>{k}</span><b>{v}</b></div>)}</div><BaseColor color={real ? color : undefined}/><label className="check"><input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)}/>{real ? '我明白:这将创建服务器上的真实档案;创建后底色只读,只保留一位伴侣。' : '我明白:这是本机演示;创建后底色只读,只保留一位伴侣。'}</label>{phase >= 0 && <ul className="phases">{(real ? ['核对真实排盘', '保存唯一档案', '档案已保存'] : ['排好底色(示例)', '形成最初的小习惯(示例)', '布置房间(示例)']).map((p, i) => <li key={p} className={i < phase ? 'ok' : i === phase ? (error ? 'fail' : 'run') : ''}><i>{i < phase ? '✓' : ''}</i>{p}</li>)}</ul>}</>}
{step === 4 && <div className="born"><div className="halo"/><h2>{name.trim()}住进来了</h2><p className="lead">刚开始,还没有共同的日子。聊着聊着就会有了。</p></div>}
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary onClick={next}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
<Status text={error} error/></div></div><footer className="wfoot">{step > 0 && step < 4 && <Button disabled={busy} onClick={() => { setError(''); setStep(error ? 0 : step - 1); }}>{error ? '返回修改' : '上一步'}</Button>}<span className="grow"/>{step < 3 ? <Button primary disabled={busy} onClick={() => void next()}>下一步</Button> : step === 3 ? <Button primary disabled={!agreed || busy} onClick={() => void create()}>{busy ? '正在创建…' : error ? '再试一次' : '确认创建'}</Button> : <Button primary onClick={onEnter}>去看看她</Button>}</footer></section></main>;
}
+40 -13
View File
@@ -1,10 +1,12 @@
import { api, type Health, type RealIdentity } from '../adapters/real';
import { useEffect, useRef, useState } from 'react';
import type { DemoState, Settings, Theme } from '../domain/model';
import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme }: {
export function ManagePanel({ state, open, onClose, onSave, fail, theme, setTheme, real = false }: {
real?: boolean;
state: DemoState;
open: boolean;
onClose: () => void;
@@ -14,10 +16,13 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
setTheme: (t: Theme) => void;
}) {
const [section, setSection] = useState('her'), [detail, setDetail] = useState(false), [draft, setDraft] = useState(() => ({ settings: structuredClone(state.settings), identity: structuredClone(state.identity) })), [message, setMessage] = useState(''), [error, setError] = useState(false), [busy, setBusy] = useState(false), [confirm, setConfirm] = useState(''), [confirmText, setConfirmText] = useState(''), [search, setSearch] = useState(''), [restore, setRestore] = useState<DemoState | null>(null), [material, setMaterial] = useState(''), [check, setCheck] = useState('');
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
useEffect(()=>{if(message) feedback.current?.scrollIntoView({block:'center'});},[message]);
const latestState = useRef(state); latestState.current = state;
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: draft.settings.city!==state.settings.city, services: JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced};
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced};
const dirty = !!dirtySections[section], anyDirty = Object.values(dirtySections).some(Boolean);
useEffect(() => { if (!open) {
request.current?.abort();
@@ -26,19 +31,37 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
useEffect(() => () => { request.current?.abort(); }, []);
useEffect(() => { if (!anyDirty)
return; const before = (e: BeforeUnloadEvent) => { e.preventDefault(); }; window.addEventListener('beforeunload', before); return () => window.removeEventListener('beforeunload', before); }, [anyDirty]);
const requestLeave = (action: () => void) => { if (anyDirty) { pendingNavigation.current = action; setConfirm('leave'); } else action(); };
useEffect(() => {
if (!open || !anyDirty) return;
const guard = (event: Event) => { if (approvedNavigation.current) return; event.preventDefault(); pendingNavigation.current = (event as CustomEvent<() => void>).detail; setConfirm('leave'); };
window.addEventListener('cg:leave-settings', guard);
return () => window.removeEventListener('cg:leave-settings', guard);
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
const settings = (value: Partial<Settings>) => setDraft(d => ({ ...d, settings: { ...d.settings, ...value } }));
const reset = () => { request.current?.abort(); generation.current++; setBusy(false); setDraft(d => ({identity: section==='relation'?structuredClone(state.identity):d.identity, settings: {...d.settings, ...(section==='city'?{city:state.settings.city}:section==='services'?{services:structuredClone(state.settings.services)}:section==='display'?{reduced:state.settings.reduced}:{})}})); setMessage('已取消,恢复为上次保存的内容。'); setError(false); };
async function save(confirmed = false) { if (section==='relation' && draft.identity.relation !== state.identity.relation && !confirmed) {
setConfirm('relation');
return;
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage('正在保存到本机…'); try {
} request.current?.abort(); const ctrl = new AbortController(); request.current = ctrl; setBusy(true); setError(false); setMessage(real ? '正在保存…' : '正在保存到本机…'); try {
if (real && section === 'relation') {
const identity = await api<RealIdentity>('relation', { method: 'PATCH', signal: ctrl.signal, body: JSON.stringify({ relation: draft.identity.relation, nickname: draft.identity.nickname, boundary: draft.identity.boundary }) });
if (!ctrl.signal.aborted) { onSave({ ...latestState.current, identity }); setMessage('已保存在服务器,聊天与底色保留。'); } return;
}
if (real && section === 'display') { localStorage.setItem('cg.real.display', JSON.stringify({ reduced: draft.settings.reduced })); onSave({ ...latestState.current, settings: { ...latestState.current.settings, reduced: draft.settings.reduced } }); setMessage('显示偏好已保存在此浏览器。'); return; }
await delay(450, ctrl.signal);
if (fail)
throw new Error('这次没存好,你的修改还在,可以再试一次。');
const current=latestState.current;
const next = {...current, identity: section==='relation'?draft.identity:current.identity, settings:{...current.settings, ...(section==='city'?{city:draft.settings.city}:section==='services'?{services:draft.settings.services}:section==='display'?{reduced:draft.settings.reduced}:{})}};
onSave(next);
setMessage('已保存在本机演示中。');
setMessage(real ? '显示偏好已应用。' : '已保存在本机演示中。');
}
catch (e) {
if (ctrl.signal.aborted)
@@ -85,16 +108,20 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
setCheck('检查没通过:' + (e instanceof SyntaxError?'清单不是有效的 JSON,请重新选择。':(e as Error).message));
} }
const switchSection = (s: string) => { request.current?.abort(); generation.current++; setBusy(false); setSection(s); setDetail(true); setMessage(''); };
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">仅本机演示</span><button className="btn-round sm" aria-label="关闭设置" onClick={onClose}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor /><div className="growth"><div><h4>最初就有的</h4>细腻、慢热(示例)</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><h2>设置</h2><span className="grow"/><span className="tag-demo">{real ? '服务器档案' : '仅本机演示'}</span><button className="btn-round sm" aria-label="关闭设置" onClick={() => requestLeave(onClose)}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon, label]) => <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/>{real && id === 'backup' ? '备份' : label}<i className="dirty"/></button>)}</nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><h3>{section === 'her' ? state.identity.name : real && section === 'backup' ? '备份' : sections.find(s => s[0] === section)?.[2]}</h3><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : real ? '资料保存在服务器。尚未接通的功能暂不可用。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}</dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}</dd></dl><BaseColor color={real ? (state.identity as RealIdentity).color : undefined}/><div className="growth"><div><h4>最初就有的</h4>{real ? '以上六层底色说明。生辰和原始人格参数保持只读。' : '细腻、慢热(示例)'}</div><div><h4>后来慢慢有的</h4>{state.memories.length ? '共同经历另存于回忆。' : real ? '长期记忆尚未接通。聊天记录已独立保存。' : '还在慢慢认识彼此。'}</div></div><p className="d-foot">首版只有一位,不提供新建、切换或清空。底色只读。</p></>}
{section === 'relation' && <><Field label="你们的关系"><select className="select" value={draft.identity.relation} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, relation: e.target.value as '朋友' | '恋人' } }))}><option>朋友</option><option>恋人</option></select></Field><Field label="她怎么称呼你"><input className="input" value={draft.identity.nickname} maxLength={40} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, nickname: e.target.value } }))}/></Field><Field label="相处边界"><textarea className="textarea" maxLength={2000} value={draft.identity.boundary} onChange={e => setDraft(d => ({ ...d, identity: { ...d.identity, boundary: e.target.value } }))}/></Field><p className="d-foot">改关系需要再次确认,已有记忆与信任都保留,底色不变。</p></>}
{section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
{section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{!real && section === 'city' && <><Field label="搜索城市"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button aria-pressed={draft.settings.city === c} onClick={() => settings({ city: c })}>{c}<small>中国 · 演示城市</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <p>暂时没找到这个城市,演示清单只包含上面的六座。</p>}<p className="d-foot">城市保存后顶栏同步;天气与作息为固定演示,不请求天气服务。</p></>}
{!real && section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{!real && section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{!real && section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{section === 'display' && <><div className="opt-row">{(['moon', 'fog'] as Theme[]).map(t => <button className="opt" data-t={t} key={t} aria-pressed={theme === t} onClick={() => setTheme(t)}><div className="sw"/><b>{t === 'moon' ? '靛蓝月光' : '蓝紫雾光'}</b><span>立即切换,保留你的选择</span></button>)}</div><label className="switch-row">减少动效<input type="checkbox" checked={draft.settings.reduced} onChange={e => settings({ reduced: e.target.checked })}/></label><p className="d-foot">也会自动尊重系统的减少动效设置。切到其他网页时暂停背景动画。</p></>}
<div ref={feedback}><Status text={message} error={error}/></div>{['relation', 'city', 'services', 'display'].includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'relation')
{real && section === 'city' && <><div className="card"><h4>城市与天气</h4><p>尚未设置可核实的城市。城市保存、天气查询与作息联动将在后续接入。</p></div><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><div className="card"><h4>当前画面</h4><p>内置参考静帧;真实素材包导入尚未接通,外观变化不会改变她的身份和底色。</p></div><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
{real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<div className="card"><h4>其余能力</h4><p>生活、长期记忆、主动消息和天气尚未接通。文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? '改变你们的关系?' : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
try {
if (fail)
@@ -124,6 +151,6 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
setMessage((e as Error).message);
setError(true);
}
} }}>{confirm === 'restore' ? <>恢复会覆盖当前本机演示资料。操作前会先下载当前资料,档案身份和底色须相同。<Field label="输入“恢复”以继续"><input className="input" value={confirmText} onChange={e => setConfirmText(e.target.value)}/></Field></> : <>记忆与信任都保留,八字底色不变。{confirm === 'appearance' ? '本次只更新演示标识,不接入未经校验的外部素材。' : ''}</>}</Modal>}
} }} confirm={confirm === 'leave' ? '放弃修改并离开' : '确认'}>{confirm === 'leave' ? <>离开会放弃尚未保存的修改。选择取消可继续编辑,已经保存的资料不会改变。</> : confirm === 'restore' ? <>恢复会覆盖当前本机演示资料。操作前会先下载当前资料,档案身份和底色须相同。<Field label="输入“恢复”以继续"><input className="input" value={confirmText} onChange={e => setConfirmText(e.target.value)}/></Field></> : <>记忆与信任都保留,八字底色不变。{confirm === 'appearance' ? '本次只更新演示标识,不接入未经校验的外部素材。' : ''}</>}</Modal>}
</section>;
}
+31
View File
@@ -0,0 +1,31 @@
import { useLayoutEffect, useRef, useState } from 'react';
// Identity is a turn, not a token or a database id. Finalizing a stream keeps
// its DOM and animation. Only a replacement retains/fades out the old sentence.
export function SoftText({ identity, text }: { identity: string; text: string }) {
const current = useRef<HTMLSpanElement>(null), outgoing = useRef<HTMLSpanElement>(null);
const previous = useRef({ identity, text });
const [leaving, setLeaving] = useState<{ identity: string; text: string } | null>(null);
useLayoutEffect(() => {
if (previous.current.identity !== identity && previous.current.text) setLeaving(previous.current);
previous.current = { identity, text };
}, [identity, text]);
useLayoutEffect(() => {
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('reduced');
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out').trim();
const animation = current.current?.animate([
{ opacity: 0, transform: reduced ? 'none' : 'translateY(3px)' },
{ opacity: 1, transform: 'none' },
], { duration: reduced ? 120 : 200, easing });
return () => animation?.cancel();
}, [identity]);
useLayoutEffect(() => {
if (!leaving || !outgoing.current) return;
const animation = outgoing.current.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 160, easing: getComputedStyle(document.documentElement).getPropertyValue('--ease-out').trim(), fill: 'forwards',
});
void animation.finished.then(() => setLeaving(null)).catch(() => {});
return () => animation.cancel();
}, [leaving]);
return <span className="soft-text"><span key={identity} ref={current} className="current-text">{text}</span>{leaving && <span ref={outgoing} className="outgoing-text" aria-hidden="true">{leaving.text}</span>}</span>;
}
+24
View File
@@ -0,0 +1,24 @@
import { useLayoutEffect, type ReactNode } from 'react';
export function Viewport({ children }: { children: ReactNode }) {
useLayoutEffect(() => {
const viewport = window.visualViewport, style = document.documentElement.style;
let settle: ReturnType<typeof setTimeout>;
const update = () => {
// Keyboard/browser-bar geometry must follow the viewport immediately;
// a decorative stage transition would cover the dialogue in between.
document.documentElement.classList.add('viewport-resizing');
clearTimeout(settle);
settle = setTimeout(() => document.documentElement.classList.remove('viewport-resizing'), 200);
const height = viewport?.height ?? innerHeight, top = viewport?.offsetTop ?? 0;
style.setProperty('--view-h', `${height}px`);
style.setProperty('--view-top', `${top}px`);
style.setProperty('--view-bottom', `${Math.max(0, innerHeight - height - top)}px`);
};
update();
viewport?.addEventListener('resize', update); viewport?.addEventListener('scroll', update);
window.addEventListener('resize', update);
return () => { clearTimeout(settle); viewport?.removeEventListener('resize', update); viewport?.removeEventListener('scroll', update); window.removeEventListener('resize', update); };
}, []);
return children;
}
+4
View File
@@ -0,0 +1,4 @@
// An open dirty form can defer a navigation until its confirmation resolves.
export function leaveSettingsThen(action: () => void) {
if (window.dispatchEvent(new CustomEvent('cg:leave-settings', { cancelable: true, detail: action }))) action();
}

Some files were not shown because too many files have changed in this diff Show More