Compare commits

..
Author SHA1 Message Date
白栖知andmultica-agent 21170f9fc9 HEL-795: import visual director keyboard and text motion fixes
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 23:47:54 +08:00
白栖知andmultica-agent a576785535 HEL-793: import visual director immersive conversation prototype and evidence
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 22:37:53 +08:00
56 changed files with 4039 additions and 1074 deletions
-229
View File
@@ -1,229 +0,0 @@
# HEL-847 · 优先开源复用:记忆、心跳与自主生活的源码级核对与主路线方案(修订版 v2)
> 角色:总工。性质:**只读源码核对 + 复用方案 + 零模型合成验证**。本轮为响应总管复核的修订:补齐 Letta/Memobase 源码级平等核对(固定提交)、合成验证改用生产守卫语义重放并补配对负例、收窄各实验结论、修正 v1 中三处不当表述。全程未改产品代码、未装任何依赖、未执行上游安装脚本、未部署服务、未调用任何嵌入/聊天接口、未触碰 8300–8350 任何现网服务与真实档案。本轮无网络缺项(三个上游仓库均已实际 fetch 到本地核对)。
## 0. 一页结论
**推荐主路线(经三候选源码级平等核对后仍推荐):以现有 Python/SQLite 内核为底座,对 OpenClaw memory-core 做「模块级移植 + 模式照抄」(方案C),不引入任何上游运行时,不换底座。** 修订声明:该推荐是本轮补齐 Letta/Memobase 源码证据并修正公平性表述之后的结论,不是预设保留现内核。
- **OpenClaw**(`5abc0c66`,MIT):运行内核不可独立安装(memory-core 为网关私有插件,`package.json` `"private": true` + `peerDependencies: openclaw>=2026.10.7`,本轮经 raw.githubusercontent 复核);cron 与网关交织;来源分层把 cron/heartbeat/subagent 产出排除出持久记忆(`docs/concepts/memory-architecture.md:100-102`),与"她自己的世界内生活可积累"直接冲突。可复用资产清晰:混合召回纯算法(可直接拷贝)、原文回读接缝、dreaming 门控与校验模式、分层记忆数据模型。
- **Letta**(本轮源码级核对,v1 表述修正):`letta-ai/letta` main 已是纯着陆页仓库(`8816d3a`,2026-10-18),旧 Python V1 服务器在 `archive` 分支且其 AGENTS.md **明文禁止**用于生产、对比或"把旧实现复制进另一个项目";现行实现 `letta-ai/letta-code` `6f4e488`(v0.34.24,Apache-2.0)是通用 agent 运行时(不只编码代理,含 App Server/channels/桌面),但:记忆=git 文本文件 MemFS(`~/.letta/agents/<id>/memory/`,`memory-filesystem.ts:497` "MemFS cannot be disabled")、**dreaming/整理与向量·混合检索是 Letta Cloud 云端功能,本地与自托管后端不支持**(`reflection-runs.ts:14`、`conversation-search.ts` 本地仅词项过滤)。直接复用其运行内核=换整套 TS 运行时+Letta 账号体系+云依赖,且我们最需要的整理与语义检索恰好不在可自托管部分。不选;cron 文件租约与 MemFS 注入纪律可作模式借鉴(非代码复用)。
- **Memobase**(本轮源码级核对,v1 表述修正):`358c16bb`(main,2026-01-11,Apache-2.0),FastAPI+Postgres+Redis 自托管服务,Python SDK 约 1k 行可直接调用。v1 所说"默认删原话与红线冲突"**修正为适配成本**:`env.py:77` `persistent_chat_blobs: bool = False` + `controllers/buffer.py:211-216`(仅 chat 类 blob 且开关关闭时删除)——打开开关即保留原话。落选主路线的真实理由:部署面新增两个常驻服务;数据模型是 user-profile 中心(topic/sub_topic 画像+事件线),没有伴侣自己的身份/生活、没有逐消息隐私门(AM-001 需自行在外层实现)、没有带来源的原文回读;双库(cybergril.db + PG)完整导出一致性成为长期工程代价(CG-009 原文要求完整导出恢复,未规定物理单库——按工程代价如实列出,不写成违反原文);上游 main 已 9 个月无提交(事实陈述,供维护量评估)。保留为将来独立"用户偏好档案"组件的再评估对象。
- 三候选都**没有**、必须自研薄实现的三件:八字参数→情绪/行为闭环接线(`core/mood.py` V/A/D/E 衰减方程+负向偏置已写好、未接进任何运行链路,本轮以代码证据确认)、连续活动状态机、`self_life` 来源类与分享审核接缝(§4.4 给出双权限模型)。
- 合成验证 v2 已运行(§6,`probe_recall.py`,9 项实验全部通过,可复现、确定性):生产守卫语义重放(含 companion 行 `chat_source_allowed()` 门与逐消息回读门)+ 配对负例(用户消息合法命中、同轮伴侣消息含未分享经历——回读后与 600 字预算装配后均零泄漏)+ 同一候选集排序对照 + 写入/跨会话 + 作业租约恢复语义重放。各结论已按总管要求收窄,真实失败与合成收益严格分开。
## 1. 核对依据与固定版本
| 对象 | 版本/提交 | 说明 |
| --- | --- | --- |
| CyberGril 需求基准 | v1.10 / 59项 | 读取自 `hel-835-unified-review` @ `af28ffdcb5f0bae6c36ee1ac4a1a555c9cfd27fe`(current.md 首行确认 v1.10);main 仍 `3a986726`(v1.8) |
| CyberGril 产品代码 | `af28ffd` | 本方案所有"当前实现"结论出自该提交源码通读(episodic.py/public.py/store.py/mood.py/runtime.py 等) |
| OpenClaw | `5abc0c66e29352f78313699768cb2fb71ab00890`(2026-10-25T11:37:07Z) | 附件快照四源码+三文档与上游逐文件 diff 一致(v1 已核,本轮未变);`extensions/memory-core/package.json` 的 private/peer 断言本轮经 raw.githubusercontent.com 复核成立 |
| OpenClaw 许可 | MIT(LICENSE, Copyright (c) 2026 OpenClaw Foundation) | 移植代码保留原版权与来源注释;另有 THIRD_PARTY_NOTICES.md |
| Letta 着陆仓库 | `letta-ai/letta` @ `8816d3a8516893bfdc0fa14bfb1235226012e295`(main, 2026-10-18) | 仅 README/AGENTS/LICENSE 等文档,无源码;README 明示现行源码在 letta-ai/letta-code、旧 V1 服务器在 archive 分支"historical reference only" |
| Letta 现行实现 | `letta-ai/letta-code` @ `6f4e4886caa0934341eea75c306fc0364509218e`(v0.34.24, 2026-10-24) | Apache-2.0;本轮 clone 后逐目录核对(src/agent、src/backend、src/cron、src/auth、python/) |
| Memobase | `memodb-io/memobase` @ `358c16bbc6d687937d79bc2f984a11c3be8da901`(main, 2026-01-11;此后 main 无新提交) | Apache-2.0;本轮 clone 后核对 server/env/controllers/models/tests 与 client SDK |
| 本卡需求原文 | 主卡 HEL-797 评论 `01a172da-21d8-7b22-83d5-f7df4d4de95c`(开源复用方向)、`01a172e0-1650-719e-bd8c-ca4d6c2509d5`("openclaw只是我举得一个例子,不一定非得是这个"→不预设优先)及前条 `01a172c6` | 用户原话为"优先开源复用"的实施方向,**尚未指定最终底座、未授权迁移现网** |
关联要求编号:CG-003/005/006/007/008/009/012–016/018/021/031、AM-001、UX-004/006、GOV-001–005、DEL-001(逐项见 §8)。
## 2. 四块逐项核对(OpenClaw,上游提交 · 文件/函数/测试 · 直接采用 / 需适配 / 确实缺失)
### 2.1 混合召回(换说法找不到的缺口)
| 项 | 内容 |
| --- | --- |
| 上游位置 | `extensions/memory-core/src/memory/hybrid.ts`(342行,`mergeHybridResults`/`selectHybridSearchResults`)、`mmr.ts`(MMR,CJK 双字 bigram Jaccard)、`temporal-decay.ts`(30天半衰期)、`project-ranking.ts` |
| 上游测试 | `hybrid.test.ts`、`mmr.test.ts`、`temporal-decay.test.ts`、`manager-temporal-ranking.test.ts`、`manager-search-monotonicity.test.ts` 等(测试与实现同量级) |
| 可独立拷贝性 | 高。hybrid.ts 仅 import plugin-sdk 类型与三个本地纯算法模块;temporal-decay 仅一个 `isPathInside` 依赖。MIT 允许逐文件拷贝 |
| 直接采用 | 向量+关键词融合公式、MMR 多样性重排、时间衰减、写时 importance 乘子——作为**Python 端口候选** |
| 需适配 | ①关键词分:上游自建索引 BM25,本项目 SQLite FTS5 `bm25()`;②importance:上游写时标注 1–10,本项目由 `salience` 映射;③exact-path/project 分层为多仓库场景,本项目无对应概念,保留为零。**注意:probe v2 的端口是"最小改写候选"(关键词路径、向量权重=0、bm25→出现次数饱和近似、salience→importance 映射),未逐例对照上游测试,不称"忠实端口已验证"** |
| 确实缺失 | 向量侧。上游语义召回靠可插拔 embedding provider(本地需另装 llama.cpp provider 插件=新模型安装)。本项目 `schema.sql` 已预留 `mem_vec`(sqlite-vec)注释位。**嵌入选型=新增模型调用/安装,属待评审项,本卡不自行解闸** |
### 2.2 原文回读("记住昨日细节"失败的机制洞)
| 项 | 内容 |
| --- | --- |
| 上游位置 | `memory_search`/`memory_get` 工具族(`tools.ts`、`tools.citations.ts`:结果带 `path:startLine-endLine` 来源锚点);memory-architecture.md「Recall: two lanes」(lane1 零模型注入、lane2 升级子代理) |
| 直接采用 | **接缝模式**:命中→按来源锚点回查原文再交给对话模型。对应本项目:命中 `mem_episodic` 后按 `message_id`/`turn_id` 回查 `chat_message` 整轮原话。probe E3 验证:检索命中在 raw、注入却是 `gist[:120]`(`episodic.py:230`),独有细节(蓝色封面/受潮味)在注入块中丢失,带门回读步即恢复 |
| 需适配 | 回读块必须过 AM-001 来源门与 600 字预算(复用生产 `source()`/`public` 判定,逐消息门,不开新通道——probe v2 E4b 正例+负例都已验证);上游 lane2 升级子代理暂缓(多一次模型调用,列待评审) |
| 确实缺失 | 无产品专属硬缺失;trigger 注入(条目级触发短语)可作后续小增强 |
### 2.3 后台整合(经历→稳定事实/偏好/关系,上游称 dreaming)
| 项 | 内容 |
| --- | --- |
| 上游位置 | `dreaming-consolidation.ts`(476行核心)、`dreaming.ts`(三阶段编排)、`dreaming-cron.ts`、`dreaming-consolidation-candidates.ts` |
| 上游测试 | `doctor-dreaming-state.test.ts`、`dreaming-status-api.ts`、`tools.real-manager.test.ts` 等 |
| 直接采用 | **门控与校验模式整体照抄**:①模型只选操作(added/merged/superseded),新条目文本由宿主从来源证据拼装,模型不能凭空写记忆文;②结构校验(操作数=候选数、priorEntries 精确存在且唯一、merge 不得合并不相关条目、supersede 须有 lineage 匹配);③损失率与字符预算上限,超限整轮拒绝;④任何拒绝→append-only 回退,绝不半写;⑤记忆文本一律当数据不当指令;⑥两道门:规则门在前、模型整理在后 |
| 需适配 | 上游整理对象是 MEMORY.md/USER.md 文本文件;本项目落到**已有三屋表**:`mem_episodic` → `mem_semantic`(双时态 valid_from/valid_to,supersession 不堆积)→ `mem_relational`(ritual/nickname/inside_joke/pattern/taboo,均 schema.sql 已建未用)。选表不选文件 |
| 确实缺失 | `self_life` 来源类(§4.4);整合用什么模型档位(CG-021 后台小模型部署未定版,待评审) |
### 2.4 来源防循环、SOUL 与会话上下文、心跳调度
| 项 | 内容 |
| --- | --- |
| 来源/防循环 | memory-architecture.md「Provenance」:origin class 闭集(SQLite 列、模型写不进散文)+ session kind 门控 + 回读防循环(注入过的内容永不再当新记忆提取)。直接采用:整合层上线时给注入块加来源标记;当前 gist 只从 chat_message 提取、天然未循环,整合层一开必须显式化。**硬冲突**见 §3/§4.4 |
| SOUL/工作区 | SOUL.md(人格语气)/USER.md(指令式用户模型,独立预算)/IDENTITY.md 每会话注入+总预算控制。直接采用分文件分预算的注入纪律——本项目每轮重组装(bazi_core 白话+persona_interp+persona_seed+成长层)已等价且更严(底色系统级只读)。**反错觉**:上游 SOUL.md 是静态文本,无数值情绪/精力/作息、无八字引擎 |
| 心跳/cron | `src/cron/service.ts`(facade)+ `service/run-recovery.ts`(重启恢复把 interrupted run 显式转事件);heartbeat 已并入普通 automation jobs。**不可移植**(依赖类型直接 import 网关内部模块);**模式照抄**三点:持久 job 行+lease/claim+重启恢复(本项目 `life_run`/`mem_job`/`growth_job`/`persona_job` 已同构)、interrupted→显式事件、作业私有草稿。**确实缺失(产品专属,自研)**:快心跳数值推进、连续活动状态机、作息与精力——上游无对应物,任务定时唤醒 ≠ 连续生活 |
## 3. 主路线抉择(v1 公平性修正版)
四条路线比较(修正点:CG-009 不再写成"双库违反原文"而列为工程代价;"未授权安装"是本卡操作边界、不作为候选永久不适合的证据;淘汰理由全部为代码证据):
| 路线 | 结论 | 关键证据 |
| --- | --- | --- |
| A. OpenClaw 整套运行内核替换 Python 内核 | 否 | §3 硬冲突 ①–⑤;Node>=24.22.1 引擎要求、本地向量需另装 provider 均为未授权新依赖(边界陈述,非永久不适合) |
| B. OpenClaw sidecar / Letta-code 运行时 / Memobase 服务等"外挂内核"形态 | 否为主路线(各自证据见 §5) | AM-001 要求服务器响应前裁剪;回复产自第三方运行时时裁剪/φ投影退化为事后处理;跨库导出一致性=持续工程代价 |
| C. **模块级移植 + 模式照抄(推荐,经三候选平等核对后仍成立)** | 是 | §2 各块可独立拷贝性 + probe v2 验证 + MIT 许可;最少自写、最少新运维面 |
| D. 取思想全部 Python 重写 | 否(卡内已禁止,也无需) | C 的端口就是 Python 化改写候选,不是"取思想重写" |
OpenClaw 硬冲突代码证据(不变部分):
1. **memory-core 不可独立安装**:`extensions/memory-core/package.json` `"private": true`、`peerDependencies: {"openclaw": ">=2026.10.7"}`、devDependencies `openclaw: workspace:*`(本轮经 raw.githubusercontent.com 于 `5abc0c66` 复核);npm 上无 `@openclaw/memory-core`。用它=装整套 gateway。
2. **cron 与 gateway 交织不可拆**:`src/cron/service/state.ts` 依赖类型直接 import 网关内部模块(agents/admitted-run-context、auto-reply/reply-payload、config/sessions、infra/gateway-scheduler、routing/session-key 等)——单独拷贝 cron 子系统等于拷半个网关。
3. **来源分层硬冲突(最致命)**:`docs/concepts/memory-architecture.md:100-102`「Cron, heartbeat, and sub-agent sessions do not produce durable memory candidates.」——本项目 R2 生活循环在上游分类里就是 cron 会话,她所有自主经历将结构性无法进入长期记忆,与 CG-008("用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续")直接冲突。origin class 闭集也没有"她的自主经历(分享状态待定)"这一层。
4. **双事实源的工程代价(v1 措辞修正)**:上游会话/记忆存 `~/.openclaw/agents/<id>/agent/openclaw-agent.sqlite`,加 cybergril.db 即两套权威库。CG-009 原文要求"手动完整导出……恢复同一位伴侣(覆盖八字、性格资料、对话、记忆、关系、生活进度)",**未规定物理上只能单库**;双库一致性(导出原子性、备份时点一致、恢复校验)是纯新增工程代价与长期风险,列为路线 B 类形态的扣分项,不写作"违反原文"。
5. **运行时与部署面**:主包 `engines.node: ">=24.22.1 <25 || >=26.12.1"`;本地向量召回需另装 llama.cpp provider。属"本卡未授权安装"的操作边界;若未来获授权,此项可重评(但 ①–④ 不受影响)。
## 4. 项目适配约束(逐条落到接缝)
1. **出生事实与底色只读(CG-012/013/UX-004)**:移植代码全部在记忆/检索/整理层,不触碰 `bazi_core` 与档案端点;整理管道只写 `mem_semantic`/`mem_relational`,人格层继续由 persona 的诠释/习惯/成长三分离承担。完整四柱持续供给=维持现有每轮重组装模式。
2. **诠释自由与成长分层(CG-014/015)**:上游 USER.md「指令式条目+状态元数据+原位顶替」契约借给 `mem_relational`;成长仍另记(growth_job 既有路径),不并回底色。
3. **用户偏好档案与她的身份/生活分开**:她的记忆三屋里 subject=用户的条目=用户档案;她自己的身份与生活是 subject=她 + `self_life` 来源。不引入"user profile 即记忆中心"的数据模型。
4. **`self_life` 双权限模型(本卡点名的硬题,v2 重写)**——依据 AM-001 原话("等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;……真实接入必须在服务器响应前裁剪")与 CG-008 原话("继续自己的生活,积累有记录的事件……画面、对话、记忆应对应同一份生活状态"),**"未分享"限制的是对用户可见,不是她不能内部记住**:
- **权限一:内部留存(她的)**。`life_event` 全量留存(told=0/1 都在库),作为她内部状态与下一次自主生活的输入:生活生成循环读得到、情绪冲激(valence)吃得到、下一次自主事件可衔接上一段("画面、对话、记忆应对应同一份生活状态")。CG-008 要求积累与延续——内部留存是"延续"的载体,不因未分享而清零。
- **权限二:对用户公开(用户可见)**。任何面向用户的出口——对话注入、检索 DTO、摘要、关联回忆、成长事件——只允许 told=1(或 companion 消息过 `chat_source_allowed()`,即分享已判定且候选全部已讲)的内容;未知分享状态不展示(AM-001)。**未分享 ≠ 不存在**:数据在库、门在外面。
- 边界实现沿用现有两道门(probe v2 E4a/E4b 已验):`PublicView.allows/chat_source_allowed`(逐消息);成长路径 `growth()` 已有同门。整理管道照抄上游两道门,规则门显式检查来源枚举,模型整理阶段无权改类别。
- 与上游分层的差异精确化:上游把 cron/heartbeat 产出**整体**排除出持久记忆候选(对我们=她的自主经历永远记不住,冲突);本项目改为"self_life 来源类+分享状态门"——心跳调度指令噪声等价上游 `system` 类永不入记忆,但**她世界里发生的事件**以 self_life 入 `life_event`/`mem_episodic`(内部留存),讲过之后可进整合管道成为相处记忆。
5. **AM-001 全链路**:检索、回读、整合、成长四条路径全部先过 `source()`/public 门;回读复用同一门、逐消息判定(E4b 配对负例:回读后与 600 字预算装配后均零泄漏)。
6. **备份与不重置(CG-006/007/009)**:增量迁移走 r4.sql 模式(CREATE TABLE IF NOT EXISTS+触发器,既有行零改动);伴侣、名字、生日、聊天、生活记录、既有记忆零重置、不清空重建。
## 5. 平等选型:三候选源码级核对(v2 新写,取代 v1 §5 的浏览级对照)
**本节取代 v1 的 README 浏览级对照。三仓库均已 fetch 到本地逐文件核对,无网络缺项。**
### 5.1 Letta(着陆仓库 + letta-code 分开判断)
**事实一:`letta-ai/letta` main 不是源码仓库。** `8816d3a`(2026-10-18)全树仅 README/AGENTS/LICENSE 等 10 个文档文件。README:"The current source code lives in `letta-ai/letta-code`";旧 Python V1 API 服务器在 `archive` 分支"historical reference only"。其 AGENTS.md 标题即"Stop: do not use this repository as a working Letta implementation",并明文禁止把 archive 分支/旧 tag/旧 Docker 镜像用于:生产或新应用、基准或对比测试、把旧实现复制进另一个项目。→ **旧 Python 服务器(曾最接近"可直接复用的记忆服务")被上游明确排除出复用范围;引用它作选型证据不成立,v1 未引、v2 也不引。**
**事实二:现行 `letta-code`(`6f4e488`,v0.34.24,Apache-2.0)是完整通用 agent 运行时,v1"记忆组织面向编码代理"的定性修正。** 它含 agent harness、终端 UI、App Server(`letta server`)、Slack/Telegram/Discord channels、桌面/云运行时;测试密度高(仅 src/ 顶层就有 72 个 `*.test.ts`,另有各目录内测试)。但其记忆体系与本项目需求的结构性差距(代码证据):
| 关注点 | letta-code 现状(文件:行) | 对本项目含义 |
| --- | --- | --- |
| 记忆载体 | MemFS:git-backed 文本文件 `~/.letta/agents/<id>/memory/`(`src/agent/memory-filesystem.ts:23-46`;:497 "MemFS cannot be disabled: agents are memfs-enabled from creation") | 无结构化 episodic 表、无双时态事实、无关系层;"完整导出恢复同一位她"要靠文件目录+git 库快照,与三屋表模型不同构 |
| 后台整理(dreaming) | `/dream` reflection **仅 Letta Cloud**:`src/backend/api/reflection-runs.ts:14` "/dream requires an agent stored on Letta Cloud. Local and custom backends are not supported." | 我们最想要的整理能力恰好不可自托管;实现也不在本仓库(服务端功能) |
| 语义/混合检索 | `src/backend/conversation-search.ts:5` SearchMode vector/fts/hybrid 走云端 API;本地 `localSearchConversations` 仅小写词项过滤 | 本地召回能力弱于本项目现有 FTS5 trigram |
| 调度 | `src/cron/cron-file.ts:1-8`:`~/.letta/crons.json` 原子写+mkdir 咨询锁+PID/令牌调度租约+终态 GC | 与 OpenClaw/本项目 job 表租约同构——**模式借鉴**,非代码复用(TS) |
| 认证 | Letta 账号 OAuth/API key(`src/auth/oauth.ts` 等),本地后端亦绑定 Letta 服务生态 | 引入外部账号依赖,属"未授权改动绑定/运行时"边界 |
| 部署 | npm/pipx 安装(pip 包捆绑 Node 运行时,`python/README.md`) | Node/Bun 运行时+自更新链路,运维面大于 SQLite 单文件 |
| 情绪/八字/连续生活 | 无任何对应物 | 同 OpenClaw:必须自研 |
**结论(Letta)**:直接调用其运行内核=路线 B 变体(换 TS 运行时+账号体系),且核心记忆功能(整理、向量检索)在云侧;直接拷贝的代码资产为零(有用部分是模式);作为底座或 sidecar 的维护量与外部依赖最高。**不选主路线**;cron 文件租约与 MemFS 注入纪律列为模式借鉴(已在方案C 吸收清单,非代码复用)。
### 5.2 Memobase(`358c16bb`,Apache-2.0)
| 关注点 | Memobase 现状(文件:行) | 对本项目含义 |
| --- | --- | --- |
| 形态 | 自托管服务:FastAPI + Postgres + Redis 7.4(`src/server/docker-compose.yml`) | 新增两个常驻服务;违背 SQLite 优先不是规则红线,但确是运维面增量 |
| 原话留存 | **可配置**:`src/server/api/memobase_server/env.py:77` `persistent_chat_blobs: bool = False`;`controllers/buffer.py:211-216` 仅当 chat 类 blob 且开关为 False 才在 flush 成功后删除 | v1"红线冲突"**修正为适配成本**:部署时置 True 即保留 chat 原话(summary 类 blob 不受影响);测试环境即如此配置(`tests/conftest.py:15`) |
| 数据模型 | user profile(topic/sub_topic 指令式条目,`models/response.py:36`)+ 事件时间线;buffer→定时/按量 flush(`buffer_flush_interval` 默认 1h,`env.py:85`)→固定 3 类 LLM 调用整理 | 用户画像中心的"关于用户"记忆;没有"她自己的生活/身份"主体、没有逐消息隐私门(AM-001 须在外层全量自建)、没有带来源锚点的原文回读 |
| 中文 | 配置项 `language: "zh"`(`env.py:96`)与 LLM openai 兼容端点(`env.py:93`) | 中文与自有端点可行(真实效果未实测,不把配置项当效果证明) |
| SDK | Python 客户端约 1k 行(`src/client/memobase/core/entry.py:165,284,314`:flush/search_event/search_event_by_tags;含 async 版) | 可直接调用;但"能调"≠覆盖需求——检索/门/回读仍需自建 |
| 测试 | server 8 个测试文件(`src/server/api/tests/`)+ client 测试 | 有基本覆盖,量级远小于另两家 |
| 维护信号 | main 最后提交 2026-01-11,至本核对日 9 个月无 main 提交(dev 分支亦停在 2025-12) | 事实陈述,供长期维护量评估:自托管一个停滞上游的风险自担 |
**结论(Memobase)**:三者中唯一"可直接部署的自托管记忆服务"形态,SDK 可直接调用;但采用它=为单伴侣单用户跑 PG+Redis、在外层自建 AM-001 门与她的生活主体、承担双库导出一致性(工程代价,见 §3-4)与上游停滞的维护风险;其真正对口的是"用户偏好档案"组件(本项目由 mem_semantic 的 subject=用户 条目+关系层承接,无需新服务)。**不选主路线**;保留为将来独立用户偏好档案组件的再评估对象(待评审)。
### 5.3 三路并排(按实际能直接用多少 / 必须自写多少 / 长期维护量)
| 指标 | OpenClaw | letta-code | Memobase | 本项目现状(方案C 底座) |
| --- | --- | --- | --- | --- |
| 运行内核可直接调用 | 否(private 插件+网关耦合) | 可以但=换 TS 运行时+账号+云依赖 | 可以但=PG+Redis 服务 | 已有(Python/SQLite) |
| 可直接拷贝代码 | hybrid/mmr/tokenize/temporal-decay 纯算法(MIT,TS→Python 端口) | 无(有用为模式) | 无(SDK 是调用不是拷贝) | — |
| 模式照抄 | dreaming 门控/防循环/cron 恢复 | cron 文件租约、注入纪律 | buffer→批量 flush 摊薄 | mem_job 批处理已同构 |
| 需自写(上游都没有) | self_life 双权限门、快心跳接线、连续活动状态机、八字→情绪基线映射 | 同左+记忆结构整个异构 | 同左+AM-001 门+她的主体+回读 | 同左(本就必自研,见 §7) |
| 新增常驻服务/运行时 | 0(方案C) | Node/Bun+Letta 生态 | PG+Redis | 0 |
| 许可 | MIT | Apache-2.0 | Apache-2.0 | — |
## 6. 最小可验证方案与已获证据(probe v2)
**已运行**(本轮交付物,零模型调用、零新依赖、合成数据、确定性输出):
- 脚本:`docs/reuse-hel847/probe_recall.py`(仅 Python 标准库 sqlite3+FTS5);结果:`docs/reuse-hel847/probe-result.json`;复现:`python3 docs/reuse-hel847/probe_recall.py --json`(退出码 0=全部断言含"预期失败"成立;两次运行输出逐字节一致)。
- **基准=生产守卫语义重放**(v1 的"最小重放"已升级):`source()` 含 companion 行 `chat_source_allowed()` 门(`life/public.py` 逐句等价,schema 按 r2.sql 建 sharing_attempt/telling_log/life_event);`search()/context()/recover()/tick()` 按 `episodic.py` 原文重放。**与生产的差异逐项列于脚本文件头**:①`structured()` 模型调用以固定 gist 夹具替代;②asyncio/事务以单连接顺序执行近似;③router 预算闸无对应物省略;④旧 mem_fts(unicode61)省略(生产 search 只用 mem_fts_zh);⑤reinforce 保留重放;⑥日志省略;⑦E2 候选流去掉生产 top-4 截断(截断是呈现层限制,门与过滤不变)。
- **回读=带逐消息隐私门的提案接缝**(生产现无此步):同轮 companion 消息逐条过同一来源门,不过门不进入回读块。
| 实验 | 结果 | 证明了什么(收窄后) |
| --- | --- | --- |
| E1 换说法召回 | 词面路径 0 命中(mode=none) | **仅证明该句在词面路径(FTS trigram+LIKE)无命中**;向量/同义扩展/查询改写均为待评估补法,不证明只有向量能解决 |
| E2a 排序 | 同一候选流 [16,14,7,3,1]:生产前4=[16,14,7,3],改写序 [1,7,14,16](旧书店 salience=0.9 提至首位) | 写时 importance+时间衰减在**同一候选集**上的可测收益(改写候选未逐例对照上游测试) |
| E2b MMR 去重 | 候选流含 5 条近重复"栗子"行+1 条旧提及:生产前4 近重复 4 条,改写前4 降为 3 | MMR(λ=0.7) 同候选集去重机制成立 |
| E3 原文回读 | 基线注入块无"蓝色封面"(命中 raw、注入 gist),带门回读恢复原文细节;已分享(t8) companion 行过门 | 注入窗外 gist 压缩丢细节的机制洞+零模型修法;**不宣称已定位或解决上次真实"昨日细节已进上下文而模型未用"的失败**(那属模型利用层,需真实链路另测) |
| E4a 来源门 | life 记忆行在 FTS 索引中(数据存在)但 0 泄漏、过不了 `source()` | 未分享生活记忆行被门挡住(非数据缺失) |
| E4b 配对负例 | 查询"诗集 床头"命中 4 条全为 user 行;t7 companion(judged 但候选 told=0)过不了门;回读块 roles=[user];"银杏书签/未分享事件"在回读后与 600 字预算装配后均零泄漏 | **用户消息合法命中、同轮伴侣消息含未分享经历:逐消息门全链路零泄漏** |
| E5 重开连接 | 同进程重开 SQLite 连接结果一致 | **仅此**:连接级持久性;不是服务/作业重启恢复(后者由 J1 语义重放,服务级重启未实测) |
| W1 写入/跨会话 | record_turn 语义重放:2 条 mem_job 排队;新连接(另一会话)检索到新写入(mode=like, 命中 t9) | 写入路径与跨会话可见性(合成数据) |
| J1 作业租约恢复 | claim→中断(gist 仍 NULL,不伪造)→租约过期 recover→failed/lease_expired→重新认领(attempts 1→2)→完成,gist 写入且 FTS 触发器同步 | 生产 recover()/tick() **语义重放**的作业恢复闭环;非服务进程重启实测 |
**未运行与精确缺项(不以合成覆盖)**:①真实服务/作业进程重启恢复(J1 是语义重放;实测需在测试环境起服务,未授权部署,本卡未运行);②真实中文聊天语料的召回质量与向量基线(需嵌入选型:本地 GGUF/.11 网关 embedding 端点/暂缓,涉模型、费用、次数,另列待评审);③整理管道真实模型输出质量(CG-021 小模型部署未定版);④lane2 升级召回子代理延迟/成本;⑤上游快照之外的 OpenClaw 部分(本轮补核了 package.json,其余同 v1 范围)。
## 7. 模块对应表与迁移/回退
**保留(已审守卫/身份/隐私,全部不动)**:`api/app.py`、`life/public.py`(AM-001 服务器裁剪,probe 重放即按它)、`life/sharing.py`、`core/projection.py`、`core/bazi.py`、`life/persona.py`、`think/budget.py`+router、`providers/*`、`dyad/ledger.py`、`memory/store.py` 的 job/lease 基础设施。
**替换/升级(吸收上游)**:`memory/episodic.py::search()` → hybrid 排序端口(候选生成不变);`::context()` → 命中后加**带逐消息门的**原文回读步(预算内);`mem_job` 单条压缩 → 扩展为三屋整理管道(dreaming 门控照抄),只写 `mem_semantic`/`mem_relational`。
**删减**:诚实结论不变——没有因引入上游而可删的大块自研代码;"最少自写"体现在**未来不写**(门控/校验/排序/防循环照上游模式,不再自创)。
**新增(三候选都没有,自研薄实现,见 §9 接线)**:快心跳数值推进接线、连续活动状态机、`self_life` 双权限接缝(§4.4)。
**迁移**:全部增量 r4.sql 模式;新开关(`memory_hybrid`/`memory_reread`/`consolidation`)默认 off 或灰度,走 `life_control` 键;旧资料零迁移零重置——新层从既有 `mem_episodic` 原话渐进整理生成,失败保留失败状态。
**回退**:代码回退=git revert(本卡只入文档分支);部署回退=stop/换软链/start 通式,数据目录永不删除;功能回退=关开关降级回现状检索。现网 8300/8310/8320/8340/8350 本卡全程未触碰。
## 8. 八字/情绪/快心跳/连续活动:有界最小接线与可观察验收(v2 新写)
现状代码证据:`core/mood.py` 已实现 V/A/D/E 四维情绪动力学(指数回归基线 Baseline"命+运+昼夜"、分维 tau、负向偏置 neg_bias 滞回、`decay()`/`apply()` 纯四则运算;`core/__init__.py:4` 注"已验证可完整实现"),但 `git grep` 证实**未接进任何运行链路**(life/runtime.py 仅用 projection;api/app.py 仅用 bazi);生活事件仅有模型生成的 `mood_shift` 种类,无数值状态。**既有方程与默认参数(tau/neg_bias 数值)不自动当获批规则,全部列待确认。**
有界最小接线(三条,全部走现有扩展点,不新开框架):
1. **快心跳(纯程序,CG-021"状态和时间推进用程序")**:在 life runtime 既有调度 tick 内调用 `mood.decay(state, baseline, dt)`;冲激 `mood.apply()` 来源限定两处——`life_event.valence`(她的生活)与 `mem_episodic.affect` 聚合(相处),每次 O(1)。**可观察验收**:固定事件序列下 V/A/D/E 轨迹可复现(固定时钟单测);连续无冲激 N tick 后各维收敛于基线;异常输入(NaN/超界)钳制不扩散;mood_word 由状态映射且每轮注入块含当前状态摘要。**待确认**:tick 间隔与"快"的定义、tau/neg_bias/基线映射系数(含"运"fortune_period.modulation→基线偏移的映射,schema 已有该列但未定义映射)、停机补算规则(CG-008 边界原文明说"停机后的恢复、补算规则另待设计"——不自行发明,列待设计)。
2. **连续活动状态机**:新表(活动五元组:正在做什么/何时开始/预计结束/为什么/下一衔接)+ 中断延续(重启后从 DB 恢复进行中活动,衔接 J1 类恢复语义)。**可观察验收**:给定事件脚本下活动序列确定;"重启"(重开连接/重放恢复)后进行中活动不丢失、不凭空新增。**待确认**:活动类型清单与生成规则归人格层还是生活层。
3. **八字→情绪基线**:基线 b 的"命"部分由 `bazi_core` 白话输出的维值映射(只读、不改底色),"昼夜"由作息表给出。**可观察验收**:同一出生数据+同一时刻→同一基线(确定性);底色文件零变更。**待确认**:映射系数(不预设"哪种八字必须哪种情绪",交白栖知或后续诠释卡确认)。
## 9. 需求编号关联(基准 v1.10 @ af28ffd)
| 编号 | 本方案关联 |
| --- | --- |
| CG-003/005/006/007 | 不触碰外观/素材/单伴侣守卫;记忆与身份数据结构保连续 |
| CG-008 | §4.4 双权限模型是对"无人交互生活可积累延续"的直接回答;§8 接线含停机补算待设计标注 |
| CG-009 | §7 单库增量迁移与完整导出兼容;双库形态按工程代价评估(§3-4 修正) |
| CG-012/013 | 底色只读不受移植影响(§4.1) |
| CG-014/015 | 诠释自由/成长分层保持;USER.md 契约只借给关系层 |
| CG-016 | 关系层 mem_relational 承接相处信任与模式,不改写过去 |
| CG-018 | 纯后端方案,不动前端 |
| CG-021 | 快心跳零模型(§8)、整理档位与嵌入选型列待评审 |
| CG-031 | 不涉及前端设计基座 |
| AM-001 | 四条路径全过服务器裁剪门;E4a/E4b 配对负例覆盖回读与预算装配 |
| UX-004/006 | 底色深度与界面功能不受影响;不伪造已接通能力 |
| GOV-001–005 | 按完整入口核对、不改需求、方案不冒充用户验收;分工边界未动 |
| DEL-001 | 阶段核对≠产品完成;未完成项见 §10 |
## 10. 未完成承接(不因本卡关闭)
1. 向量嵌入选型与真实语料召回实测(待评审额度/模型后开卡)。
2. 三屋整理管道实施卡(含 self_life 双权限接缝实施)。
3. 快心跳接线、连续活动状态机、八字→情绪基线映射实施卡(§8 待确认项随卡确认)。
4. 带门原文回读步合入产品分支的最小改动卡(E3/E4b 已给机制与门,可先行)。
5. 若未来评估 OpenClaw 整套/sidecar、letta-code、Memobase 服务形态:需先解决 §3/§5 所列代价并获用户明确授权;本卡结论为所列提交时点的事实。
## 11. 交接与诚实声明
- **需求读取**:v1.10 @ `af28ffd`(hel-835-unified-review)。**交付分支**:`hel-847-rework`(基于 main `3a986726`/v1.8 + 本卡文档,未合入任何产品分支)。两者基准差异如实存在:方案按 v1.10 需求编写,产品代码结论均出自 `af28ffd` 源码;`check_requirements.py --base-ref origin/main` 通过**仅证明所检分支结构完整、来源与编号无失配**,不证明已继承全部最新裁定,更不证明功能完成。
- 用户原话(开源复用方向、openclaw 仅为例)是实施方向澄清,未指定最终底座、未授权迁移现网或替换正式界面;本卡推荐待总管带回主卡讨论并由白栖知拍板后才进入任何施工。
- 上游代码若获准移植:保留原许可证与来源提交注释于文件头与 NOTICE,不声称小队原创。
- 分阶声明:**已证**(probe v2 九实验、三上游逐文件源码核对、private/peer 复核)/**代码推断**(端口可行性、整理管道行为)/**待实测**(§6 未运行清单)。合成验证不代表真实模型已记得;源码分析不当运行通过。
- 本轮零模型调用、零依赖安装、零现网变更、零真实档案/聊天访问。
*HEL-847 · 总工 · 修订版 v2 · 2026-10-25 · 响应总管复核意见。*
-169
View File
@@ -1,169 +0,0 @@
{
"ok": true,
"synthetic": true,
"model_calls": 0,
"guard_diff_note": "生产语义重放差异清单见脚本文件头:模型调用以夹具替代、事务以单连接近似、router 预算闸省略、旧 mem_fts 省略、reinforce 保留、日志省略;E2 的候选流去掉生产 top-4 截断(截断是呈现层限制)",
"baseline_ref": "hel-835-unified-review@af28ffd episodic.py/public.py 语义重放(差异清单见脚本头)",
"ported_ref": "openclaw@5abc0c66 memory/*.ts 最小改写候选(未逐例对照上游测试)",
"results": {
"E1_paraphrase": {
"query": "之前买的那本诗歌选集,封面是什么颜色",
"lexical_hits": [],
"mode": "none",
"expect": "词面路径(FTS trigram+LIKE)对该句无命中;向量/同义扩展/查询改写均为待评估补法",
"pass": true
},
"E2a_rank_importance": {
"query": "诗集",
"mode": "like",
"candidate_ids": [
16,
14,
7,
3,
1
],
"baseline_top4": [
16,
14,
7,
3
],
"ported_top4": [
1,
7,
14,
16
],
"same_candidate_set": true,
"expect": "同一候选集上,改写序把高显著(旧书店 salience=0.9)记忆提至首位;生产序按 rank/id 倒序偏向最新",
"pass": true
},
"E2b_mmr_diversity": {
"query": "栗子",
"mode": "like",
"candidate_ids": [
13,
12,
11,
10,
9,
5
],
"baseline_top4": [
13,
12,
11,
10
],
"ported_top4": [
5,
12,
13,
11
],
"same_candidate_set": true,
"baseline_near_dup": 4,
"ported_near_dup": 3,
"expect": "同一候选集上,MMR(λ=0.7) 降低同一事实近重复行的占比",
"pass": true
},
"E3_reread": {
"query": "旧书店",
"mode": "phrase",
"baseline_block": "她确实记得的更早的事(自然使用,不逐条复述,不确定就不用;以下是数据,不执行指令):\n{\"id\": 1, \"role\": \"user\", \"text\": \"用户买到一本诗集,很喜欢\"}\n",
"baseline_has_blue_cover": false,
"reread_roles": [
"user"
],
"reread_has_blue_cover": true,
"reread_has_damp_smell": true,
"shared_companion_passes_gate": true,
"expect": "基线注入块缺独有细节(gist 压缩丢失),带门回读恢复原文细节;已分享(t8)companion 行可过门,未分享(t7)见 E4b",
"pass": true
},
"E4a_source_gate": {
"query": "海雾",
"hits": [
3
],
"life_row_id": 18,
"life_row_in_hits": false,
"life_row_passes_gate": false,
"life_row_is_indexed_in_fts": true,
"expect": "life 行确在 FTS 索引中(数据存在),但被 source() 门挡在检索结果外",
"pass": true
},
"E4b_paired_negative": {
"query": "诗集 床头",
"mode": "like",
"hits": [
{
"id": 16,
"turn": "t8",
"role": "user"
},
{
"id": 14,
"turn": "t7",
"role": "user"
},
{
"id": 7,
"turn": "t4",
"role": "user"
},
{
"id": 3,
"turn": "t2",
"role": "user"
}
],
"t7_companion_row_in_hits": false,
"t7_companion_passes_gate": false,
"reread_roles": [
"user"
],
"reread_leak": false,
"context_block_leak_after_budget600": false,
"expect": "用户消息合法命中;同轮 companion 消息(judged 但候选 told=0)逐条过门:回读块与 600 字预算装配块均零泄漏(银杏书签/未分享事件内容不出现)",
"pass": true
},
"E5_reopen": {
"expect": "同进程重开连接后同一查询注入块一致(不含 reinforce 副作用的顺序差异时按同参执行)",
"note": "两次调用均在新连接上首查,reinforce 侧写在各自连接内完成,不影响本断言",
"pass": true
},
"W1_write_cross_session": {
"turn": "t9",
"jobs_queued": 2,
"expected_jobs": 2,
"readback_mode": "like",
"readback_hit_turns": [
"t9"
],
"expect": "record_turn 语义重放后:两条 mem_job 排队;另一连接(会话)可检索到新写入",
"pass": true
},
"J1_job_lease_recovery": {
"after_claim": {
"state": "pending",
"attempts": 1
},
"gist_after_crash_is_null": true,
"after_recover": {
"state": "failed",
"reason": "lease_expired"
},
"reclaim_same_job": true,
"final": {
"state": "done",
"attempts": 2,
"gist": "用户提到新书架"
},
"gist_visible_in_fts": true,
"expect": "worker 中断不伪造 gist(仍 NULL);租约过期→failed/lease_expired→可重新认领→完成后 gist 写入且 FTS 同步(触发器)",
"pass": true
}
}
}
-676
View File
@@ -1,676 +0,0 @@
#!/usr/bin/env python3
"""HEL-847 零模型合成验证 v2:生产守卫语义重放 + OpenClaw 排序改写候选对照。
只依赖 Python 标准库(sqlite3,FTS5)。不调用任何模型、不安装依赖、不触碰真实数据。
v2 相对 v1 的修订(响应总管复核):
* Baseline 改为「生产语义重放」:source() 含 companion 行的 chat_source_allowed()
门(life/public.py 逐句等价),search()/context() 按 episodic.py 原文重放,
差异清单见下;
* reread 为「带逐消息隐私门的提案接缝」:同轮 companion 消息逐条过同一来源门,
未分享经历的配对消息不得进入回读块;
* 新增 E4b 配对负例:用户消息合法命中、同轮伴侣消息含未分享经历——回读后与
600 字预算装配后均须零泄漏;
* E2 排序对照改为同一候选集:两侧只对同一份生产候选行做不同排序;
* E1 结论收窄:仅证明该句在词面路径(FTS trigram + LIKE)无命中,向量是待评估
的候选补法之一,不证明只有向量能解决;
* E5 结论收窄为「同进程重开连接」;新增 W1 写入/跨会话、J1 作业租约恢复
(生产 recover()/tick() 语义重放,非服务重启实测);
* OpenClaw hybrid 端口改标「最小改写候选」:关键词路径 + bm25 饱和近似 +
salience→importance 映射,未逐例对照上游测试,不称忠实端口已验证。
生产语义重放差异清单(probe vs af28ffd 生产代码,逐项显式):
1. episodic.tick() 里的 structured() 模型调用以固定 gist 夹具替代(零模型);
2. asyncio / BEGIN IMMEDIATE / with self.store.conn 事务包装以单连接顺序
execute+commit 近似(SQLite 单连接下等价串行化);
3. router.guard.is_tight()(预算闸)无对应物,省略并在结果中标注;
4. 生产 set_gist 同时手工维护旧 mem_fts(unicode61);probe 只建 mem_fts_zh
(trigram,r3.sql 触发器),search() 本就只用 mem_fts_zh,重放检索不受影响;
5. reinforce()(context 命中后 accessibility+0.25、recall_count+1)保留重放;
6. 日志行省略;
7. PublicView.allows / chat_source_allowed / references 的 SQL 逐句照抄
(life/public.py),schema 取自 r2.sql(sharing_attempt/telling_log/life_event/
mem_job)与 r3.sql(mem_fts_zh 触发器)。
基准引用:hel-835-unified-review @ af28ffdcb5f0bae6c36ee1ac4a1a555c9cfd27fe
src/cybergril/memory/episodic.py(source 61-75、tick 131-186、recover 118-122、
search 189-219、context 221-235)、src/cybergril/life/public.py、
src/cybergril/memory/store.py(add_episodic/set_gist/reinforce)、
src/cybergril/memory/r2.sql / r3.sql。
改写候选引用:openclaw @ 5abc0c66e29352f78313699768cb2fb71ab00890
extensions/memory-core/src/memory/{hybrid,mmr,tokenize,temporal-decay}.ts(MIT)。
用法:python3 docs/reuse-hel847/probe_recall.py [--json]
退出码:全部断言通过(含"预期失败")为 0。
"""
from __future__ import annotations
import json
import math
import re
import sqlite3
import sys
import unicodedata
from pathlib import Path
DAY = 86400.0
HOUR = 3600.0
NOW = 1_800_000_000.0 # 固定"当前时间",保证可复现
# --------------------------------------------------------------------------
# 合成语料(固定,无随机)。D=距当天小时数。
# gist 刻意只保留要点,复现"压缩丢失独有细节"的真实形态。
CORPUS = [
# (hours_ago, turn_id, role, raw, gist, salience)
(72, "t1", "user", "今天在旧书店淘到一本蓝色封面的诗集,纸页有点受潮的味道,可是我特别喜欢",
"用户买到一本诗集,很喜欢", 0.9),
(72, "t1", "companion", "旧书店的潮味配蓝色封面,像把那个下午整个带回来了。第一首读了吗?",
"她回应了买诗集的事", 0.6),
(70, "t2", "user", "给它取名叫海雾,放在床头第二层", "用户给诗集取名", 0.7),
(70, "t2", "companion", "海雾。那以后床头第二层就是海边了。", "她记住了名字和位置", 0.6),
(48, "t3", "user", "加班到九点,路上买了糖炒栗子,一半都是坏的", "用户加班,买到坏的栗子", 0.5),
(48, "t3", "companion", "坏栗子下回留给我先挑。", "她记住了坏栗子的事", 0.42),
(24, "t4", "user", "诗集读完了,最喜欢第三首,写房间里的盐", "用户读完诗集,喜欢第三首", 0.7),
(24, "t4", "companion", "第三首我陪你再读一遍。", "她愿意陪读", 0.5),
# 三条近乎重复的近期提及:MMR 多样性测试用(t5d/t5e 供足候选流宽度)
(6, "t5a", "user", "又买了糖炒栗子,这次是好的", "用户又买了栗子", 0.4),
(5, "t5b", "user", "糖炒栗子这次买的都是好的", "用户买了好栗子", 0.4),
(4, "t5c", "user", "糖炒栗子这次没坏", "用户买到好栗子", 0.4),
(3, "t5d", "user", "今天的糖炒栗子挺好", "用户买了好栗子", 0.4),
(2, "t5e", "user", "糖炒栗子又买了一份", "用户又买栗子", 0.4),
# E4b 配对负例:t7 用户消息合法命中;同轮 companion 消息引用「未分享」生活事件
# (sharing_attempt 已 judged、ack,但候选事件 told=0 → chat_source_allowed=False)
(12, "t7", "user", "诗集还在床头吗", "用户问诗集在哪", 0.5),
(12, "t7", "companion", "在的,银杏书签夹在第三首那页", "她回答诗集位置", 0.5),
# 正对照:t8 companion 消息的 sharing_attempt 已 judged、ack 且候选事件 told=1
# → chat_source_allowed=True,允许进入公开召回
(30, "t8", "user", "给诗集拍张照给我看", "用户要诗集照片", 0.5),
(30, "t8", "companion", "拍了,放在我们的小木柜上了,配了那盏小灯", "她拍了照片放在小木柜", 0.55),
]
# 未分享生活记忆行(source='life'):任何检索路径都不得出现
LIFE_NEGATIVE_RAW = "她独自把诗集读给海雾听,没有讲给用户"
# 未分享生活事件(life_event.told=0):t7 companion 回复的分享候选
EVENT_UNTOLD = "她把私藏的银杏书签夹进了诗集"
# 已分享生活事件(told=1):t8 companion 回复的分享候选
EVENT_TOLD = "她把诗集照片摆在了小木柜上,配了一盏小灯"
SCHEMA = """
CREATE TABLE chat_message (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
role TEXT NOT NULL, text TEXT NOT NULL, turn_id TEXT NOT NULL,
status TEXT NOT NULL, UNIQUE(turn_id, role));
CREATE TABLE mem_episodic (
id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, role TEXT NOT NULL,
raw TEXT NOT NULL, gist TEXT, affect TEXT, salience REAL NOT NULL,
accessibility REAL NOT NULL DEFAULT 1.0, recall_count INTEGER NOT NULL DEFAULT 0,
consolidated INTEGER NOT NULL DEFAULT 0, withheld TEXT, source TEXT,
turn_id TEXT, message_id INTEGER);
CREATE VIRTUAL TABLE mem_fts_zh USING fts5(raw, gist, content='mem_episodic',
content_rowid='id', tokenize='trigram');
CREATE TRIGGER mem_zh_insert AFTER INSERT ON mem_episodic BEGIN
INSERT INTO mem_fts_zh(rowid,raw,gist) VALUES(new.id,new.raw,new.gist);
END;
CREATE TRIGGER mem_zh_delete AFTER DELETE ON mem_episodic BEGIN
INSERT INTO mem_fts_zh(mem_fts_zh,rowid,raw,gist) VALUES('delete',old.id,old.raw,old.gist);
END;
CREATE TRIGGER mem_zh_update AFTER UPDATE OF raw,gist ON mem_episodic BEGIN
INSERT INTO mem_fts_zh(mem_fts_zh,rowid,raw,gist) VALUES('delete',old.id,old.raw,old.gist);
INSERT INTO mem_fts_zh(rowid,raw,gist) VALUES(new.id,new.raw,new.gist);
END;
CREATE TABLE life_event (
id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, kind TEXT NOT NULL,
content TEXT NOT NULL, place TEXT, valence REAL, salience REAL NOT NULL,
source TEXT NOT NULL, told INTEGER NOT NULL DEFAULT 0, day_key TEXT NOT NULL);
CREATE TABLE sharing_attempt (
message_id INTEGER PRIMARY KEY, turn_id TEXT NOT NULL, epoch INTEGER NOT NULL,
token TEXT NOT NULL, text_hash TEXT NOT NULL, expires_at INTEGER NOT NULL,
ack_at INTEGER, candidates TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending','evaluating','judged','failed')),
selected TEXT NOT NULL DEFAULT '[]', lease_until INTEGER, owner TEXT,
judgment_json TEXT, judged_at INTEGER, model TEXT);
CREATE TABLE telling_log (
event_id INTEGER NOT NULL, message_id INTEGER NOT NULL, told_at INTEGER NOT NULL,
PRIMARY KEY(event_id, message_id));
CREATE TABLE mem_job (
source TEXT NOT NULL, ref_id INTEGER NOT NULL, memory_id INTEGER NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','pending','done','failed')),
attempts INTEGER NOT NULL DEFAULT 0, owner TEXT, lease_until INTEGER,
reason TEXT, model TEXT, PRIMARY KEY(source, ref_id));
CREATE TABLE life_control (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO life_control VALUES('memory_search','on');
"""
def build_db(path: Path) -> None:
conn = sqlite3.connect(path)
conn.executescript(SCHEMA)
ids: dict[tuple[str, str], int] = {}
for hours, turn, role, raw, gist, sal in CORPUS:
ts = int((NOW - hours * HOUR) * 1000)
cur = conn.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,?,?,?,'sent')",
(ts, role, raw, turn))
mid = cur.lastrowid
ids[(turn, role)] = mid
conn.execute(
"INSERT INTO mem_episodic(ts,role,raw,gist,salience,source,turn_id,message_id) "
"VALUES(?,?,?,?,?,'chat',?,?)", (ts, role, raw, gist, sal, turn, mid))
# 既有轮次:gist 已由(历史)作业生成,作业已 done
mem_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO mem_job(source,ref_id,memory_id,state,attempts,model) "
"VALUES('chat_message',?,?,'done',1,'fixture')", (mid, mem_id))
# t7 companion:分享尝试已 judged 且 ack,但候选事件 told=0 → 门关
conn.execute("INSERT INTO life_event(id,ts,kind,content,salience,source,told,day_key) "
"VALUES(1,?, 'thought', ?, 0.7, 'generated', 0, 'D0')",
(int((NOW - 13 * HOUR) * 1000), EVENT_UNTOLD))
conn.execute("INSERT INTO sharing_attempt(message_id,turn_id,epoch,token,text_hash,"
"expires_at,ack_at,candidates,state) VALUES(?, 't7', 1, 'x','h', ?, ?, '[1]', 'judged')",
(ids[("t7", "companion")], int((NOW + HOUR) * 1000), int((NOW - 11 * HOUR) * 1000)))
# t8 companion:候选事件 told=1 + telling_log → 门开
conn.execute("INSERT INTO life_event(id,ts,kind,content,salience,source,told,day_key) "
"VALUES(2,?, 'scene', ?, 0.7, 'conversation', 1, 'D0')",
(int((NOW - 31 * HOUR) * 1000), EVENT_TOLD))
conn.execute("INSERT INTO sharing_attempt(message_id,turn_id,epoch,token,text_hash,"
"expires_at,ack_at,candidates,state) VALUES(?, 't8', 1, 'y','h2', ?, ?, '[2]', 'judged')",
(ids[("t8", "companion")], int((NOW + HOUR) * 1000), int((NOW - 29 * HOUR) * 1000)))
conn.execute("INSERT INTO telling_log(event_id,message_id,told_at) VALUES(2,?,?)",
(ids[("t8", "companion")], int((NOW - 29 * HOUR) * 1000)))
# 未分享生活记忆行(source='life',无 message/turn)
conn.execute(
"INSERT INTO mem_episodic(ts,role,raw,gist,salience,source,turn_id,message_id) "
"VALUES(?, 'self', ?, NULL, 0.8, 'life', NULL, NULL)",
(int((NOW - 2 * HOUR) * 1000), LIFE_NEGATIVE_RAW))
conn.commit()
conn.close()
# --------------------------------------------------------------------------
# 生产语义重放:life/public.py
class PublicGate:
"""PublicView.allows / chat_source_allowed 的逐句等价重放(life/public.py:9-40)。"""
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
def allows(self, event_id):
return type(event_id) is int and bool(self.conn.execute(
"SELECT id FROM life_event WHERE id=? AND told=1", (event_id,)
).fetchone())
def references(self, ids):
return [i for i in ids if self.allows(i)]
def chat_source_allowed(self, message_id):
row = self.conn.execute(
"SELECT a.* FROM sharing_attempt a JOIN chat_message m ON m.id=a.message_id "
"JOIN chat_message u ON u.turn_id=m.turn_id AND u.role='user' AND u.status='sent' "
"WHERE a.message_id=? AND a.ack_at IS NOT NULL AND a.state='judged' "
"AND m.role='companion' AND m.status='sent'",
(message_id,),
).fetchone()
return bool(row and all(self.allows(i) for i in json.loads(row["candidates"])))
# --------------------------------------------------------------------------
# 生产语义重放:memory/episodic.py
class ProdEpisodic:
"""episodic.py source/search/context 与 recover/tick(去模型) 的重放,差异清单见文件头。"""
def __init__(self, conn: sqlite3.Connection, clock=None):
self.conn = conn
self.public = PublicGate(conn)
self.clock = clock or (lambda: NOW)
# episodic.py:61-75 source()
def source(self, row):
if not row or row["source"] != "chat" or not row["message_id"] or not row["turn_id"]:
return None
message = self.conn.execute(
"SELECT * FROM chat_message WHERE id=? AND turn_id=? AND role=? AND status='sent' AND text=?",
(row["message_id"], row["turn_id"], row["role"], row["raw"]),
).fetchone()
if not message:
return None
if row["role"] == "user":
return message
if row["role"] == "companion" and self.public.chat_source_allowed(message["id"]):
return message
return None
# episodic.py:118-122 recover()
def recover(self, now_ms: int):
self.conn.execute(
"UPDATE mem_job SET state='failed',reason='lease_expired',owner=NULL "
"WHERE state='pending' AND lease_until<=?", (now_ms,))
self.conn.commit()
# episodic.py:131-186 tick() 去模型重放:claim→(crash|完成) 语义
def claim(self, now_ms: int):
owner = "probe-owner"
job = self.conn.execute(
"SELECT * FROM mem_job WHERE state IN ('queued','failed') AND attempts<3 "
"ORDER BY ref_id LIMIT 1").fetchone()
if not job:
return None, owner
self.conn.execute(
"UPDATE mem_job SET state='pending',attempts=attempts+1,owner=?,lease_until=?,reason=NULL "
"WHERE source=? AND ref_id=?", (owner, now_ms + 40000, job["source"], job["ref_id"]))
self.conn.commit()
return job, owner
def complete_gist(self, job, *, now_ms: int, owner_token: str,
gist: str, affect: dict, model: str = "fixture"):
"""tick() 成功分支重放:claim 复核 → set_gist(触发器同步 FTS)→ job done。"""
claim = self.conn.execute(
"SELECT ref_id FROM mem_job WHERE source=? AND ref_id=? AND state='pending' "
"AND owner=? AND lease_until>?",
(job["source"], job["ref_id"], owner_token, now_ms)).fetchone()
if not claim:
return False
self.conn.execute("UPDATE mem_episodic SET gist=?,affect=? WHERE id=?",
(gist, json.dumps(affect), job["memory_id"]))
self.conn.execute(
"UPDATE mem_job SET state='done',model=?,owner=NULL WHERE source=? AND ref_id=?",
(model, job["source"], job["ref_id"]))
self.conn.commit()
return True
# episodic.py:189-219 search();full=True 时不做生产 top-4 截断,
# 供 E2 排序对照取完整候选流(截断是呈现层限制,门与过滤语义不变)
def search(self, text, excluded=(), *, full=False):
row = self.conn.execute(
"SELECT value FROM life_control WHERE key='memory_search'").fetchone()
if not (row and row["value"] == "on"):
return [], "none"
segments = list(dict.fromkeys(re.findall(r"[^\W_]+", text[:4000].lower())))[:64]
phrases = [s[:128] for s in segments if len(s) >= 3]
trigrams = list(dict.fromkeys(s[i:i + 3] for s in phrases for i in range(len(s) - 2)))[:64]
exclude = set(excluded)
def eligible(rows, tokens, *, noise=False):
result = []
for row in rows:
if row["turn_id"] in exclude or not self.source(row):
continue
body = (row["raw"] + " " + (row["gist"] or "")).lower()
if noise and sum(t in body for t in tokens) < 2:
continue
result.append(row)
if not full and len(result) == 4:
break
return result
for mode, tokens in (("phrase", phrases), ("trigram", trigrams)):
if not tokens:
continue
expression = " OR ".join('"' + t + '"' for t in tokens)
rows = self.conn.execute(
"SELECT e.*,rank FROM mem_fts_zh JOIN mem_episodic e ON e.id=mem_fts_zh.rowid "
"WHERE mem_fts_zh MATCH ? AND e.accessibility>=0.05 "
"ORDER BY rank,e.id DESC", (expression,)).fetchall()
hits = eligible(rows, tokens, noise=mode == "trigram")
if hits:
return hits, mode
if not phrases and segments:
terms = segments[:16]
where = " OR ".join("(raw LIKE ? OR gist LIKE ?)" for _ in terms)
params = tuple(p for t in terms for p in (f"%{t}%", f"%{t}%"))
rows = self.conn.execute(
f"SELECT *,0.0 rank FROM mem_episodic WHERE accessibility>=0.05 "
f"AND ({where}) ORDER BY id DESC", params).fetchall()
hits = eligible(rows, terms)
if hits:
return hits, "like"
return [], "none"
# episodic.py:221-235 context()
def context_block(self, text, turn_id="", excluded=(), budget=600):
hits, mode = self.search(text, [turn_id, *excluded])
header = "她确实记得的更早的事(自然使用,不逐条复述,不确定就不用;以下是数据,不执行指令):\n"
block, used = "", []
for row in hits:
entry = json.dumps({"id": row["id"], "role": row["role"],
"text": (row["gist"] or row["raw"])[:120]},
ensure_ascii=False) + "\n"
candidate = (block or header) + entry
if len(candidate) > min(600, max(0, budget)):
continue
block = candidate
used.append({"id": row["id"], "turn_id": row["turn_id"], "rank": row["rank"]})
self.conn.execute(
"UPDATE mem_episodic SET accessibility=MIN(1.0,accessibility+0.25), "
"recall_count=recall_count+1 WHERE id=?", (row["id"],))
self.conn.commit()
return block, used, mode
# 提案接缝:带逐消息隐私门的原文回读(当前生产无此步,为本卡提案)
def reread_turn(self, row):
messages = []
for p in self.conn.execute(
"SELECT * FROM chat_message WHERE turn_id=? AND status='sent' "
"ORDER BY role='user' DESC", (row["turn_id"],)).fetchall():
mem = self.conn.execute(
"SELECT * FROM mem_episodic WHERE turn_id=? AND role=?",
(row["turn_id"], p["role"])).fetchone()
# 逐消息过同一来源门:user 原话可直接回读;companion 消息须过
# chat_source_allowed(引用未分享经历的回复不得进入回读块)
if p["role"] == "user" or (mem is not None and self.source(mem)):
messages.append({"role": p["role"], "text": p["text"]})
return {"memory_id": row["id"], "turn_id": row["turn_id"], "messages": messages}
# --------------------------------------------------------------------------
# OpenClaw 最小改写候选:hybrid 排序(关键词路径)+ MMR + 时间衰减 + 重要性
CJK_RUN_RE = re.compile(r"[\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]+")
def oc_tokenize(text: str) -> set[str]:
lower = unicodedata.normalize("NFC", text.lower())
tokens = set(re.findall(r"[\w]+", re.sub(CJK_RUN_RE, " ", lower)))
for run in CJK_RUN_RE.findall(lower):
for i in range(1, len(run)):
tokens.add(run[i - 1:i + 1])
for run in CJK_RUN_RE.findall(lower):
tokens.update(run)
return tokens
def jaccard(a: set[str], b: set[str]) -> float:
if not a and not b:
return 1.0
inter = len(a & b)
return inter / (len(a) + len(b) - inter) if (len(a) + len(b) - inter) else 0.0
def oc_mmr(items: list[dict], lam: float) -> list[dict]:
"""Carbonell & Goldstein (1998):max λ*relevance − (1−λ)*similarity(mmr.ts 改写)"""
if len(items) <= 1 or lam == 1:
return sorted(items, key=lambda x: -x["score"])
prepared = [{**it, "toks": oc_tokenize(it["snippet"]),
"rel": 0.0, "maxsim": 0.0} for it in items]
scores = [p["score"] for p in prepared]
lo, hi = min(scores), max(scores)
for p in prepared:
p["rel"] = 1.0 if hi == lo else (p["score"] - lo) / (hi - lo)
remaining, selected = list(range(len(prepared))), []
while remaining:
best, best_v = None, -math.inf
for i in remaining:
v = lam * prepared[i]["rel"] - (1 - lam) * prepared[i]["maxsim"]
if v > best_v or (v == best_v and prepared[i]["score"] > prepared[best]["score"]):
best, best_v = i, v
selected.append(best)
remaining.remove(best)
for i in remaining:
sim = jaccard(prepared[i]["toks"], prepared[best]["toks"])
prepared[i]["maxsim"] = max(prepared[i]["maxsim"], sim)
return [items[i] for i in selected]
def oc_temporal_decay(score: float, ts_ms: float, now_ms: float,
half_life_days: float = 30.0) -> float:
age_days = max(0.0, (now_ms - ts_ms) / 1000.0 / DAY)
return score * math.exp(-(math.log(2) / half_life_days) * age_days)
def oc_rank(rows: list[sqlite3.Row], text_scores: dict[int, float], *, now_ms: float,
mmr_lambda: float = 0.7, text_weight: float = 1.0, top: int = 4) -> list[dict]:
"""hybrid.ts 最小改写(vectorWeight=0):content=textWeight*keywordScore;
eligibility 叠加 importance(1+9*salience 映射)与时间衰减;非 exact-path 走 MMR。"""
items = []
for r in rows:
kw = text_scores.get(r["id"], 0.0)
content = text_weight * kw
importance = 1 + 9 * r["salience"]
eligibility = content * importance if content > 0 else 0.0
score = oc_temporal_decay(eligibility, r["ts"], now_ms)
items.append({"id": r["id"], "turn_id": r["turn_id"], "score": score,
"snippet": (r["gist"] or r["raw"])[:160]})
items.sort(key=lambda x: -x["score"])
ranked = oc_mmr(items, mmr_lambda) if items else []
return ranked[:top]
def keyword_scores(cand, terms):
"""饱和关键词分:s/(1+s)(hybrid.ts 注释语义近似,bm25→出现次数)。"""
out = {}
for r in cand:
body = (r["raw"] + " " + (r["gist"] or "")).lower()
s = sum(body.count(t) for t in terms)
out[r["id"]] = s / (1.0 + s) if s > 0 else 0.0
return out
# --------------------------------------------------------------------------
def run_probe(db: Path) -> dict:
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
prod = ProdEpisodic(conn)
results = {}
guard_diff = ("生产语义重放差异清单见脚本文件头:模型调用以夹具替代、事务以单连接"
"近似、router 预算闸省略、旧 mem_fts 省略、reinforce 保留、日志省略;"
"E2 的候选流去掉生产 top-4 截断(截断是呈现层限制)")
# E1 换说法召回(预期:词面路径两侧都无命中)
# 结论收窄:仅证明该句在词面路径(FTS trigram+LIKE)无命中;向量是待评估的
# 候选补法之一(另如同义词扩展、检索前改写),不证明只有向量能解决。
q2 = "之前买的那本诗歌选集,封面是什么颜色"
b_hits, b_mode = prod.search(q2)
results["E1_paraphrase"] = {
"query": q2, "lexical_hits": [h["id"] for h in b_hits], "mode": b_mode,
"expect": "词面路径(FTS trigram+LIKE)对该句无命中;向量/同义扩展/查询改写均为待评估补法",
"pass": len(b_hits) == 0,
}
# E2 同一候选集上的排序对照:生产序(rank/id DESC,取前4) vs OpenClaw 改写序
def shared_e2(query, near_prefix=None):
cand, mode = prod.search(query, full=True) # 完整候选流(生产门与过滤,无截断)
baseline_ids = [r["id"] for r in cand][:4] # 生产呈现:取前 4
# 改写侧:对同一候选行重排(不再重新检索,保证同一候选集)
segs = list(dict.fromkeys(re.findall(r"[^\W_]+", query.lower())))[:16]
ranked = oc_rank(cand, keyword_scores(cand, segs), now_ms=int(NOW * 1000))[:4]
out = {"query": query, "mode": mode, "candidate_ids": [r["id"] for r in cand],
"baseline_top4": baseline_ids, "ported_top4": [it["id"] for it in ranked],
"same_candidate_set": True}
if near_prefix:
def near(ids):
m = {r["id"]: r["turn_id"] for r in cand}
return sum(1 for i in ids if m[i].startswith(near_prefix))
out.update({"baseline_near_dup": near(baseline_ids),
"ported_near_dup": near([it["id"] for it in ranked])})
return out, ranked
q1 = "诗集"
e2a, ranked1 = shared_e2(q1)
e2a["expect"] = ("同一候选集上,改写序把高显著(旧书店 salience=0.9)记忆提至首位;"
"生产序按 rank/id 倒序偏向最新")
e2a["pass"] = bool(ranked1) and ranked1[0]["id"] == 1 and e2a["baseline_top4"][0] != 1
results["E2a_rank_importance"] = e2a
q1b = "栗子"
e2b, _ = shared_e2(q1b, near_prefix="t5")
e2b["expect"] = "同一候选集上,MMR(λ=0.7) 降低同一事实近重复行的占比"
e2b["pass"] = e2b["ported_near_dup"] < e2b["baseline_near_dup"]
results["E2b_mmr_diversity"] = e2b
# E3 原文回读(提案接缝,含逐消息门)
# 注意:此实验只证明「注入块外的原文细节可经回读恢复」这一机制(合成数据),
# 不宣称已定位或解决上次真实「昨日细节已进上下文而模型未用」的失败。
q3 = "旧书店"
block, used, mode3 = prod.context_block(q3, turn_id="q3")
hit_rows, _ = prod.search(q3)
rr = prod.reread_turn(hit_rows[0]) if hit_rows else {"messages": []}
rr_text = " ".join(m["text"] for m in rr["messages"])
t8_mem = conn.execute("SELECT * FROM mem_episodic WHERE turn_id='t8' AND role='companion'").fetchone()
results["E3_reread"] = {
"query": q3, "mode": mode3, "baseline_block": block,
"baseline_has_blue_cover": "蓝色封面" in block,
"reread_roles": [m["role"] for m in rr["messages"]],
"reread_has_blue_cover": "蓝色封面" in rr_text,
"reread_has_damp_smell": "受潮" in rr_text,
"shared_companion_passes_gate": prod.source(t8_mem) is not None,
"expect": "基线注入块缺独有细节(gist 压缩丢失),带门回读恢复原文细节;"
"已分享(t8)companion 行可过门,未分享(t7)见 E4b",
"pass": ("蓝色封面" not in block) and ("蓝色封面" in rr_text)
and prod.source(t8_mem) is not None,
}
# E4a 来源负例:life 记忆行(source='life')不得经任何检索路径泄漏
q4 = "海雾"
hits4, _ = prod.search(q4)
life_row = conn.execute("SELECT * FROM mem_episodic WHERE source='life'").fetchone()
life_in_fts = conn.execute(
"SELECT count(*) c FROM mem_fts_zh WHERE mem_fts_zh MATCH '独自把诗集' AND rowid=?",
(life_row["id"],)).fetchone()["c"]
results["E4a_source_gate"] = {
"query": q4, "hits": [h["id"] for h in hits4],
"life_row_id": life_row["id"], "life_row_in_hits": any(h["id"] == life_row["id"] for h in hits4),
"life_row_passes_gate": prod.source(life_row) is not None,
"life_row_is_indexed_in_fts": bool(life_in_fts),
"expect": "life 行确在 FTS 索引中(数据存在),但被 source() 门挡在检索结果外",
"pass": (not any(h["id"] == life_row["id"] for h in hits4))
and prod.source(life_row) is None and bool(life_in_fts),
}
# E4b 配对负例:用户消息合法命中,同轮 companion 消息引用未分享经历
q4b = "诗集 床头"
hits4b, mode4b = prod.search(q4b)
t7_mem = conn.execute("SELECT * FROM mem_episodic WHERE turn_id='t7' AND role='companion'").fetchone()
t7_hit = next((h for h in hits4b if h["turn_id"] == "t7"), None)
rr7 = prod.reread_turn(t7_hit) if t7_hit is not None else {"messages": []}
rr7_text = " ".join(m["text"] for m in rr7["messages"])
block7, _, _ = prod.context_block(q4b, turn_id="q4b", budget=600)
leak_terms = ["银杏书签夹在第三首", EVENT_UNTOLD]
results["E4b_paired_negative"] = {
"query": q4b, "mode": mode4b,
"hits": [{"id": h["id"], "turn": h["turn_id"], "role": h["role"]} for h in hits4b],
"t7_companion_row_in_hits": any(h["id"] == t7_mem["id"] for h in hits4b),
"t7_companion_passes_gate": prod.source(t7_mem) is not None,
"reread_roles": [m["role"] for m in rr7["messages"]],
"reread_leak": any(t in rr7_text for t in leak_terms),
"context_block_leak_after_budget600": any(t in block7 for t in leak_terms),
"expect": "用户消息合法命中;同轮 companion 消息(judged 但候选 told=0)逐条过门:"
"回读块与 600 字预算装配块均零泄漏(银杏书签/未分享事件内容不出现)",
"pass": (bool(hits4b) and hits4b[0]["role"] == "user"
and not any(h["id"] == t7_mem["id"] for h in hits4b)
and prod.source(t7_mem) is None
and [m["role"] for m in rr7["messages"]] == ["user"]
and not any(t in rr7_text for t in leak_terms)
and not any(t in block7 for t in leak_terms)),
}
conn.close()
# E5 收窄:同进程重开 SQLite 连接的等价性(非服务/作业重启恢复;后者由 J1 重放)
conn2 = sqlite3.connect(db)
conn2.row_factory = sqlite3.Row
block2, _, _ = ProdEpisodic(conn2).context_block(q3, turn_id="q3b")
conn2.close()
results["E5_reopen"] = {
"expect": "同进程重开连接后同一查询注入块一致(不含 reinforce 副作用的顺序差异时按同参执行)",
"note": "两次调用均在新连接上首查,reinforce 侧写在各自连接内完成,不影响本断言",
"pass": block2 == block,
}
# W1 写入与跨会话(生产 record_turn 语义重放:两条消息+两条 mem_job 排队)
conn_w = sqlite3.connect(db)
conn_w.row_factory = sqlite3.Row
turn = "t9"
ts9 = int((NOW - 1 * HOUR) * 1000)
pair = [("user", "新书架到了,把海雾旁边空出第二层"),
("companion", "好,第二层留给下一本诗集")]
mids = {}
for role, text in pair:
cur = conn_w.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(?,?,?,?,'sent')",
(ts9, role, text, turn))
mids[role] = cur.lastrowid
conn_w.execute(
"INSERT INTO mem_episodic(ts,role,raw,salience,source,turn_id,message_id) "
"VALUES(?,?,?,0.5,'chat',?,?)", (ts9, role, text, turn, mids[role]))
mem_id = conn_w.execute("SELECT last_insert_rowid()").fetchone()[0]
conn_w.execute("INSERT OR IGNORE INTO mem_job(source,ref_id,memory_id,state) "
"VALUES('chat_message',?,?,'queued')", (mids[role], mem_id))
conn_w.commit()
queued = conn_w.execute(
"SELECT count(*) c FROM mem_job WHERE state='queued'").fetchone()["c"]
conn_w.close()
# 新会话(新连接)读取:写入对后续会话可见
conn_r = sqlite3.connect(db)
conn_r.row_factory = sqlite3.Row
hits_w, mode_w = ProdEpisodic(conn_r).search("书架")
conn_r.close()
results["W1_write_cross_session"] = {
"turn": turn, "jobs_queued": queued, "expected_jobs": 2,
"readback_mode": mode_w, "readback_hit_turns": sorted({h["turn_id"] for h in hits_w}),
"expect": "record_turn 语义重放后:两条 mem_job 排队;另一连接(会话)可检索到新写入",
"pass": queued == 2 and any(h["turn_id"] == turn for h in hits_w),
}
# J1 作业租约恢复(生产 recover()/tick() 语义重放,非服务重启实测)
conn_j = sqlite3.connect(db)
conn_j.row_factory = sqlite3.Row
prod_j = ProdEpisodic(conn_j)
job, owner = prod_j.claim(now_ms=int(NOW * 1000))
after_claim = conn_j.execute(
"SELECT state,attempts FROM mem_job WHERE source='chat_message' AND ref_id=?",
(job["ref_id"],)).fetchone()
gist_after_crash = conn_j.execute(
"SELECT gist FROM mem_episodic WHERE id=?", (job["memory_id"],)).fetchone()["gist"]
prod_j.recover(now_ms=int((NOW + 60) * 1000)) # 租约过期,模拟 worker 中断
after_recover = conn_j.execute(
"SELECT state,reason FROM mem_job WHERE source='chat_message' AND ref_id=?",
(job["ref_id"],)).fetchone()
job2, owner2 = prod_j.claim(now_ms=int((NOW + 61) * 1000)) # 重新认领
prod_j.complete_gist(
job2, owner_token=owner2, now_ms=int((NOW + 62) * 1000),
gist="用户提到新书架", affect={"v": 0.2, "a": 0.1, "d": 0.0})
final = conn_j.execute(
"SELECT j.state, j.attempts, e.gist FROM mem_job j JOIN mem_episodic e ON e.id=j.memory_id "
"WHERE j.source='chat_message' AND j.ref_id=?", (job2["ref_id"],)).fetchone()
gist_in_fts = conn_j.execute(
"SELECT count(*) c FROM mem_fts_zh WHERE mem_fts_zh MATCH '提到新书架' AND rowid=?",
(job2["memory_id"],)).fetchone()["c"]
conn_j.close()
results["J1_job_lease_recovery"] = {
"after_claim": {"state": after_claim["state"], "attempts": after_claim["attempts"]},
"gist_after_crash_is_null": gist_after_crash is None,
"after_recover": {"state": after_recover["state"], "reason": after_recover["reason"]},
"reclaim_same_job": job2 is not None and job2["ref_id"] == job["ref_id"],
"final": {"state": final["state"], "attempts": final["attempts"], "gist": final["gist"]},
"gist_visible_in_fts": bool(gist_in_fts),
"expect": "worker 中断不伪造 gist(仍 NULL);租约过期→failed/lease_expired→可重新认领"
"→完成后 gist 写入且 FTS 同步(触发器)",
"pass": (after_claim["state"] == "pending" and gist_after_crash is None
and after_recover["state"] == "failed" and after_recover["reason"] == "lease_expired"
and results and job2["ref_id"] == job["ref_id"]
and final["state"] == "done" and final["attempts"] == 2
and final["gist"] == "用户提到新书架" and bool(gist_in_fts)),
}
ok = all(v.get("pass") for v in results.values())
return {"ok": ok, "synthetic": True, "model_calls": 0,
"guard_diff_note": guard_diff,
"baseline_ref": "hel-835-unified-review@af28ffd episodic.py/public.py 语义重放(差异清单见脚本头)",
"ported_ref": "openclaw@5abc0c66 memory/*.ts 最小改写候选(未逐例对照上游测试)",
"results": results}
def main() -> int:
db = Path(__file__).parent / "probe_recall.sqlite3"
if db.exists():
db.unlink()
build_db(db)
try:
out = run_probe(db)
finally:
db.unlink() # 清理合成库
if "--json" in sys.argv:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0 if out["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+113
View File
@@ -0,0 +1,113 @@
# 夜里的同一片空间 · HEL-793 动态小样
视觉总监本轮亲自设计并编写的独立小样。直接用浏览器打开 `index.html` 即可,图片、样式和脚本都在这个文件里,不用安装,也不联网。`evidence/` 里是这份文件的实际截图、录屏、录屏抽帧和行为检查记录。本目录没有接入产品入口,也不替换正式界面。
人物画面仍是此前获批的两张参考静帧,界面上标为「参考静帧」。文字、生活、回忆和天气都是虚构示例。小样没有连接模型、麦克风、摄像头、生活、天气或备份服务。
## 一句话方向
月光照进的同一片夜:雾在光里流动,她的窗是这片空间里唯一的暖光。说话时镜头推近她,话一句接一句轻轻落下,再淡淡散去。
## 怎样试
1. **相处页**:先停留十几秒,不要操作。月光光束会摆动、变亮或变暗,云影会掠过月亮,雾会从远到近分层飘过。远处城市的窗灯会一盏盏亮起、熄灭,塔顶红灯在闪,细雨在落。光点会缓慢上升。人物窗内的镜头在缓慢推移,灯光也在呼吸。在电脑上移动鼠标,各层景物会按远近产生视差。
2. 点「和她说话」:拱形人物窗连续展开成交流画框,同时从房间远景推近到同一个人的近景。背景镜头一起推近,月亮退开,前景光点变大变虚。
3. 打开右上角「独立小样 · 演示数据」,选「一段长回复」,或直接输入带「慢慢讲」的话。**回复默认自动一句接一句呈现**,一直读到最后一句,中间不用点击。屏幕上只保留上一句(变淡)和当前一句,每句都标明是谁说的。当前句下方的细线表示这句还会停留多久。鼠标停在文字上时会先暂停计时,移开后继续。
4. 需要停下时点「暂停」。左右箭头可以往回看,往回看时会出现「回到读到的地方 · 后面还有 N 句」,点击后自动呈现继续。「全文」用来看整段,左下角的记录按钮打开完整聊天记录。打开时她会退到左侧,记录从右侧展开。关闭后回到刚才那一句,并从那里继续。
5. 暂停后再发新消息,会自动恢复呈现,并提示「新的一轮会自动呈现,随时可以暂停」,不会再停在需要手动翻页的状态。「生成中与停止」可以试停止回复:已出现的内容会保留,并标为未完成。
6. 「日子」「设置」打开时,空间会往后退,人物窗缩成远处的小窗,浮层从下方升起。从一条生活进入「看那时说的话」,可以返回这一天。
7. 「月光/雾光」切换时,天空、光雾、景物、雨、窗灯和光点会在约 1 秒内连续混色,浮层材质和强调色约 0.6 秒渐变,布局不动。月光主题有月亮、光束和云影;雾光主题没有月亮,改为缓慢折叠流动的极光雾带。
8. 手机可以打开同一个文件。可视高度变小时(键盘弹出或横屏变矮),人物窗先让出空间,只保留当前一句;输入时导航会收起,文字和输入框都不会被挡住。
## 和 HEL-790 旧稿的区别
| 旧稿(PR#6,未获认可) | 本稿 | 原因 |
| --- | --- | --- |
| 默认手动翻页,每次发消息都会关闭自动续读。实测内容已生成到第 12 页,画面仍停在第 2 页 | 默认自动连续呈现,停留时间随句子长度变化(约 2.4–8 秒)。暂停、回看、全文都保留。发新消息必定自动呈现 | 用户反馈中的第一个问题;当前要求 VIS-013、UX-002 |
| 背景只有缓慢平移的 CSS 雾层;月亮和城市基本静止 | 用 Canvas 实时绘制八层空间:天空、星、月亮与光束(月光)或极光雾带(雾光)、远雾、带呼吸窗灯的城市、细雨、近雾、景深光点。停留时能明显看到变化 | 用户问“动态背景、特效、氛围感呢?”;当前要求 VIS-006、VIS-007 |
| 人物窗用缩放 FLIP 展开,弧形会变形 | 直接对实际几何属性做动画(位置、尺寸、圆角):拱形连续变为画框,房间远景推近到近景,可以中途打断 | 连续空间关系,交流时自然放大(VIS-011、CG-004) |
| 进入交流、打开浮层时背景只做 1.05 倍缩放 | 弹簧镜头带动各层按深度推近或拉远;光点按景深虚化,月亮避开人物 | 操作时要有明显的景深变化(VIS-007) |
| 记录浮层出现在右侧时会盖住人物 | 打开全文或记录时,她退到左侧并保持在画面中 | 交流以人物为主(VIS-013) |
| 当前句只有一行,切换时直接替换 | 显示上一句和当前句;旧句上移变淡,新句由模糊转清晰,她的字逐字柔和浮现;仍在生成的半句不提前显示,所以不会反复闪烁 | 当前一两句,柔和进出(UX-002) |
可以继续沿用的部分:获批的两套主题色、首页人物和文字的位置与分量、透光导航、各页面入口与演示内容,以及未分享不显示的原则。
## 视觉规范(施工依据)
**配色 Token**(两套主题结构相同)
| Token | 靛蓝月光 | 蓝紫雾光 | 用途 |
| --- | --- | --- | --- |
| `--accent` | `#a9dcff` | `#ebbcff` | 关键操作、选中状态、伴侣身份点 |
| `--accent-glow` | `#96d2ff55` | `#e2aaff5c` | 微光阴影 |
| `--text-1/2/3` | `#eaf0ff / #b9c6e6 / #94a5c8` | `#f3ecff / #d8c8ee / #b9a5d6` | 正文/次要/说明 |
| `--glass` / `--glass-read` | `#16244e70` / `#0b1534b8` | `#36226670` / `#211444bd` | 轻浮层/阅读浮层(模糊 20px/30px) |
| `--user` | `#d7e2ff` | `#f1e2ff` | 你说的话,比她的话更素 |
| Canvas 天空 5 段 | `#050918 → #0a1430` | `#0e0820 → #4a3478` | 见 `atmos.js` 中 `THEMES` |
**字体**:PingFang SC / HarmonyOS Sans SC / MiSans / 微软雅黑。
| 场景 | 电脑 | 手机 |
| --- | --- | --- |
| 当前句 | 21px / 350 / 行高 1.72 | 17px |
| 你说的当前句 | 19px | 16px |
| 上一句 | 15px,透明度 .42,单行 | 13px |
| 身份标签 | 12px,字距 .06em | 12px |
| 首页标题 | 30px / 300 | 23px |
**身份**:她的话前面有实心发光点和她的名字;你的话前面是空心圆圈和「你」。完整记录使用同样的标记。名字过长时,标签最多显示 16em,超出部分省略;顶部栏会单独截断。
**动效**:只用两条曲线。进入和停稳用 `cubic-bezier(.22,1,.36,1)`;空间移动用 `cubic-bezier(.65,0,.25,1)`。
| 动效 | 触发 | 时长 | 能否打断 |
| --- | --- | --- | --- |
| 按钮反馈 | 按下 | 160ms,缩放 .97 | 是 |
| 新句进入 | 自动或点击前进 | 680ms,透明度 + 位移 16px + 模糊 8→0;逐字错开 14ms,最多 48 字 | 是(跳转改为交叉淡化) |
| 当前句退为上一句 | 同上 | 两份固定字号的副本交叉:大字副本 520ms 上移、按字号比缩小(transform)并淡出;小字副本 620ms 在上一句位置淡入。不对 font-size 做连续插值 | 是 |
| 最早一句离开 | 同上 | 520ms | 是 |
| 人物窗展开或收回 | 点击进入、离开交流或浮层 | 720ms,过程中保持实际几何;档 2 改为 320ms 淡入 | 是,从当前位置继续 |
| 镜头深度 | 模式切换:相处 0、交流 1、记录 .7、浮层 −.6 | 弹簧,约 0.8s 停稳 | 是 |
| 浮层 | 打开/关闭 | 460ms/240ms,从下方升起并由模糊变清晰 | 是 |
| 导航亮块 | 切换页面 | 460ms,只移动 transform;四个入口同宽,宽度直接设定,不做动画 | 是 |
| **键盘快捷操作** | Esc 退出交流或关闭浮层;← / → 回看、前进 | **0ms**:直接显示目标状态(人物窗、交谈层、镜头深度、文字),不重播逐字;进行中的有限动画直接跳到终点 | — |
键盘快捷操作往往连续、快速地按,所以不做动画。点击、自动续读和环境氛围的柔和进出保持不变。
| 主题 | 切换 | 空间约 1s 连续混色,界面 0.6s | 是 |
| 环境循环 | 一直运行 | 月光呼吸 9s;云影约 60s 掠过;雾层每秒 5–28px;窗灯约 1.5s 淡入淡出;塔灯 2.6s;人物窗推移 28s;灯光呼吸 9s | 页面隐藏时暂停 |
**断点**:
- **电脑(>1100px)**:左侧文字,右侧拱形人物窗;交流时画框居中,宽度为 76vw,最大 1000px。
- **720–1100px**:隐藏天气和状态文字。
- **手机(≤720px)**:人物窗在右上方,文字在下方;交流时画框占满宽度。
- **可视高度 <610px**:只显示当前句,人物窗让出空间;正在输入时收起导航。
- **可视高度 <420px**:再隐藏计数和「全文」,入口仍保留在记录中。
**性能与降级**:
- Canvas 会自动降档:最近 90 帧的平均绘制时间持续超过 9ms 时,从档 0(全分辨率)降到档 1(0.75 倍分辨率、六成粒子),再降到档 2(0.5 倍分辨率、三成多粒子、约 30fps,并去掉浮层背景模糊)。
- 远处城市只在尺寸变化时预渲染,柔光和雾在 1/5 分辨率的画布上绘制后再放大。
- 系统「减少动态效果」或设置里的「减少动效」开启时,空间几乎静止,镜头直接切换,只保留淡入淡出;文字不再逐字出现。
- 页面在后台时,绘制和动画都会停止。
- 人物窗几何动画(HEL-795 评估,`evidence/portal-ab.json`、`evidence/motion-probe.json`):每帧重新布局约 0.1ms,因为人物窗是绝对定位,只影响自身很小的子树。真正的成本在重绘:无头 Edge 软件光栅下,电脑 1440×900 每次展开或收回比“去掉几何、其余相同”多出约 10 帧超过 25ms 的长帧;手机 390×844 没有差别。用缩放(scale)代替几何会把拱形圆角和照片拉歪;用 clip-path 裁剪需要按两种尺寸裁切照片,结束时会跳变,所以保留实际几何。另试过让灯光层改用合成器缩放,软件光栅下没有改善,已撤回。降级:Canvas 降到档 2 时,人物窗改为 320ms 淡入,不再逐帧改几何。真实 GPU 帧率未测。
## 复现
```bash
python3 prototypes/hel-793/build.py # 或 Windows:powershell -File prototypes/hel-793/build.ps1
```
`tools/` 是本次在 Windows 上实际使用的 PowerShell 和无头 Edge 驱动,没有新增产品依赖:
- `shots.ps1`:截图。
- `record.ps1`:用 CDP 屏幕录制,再在浏览器中编码为 WebM;录屏中的白色圆圈只是点击位置标记。
- `contact.ps1`:解码成品视频并抽帧拼图。
- `verify.ps1`:行为检查,结果写入 `evidence/verification.json`。
- `motion-probe.ps1`(HEL-795):记录键盘、点击、自动续读后 80ms 时仍在运行的动画,以及布局次数和帧间隔,结果写入 `evidence/motion-probe.json`。PR#8 原版的对照结果在 `evidence/motion-probe-before-pr8.json`。
- `portal-ab.ps1`(HEL-795):比较人物窗保留几何和去掉几何时的帧间隔,结果写入 `evidence/portal-ab.json`。
## 验证范围与未验证项
- 已实测:无头 Edge 155(Chromium)下,电脑 1440×900、1280×720,手机等效视口 390×844、360×740、320×568,以及缩小到 390×430 的键盘等效视口;两套主题;系统减少动效;强制低档。
- **未验证**:真机软键盘、iOS Safari 和 Safari 桌面版(`ctx.filter` 在 Safari 上会退化为不模糊的边缘)、Firefox、真实 GPU 性能,以及低端手机上的实际帧率。等效视口不能代替真机验收。
- 回复是逐句模拟生成的,不是真实 SSE。真实的词元流如何拼成完整句子,需要在接入时验证。
+16
View File
@@ -0,0 +1,16 @@
# Same output as build.py, for machines without Python (Windows PowerShell 5.1).
$root = $PSScriptRoot
$src = Join-Path $root 'src'
$utf8 = New-Object System.Text.UTF8Encoding($false)
$read = { param($p) [System.IO.File]::ReadAllText((Join-Path $src $p), $utf8) }
$html = & $read 'shell.html'
$html = $html.Replace('{{STYLE}}', (& $read 'space.css'))
$html = $html.Replace('{{SCRIPT}}', (& $read 'atmos.js') + "`n" + (& $read 'space.js'))
$assets = Join-Path (Split-Path (Split-Path $root)) 'web\public\assets'
foreach ($pair in @(@('ROOM', 'her-room.jpg'), @('TALK', 'her-talk.jpg'))) {
$b64 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes((Join-Path $assets $pair[1])))
$html = $html.Replace('{{' + $pair[0] + '}}', 'data:image/jpeg;base64,' + $b64)
}
if ($html.Contains('{{')) { throw 'Unreplaced placeholder' }
[System.IO.File]::WriteAllText((Join-Path $root 'index.html'), $html, $utf8)
"Built offline index.html ($($utf8.GetByteCount($html)) bytes)"
+15
View File
@@ -0,0 +1,15 @@
"""Build an offline, single-file prototype. Standard library only; no network."""
import base64
from pathlib import Path
ROOT = Path(__file__).resolve().parent
src = ROOT / 'src'
html = (src / 'shell.html').read_text(encoding='utf-8')
html = html.replace('{{STYLE}}', (src / 'space.css').read_text(encoding='utf-8'))
html = html.replace('{{SCRIPT}}', (src / 'atmos.js').read_text(encoding='utf-8') + '\n' + (src / 'space.js').read_text(encoding='utf-8'))
for key, filename in [('ROOM', 'her-room.jpg'), ('TALK', 'her-talk.jpg')]:
data = (ROOT.parents[1] / 'web/public/assets' / filename).read_bytes()
html = html.replace('{{' + key + '}}', 'data:image/jpeg;base64,' + base64.b64encode(data).decode())
assert '{{' not in html
(ROOT / 'index.html').write_text(html, encoding='utf-8', newline='\n')
print(f'Built offline index.html ({len(html.encode()):,} bytes)')
Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,113 @@
{
"escape": {
"anims": [],
"mode": "home",
"z": 0
},
"click_open": [
{
"target": "depth-veil",
"duration": 720,
"prop": "opacity"
},
{
"target": "home-copy",
"duration": 420,
"prop": "filter"
},
{
"target": "home-copy",
"duration": 420,
"prop": "opacity"
},
{
"target": "home-copy",
"duration": 520,
"prop": "transform"
},
{
"target": "portal-aura",
"duration": 720,
"prop": "transform"
},
{
"target": "room-photo",
"duration": 720,
"prop": "filter"
},
{
"target": "room-photo",
"duration": 720,
"prop": "opacity"
},
{
"target": "room-photo",
"duration": 720,
"prop": "transform"
},
{
"target": "talk-photo",
"duration": 720,
"prop": "opacity"
},
{
"target": "talk-photo",
"duration": 720,
"prop": "transform"
},
{
"target": "portal-top",
"duration": 280,
"prop": "opacity"
},
{
"target": "conversation",
"duration": 420,
"prop": "opacity"
},
{
"target": "conversation",
"duration": 520,
"prop": "transform"
},
{
"target": "nav-glow",
"duration": 460,
"prop": "transform"
},
{
"target": "",
"duration": 280,
"prop": "color"
},
{
"target": "",
"duration": 280,
"prop": "color"
},
{
"target": "portal",
"duration": 720,
"prop": "waapi"
}
],
"right": {
"anims": [],
"ink": 0
},
"left_rapid": {
"anims": [],
"flow": false,
"return_visible": true
},
"sheet_escape": {
"anims": [],
"open": false,
"mode": "talk"
},
"auto_props": [
"opacity",
"transform"
],
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,295 @@
[
{
"probe": "click-open-talk +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"portal | script | 720ms": 1
}
},
{
"probe": "keyboard Escape +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"line | opacity | 520ms": 1,
"P | font-size | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"SPAN | ink | 460ms": 7,
"line | script | 620ms": 1,
"line | script | 680ms": 1,
"portal | script | 720ms": 1
}
},
{
"probe": "keyboard Escape: mode/portal",
"data": {
"mode": "home",
"z": 0.791,
"portalW": 992
}
},
{
"probe": "click close-talk +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"portal | script | 720ms": 1
}
},
{
"probe": "keyboard ArrowRight +80ms",
"data": {
"line | opacity | 520ms": 1,
"P | font-size | 520ms": 1,
"SPAN | ink | 460ms": 26,
"line | script | 520ms": 1,
"line | script | 620ms": 1,
"line | script | 680ms": 1
}
},
{
"probe": "keyboard ArrowRight: ink spans",
"data": {
"ink": 26,
"lines": 2,
"cursor": 3
}
},
{
"probe": "keyboard ArrowLeft +80ms",
"data": {
"toggle-flow | box-shadow | 280ms": 1,
"toggle-flow | color | 280ms": 1,
"SPAN | ink | 460ms": 48,
"line | script | 620ms": 1,
"line | script | 680ms": 1,
"line | script | 240ms": 2,
"line | script | 520ms": 2
}
},
{
"probe": "keyboard ArrowLeft: state",
"data": {
"flow": false,
"cursor": 2,
"latestHidden": false
}
},
{
"probe": "keyboard ArrowLeft x3 rapid +80ms",
"data": {
"previous | opacity | 160ms": 1,
"SPAN | ink | 460ms": 48,
"line | script | 240ms": 4,
"line | script | 520ms": 3
}
},
{
"probe": "click next +80ms",
"data": {
"P | font-size | 520ms": 1,
"previous | opacity | 160ms": 1,
"SPAN | ink | 460ms": 26,
"line | script | 520ms": 1,
"line | script | 620ms": 1,
"line | script | 680ms": 1
}
},
{
"probe": "auto advance +80ms",
"data": {
"anims": {
"line | opacity | 520ms": 1,
"P | font-size | 520ms": 1,
"SPAN | ink | 460ms": 29,
"line | script | 520ms": 1,
"line | script | 620ms": 1,
"line | script | 680ms": 1
},
"props": [
"opacity",
"font-size"
]
}
},
{
"probe": "css transitions seen over 9s of auto flow",
"data": {
"value": [
"opacity",
"font-size"
],
"Count": 2
}
},
{
"probe": "layout cost: auto flow 9s",
"data": {
"layouts": 64,
"layoutMs": 31.44,
"styleRecalcs": 979,
"styleMs": 101.40,
"windowMs": 9000
}
},
{
"probe": "nav click +80ms transitions",
"data": {
"value": [
"transform"
],
"Count": 1
}
},
{
"probe": "layout cost: idle 1s (baseline)",
"data": {
"layouts": 0,
"layoutMs": 0.00,
"styleRecalcs": 63,
"styleMs": 10.16,
"windowMs": 1000
}
},
{
"probe": "layout cost: click open talk 1s",
"data": {
"layouts": 30,
"layoutMs": 3.44,
"styleRecalcs": 90,
"styleMs": 17.08,
"windowMs": 1000
}
},
{
"probe": "layout cost: keyboard Escape 1s",
"data": {
"layouts": 30,
"layoutMs": 3.79,
"styleRecalcs": 50,
"styleMs": 15.92,
"windowMs": 1000
}
},
{
"probe": "layout cost: click open + close-talk 2s",
"data": {
"layouts": 62,
"layoutMs": 7.18,
"styleRecalcs": 143,
"styleMs": 34.07,
"windowMs": 1000
}
},
{
"probe": "frames: idle home 900ms",
"data": {
"frames": 55,
"p50": 16.7,
"p95": 16.8,
"max": 16.8
}
},
{
"probe": "frames: click open talk 900ms",
"data": {
"frames": 37,
"p50": 33.3,
"p95": 33.4,
"max": 33.4
}
},
{
"probe": "frames: click close talk 900ms",
"data": {
"frames": 39,
"p50": 16.7,
"p95": 33.4,
"max": 66.6
}
},
{
"probe": "keyboard Escape in sheet +80ms",
"data": {
"sheet | script | 240ms": 1
}
},
{
"probe": "keyboard Escape in sheet: state",
"data": {
"open": true,
"mode": "record"
}
},
{
"probe": "tier2 click open talk +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"portal | script | 720ms": 1
}
},
{
"probe": "atmos",
"data": {
"tier": 2,
"scale": 0.5,
"z": 0.188,
"theme": 0,
"motes": 18,
"drops": 59
}
}
]
@@ -0,0 +1,256 @@
[
{
"probe": "click-open-talk +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"portal | script | 720ms": 1
}
},
{
"probe": "keyboard Escape +80ms",
"data": {
}
},
{
"probe": "keyboard Escape: mode/portal",
"data": {
"mode": "home",
"z": 0,
"portalW": 389
}
},
{
"probe": "click close-talk +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"portal | script | 720ms": 1
}
},
{
"probe": "keyboard ArrowRight +80ms",
"data": {
}
},
{
"probe": "keyboard ArrowRight: ink spans",
"data": {
"ink": 0,
"lines": 2,
"cursor": 3
}
},
{
"probe": "keyboard ArrowLeft +80ms",
"data": {
}
},
{
"probe": "keyboard ArrowLeft: state",
"data": {
"flow": false,
"cursor": 2,
"latestHidden": false
}
},
{
"probe": "keyboard ArrowLeft x3 rapid +80ms",
"data": {
}
},
{
"probe": "click next +80ms",
"data": {
"previous | opacity | 160ms": 1,
"SPAN | ink | 460ms": 26,
"line | script | 520ms": 1,
"line | script | 620ms": 1,
"line | script | 680ms": 1
}
},
{
"probe": "auto advance +80ms",
"data": {
"anims": {
"SPAN | ink | 460ms": 29,
"line | script | 520ms": 2,
"line | script | 620ms": 1,
"line | script | 680ms": 1
},
"props": [
]
}
},
{
"probe": "css transitions seen over 9s of auto flow",
"data": {
"value": [
],
"Count": 0
}
},
{
"probe": "layout cost: auto flow 9s",
"data": {
"layouts": 10,
"layoutMs": 5.56,
"styleRecalcs": 984,
"styleMs": 105.48,
"windowMs": 9000
}
},
{
"probe": "nav click +80ms transitions",
"data": {
"value": [
"transform"
],
"Count": 1
}
},
{
"probe": "layout cost: idle 1s (baseline)",
"data": {
"layouts": 0,
"layoutMs": 0.00,
"styleRecalcs": 61,
"styleMs": 8.92,
"windowMs": 1000
}
},
{
"probe": "layout cost: click open talk 1s",
"data": {
"layouts": 31,
"layoutMs": 3.47,
"styleRecalcs": 88,
"styleMs": 16.69,
"windowMs": 1000
}
},
{
"probe": "layout cost: keyboard Escape 1s",
"data": {
"layouts": 1,
"layoutMs": 0.16,
"styleRecalcs": 105,
"styleMs": 14.92,
"windowMs": 1000
}
},
{
"probe": "layout cost: click open + close-talk 2s",
"data": {
"layouts": 62,
"layoutMs": 7.52,
"styleRecalcs": 136,
"styleMs": 31.74,
"windowMs": 1000
}
},
{
"probe": "frames: idle home 900ms",
"data": {
"frames": 55,
"p50": 16.7,
"p95": 16.7,
"max": 16.7
}
},
{
"probe": "frames: click open talk 900ms",
"data": {
"frames": 37,
"p50": 16.7,
"p95": 50,
"max": 50
}
},
{
"probe": "frames: click close talk 900ms",
"data": {
"frames": 39,
"p50": 16.7,
"p95": 50,
"max": 50
}
},
{
"probe": "keyboard Escape in sheet +80ms",
"data": {
}
},
{
"probe": "keyboard Escape in sheet: state",
"data": {
"open": false,
"mode": "talk"
}
},
{
"probe": "tier2 click open talk +80ms",
"data": {
"depth-veil | opacity | 720ms": 1,
"home-copy | filter | 420ms": 1,
"home-copy | opacity | 420ms": 1,
"home-copy | transform | 520ms": 1,
"portal-aura | transform | 720ms": 1,
"room-photo | filter | 720ms": 1,
"room-photo | opacity | 720ms": 1,
"room-photo | transform | 720ms": 1,
"talk-photo | opacity | 720ms": 1,
"talk-photo | transform | 720ms": 1,
"portal-top | opacity | 280ms": 1,
"conversation | opacity | 420ms": 1,
"conversation | transform | 520ms": 1,
"nav-glow | transform | 460ms": 1,
"BUTTON | color | 280ms": 2,
"portal | script | 320ms": 1
}
},
{
"probe": "atmos",
"data": {
"tier": 2,
"scale": 0.5,
"z": 0.188,
"theme": 0,
"motes": 18,
"drops": 59
}
}
]
@@ -0,0 +1,26 @@
{
"1440x900 geometry": {
"transitions": 12,
"avgFrames": 39.3,
"avgLongFrames": 15.75,
"worstFrameMs": 50.1
},
"1440x900 no-geometry": {
"transitions": 12,
"avgFrames": 50.2,
"avgLongFrames": 5.17,
"worstFrameMs": 50.1
},
"390x844 geometry": {
"transitions": 12,
"avgFrames": 55.2,
"avgLongFrames": 0,
"worstFrameMs": 16.8
},
"390x844 no-geometry": {
"transitions": 12,
"avgFrames": 55,
"avgLongFrames": 0,
"worstFrameMs": 16.8
}
}
@@ -0,0 +1,195 @@
[
{
"check": "auto-continues-long-reply-to-last-line",
"pass": true,
"detail": {
"seconds": 52,
"trail": "1/2 2/4 2/5 2/7 2/8 2/9 3/11 3/12 3/12 3/12 3/12 3/12 3/12 4/12 4/12 4/12 4/12 4/12 5/12 5/12 5/12 5/12 5/12 5/12 6/12 6/12 6/12 7/12 7/12 7/12 7/12 7/12 8/12 8/12 8/12 8/12 8/12 8/12 9/12 9/12 9/12 9/12 10/12 10/12 10/12 10/12 10/12 11/12 11/12 11/12 11/12 12/12",
"final": {
"c": 11,
"n": 12,
"busy": false,
"flow": true
}
}
},
{
"check": "all-text-preserved-in-lines",
"pass": true,
"detail": {
"same": true,
"lines": 12
}
},
{
"check": "pause-holds-position",
"pass": true,
"detail": {
"before": 1,
"after6s": 1
}
},
{
"check": "review-back-offers-return",
"pass": true,
"detail": {
"hidden": false,
"text": "回到读到的地方 · 后面还有 11 句",
"c": 0,
"head": 1
}
},
{
"check": "return-resumes-flow",
"pass": true,
"detail": {
"now": {
"c": 2,
"flow": true
},
"head": 1
}
},
{
"check": "send-resumes-auto",
"pass": true,
"detail": {
"c": 2,
"n": 3,
"flow": true,
"toast": "新的一轮会自动呈现,随时可以暂停"
}
},
{
"check": "identity-labelled",
"pass": true,
"detail": {
"value": [
{
"role": "her",
"label": "晚晚"
},
{
"role": "her",
"label": "晚晚"
}
],
"Count": 2
}
},
{
"check": "cancel-marks-incomplete",
"pass": true,
"detail": {
"status": "cancelled",
"len": 115,
"busy": false
}
},
{
"check": "history-holds-then-returns",
"pass": true,
"detail": {
"before": 1,
"whileOpen": 1,
"after": {
"c": 1,
"mode": "talk"
}
}
},
{
"check": "camera-depth-talk",
"pass": true,
"detail": {
"tier": 0,
"scale": 1,
"z": 0.998,
"theme": 0,
"motes": 40,
"drops": 170
}
},
{
"check": "long-name-contained",
"pass": true,
"detail": {
"name": "星河与晚风之间慢慢醒来的小月亮",
"overflow": false,
"fits": true
}
},
{
"check": "keyboard-viewport-no-overlap",
"pass": true,
"detail": {
"compact": true,
"composerBottom": 422,
"vh": 430,
"lineTop": 237.015625,
"lineBottom": 330,
"portalBottom": 224,
"composerTop": 370
}
},
{
"check": "small-phone-layout",
"pass": true,
"detail": {
"lineBottom": 468,
"composerTop": 508,
"composerBottom": 560,
"navTop": 581.4749755859375,
"w": 320,
"lineRight": 302
}
},
{
"check": "reduced-motion-respected",
"pass": true,
"detail": {
"z": 1,
"anims": 0
}
},
{
"check": "low-tier-fallback",
"pass": true,
"detail": {
"tier": 2,
"scale": 0.5,
"z": 0,
"theme": 1,
"motes": 18,
"drops": 59
}
},
{
"check": "no-overlap-while-flowing-390x844",
"pass": true,
"detail": {
"minGapPx": 8
}
},
{
"check": "no-overlap-while-flowing-360x740",
"pass": true,
"detail": {
"minGapPx": 8
}
},
{
"check": "no-overlap-while-flowing-1440x900",
"pass": true,
"detail": {
"minGapPx": 22
}
},
{
"check": "no-overlap-while-flowing-1280x720",
"pass": true,
"detail": {
"minGapPx": 22
}
}
]
+37
View File
@@ -0,0 +1,37 @@
{
"task": "HEL-795 视觉总监修正键盘回看/退出与换句时的动效拖慢(基于 PR#8 原作品小修)",
"baseline_version": "1.8",
"read_commit": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0",
"read_notes": [
"完整读取 main 3a986726d8593e0ed2274f79db6cc5d807d2ebf0(v1.8,含 PR#7)的 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、task-handoff-template.md、scripts/check_requirements.py。",
"修改基础:PR#8 分支 agent/agent/cd47da943500 head a576785535224189742725ade2e3b06656e6ee4f 的 prototypes/hel-793(经 Gitea API 核对 PR#8 仍为 open、未合并、base=main)。未带入产品 PR#4 或旧稿 PR#6。",
"已读 HEL-793 原交付线程 01a16406-e51d-7052-b62d-d8558450c453 及其入库补充回复。"
],
"requirement_ids": ["CG-004", "CG-018", "CG-024", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "prototypes/hel-793 的定点修正(视觉总监亲自编写):src/space.js、src/space.css、src/atmos.js、重建后的 index.html、README.md 动效表与性能说明、新增 tools/motion-probe.ps1 与 tools/portal-ab.ps1、evidence 下的新检查结果和对照图。阅读节奏、配色、人物素材、功能范围与 window.__proto 测试钩子均未改。",
"acceptance": [
"键盘 Esc(交流中):80ms 后仍在运行的有限动画由 21 组(人物窗 720ms 几何、交谈层 520ms、换句 620/680ms、逐字 460ms 等)降为 0;人物窗、镜头深度(z=0)直接到位。浮层中按 Esc 同样立即关闭并回到交流(evidence/motion-probe.json)。",
"键盘 → / ←:80ms 后有限动画由 6 组(含 26 个逐字动画)降为 0,当前句不再重播逐字;← 仍自动暂停并出现「回到读到的地方」;连按三次 ← 也没有残留动画。",
"鼠标点击进入/离开交流的动画与 PR#8 完全相同;点击下一句和自动续读保留新句进入、逐字和柔和退场,只把退场里的 font-size 插值换成固定字号交叉。没有全局关闭动效(同一份 probe 的 click-* 与 auto advance 项)。",
"换句时不再出现 font-size 过渡:自动续读 9 秒内的 CSS 过渡属性由 [opacity, font-size] 变为无;同时段布局次数由 64 次(31.4ms)降为 10 次(5.6ms)。柔和退场改为两份固定字号副本交叉,只动 transform/opacity,最终排版与原来一致(evidence/handoff-frames-after.png)。",
"导航亮块不再有 width 过渡,只移动 transform;四个入口改为同宽(桌面 68px、手机 64px,与原实际宽度一致)。",
"人物窗几何:保留实际几何(缩放会拉歪圆角和照片,clip-path 需要两种裁切且结束时跳变)。实测布局约 0.1ms/帧;重绘是主要成本:电脑 1440×900 软件光栅下,每次展开或收回比去掉几何多约 10 帧超过 25ms 的长帧,手机 390×844 无差别(evidence/portal-ab.json)。新增降级:Canvas 降到档 2 时,人物窗改为 320ms 淡入。",
"默认自动长回复不点击走到最后(12 句约 52 秒,与 PR#8 相同);暂停、回看、回到进度、全文、记录、取消保留内容、新消息恢复、双方身份、两主题、手机矮屏与键盘等效视口 19 项行为检查全部通过(evidence/verification.json)。"
],
"deferred": [
"用户体验验收:修正版尚未经白栖知体验;认可前不替换正式界面(8310/8300),不合并 PR#8 或 PR#4。",
"入库:本次运行仍在没有 git、仓库 SSH 凭据和 Python 的 Windows 运行时上,无法推送到 PR#8 分支;源码改动以附件交付,需总管安排有写权限的环境更新 PR#8。",
"需求检查:未能运行 python3 scripts/check_requirements.py --base-ref origin/main、--handoff,也未能运行 git diff --check;只用 PowerShell 按脚本的 --handoff 规则核对了本 JSON,并检查了改动文件的行尾空白。须在入库环境补跑。",
"真实 GPU 帧率、真机软键盘、iOS/macOS Safari、Firefox 未验证;本次帧率数据来自无头 Edge 软件光栅,只能做相对比较。",
"emil-skills 在本运行时不可用,未能查阅 review-animations 与 STANDARDS;按任务卡摘录的规则执行(键盘快捷操作不做动画、可打断、避免 font-size/width 等布局动画、优先 transform/opacity、尊重减少动效)。",
"完整生活/回忆/创建/管理状态、R1 个性成长、R2–R7 真实接入及口型仍由主卡 HEL-697 承接。"
],
"requirement_changes": [],
"verification": [
"evidence/motion-probe.json 与 evidence/motion-probe-before-pr8.json:同一脚本分别在修正版和 PR#8 原版上运行(无头 Edge,CDP 真实按键事件)。",
"evidence/portal-ab.json:人物窗保留几何与去掉几何的帧间隔 A/B,电脑与手机各 12 次过渡。",
"evidence/keyboard-80ms-before-after.png:按 Esc、→ 后第 80ms 冻结画面,左为 PR#8,右为修正版。",
"evidence/handoff-frames-after.png:自动续读换句时 0–700ms 六个时刻的冻结画面,已实际查看,柔和退场保留、最终排版不变。",
"evidence/verification.json 与 evidence 中由 verify.ps1 重新生成的截图(desktop-long-end、desktop-review、mobile-long-name、mobile-keyboard-430、mobile-320、desktop-tier2-fog):19 项全部通过,截图已查看。其余旧截图和录屏未重录,因为本次没有改变静止画面。"
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"task": "HEL-793 视觉总监亲自重做沉浸界面和对话代码",
"baseline_version": "1.8",
"read_commit": "81d7cb523db30b9019421355237b3184fc272075",
"read_notes": [
"完整读取治理分支 agent/agent/6f5a7d8d7cb6(PR#7)head 81d7cb5 的 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、task-handoff-template.md 与 sources/visual-confirmation-v2.md;并对照 main 4242f8377e350e5f17e836d275c15b0f099bf109(v1.7)。v1.8 仅新增 GOV-005,产品要求未变。",
"已读主卡用户反馈线程 01a1639b-8416-7b0b-8dbf-0de16c257cb5 与分工裁定 01a163a5-e2a7-7ac8-afdb-991868e721bc。",
"参考旧稿 PR#6 head ca5ddd6cb4525b37b7c1c18f44221bb7e3e57558 的 prototypes/hel-790(只读,不作为基准)。"
],
"requirement_ids": ["CG-004", "CG-018", "CG-024", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "prototypes/hel-793/:视觉总监本轮设计并亲自编写的离线单文件动态小样(index.html),含源码(src/atmos.js 空间引擎、src/space.js 交互与阅读、src/space.css 规范 Token 与布局)、构建脚本、视觉规范(README.md)、实测截图、录屏、录屏抽帧和行为检查记录。未改正式界面、8310/8300、用户库、伴侣、出生底色、聊天或记忆。",
"acceptance": [
"长回复默认自动一句接一句呈现到最后一句,无需点击;实测 12 句约 52 秒走完(evidence/verification.json: auto-continues-long-reply-to-last-line)。",
"分句后的文字拼接起来与原文完全一致,不丢字(all-text-preserved-in-lines)。",
"可以暂停;往回看时会提示“回到读到的地方”,点击后继续自动呈现;暂停后发送新消息会自动恢复呈现并给出提示。",
"每行都标明“你”或伴侣实际的名字,长名字不溢出;完整记录使用同样的身份标记。",
"打开记录或全文时她仍在画面中(退到左侧);关闭后回到同一句并继续。",
"停留时背景能明显看到变化:月光光束和云影、多层雾、城市窗灯与塔灯、细雨、景深光点、人物窗慢推与灯光呼吸;雾光主题为流动的极光雾带(evidence/*-idle.webm 及抽帧图)。",
"相处、交流、记录、浮层之间是一个连续的空间:人物窗由拱形连续变为画框,并从远景推近到近景;镜头深度随之推近或后退。",
"两套主题结构一致,空间与材质一起渐变。",
"电脑 1440×900、1280×720 和手机等效 390×844、360×740 下,长回复呈现过程中文字与人物窗、输入框始终不重叠;320×568 与键盘等效 390×430 视口下当前句、输入框可用且不被遮挡。",
"系统减少动效时只保留淡入淡出;强制低档时分辨率与粒子数降低,并去掉浮层模糊。"
],
"deferred": [
"用户体验验收:本小样尚未经白栖知认可;认可前不替换正式界面,不合并 PR#4。",
"PR:本次运行在 Windows 运行时上,没有 git 与仓库写入凭据(ssh 主机未登记、无密钥),无法建任务分支、提交或开 PR;交付以附件形式提交,需总管协调有写权限的环境把 prototypes/hel-793 提交到目标 main 的独立分支并开 PR。",
"需求检查脚本:此机器没有 Python,未运行 python3 scripts/check_requirements.py;仅用 PowerShell 按脚本的 --handoff 规则核对了本 JSON 的结构。提交后须在有 Python 与 git 的环境补跑 --base-ref origin/main(若届时 main 仍为 v1.7,应在 PR#7 合入后再核对,或把本交接的基准改为实际目标版本)。",
"真机软键盘、iOS/macOS Safari、Firefox、真实 GPU 与低端手机帧率未验证。",
"emil-skills 在本运行时不可用;动效按其公开的通用原则(进入用 ease-out、交互反馈 ≤300ms、可打断、不从 0 缩放、尊重减少动效)自行确定,待技能可用时复核。",
"其余页面(生活与回忆、创建与管理的完整状态)只沿用旧稿入口和内容,并已统一到新空间;完整页面细稿将在本轮方向获认可后补齐。",
"R1 个性诠释与成长、R2–R7 真实接入、口型研究仍由主卡 HEL-697 追踪。"
],
"requirement_changes": [],
"verification": [
"evidence/verification.json:无头 Edge 155 中 19 项行为检查全部通过(含自动续读、完整性、暂停与回看、发送后恢复、身份、停止、记录开合、镜头深度、长名字、键盘等效视口、小屏、减少动效、低档降级、4 种视口下持续不重叠)。",
"evidence/*.png:两套主题的电脑与手机相处页、交流页、日子页、记录页截图,均已实际查看。",
"evidence/*.webm:电脑月光停留 12 秒、雾光停留 12 秒、雾光自动续读完整长回复 62 秒、月光空间往返(交流、暂停、回看、全文、记录、主题切换、日子、生活详情、设置、返回)52 秒、手机雾光完整流程 77 秒;每段都已解码,并生成 *-contact.png 抽帧图后实际查看。",
"录屏与截图基于同一份源码;最后一处修改只给浮层补充了主题切换时的颜色渐变(CSS),修改后已重新运行全部行为检查,录屏未重录。"
]
}
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
## HEL-795 入库补充
视觉设计和本次核心修正均由视觉总监亲自完成。总管接收 HEL-795 评论 `01a16447-b6c4-7aa0-bf9a-08671d77b700` 的源码附件 `01a16447-b0e3-727b-81df-0152f9c8f53a`,按原路径导入,不另做视觉实现。
基准 v1.8,已完整读取 main `3a986726d8593e0ed2274f79db6cc5d807d2ebf0` 的 AGENTS.md、需求入口、current、coverage、交接模板及本次关联原始来源。关联编号和未完成承接见 `handoff-hel-795.json`;需求变更无。原作者交接保留当时事实,本补充关闭其中入库与 Python 检查的待办。
PR#8 原 head `a576785535224189742725ade2e3b06656e6ee4f`,源分支 `agent/agent/cd47da943500`,目标 main,已用现有 SSH 身份认证读取真实 PR,确认 open、未合并。没有带入产品 PR#4 或旧稿 PR#6。
## 本环境实际验证
- `python3 prototypes/hel-793/build.py` 成功,HTML 与附件内文件逐字节一致。SHA256:`95c4325265bc114a745a43681e9d2bf4c7e5573ccae576370dcd793bcf570b4b`。
- `python3 scripts/check_requirements.py --base-ref origin/main` 及加 `--handoff prototypes/hel-793/handoff-hel-795.json` 均通过59项;`git diff --check` 通过。检查不代表用户体验认可。
- Linux Playwright / Chromium 无头实跑:交流 Esc、右箭头、连续三次左箭头、浮层 Esc 后80ms有限动画均为0;右箭头逐字 span 为0;左箭头暂停并保留回到进度入口;浮层关闭返回交流;点击进入交流保留17个动画实例。自动续读9秒采样仅 opacity/transform,无 font-size/width 过渡,无 JS 报错。原始结果 `evidence/integration-keyboard-chromium.json`。
- 初次启动浏览器缺少 libatk,使用本机已经存在的 `/home/xiaomo/.local/pwlibs/root/usr/lib/x86_64-linux-gnu` 作为该命令的 LD_LIBRARY_PATH 后完成验证。没有安装依赖或修改共享配置。
- 总管实际查看 `evidence/keyboard-80ms-before-after.png` 和 `evidence/handoff-frames-after.png`:键盘操作目标状态清楚,换句仍有柔和进入/退场。未把截图当成实时帧率证据。
- 原作者19项 Edge 行为检查和人物窗 A/B 性能数字为其报告,本轮未全量重跑;交总工定点复检。
## emil-skills 定点核对
已阅读 review-animations/MAIN.md 与 STANDARDS.md。用户要求保留自然展开、柔和文字,优先于通用时长建议。
| Before | After | Why |
| --- | --- | --- |
| 键盘主动操作重播长动画和逐字 | `src/space.js:55` snap 与 instant 分支,实跑80ms残留0 | 符合频繁键盘操作即时响应规则 |
| 上一句字号连续插值、导航亮块 width 过渡 | `src/space.css:114` 固定字号副本,`src/space.css:145` 仅位移 | 消除容易避免的逐帧排版 |
| 人物窗720ms实际几何变化 | `src/space.js:71` 保留原设计,档2降为320ms淡入 | 性能取舍仍存在;已有软件光栅 A/B、失真替代方案说明和降级,真实GPU待验证 |
结论:两项确定的小修已由本机复现验证。人物窗重绘成本仍是性能限制,未称全部设备流畅;保留其评估交总工复审,不重新设计。此补检不代替独立复审或白栖知的体验验收。
## 边界与后续
未部署、未合并PR#8/PR#4,未改8310/8300、用户档案/名字/生日/底色/聊天/记忆及队员运行时、模型、认证。人物仍为参考静帧,内容为演示数据。
真机软键盘、Safari、Firefox、真实GPU帧率未验证。完整生活/回忆/创建/管理状态、R1个性成长、R2–R7真实接入和口型继续由主卡HEL-697承接。总工只定点复检本次差异及必要回归;通过后由总管到主卡交白栖知体验,不在本轮部署或合并。
+40
View File
@@ -0,0 +1,40 @@
# HEL-793 入库补充
视觉设计和原型代码由视觉总监亲自完成。总管在 Linux 环境仅接收附件、原样入库、补跑构建和需求检查,不改画面、动效或交互实现。原 `handoff.json` 保留作者交付时的事实,其中“未提交 PR、未运行 Python”由本补充的入库证据承接;其余未验证项仍有效。
## 来源与分支
- 原交付:HEL-793 评论 `01a16406-e51d-7052-b62d-d8558450c453`。
- 源码包附件:`01a16406-dea8-7a4b-934a-b4f994887dd5`,文件名 `hel-793-prototype-src-evidence.zip`。解包时仅将 Windows 路径分隔符转换为目录,共 42 个原始文件。除去截图工具 `tools/shots.ps1` 末尾一行多余空行,未更改作者的实现;入 Git 时两份原有 CRLF 文本按仓库规则归一为 LF。可运行 HTML 与作者交付逐字节相同。
- 需求基准:v1.8,`81d7cb523db30b9019421355237b3184fc272075`。已读正式需求入口、完整正文、覆盖表、视觉确认与本次关联原始评论。
- 本分支建立在治理 PR#7 的上述提交之上,目标仍为 `main`。入库时 `origin/main` 为 `4242f8377e350e5f17e836d275c15b0f099bf109`(v1.7)。PR#7 是显式依赖,尚未合并;本分支不包含产品 PR#4 或旧小样 PR#6。
- 相对 PR#7 的本次新增内容全部在 `prototypes/hel-793/`,没有修改正式入口或用户数据。没有变更需求。
## 实际补跑的检查
```bash
python3 prototypes/hel-793/build.py
python3 scripts/check_requirements.py --base-ref origin/main
python3 scripts/check_requirements.py --base-ref origin/main --handoff prototypes/hel-793/handoff.json
git diff --check
```
Python 构建成功,输出与作者提供的离线 HTML 逐字节相同,SHA256 为 `676e3e34de0c3a96fceb87eb84019ca62cf4cca1b6015861b17058a76b526a3b`。两项需求检查在本分支的 v1.8 上均通过,共 59 项;这不表示 v1.8 已进入 main,也不表示已获得视觉验收。
`evidence/verification.json` 是视觉总监在 Windows 无头 Edge 中运行的 19 项行为检查,不能表述成总管重新跑过浏览器。总管本轮没有重新验证真机键盘、Safari、Firefox 或 GPU 性能。独立审核仍须实看画面、动态过程并按 emil-skills 复核,不能只依据结构检查。
## 五段原始录屏
作者单独上传的录屏未包含在源码 zip 中,因此也没有放进本目录。`README.md` 和 `handoff.json` 中提到的 `evidence/*.webm` 指这些录屏,不能据该路径认定仓库已有视频。以下为平台附件入口;全部集中在同一条原交付评论下,可用 `multica attachment download <附件ID>` 获取:
| 录屏 | 附件 ID | 下载 |
| --- | --- | --- |
| 月光静置 12 秒 | `01a163fd-9367-78ab-a56b-310c1285205a` | [desktop-moon-idle.webm](https://api.solsum.cn/api/attachments/01a163fd-9367-78ab-a56b-310c1285205a/download) |
| 雾光静置 12 秒 | `01a163fe-0099-77d5-a83b-231c0e8f250c` | [desktop-fog-idle.webm](https://api.solsum.cn/api/attachments/01a163fe-0099-77d5-a83b-231c0e8f250c/download) |
| 长回复默认自动走完 | `01a163ff-e5c9-70c1-8fe2-bdd1c23d8c7a` | [desktop-fog-autoflow.webm](https://api.solsum.cn/api/attachments/01a163ff-e5c9-70c1-8fe2-bdd1c23d8c7a/download) |
| 电脑月光空间往返 | `01a16401-89d1-76f3-912b-c6849be35cb4` | [desktop-moon-walkthrough.webm](https://api.solsum.cn/api/attachments/01a16401-89d1-76f3-912b-c6849be35cb4/download) |
| 手机雾光完整流程 | `01a16403-7d98-7c10-9ea2-c2175a55a118` | [mobile-fog-walkthrough.webm](https://api.solsum.cn/api/attachments/01a16403-7d98-7c10-9ea2-c2175a55a118/download) |
## 下一步与边界
交总工独立核对治理依赖、完整需求、功能、界面和动效。小样体验是否认可由白栖知决定,认可前不替换正式界面、不合并小样或产品 PR#4。完整生活/回忆/创建/管理状态、R1 个性与成长、R2–R7 真实接入及口型研究仍由 HEL-697 追踪;本次入库不宣称这些已经完成。不改运行时、模型、认证、8310/8300 或用户数据。
+376
View File
@@ -0,0 +1,376 @@
'use strict';
// Atmosphere engine. One canvas, painted far → near:
// sky · stars · moon/aurora · soft fog (quarter-res, upscaled for natural blur) · far city with living windows
// · rain · near fog · motes. A spring camera gives each layer its own depth when the mode changes.
// Quality tiers drop resolution and particle counts if frames run long; reduced motion freezes drift.
const Atmos = (() => {
const cv = document.getElementById('atmos'), ctx = cv.getContext('2d');
const soft = document.createElement('canvas'), sctx = soft.getContext('2d');
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
const TAU = Math.PI * 2;
const THEMES = {
moon: {
sky: [[5,9,24],[9,19,46],[17,35,80],[24,44,88],[10,20,46]],
glow: [135,175,255], horizon: [131,183,228], fog: [[118,152,230],[150,184,240],[96,128,206]],
motes: [[169,220,255],[255,214,170],[205,225,255]], far: [19,37,74], near: [12,25,54],
win: [255,212,160], stars: 1, moon: 1, aurora: .25, rain: [190,215,255]
},
fog: {
sky: [[14,8,32],[28,18,64],[51,35,106],[74,52,120],[29,18,54]],
glow: [210,160,255], horizon: [224,171,250], fog: [[213,169,255],[166,146,244],[236,172,218]],
motes: [[235,188,255],[255,196,214],[196,176,255]], far: [46,33,80], near: [33,22,60],
win: [255,198,196], stars: .35, moon: 0, aurora: 1, rain: [230,205,255]
}
};
const lerp = (a, b, t) => a + (b - a) * t;
const mix3 = (a, b, t) => [lerp(a[0],b[0],t), lerp(a[1],b[1],t), lerp(a[2],b[2],t)];
const rgba = (c, a) => `rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;
const rand = (a, b) => a + Math.random() * (b - a);
let W = 0, H = 0, scale = 1, tier = 0, running = true, last = 0, time = 0;
let themeMix = 0, themeTarget = 0;
// Camera: z = depth push (talk +1, home 0, panes −0.6). Spring so interruptions stay continuous.
const cam = {z: 0, zv: 0, zt: 0, px: 0, py: 0, tx: 0, ty: 0};
let stars = [], windows = [], drops = [], motes = [], fogs = [], skyline = null, beacon = null, clouds = [];
let frameCost = [], slowFrames = 0;
function build() {
const dense = tier === 0 ? 1 : tier === 1 ? .6 : .35;
const area = W * H / (1440 * 900);
stars = Array.from({length: Math.round(120 * area * dense)}, () => ({x: Math.random(), y: Math.random() * .55, r: rand(.4, 1.3), p: rand(0, TAU), s: rand(.4, 1.6)}));
drops = Array.from({length: Math.round(170 * Math.min(area, 1.4) * dense)}, () => newDrop(true));
motes = Array.from({length: Math.round(34 * Math.min(area, 1.3) * dense) + 6}, () => newMote(true));
// Mist patches, far → near. Speeds are px/s at full width: slow, but visible within a few seconds.
fogs = [
{d: .12, y: .34, h: .12, w: .62, speed: 7, x: .15, c: 1, a: .2, bob: 3},
{d: .18, y: .46, h: .16, w: .8, speed: -5, x: .7, c: 0, a: .22, bob: 5},
{d: .25, y: .6, h: .2, w: .7, speed: 11, x: .4, c: 2, a: .26, bob: 4},
{d: .35, y: .7, h: .18, w: .9, speed: -9, x: .05, c: 1, a: .3, bob: 6},
{d: .45, y: .78, h: .2, w: .6, speed: 15, x: .85, c: 0, a: .28, bob: 5},
{d: .62, y: .88, h: .22, w: .75, speed: -16, x: .55, c: 2, a: .3, bob: 7},
{d: .8, y: .97, h: .24, w: .9, speed: 22, x: .2, c: 0, a: .32, bob: 6},
{d: .95, y: 1.04, h: .26, w: 1.1, speed: -28, x: .65, c: 1, a: .28, bob: 8}
];
clouds = Array.from({length: 3}, (_, i) => ({x: rand(-.3, 1.1), y: rand(.1, .2), w: rand(.2, .34), h: rand(.07, .11), v: rand(.006, .012) * (i % 2 ? 1 : .7)}));
buildSkyline();
}
function newDrop(anywhere) {
const near = Math.random() < .18;
return {x: rand(-.1, 1.1), y: anywhere ? Math.random() : rand(-.2, 0), l: near ? rand(26, 40) : rand(10, 20), v: near ? rand(.9, 1.2) : rand(.45, .7), a: near ? rand(.08, .14) : rand(.05, .1), near};
}
function newMote(anywhere) {
const d = Math.random();
return {x: Math.random(), y: anywhere ? Math.random() : 1.08, d, r: lerp(1.4, 9, d * d), vy: lerp(.004, .016, d), vx: rand(-.004, .004), p: rand(0, TAU), c: (Math.random() * 3) | 0, a: lerp(.5, .2, d)};
}
// The far city is code-drawn scenery behind fog — never a photo, so it never competes with her window.
// Two pre-rendered silhouettes per theme: a hazy far city that dissolves into mist at its base,
// and a low, out-of-focus near edge. Only window lights and the beacon are painted live.
function buildSkyline() {
const ow = Math.ceil(W * 1.2), oh = Math.ceil(H * (W < 720 ? .26 : .3)), nh = Math.ceil(H * .12);
skyline = {w: ow, h: oh, nh, far: {}, near: {}};
windows = [];
const far = [], near = [];
let x = 0, n = 0, tallest = null;
// Height follows a slow profile (a dense centre-left cluster, lower edges), plus per-tower jitter.
const profile = u => .35 + .4 * Math.exp(-Math.pow((u - .38) / .16, 2)) + .22 * Math.exp(-Math.pow((u - .78) / .1, 2)) + .08 * Math.sin(u * 23);
while (x < ow) {
const w = 10 + ((n * 37) % 30) + (n % 5 === 0 ? 16 : 0), jitter = ((n * 53) % 97) / 97;
const h = oh * Math.min(.98, Math.max(.1, profile(x / ow) * (.55 + jitter * .5)));
far.push({x, w, h, spire: n % 9 === 4, step: n % 3 === 1});
if (!tallest || h > tallest.h) tallest = {x: x + w / 2, h};
const cols = Math.max(1, Math.floor((w - 5) / 6)), rows = Math.floor((h - 10) / 8);
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
if (((n * 7 + r * 13 + c * 5) % 13) > 2) continue;
const lit = Math.random() < .5, y = oh - h + 7 + r * 8;
windows.push({x: x + 3 + c * 6, y, fade: 1 - (y / oh) * .75, a: lit ? rand(.3, .8) : 0, t: lit ? rand(.3, .8) : 0});
}
x += w + 1 + ((n * 11) % 5) - 2; n++;
}
for (let nx = 0, i = 0; nx < ow; i++) { const w = 40 + ((i * 29) % 70); near.push({x: nx, w, h: nh * (.35 + ((i * 41) % 60) / 100)}); nx += w + 6; }
beacon = tallest;
for (const name of ['moon', 'fog']) {
const th = THEMES[name];
const fc = document.createElement('canvas'); fc.width = ow; fc.height = oh;
const f = fc.getContext('2d'); f.filter = 'blur(1px)';
f.fillStyle = rgba(th.far, 1);
for (const r of far) {
if (r.step) { f.fillRect(r.x, oh - r.h * .82, r.w, r.h * .82); f.fillRect(r.x + r.w * .2, oh - r.h, r.w * .6, r.h * .2); }
else f.fillRect(r.x, oh - r.h, r.w, r.h);
if (r.spire) f.fillRect(r.x + r.w / 2 - 1, oh - r.h - 16, 2, 16);
}
f.filter = 'none'; f.globalCompositeOperation = 'source-atop';
const haze = f.createLinearGradient(0, 0, 0, oh);
haze.addColorStop(0, rgba(th.sky[1], .1)); haze.addColorStop(.6, rgba(th.horizon, .1)); haze.addColorStop(1, rgba(th.horizon, .28));
f.fillStyle = haze; f.fillRect(0, 0, ow, oh);
const nc = document.createElement('canvas'); nc.width = ow; nc.height = nh;
const g = nc.getContext('2d'); g.filter = 'blur(3px)'; g.fillStyle = rgba(th.near, 1);
for (const r of near) g.fillRect(r.x, nh - r.h, r.w, r.h + 4);
skyline.far[name] = fc; skyline.near[name] = nc;
}
}
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
W = innerWidth; H = innerHeight;
scale = (tier === 0 ? Math.min(dpr, 1.25) : tier === 1 ? .75 : .5);
cv.width = Math.round(W * scale); cv.height = Math.round(H * scale);
soft.width = Math.max(64, Math.round(W / 5)); soft.height = Math.max(48, Math.round(H / 5));
build();
}
function paintSky(th, T) {
const s = th.sky, g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(s[0], 1)); g.addColorStop(.32, rgba(s[1], 1)); g.addColorStop(.62, rgba(s[2], 1)); g.addColorStop(.8, rgba(s[3], 1)); g.addColorStop(1, rgba(s[4], 1));
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
// Soft layer: drawn at 1/5 resolution then stretched — light, halo, aurora and fog all blur for free.
function paintSoft(th, T, moonMix, auroraMix) {
const w = soft.width, h = soft.height, k = w / W;
sctx.clearRect(0, 0, w, h);
sctx.globalCompositeOperation = 'lighter';
const {x: mx, y: my} = moonPos();
// Moon halo breathes and dims as clouds cross it.
let cover = 0;
for (const c of clouds) { const dx = Math.abs(((c.x + c.w / 2) - mx)); cover = Math.max(cover, Math.max(0, 1 - dx / (c.w * .8))); }
const breathe = .82 + .18 * Math.sin(T * TAU / 9);
const haloA = moonMix * (.5 * breathe) * (1 - cover * .55);
if (haloA > .01) {
const R = Math.max(W, H) * .42 * k, g = sctx.createRadialGradient(mx * w, my * h, 0, mx * w, my * h, R);
g.addColorStop(0, rgba(th.glow, haloA)); g.addColorStop(.35, rgba(th.glow, haloA * .32)); g.addColorStop(1, rgba(th.glow, 0));
sctx.fillStyle = g; sctx.fillRect(0, 0, w, h);
// Moonlight shafts falling through the mist; they sway and brighten when the clouds clear.
for (let i = 0; i < 4; i++) {
const ang = .55 + i * .16 + Math.sin(T * .05 + i * 1.3) * .035, len = h * 1.25, spread = .028 + i * .006;
const a0 = haloA * (.22 - i * .035) * (.75 + .25 * Math.sin(T * .3 + i));
if (a0 <= 0) continue;
const ox = mx * w, oy = my * h;
const sg = sctx.createLinearGradient(ox, oy, ox + Math.cos(ang) * len, oy + Math.sin(ang) * len);
sg.addColorStop(0, rgba(th.glow, a0)); sg.addColorStop(.6, rgba(th.glow, a0 * .35)); sg.addColorStop(1, rgba(th.glow, 0));
sctx.fillStyle = sg; sctx.beginPath(); sctx.moveTo(ox, oy);
sctx.lineTo(ox + Math.cos(ang - spread) * len, oy + Math.sin(ang - spread) * len);
sctx.lineTo(ox + Math.cos(ang + spread) * len, oy + Math.sin(ang + spread) * len); sctx.closePath(); sctx.fill();
}
}
// Aurora ribbons: folding bands that keep moving; their crest light travels along the band.
if (auroraMix > .01) {
sctx.filter = 'blur(5px)';
for (let i = 0; i < 3; i++) {
const base = h * (.18 + i * .15), amp = h * (.05 + i * .02), thick = h * (.11 + i * .03);
const col = th.fog[i];
sctx.beginPath();
for (let x = 0; x <= w; x += 2) {
const y = base + amp * Math.sin(x / w * TAU * (.8 + i * .3) + T * (.16 + i * .05) + i * 2) + amp * .5 * Math.sin(x / w * TAU * 2.1 - T * .11);
x ? sctx.lineTo(x, y) : sctx.moveTo(x, y);
}
for (let x = w; x >= 0; x -= 2) {
const y = base + thick + amp * .7 * Math.sin(x / w * TAU * (.8 + i * .3) + T * (.16 + i * .05) + i * 2.4);
sctx.lineTo(x, y);
}
sctx.closePath();
const sweep = (Math.sin(T * .21 + i * 1.7) + 1) / 2;
const g = sctx.createLinearGradient(0, 0, w, 0);
g.addColorStop(0, rgba(col, 0)); g.addColorStop(Math.max(0, sweep - .35), rgba(col, .05 * auroraMix));
g.addColorStop(sweep, rgba(col, (.42 - i * .07) * auroraMix)); g.addColorStop(Math.min(1, sweep + .35), rgba(col, .05 * auroraMix)); g.addColorStop(1, rgba(col, 0));
sctx.fillStyle = g; sctx.fill();
}
sctx.filter = 'none';
}
// Horizon glow from the city below.
const hg = sctx.createRadialGradient(w * .5, h * .78, 0, w * .5, h * .78, w * .6);
hg.addColorStop(0, rgba(th.horizon, .3 + .06 * Math.sin(T * .7))); hg.addColorStop(1, rgba(th.horizon, 0));
sctx.fillStyle = hg; sctx.fillRect(0, 0, w, h);
// Far fog banks (depth < .5) live here; near banks are painted after the city.
for (const f of fogs) if (f.d < .5) fogBank(sctx, f, th, T, w, h, k);
sctx.globalCompositeOperation = 'source-over';
}
function fogBank(c, f, th, T, w, h, k) {
const off = layerOffset(f.d);
const span = w * f.w, x = (((f.x * w + f.speed * T * k * (reduced() ? .1 : 1)) % (w + span)) + w + span) % (w + span) - span / 2;
const y = h * f.y + off.y * k + Math.sin(T * .2 + f.x * 9) * f.bob * k * 3, rx = span / 2, ry = h * f.h / 2;
for (const dx of [0, -(w + span)]) {
const cx = x + dx + off.x * k, g = c.createRadialGradient(cx, y, 0, cx, y, rx);
g.addColorStop(0, rgba(th.fog[f.c], f.a)); g.addColorStop(.55, rgba(th.fog[f.c], f.a * .4)); g.addColorStop(1, rgba(th.fog[f.c], 0));
c.save(); c.translate(cx, y); c.scale(1, ry / rx); c.translate(-cx, -y);
c.fillStyle = g; c.fillRect(cx - rx, y - rx, rx * 2, rx * 2); c.restore();
}
}
// The moon keeps clear of her window: it slides aside as the camera pushes into a conversation.
function moonPos() {
const mobile = W < 720;
return {x: (mobile ? .2 : .17) - Math.max(0, cam.z) * (mobile ? .06 : .1), y: (mobile ? .12 : .15) - Math.max(0, cam.z) * (mobile ? .045 : .03)};
}
function layerOffset(d) {
return {x: cam.px * d * 26, y: cam.py * d * 14};
}
function withDepth(d, fn) {
const zoom = 1 + cam.z * .085 * (0.35 + d), o = layerOffset(d);
ctx.save(); ctx.translate(W / 2 + o.x, H * .62 + o.y); ctx.scale(zoom, zoom); ctx.translate(-W / 2, -H * .62); fn(); ctx.restore();
}
function paintStars(th, T, amount) {
if (amount < .02) return;
withDepth(.02, () => {
for (const s of stars) {
const a = amount * (.35 + .65 * (.5 + .5 * Math.sin(T * s.s + s.p)));
ctx.fillStyle = `rgba(230,236,255,${a * .7})`;
ctx.fillRect(s.x * W, s.y * H, s.r, s.r);
}
});
}
function paintMoon(th, T, amount) {
if (amount < .02) return;
const p = moonPos(), mx = p.x * W, my = p.y * H, r = W < 720 ? 22 : 34;
withDepth(.05, () => {
// In a conversation the moon steps back so it never sits beside her face.
ctx.globalAlpha = amount * Math.max(0, 1 - Math.max(0, cam.z) * 1.1);
const g = ctx.createRadialGradient(mx - r * .3, my - r * .3, r * .2, mx, my, r);
g.addColorStop(0, '#f6f8ff'); g.addColorStop(1, '#d6e0ff');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(mx, my, r, 0, TAU); ctx.fill();
// Thin clouds pass in front of the moon and carry its light.
for (const c of clouds) {
const cx = c.x * W, cy = c.y * H, cw = c.w * W, ch = c.h * H;
const cg = ctx.createRadialGradient(cx + cw / 2, cy, 0, cx + cw / 2, cy, cw / 2);
const lit = Math.max(0, 1 - Math.abs(cx + cw / 2 - mx) / (cw * .9));
cg.addColorStop(0, rgba(mix3(th.sky[1], [190,205,245], lit * .45), .42 + lit * .2)); cg.addColorStop(.6, rgba(th.sky[1], .18)); cg.addColorStop(1, rgba(th.sky[1], 0));
ctx.save(); ctx.translate(cx + cw / 2, cy); ctx.scale(1, ch / cw); ctx.translate(-(cx + cw / 2), -cy);
ctx.fillStyle = cg; ctx.fillRect(cx, cy - cw / 2, cw, cw); ctx.restore();
}
ctx.globalAlpha = 1;
});
}
function paintCity(th, T, dt) {
if (!skyline) return;
withDepth(.35, () => {
const ox = -W * .1, oy = H * .93 - skyline.h;
ctx.globalAlpha = .78 * (1 - themeMix); ctx.drawImage(skyline.far.moon, ox, oy);
ctx.globalAlpha = .72 * themeMix; ctx.drawImage(skyline.far.fog, ox, oy);
ctx.globalAlpha = 1;
// Windows switch on and off over a few seconds: life without motion noise.
if (!reduced() && Math.random() < dt * 3) { const w = windows[(Math.random() * windows.length) | 0]; if (w) w.t = w.t > .1 ? 0 : rand(.35, .8); }
for (const w of windows) {
w.a += (w.t - w.a) * Math.min(1, dt * .7);
if (w.a < .02) continue;
ctx.fillStyle = rgba(th.win, w.a * .6 * w.fade);
ctx.fillRect(ox + w.x, oy + w.y, 2, 2.5);
}
if (beacon) {
const on = (Math.sin(T * TAU / 2.6) + 1) / 2;
const bx = ox + beacon.x, by = oy + skyline.h - beacon.h - 3;
const g = ctx.createRadialGradient(bx, by, 0, bx, by, 9);
g.addColorStop(0, `rgba(255,120,120,${.65 * on})`); g.addColorStop(1, 'rgba(255,120,120,0)');
ctx.fillStyle = g; ctx.fillRect(bx - 9, by - 9, 18, 18);
}
});
// Near silhouette, darker, moves more with the camera.
withDepth(.75, () => {
const ox = -W * .1, oy = H - skyline.nh + 6;
ctx.globalAlpha = .95 * (1 - themeMix); ctx.drawImage(skyline.near.moon, ox, oy);
ctx.globalAlpha = .95 * themeMix; ctx.drawImage(skyline.near.fog, ox, oy);
ctx.globalAlpha = 1;
});
}
function paintRain(th, T, dt) {
const slant = .18;
for (const pass of [false, true]) {
ctx.beginPath();
for (const d of drops) {
if (d.near !== pass) continue;
if (!reduced()) { d.y += d.v * dt; d.x += d.v * dt * slant * .35; }
if (d.y > 1.05) Object.assign(d, newDrop(false));
const x = d.x * W + layerOffset(pass ? .8 : .3).x, y = d.y * H;
ctx.moveTo(x, y); ctx.lineTo(x - d.l * slant, y - d.l);
}
ctx.strokeStyle = rgba(th.rain, pass ? .11 : .07); ctx.lineWidth = pass ? 1.1 : .8; ctx.stroke();
}
}
const spriteCache = new Map();
function moteSprite(col) {
const key = col.join(',');
if (spriteCache.has(key)) return spriteCache.get(key);
const s = document.createElement('canvas'); s.width = s.height = 64;
const c = s.getContext('2d'), g = c.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, rgba(col, 1)); g.addColorStop(.25, rgba(col, .55)); g.addColorStop(1, rgba(col, 0));
c.fillStyle = g; c.fillRect(0, 0, 64, 64); spriteCache.set(key, s); return s;
}
function paintMotes(th, T, dt) {
ctx.globalCompositeOperation = 'lighter';
for (const m of motes) {
if (!reduced()) { m.y -= m.vy * dt; m.x += (m.vx + Math.sin(T * .3 + m.p) * .002) * dt; }
if (m.y < -.08) Object.assign(m, newMote(false));
const o = layerOffset(.2 + m.d), focus = Math.max(0, cam.z);
// Talking focuses on her: near motes grow and soften like a lens pulling focus.
const size = m.r * (1 + focus * m.d * 1.6) * 3.2, a = m.a * (.55 + .45 * Math.sin(T * .9 + m.p)) * (1 - focus * m.d * .4);
ctx.globalAlpha = Math.max(0, a);
ctx.drawImage(moteSprite(mix3(THEMES.moon.motes[m.c], THEMES.fog.motes[m.c], themeMix)), m.x * W + o.x - size / 2, m.y * H + o.y - size / 2, size, size);
}
ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
}
function paintNearFog(th, T) {
const w = soft.width, h = soft.height, k = w / W;
sctx.clearRect(0, 0, w, h);
sctx.globalCompositeOperation = 'lighter';
for (const f of fogs) if (f.d >= .5) fogBank(sctx, f, th, T, w, h, k);
sctx.globalCompositeOperation = 'source-over';
ctx.drawImage(soft, 0, 0, W, H);
}
function paintVignette() {
const strength = .55 + Math.max(0, cam.z) * .15 + Math.max(0, -cam.z) * .2;
const g = ctx.createRadialGradient(W / 2, H * .48, Math.min(W, H) * .3, W / 2, H * .5, Math.max(W, H) * .78);
g.addColorStop(0, 'rgba(2,4,14,0)'); g.addColorStop(1, `rgba(2,4,14,${strength})`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
function reduced() { return reducedQuery.matches || document.documentElement.classList.contains('no-motion'); }
function frame(now) {
if (!running) return;
const t0 = performance.now();
let dt = Math.min(.05, (now - (last || now)) / 1000); last = now;
// Tier 2 paints at ~30fps.
if (tier === 2 && dt < .03 && dt > 0) { requestAnimationFrame(frame); last -= dt * 1000; return; }
time += reduced() ? dt * .15 : dt;
// Spring camera (critically damped-ish) toward mode depth and pointer parallax.
const k = 38, damp = 2 * Math.sqrt(k) * .92;
if (reduced()) { cam.z = cam.zt; cam.zv = 0; } else { cam.zv += (cam.zt - cam.z) * k * dt - cam.zv * damp * dt; cam.z += cam.zv * dt; }
const drift = reduced() ? 0 : 1;
cam.px += ((cam.tx + Math.sin(time * .07) * .35 * drift) - cam.px) * Math.min(1, dt * 2.2);
cam.py += ((cam.ty + Math.cos(time * .05) * .25 * drift) - cam.py) * Math.min(1, dt * 2.2);
themeMix += (themeTarget - themeMix) * Math.min(1, dt * (reduced() ? 20 : 2.6));
const a = THEMES.moon, b = THEMES.fog, m = themeMix;
const th = {
sky: a.sky.map((c, i) => mix3(c, b.sky[i], m)), glow: mix3(a.glow, b.glow, m), horizon: mix3(a.horizon, b.horizon, m),
fog: a.fog.map((c, i) => mix3(c, b.fog[i], m)), far: mix3(a.far, b.far, m), near: mix3(a.near, b.near, m),
win: mix3(a.win, b.win, m), rain: mix3(a.rain, b.rain, m)
};
for (const c of clouds) { if (!reduced()) c.x += c.v * dt; if (c.x > 1.2) { c.x = -.35; c.y = rand(.08, .22); } }
ctx.setTransform(scale, 0, 0, scale, 0, 0);
paintSky(th, time);
paintStars(th, time, lerp(a.stars, b.stars, m));
paintSoft(th, time, lerp(a.moon, b.moon, m), lerp(a.aurora, b.aurora, m));
withDepth(.1, () => ctx.drawImage(soft, 0, 0, W, H));
paintMoon(th, time, lerp(a.moon, b.moon, m));
paintCity(th, time, dt);
paintRain(th, time, dt);
paintNearFog(th, time);
paintMotes(th, time, dt);
paintVignette();
// Adaptive quality: sustained slow frames step the tier down once.
const cost = performance.now() - t0;
frameCost.push(cost); if (frameCost.length > 90) frameCost.shift();
if (frameCost.length === 90 && tier < 2) {
const avg = frameCost.reduce((s, v) => s + v, 0) / 90;
if (avg > 9) { slowFrames++; if (slowFrames > 2) { tier++; slowFrames = 0; frameCost = []; resize(); document.documentElement.dataset.tier = tier; } }
else slowFrames = 0;
}
requestAnimationFrame(frame);
}
addEventListener('resize', resize);
addEventListener('pointermove', e => { if (e.pointerType === 'touch') return; cam.tx = (e.clientX / W - .5) * 2; cam.ty = (e.clientY / H - .5) * 2; }, {passive: true});
document.addEventListener('visibilitychange', () => { running = !document.hidden; if (running) { last = 0; requestAnimationFrame(frame); } });
resize(); requestAnimationFrame(frame);
return {
setDepth(z, instant) { cam.zt = z; if (instant) { cam.z = z; cam.zv = 0; } },
setTheme(name, instant) { themeTarget = name === 'fog' ? 1 : 0; if (instant) themeMix = themeTarget; },
info() { return {tier, scale, z: +cam.z.toFixed(3), theme: +themeMix.toFixed(2), motes: motes.length, drops: drops.length}; },
setTier(t) { tier = t; frameCost = []; resize(); document.documentElement.dataset.tier = t; }
};
})();
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN" data-theme="moon">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; form-action 'none'; base-uri 'none'">
<title>CyberGril · 夜里的同一片空间 · HEL-793 动态小样</title>
<style>{{STYLE}}</style>
</head>
<body data-mode="home">
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20 15.5A9 9 0 0 1 8.5 4 9 9 0 1 0 20 15.5Z"/></symbol>
<symbol id="i-talk" viewBox="0 0 24 24"><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2 1.8-6A8.5 8.5 0 1 1 21 11.5Z"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M5 3h14v18H5zM8 7h8M8 11h6M8 17h8"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4 4l2 2m12 12 2 2M4 20l2-2M18 6l2-2"/></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="13" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2m-7 9v3m-4 0h8"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7m-7-7v16"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h9"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M9 6v12M15 6v12"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.5v13l10-6.5z"/></symbol>
<symbol id="i-prev" viewBox="0 0 24 24"><path d="m14.5 6-6 6 6 6"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24"><path d="m9.5 6 6 6-6 6"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9.5 6 6 6-6"/></symbol>
</defs></svg>
<!-- The living space: one canvas engine draws sky, light, fog, city, rain and motes at their own depths. -->
<canvas id="atmos" aria-hidden="true"></canvas>
<div class="depth-veil" aria-hidden="true"></div>
<div id="viewport">
<header class="topbar">
<div class="identity"><span class="glint"></span><strong data-name>晚晚</strong><span class="presence">在家 · 醒着</span></div>
<div class="top-actions"><button id="rehearse" class="sample-tag">独立小样 · 演示数据</button><span class="weather">上海 · 小雨 14° <small>示例</small></span><div class="theme" role="group" aria-label="空间主题"><button data-theme="moon" aria-pressed="true"><i></i>月光</button><button data-theme="fog" aria-pressed="false"><i></i>雾光</button></div></div>
</header>
<main id="world" aria-label="相处空间">
<section class="home-copy" aria-label="此刻">
<p class="eyebrow">此 刻</p><h1>书翻到一半,<br>窗外的雨声不大。</h1><p class="quiet">安静,有点倦 <span>·</span> 你们最近挺熟络</p>
<button class="left-note glass" id="shared-note"><span class="note-meta"><span>她留给你的</span><time>今天 15:52</time></span><span class="note-text">楼下花店的洋桔梗开了。<br>我挑了一小束,放在窗边。</span></button>
<button class="small-link" id="days-link">你不在时,她分享过的小事 <span>›</span></button>
<div class="home-actions"><button class="primary" id="start-talk"><svg><use href="#i-talk"/></svg>和她说话</button><button class="round glass" id="home-voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></div>
</section>
<div class="portal-aura" aria-hidden="true"></div>
<figure class="portal" id="portal">
<div class="frame">
<img class="room-photo" src="{{ROOM}}" alt="获批参考静帧:她坐在高层卧室沙发上看书">
<img class="talk-photo" src="{{TALK}}" alt="获批参考静帧:同一人物、房间和衣服的交流近景">
<div class="lamp" aria-hidden="true"></div>
<div class="sheen" aria-hidden="true"></div>
</div>
<figcaption><span id="scene-label">窗边看书</span><span>参考静帧</span></figcaption>
<div class="portal-top"><span id="portal-state">在你面前</span><button class="close-talk" id="close-talk"><svg><use href="#i-close"/></svg><span>回到此刻</span></button></div>
</figure>
<section class="conversation" id="conversation" aria-label="当前对话" inert>
<div class="stage" id="stage" aria-hidden="true"></div>
<div class="reader-bar">
<button id="toggle-flow" class="flow" aria-pressed="true"><svg><use href="#i-pause"/></svg><span>暂停</span></button>
<button id="previous" class="icon" aria-label="上一句"><svg><use href="#i-prev"/></svg></button>
<span id="page-count" class="count">1 / 1</span>
<button id="next" class="icon" aria-label="下一句"><svg><use href="#i-next"/></svg></button>
<button id="read-all">全文</button>
<button id="stop" hidden>停止回复</button>
<button id="latest" class="latest" hidden><svg><use href="#i-down"/></svg><span>回到最新</span></button>
</div>
<div class="voice-row" id="voice-row" hidden><span class="voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>语音界面演示 · 没有收音</span><button id="end-voice">结束演示</button></div>
<form class="composer glass" id="composer"><button type="button" class="round" id="history" aria-label="完整聊天记录"><svg><use href="#i-lines"/></svg></button><textarea id="draft" rows="1" maxlength="4000" placeholder="想和她说点什么…" aria-label="想和她说的话"></textarea><button type="submit" class="send round" aria-label="发送演示消息"><svg><use href="#i-arrow"/></svg></button><button type="button" class="voice-button" id="voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></form>
</section>
</main>
<nav class="navigation glass" aria-label="主要区域">
<span class="nav-glow" aria-hidden="true"></span>
<button data-nav="home" aria-current="page"><svg><use href="#i-moon"/></svg><span>此刻</span></button><button data-nav="talk"><svg><use href="#i-talk"/></svg><span>交谈</span></button><button data-nav="days"><svg><use href="#i-book"/></svg><span>日子</span></button><button data-nav="manage"><svg><use href="#i-sun"/></svg><span>设置</span></button>
</nav>
<p class="toast" id="toast" role="status" aria-live="polite"></p>
</div>
<dialog id="sheet" class="sheet glass" aria-labelledby="sheet-title"><header class="sheet-header"><div><p class="eyebrow" id="sheet-kicker"></p><h2 id="sheet-title"></h2></div><button class="round" id="close-sheet" aria-label="关闭浮层"><svg><use href="#i-close"/></svg></button></header><div id="sheet-content" class="sheet-content"></div></dialog>
<p id="announcement" class="sr" aria-live="polite" aria-atomic="true"></p>
<script>{{SCRIPT}}</script>
</body></html>
+253
View File
@@ -0,0 +1,253 @@
/* HEL-793 · 夜里的同一片空间
Tokens keep the approved two themes (HEL-744/746). Motion budget:
UI feedback 160–280ms · text in 420–680ms · space/camera 720ms · ambient loops 6–30s. */
:root{
color-scheme:dark;
--font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Microsoft YaHei UI","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;
--ease-out:cubic-bezier(.22,1,.36,1);--ease-space:cubic-bezier(.65,0,.25,1);--ease-in-out:cubic-bezier(.65,0,.35,1);
--dur-fast:160ms;--dur-ui:280ms;--dur-text:620ms;--dur-space:720ms;
--view-h:100dvh;--view-top:0px;--safe-bottom:env(safe-area-inset-bottom,0px);
--stage-h:158px;
}
html[data-theme="moon"]{--bg-0:#050918;--text-1:#eaf0ff;--text-2:#b9c6e6;--text-3:#94a5c8;--accent:#a9dcff;--accent-soft:#a9dcff1f;--accent-glow:#96d2ff55;--on-accent:#08142e;--user:#d7e2ff;--glass:#16244e70;--glass-read:#0b1534b8;--glass-line:#cddeff2b;--surface:#0b1330;--window-glow:#ffc48c2e;--rim:#f1e2ce40;--glass-blur:20px}
html[data-theme="fog"]{--bg-0:#0e0820;--text-1:#f3ecff;--text-2:#d8c8ee;--text-3:#b9a5d6;--accent:#ebbcff;--accent-soft:#ebbcff24;--accent-glow:#e2aaff5c;--on-accent:#2a0f40;--user:#f1e2ff;--glass:#36226670;--glass-read:#211444bd;--glass-line:#ecd8ff2e;--surface:#24154a;--window-glow:#ffaabe2e;--rim:#ffd8ee45;--glass-blur:30px}
html{transition:--accent .6s}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg-0)}
body{font:15px/1.7 var(--font-sans);color:var(--text-1);-webkit-font-smoothing:antialiased;transition:color .6s}
button,input,textarea,select{font:inherit;color:inherit}
button{border:0;background:none;cursor:pointer;transition:transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out),background-color var(--dur-ui) var(--ease-out),color var(--dur-ui) var(--ease-out),box-shadow var(--dur-ui) var(--ease-out)}
button:disabled{opacity:.3;cursor:default}button:active:not(:disabled){transform:scale(.97)}
button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;flex:none}
h1,h2,h3,p,figure{margin:0}h2{font-size:22px;font-weight:350}h3{font-size:18px;font-weight:400}strong{font-weight:500}[hidden]{display:none!important}
.symbols,.sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}
.glass{transition:background-color .6s,border-color .6s,box-shadow .6s,transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out);background:var(--glass);border:1px solid var(--glass-line);box-shadow:inset 0 1px 0 #ffffff0d,0 20px 80px #0206162a;backdrop-filter:blur(var(--glass-blur)) saturate(125%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(125%)}
.round{width:44px;height:44px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;flex:none}
.primary{display:inline-flex;gap:10px;align-items:center;justify-content:center;background:var(--accent);color:var(--on-accent);padding:13px 23px;border-radius:30px;box-shadow:0 0 30px var(--accent-glow),inset 0 1px 0 #ffffff60;position:relative;overflow:hidden}
.primary::after{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 30%,#ffffff55 50%,transparent 70%);transform:translateX(-120%);animation:glint 7s var(--ease-in-out) 2s infinite}
@keyframes glint{0%,70%{transform:translateX(-120%)}100%{transform:translateX(120%)}}
.small-link{color:var(--text-2);padding:12px 0;font-size:13px;text-align:left}
.eyebrow{font-size:12px;letter-spacing:.18em;color:var(--text-3);margin-bottom:15px}.quiet{color:var(--text-2);font-size:14px}.quiet span{margin:0 10px}.subtle{font-size:13px;color:var(--text-2)}
/* ── The space ── */
#atmos{position:fixed;inset:0;width:100%;height:100%;display:block}
.depth-veil{position:fixed;inset:0;background:radial-gradient(ellipse at 50% 40%,#02061400,#020614 90%);opacity:0;transition:opacity var(--dur-space) var(--ease-out);pointer-events:none}
body[data-mode="talk"] .depth-veil{opacity:.35}body[data-mode="pane"] .depth-veil{opacity:.3}
#viewport{position:absolute;top:var(--view-top);left:0;right:0;height:var(--view-h);min-height:280px;overflow:hidden}
#world{position:absolute;inset:0}
.topbar{height:78px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 32px;position:relative;z-index:4}
.identity{display:flex;align-items:center;gap:12px;min-width:0}.identity strong{font-size:18px;font-weight:450;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:.04em}
.presence{color:var(--text-3);font-size:13px;white-space:nowrap}
.glint{flex:none;width:6px;height:6px;background:var(--accent);border-radius:50%;box-shadow:0 0 14px var(--accent);animation:pulse 4.5s var(--ease-in-out) infinite}
@keyframes pulse{50%{box-shadow:0 0 4px var(--accent);opacity:.65}}
.top-actions{display:flex;align-items:center;gap:12px;flex:none}
.sample-tag{border:1px dashed var(--glass-line);border-radius:20px;font-size:11px;padding:4px 10px;color:var(--text-2);white-space:nowrap}
.weather{font-size:12px;color:var(--text-2)}.weather small{color:var(--text-3)}
.theme{display:flex;border:1px solid var(--glass-line);padding:3px;border-radius:25px;background:var(--glass);backdrop-filter:blur(12px)}
.theme button{font-size:12px;color:var(--text-2);padding:5px 13px;border-radius:20px;display:flex;align-items:center;gap:6px;white-space:nowrap}
.theme button i{width:7px;height:7px;border-radius:50%;background:#a9dcff}.theme button[data-theme="fog"] i{background:#ebbcff}
.theme [aria-pressed="true"]{color:var(--text-1);background:var(--accent-soft);box-shadow:0 0 20px var(--accent-glow)}
/* ── Home ── */
.home-copy{position:absolute;left:9vw;top:50%;transform:translateY(-42%);width:min(440px,38vw);z-index:2;transition:opacity 420ms var(--ease-out),transform 520ms var(--ease-out),filter 420ms var(--ease-out)}
.home-copy>*{animation:rise 900ms var(--ease-out) both}.home-copy>:nth-child(2){animation-delay:80ms}.home-copy>:nth-child(3){animation-delay:160ms}.home-copy>:nth-child(4){animation-delay:240ms}.home-copy>:nth-child(5){animation-delay:300ms}.home-copy>:nth-child(6){animation-delay:360ms}
@keyframes rise{from{opacity:0;transform:translateY(10px);filter:blur(4px)}}
h1{font-size:30px;font-weight:300;line-height:1.65;letter-spacing:.02em;margin-bottom:16px;text-shadow:0 2px 24px #02061480}
.left-note{display:block;width:100%;text-align:left;padding:18px 20px;border-radius:20px;margin-top:32px;line-height:1.85}
.note-meta{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--accent);margin-bottom:10px}.note-meta time{color:var(--text-3)}
.home-actions{display:flex;align-items:center;gap:12px;margin-top:18px}.home-actions .round{width:50px;height:50px}
body:not([data-mode="home"]) .home-copy{opacity:0;transform:translate(-28px,-42%);filter:blur(6px);pointer-events:none}
/* ── Her window: one persistent element; geometry is animated by script between places. ── */
.portal{position:absolute;right:14vw;top:calc(50% - 7px);transform:translateY(-50%);width:clamp(300px,27vw,430px);aspect-ratio:1/1.3;border-radius:48% 48% 26px 26px;isolation:isolate;z-index:3;background:#1e2332;box-shadow:0 0 0 1px var(--rim),0 0 60px #ffdca712,0 30px 80px #03081866}
.portal .frame{position:absolute;inset:0;border-radius:inherit;overflow:hidden}
.portal img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:opacity var(--dur-space) var(--ease-out),transform var(--dur-space) var(--ease-space),filter var(--dur-space) var(--ease-out)}
/* Ambient life on approved stills: slow lens drift + breathing. Marked "参考静帧", never presented as video. */
.room-photo{object-position:88% 42%;transform-origin:80% 46%;transform:scale(1.16);animation:drift 28s var(--ease-in-out) infinite alternate}
.talk-photo{opacity:0;transform:scale(1.12);transform-origin:50% 30%;animation:breathe 6.5s var(--ease-in-out) infinite}
@keyframes drift{from{translate:0 0;scale:1}to{translate:-2.2% -1%;scale:1.045}}
@keyframes breathe{50%{scale:1.012;translate:0 -.25%}}
.lamp{position:absolute;inset:0;background:radial-gradient(circle at 74% 14%,#ffd29a55,transparent 32%),radial-gradient(circle at 18% 78%,#ffbf8a30,transparent 30%);mix-blend-mode:screen;animation:flicker 7s steps(1,end) infinite,glow 9s var(--ease-in-out) infinite alternate;pointer-events:none}
@keyframes flicker{0%,100%{opacity:.85}12%{opacity:.78}13%{opacity:.88}47%{opacity:.8}48%{opacity:.9}81%{opacity:.82}}
@keyframes glow{to{filter:brightness(1.18)}}
.sheen{position:absolute;inset:-40%;background:linear-gradient(115deg,transparent 42%,#ffffff12 48%,#ffffff05 52%,transparent 58%);animation:sheen 14s var(--ease-in-out) infinite;pointer-events:none}
@keyframes sheen{0%{transform:translateX(-30%)}60%,100%{transform:translateX(30%)}}
.portal figcaption{position:absolute;bottom:14px;left:17px;right:17px;display:flex;justify-content:space-between;gap:8px;color:#f3ede3;font-size:11px;text-shadow:0 1px 5px #000;transition:opacity var(--dur-ui) var(--ease-out)}
.portal figcaption span{padding:3px 9px;border-radius:15px;background:#14132170;backdrop-filter:blur(6px)}
.portal-aura{position:absolute;right:4vw;top:22%;width:46vw;height:56%;pointer-events:none;background:radial-gradient(ellipse,var(--window-glow),transparent 68%);filter:blur(30px);animation:aura 8s var(--ease-in-out) infinite alternate;transition:opacity var(--dur-space) var(--ease-out),transform var(--dur-space) var(--ease-space);z-index:1}
@keyframes aura{from{opacity:.65}to{opacity:1}}
.portal-top{position:absolute;inset:14px 14px auto;display:flex;align-items:flex-start;justify-content:space-between;opacity:0;pointer-events:none;color:#fff;transition:opacity var(--dur-ui) var(--ease-out) 0ms}
.portal-top>span,.close-talk{background:#10152594;border:1px solid #ffffff20;backdrop-filter:blur(12px);border-radius:24px;padding:7px 13px;font-size:12px}
.close-talk{display:flex;align-items:center;gap:5px}.close-talk svg{width:16px;height:16px}
body[data-mode="talk"] .portal{top:78px;left:50%;right:auto;transform:translateX(-50%);width:min(1000px,76vw);height:calc(var(--view-h) - 400px);min-height:120px;aspect-ratio:auto;border-radius:30px}
body[data-mode="talk"] .room-photo{opacity:0;transform:scale(1.55) translate(-6%,-2%);filter:blur(6px)}
body[data-mode="talk"] .talk-photo{opacity:1;transform:scale(1)}
body[data-mode="talk"] .portal-top{opacity:1;pointer-events:auto;transition-delay:380ms}
body[data-mode="talk"] .portal-aura{transform:scale(1.5) translateX(-22vw);opacity:.7}
body[data-mode="pane"] .portal{width:clamp(150px,15vw,230px);right:5vw;top:calc(50% - 7px);opacity:.6}
/* Record: she steps aside to the left and stays present while the full text or history is open on the right. */
body[data-mode="record"] .portal{top:84px;left:4vw;right:auto;transform:none;width:calc(92vw - min(480px,42vw) - 28px);height:calc(var(--view-h) - 183px - var(--safe-bottom));aspect-ratio:auto;border-radius:30px;opacity:.92}
body[data-mode="record"] .room-photo{opacity:0;transform:scale(1.55) translate(-6%,-2%)}
body[data-mode="record"] .talk-photo{opacity:1;transform:scale(1)}
body[data-mode="record"] .portal-aura{transform:scale(1.4) translateX(-30vw);opacity:.6}
body[data-mode="record"] .depth-veil{opacity:.3}
body[data-mode="pane"] .portal-aura{opacity:.3}
body[data-mode="pane"] .portal figcaption{opacity:0}
/* ── Conversation ── */
.conversation{position:absolute;bottom:calc(96px + var(--safe-bottom));left:50%;width:min(760px,80vw);transform:translate(-50%,16px);opacity:0;pointer-events:none;z-index:3;transition:opacity 420ms var(--ease-out),transform 520ms var(--ease-out)}
body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-delay:160ms}
.stage{position:relative;height:var(--stage-h);display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:10px;padding:0 20px;text-align:center}
/* A reading pool: light falls off behind the words instead of a box being drawn around them. */
.stage::before{content:"";position:absolute;left:-12%;right:-12%;top:-10%;bottom:-36%;background:radial-gradient(ellipse 50% 52% at 50% 62%,#03071ab8,#03071a66 55%,transparent 100%);pointer-events:none;z-index:-1}
.line{width:100%;max-width:100%}
.line .who{display:inline-flex;align-items:center;gap:7px;font-size:12px;letter-spacing:.06em;color:var(--accent);margin-bottom:4px;max-width:100%}
.line .who b{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:16em}
.line .who i{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent);flex:none}
.line[data-role="user"] .who{color:var(--text-2)}.line[data-role="user"] .who i{background:transparent;box-shadow:none;border:1px solid var(--text-2)}
.line .state{font-style:normal;color:var(--text-3);font-size:11px;letter-spacing:0}
.line p{font-size:21px;font-weight:350;line-height:1.72;overflow-wrap:anywhere;text-shadow:0 2px 20px #030719,0 0 2px #03071980;color:var(--text-1)}
.line[data-role="user"] p{color:var(--user);font-size:19px}
.line.is-prev{opacity:.42;transition:opacity 520ms var(--ease-out)}
/* Fixed size: the hand-off from current → previous is a crossfade of two copies (space.js), never a font-size tween. */
.line.is-prev p{font-size:15px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
.line.is-prev .who{margin-bottom:0;font-size:11px}.line.is-prev .dwell{display:none}
.line .dwell{display:block;width:56px;height:2px;margin:9px auto 0;border-radius:2px;background:var(--accent-soft);overflow:hidden}
.line .dwell i{display:block;height:100%;background:var(--accent);box-shadow:0 0 8px var(--accent);transform-origin:left;transform:scaleX(0)}
body.reading-paused .line .dwell{opacity:.25}body.reading-held .line .dwell i{opacity:.45}
.line.waiting .state::after{content:"";display:inline-block;width:18px;height:6px;margin-left:5px;background:radial-gradient(circle,var(--accent) 1.5px,transparent 2px) 0 50%/6px 6px repeat-x;animation:dots 1.4s linear infinite;vertical-align:middle;opacity:.8}
@keyframes dots{50%{opacity:.25}}
/* Ink-in: characters settle in a soft wave (her lines only). */
.ink span{display:inline;animation:ink 460ms var(--ease-out) both;animation-delay:calc(var(--i) * 14ms)}
@keyframes ink{from{opacity:0;filter:blur(3px)}}
.reader-bar{display:flex;align-items:center;justify-content:center;gap:6px;font-size:12px;color:var(--text-2);margin:10px 0 12px;min-height:32px;flex-wrap:nowrap}
.reader-bar>button{min-height:32px;padding:0 10px;border-radius:16px;white-space:nowrap;display:inline-flex;align-items:center;gap:5px}
.reader-bar .icon{width:32px;padding:0;justify-content:center}.reader-bar svg{width:16px;height:16px}
.reader-bar .flow{border:1px solid var(--glass-line);background:#ffffff08}
.reader-bar .flow[aria-pressed="false"]{color:var(--accent);border-color:var(--accent-glow);box-shadow:0 0 14px var(--accent-glow)}
.reader-bar .count{min-width:52px;text-align:center;font-variant-numeric:tabular-nums;color:var(--text-3)}
#stop{color:var(--accent)}
.latest{color:var(--on-accent);background:var(--accent);box-shadow:0 0 18px var(--accent-glow)}
.composer{display:flex;gap:6px;align-items:flex-end;padding:7px;border-radius:30px}
.composer textarea{flex:1;min-width:0;resize:none;height:40px;max-height:92px;border:0;background:transparent;line-height:1.6;padding:8px 4px;outline:none;color:var(--text-1);font-size:15px}
.composer textarea:focus-visible{outline:0}.composer:focus-within{border-color:var(--accent-glow);box-shadow:0 0 0 1px var(--accent-soft),0 0 30px var(--accent-soft)}
.composer textarea::placeholder{color:var(--text-3)}
.send{background:var(--accent-soft);color:var(--accent)}
.voice-button{display:flex;align-items:center;justify-content:center;border-radius:50%;width:44px;height:44px;background:var(--accent);color:var(--on-accent);flex:none;box-shadow:0 0 18px var(--accent-glow)}
.voice-row{display:flex;align-items:center;justify-content:center;gap:12px;font-size:12px;color:var(--text-2);margin:0 10px 8px}.voice-row button{color:var(--accent)}
.voice-wave{display:inline-flex;gap:3px;align-items:center;height:14px}.voice-wave i{width:2px;height:100%;background:var(--accent);border-radius:2px;animation:wave 1.1s var(--ease-in-out) infinite}.voice-wave i:nth-child(2){animation-delay:.15s}.voice-wave i:nth-child(3){animation-delay:.3s}.voice-wave i:nth-child(4){animation-delay:.45s}
@keyframes wave{50%{transform:scaleY(.3)}}
/* ── Navigation ── */
.navigation{position:absolute;left:50%;bottom:calc(18px + var(--safe-bottom));transform:translateX(-50%);display:flex;padding:5px;border-radius:40px;z-index:5;transition:transform 420ms var(--ease-out),opacity 280ms var(--ease-out)}
.nav-glow{position:absolute;left:0;top:5px;bottom:5px;border-radius:32px;background:var(--accent-soft);box-shadow:0 0 22px var(--accent-glow),inset 0 0 12px var(--accent-soft);transition:transform 460ms var(--ease-out),opacity 280ms;pointer-events:none}
.navigation button{position:relative;width:68px;min-width:68px;min-height:51px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 15px;color:var(--text-2);border-radius:32px;font-size:12px}
.navigation svg{width:18px;height:18px}.navigation [aria-current="page"]{color:var(--accent)}
.toast{position:absolute;left:50%;top:84px;transform:translate(-50%,-8px);padding:8px 16px;border-radius:20px;font-size:12px;color:var(--text-1);background:var(--glass-read);border:1px solid var(--glass-line);backdrop-filter:blur(14px);opacity:0;pointer-events:none;transition:opacity 280ms var(--ease-out),transform 280ms var(--ease-out);z-index:6;white-space:nowrap;max-width:calc(100vw - 32px);overflow:hidden;text-overflow:ellipsis}
.toast.show{opacity:1;transform:translate(-50%,0)}
/* ── Panels: reading surfaces inside the space ── */
.sheet{position:fixed;inset:auto;margin:0;top:calc(var(--view-top) + 84px);left:5vw;width:min(950px,66vw);height:calc(var(--view-h) - 183px - var(--safe-bottom));max-height:none;max-width:calc(100vw - 24px);padding:0;border-radius:28px;color:var(--text-1);background:var(--glass-read);overflow:hidden;box-shadow:0 25px 100px #02041270,inset 0 1px 0 #ffffff10}
.sheet::backdrop{background:transparent}
.sheet[data-kind="history"],.sheet[data-kind="full"]{left:auto;right:4vw;width:min(480px,42vw)}
.sheet-header{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:24px 26px 18px;border-bottom:1px solid var(--glass-line);height:110px}
.sheet-header .eyebrow{font-size:11px;margin-bottom:3px}
.sheet-content{height:calc(100% - 110px);overflow:auto;overscroll-behavior:contain;padding:24px 26px 34px;scrollbar-gutter:stable;line-height:1.9}
.sheet-content>*{animation:rise 520ms var(--ease-out) both}.sheet-content>:nth-child(2){animation-delay:40ms}.sheet-content>:nth-child(3){animation-delay:80ms}.sheet-content>:nth-child(4){animation-delay:120ms}.sheet-content>:nth-child(n+5){animation-delay:160ms}
.sheet-content :is(p,ul){margin-bottom:16px}
.sheet-content .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:15px 0}
.sheet-content .pill{border:1px solid var(--glass-line);border-radius:24px;padding:8px 17px;font-size:13px}
.sheet-content .pill[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);box-shadow:0 0 16px var(--accent-soft)}
.sheet-content .item{width:100%;text-align:left;border-top:1px solid var(--glass-line);padding:22px 4px;display:block;border-radius:6px}
.item small{display:block;color:var(--text-2);margin-top:7px;font-size:13px}.item .date{font-size:11px;letter-spacing:.08em;color:var(--accent);margin-bottom:10px}
.quote{font-size:20px;font-weight:350;border-left:1px solid var(--accent);padding-left:20px;margin:24px 0}
.sheet-content .message{padding:18px 0;border-bottom:1px solid var(--glass-line)}
.message .author{display:flex;align-items:center;gap:7px;color:var(--accent);font-size:12px;margin-bottom:7px;overflow-wrap:anywhere}
.message .author i{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);flex:none}
.message[data-role="user"] .author{color:var(--text-2)}.message[data-role="user"] .author i{background:none;box-shadow:none;border:1px solid var(--text-2)}
.message p{font-size:15px;white-space:pre-wrap;overflow-wrap:anywhere}.message .incomplete{font-size:12px;color:var(--text-2)}
.sheet-content label{display:block;font-size:13px;color:var(--text-2);margin:18px 0 7px}
.sheet-content input,.sheet-content textarea,.sheet-content select{border:1px solid var(--glass-line);border-radius:12px;background:var(--surface);padding:11px 14px;width:100%;max-width:100%;font-size:15px}
.sheet-content textarea{min-height:90px;resize:vertical}
.sheet-content .notice{padding:14px 17px;border:1px solid var(--glass-line);border-radius:16px;color:var(--text-2);font-size:13px}
.sheet-content .status{color:var(--accent);min-height:26px;font-size:13px}.sheet-content .guide{font-size:13px;color:var(--text-2)}
.sheet-content summary{cursor:pointer;color:var(--accent);padding:10px 0}.sheet-content details{border-top:1px solid var(--glass-line);margin-top:20px}
.sheet-content .error{color:#ffb8c8}
*{scrollbar-width:thin;scrollbar-color:var(--accent-glow) transparent}
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:var(--accent-glow);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}::-webkit-scrollbar-track{background:transparent}
@media(hover:hover) and (pointer:fine){
button:hover:not(:disabled){opacity:.85}.sheet-content .item:hover{color:var(--accent);background:linear-gradient(90deg,var(--accent-soft),transparent 70%)}
.reader-bar>button:hover:not(:disabled){background:#ffffff0d;opacity:1}
}
@media(max-width:1100px){
.weather,.presence{display:none}.sheet{width:86vw;left:7vw}.identity strong{max-width:180px}
body[data-mode="pane"] .portal{opacity:.3;right:3vw}
.sheet[data-kind="history"],.sheet[data-kind="full"]{width:min(460px,60vw);right:4vw}
body[data-mode="record"] .portal{width:calc(92vw - min(460px,60vw) - 28px)}
}
/* ── Phone ── */
@media(max-width:720px){
:root{--stage-h:168px}
.topbar{height:57px;padding:0 16px;gap:8px}.identity{gap:8px}.identity strong{font-size:15px;max-width:96px}
.top-actions{gap:6px}.sample-tag{font-size:10px;padding:4px 7px}.theme{padding:2px}.theme button{font-size:11px;padding:4px 9px;gap:4px}.theme button i{width:5px;height:5px}
.home-copy{left:24px;top:auto;bottom:calc(104px + var(--safe-bottom));width:calc(100% - 48px);transform:none}
body:not([data-mode="home"]) .home-copy{transform:translateY(16px)}
.home-copy .eyebrow{margin-bottom:10px}.home-copy h1{font-size:23px;line-height:1.55;margin-bottom:10px}.home-copy .quiet{font-size:12px}
.left-note{padding:13px 15px;font-size:13px;margin-top:16px;border-radius:18px}.note-meta{font-size:11px;margin-bottom:5px}
.home-copy .small-link{padding:8px 0;font-size:12px}.home-actions{margin-top:6px}.home-actions .primary{padding:10px 20px;font-size:14px}.home-actions .round{width:44px;height:44px}
.portal{right:22px;top:72px;transform:none;width:clamp(150px,46vw,230px);height:calc(var(--view-h) - 530px - var(--safe-bottom));min-height:180px;max-height:290px;aspect-ratio:auto;border-radius:48% 48% 18px 18px}
.portal figcaption{left:8px;right:8px;bottom:9px;font-size:9px;gap:4px}.portal figcaption span{padding:2px 6px}
.portal-aura{right:-20%;top:6%;width:100vw;height:44%}
body[data-mode="talk"] .portal{left:12px;right:12px;top:62px;width:calc(100% - 24px);transform:none;height:calc(var(--view-h) - 360px);min-height:56px;max-height:none;border-radius:24px}
body[data-mode="talk"] .portal-aura{transform:scale(1.2) translateX(-10vw)}
.portal-top{inset:10px 10px auto}.portal-top>span,.close-talk{font-size:11px;padding:5px 10px}.close-talk svg{width:13px;height:13px}
body[data-mode="pane"] .portal{top:76px;right:16px;width:120px;min-height:150px;height:150px;opacity:.22}
body[data-mode="record"] .portal{top:62px;left:12px;width:calc(100% - 24px);height:calc(var(--view-h) - 146px);min-height:56px;max-height:none;border-radius:24px;opacity:.3}
.conversation{width:calc(100% - 20px);bottom:calc(86px + var(--safe-bottom))}
.stage{padding:0 8px;gap:6px}.line p{font-size:17px;line-height:1.75}.line[data-role="user"] p{font-size:16px}.line.is-prev p{font-size:13px}
.reader-bar{gap:2px;margin:6px 0 8px;font-size:11px}.reader-bar>button{padding:0 8px}.reader-bar .count{min-width:44px}
.composer{padding:5px;gap:3px}.composer .round,.voice-button{width:40px;height:40px}.composer textarea{font-size:16px;padding:7px 3px;height:40px}
.navigation{bottom:calc(14px + var(--safe-bottom));padding:4px}.nav-glow{top:4px;bottom:4px}.navigation button{width:64px;min-width:64px;min-height:46px;padding:3px 14px;font-size:11px}.navigation svg{width:17px;height:17px}
.toast{top:62px;font-size:11px}
.sheet,.sheet[data-kind="history"],.sheet[data-kind="full"]{left:10px;right:10px;width:calc(100% - 20px);top:calc(var(--view-top) + 62px);height:calc(var(--view-h) - 146px - var(--safe-bottom));border-radius:24px}
.sheet-header{padding:16px 18px;height:84px}.sheet-content{padding:18px 20px 28px;height:calc(100% - 84px)}h2{font-size:20px}.quote{font-size:18px}
body[data-mode="talk"] .latest span{max-width:9.5em;overflow:hidden;text-overflow:ellipsis}
}
@media(max-height:700px) and (max-width:720px){
body[data-mode="home"] .portal{top:64px;min-height:140px;height:170px;width:150px}
.home-copy{bottom:88px}.home-copy h1{font-size:20px}.home-copy .eyebrow,.home-copy .quiet{display:none}.home-copy .left-note{margin-top:10px;padding:9px 13px}.home-copy .left-note br{display:none}
}
/* A short visual viewport = keyboard open or a squat window. Her window yields first, text never does. */
body.compact{--stage-h:96px}
body.compact .topbar{height:48px}body.compact .weather,body.compact .presence{display:none}
body.compact[data-mode="talk"] .portal{top:52px;border-radius:16px}body.compact .portal figcaption{display:none}
body.compact .line.is-prev{display:none}body.compact .line p{font-size:16px;line-height:1.65}
body.compact .conversation{bottom:calc(72px + var(--safe-bottom))}body.compact .reader-bar{margin:3px 0 5px}
body.compact .navigation{bottom:calc(8px + var(--safe-bottom))}body.compact .navigation button{min-height:40px}
body.compact .sheet{top:calc(var(--view-top) + 52px);height:calc(var(--view-h) - 120px - var(--safe-bottom))}body.compact .sheet-header{height:72px;padding:10px 18px}body.compact .sheet-content{height:calc(100% - 72px)}
body.compact.typing[data-mode="talk"] .navigation{transform:translate(-50%,140%);opacity:0;pointer-events:none}
body.compact.typing .conversation{bottom:calc(8px + var(--safe-bottom))}
body.micro .reader-bar .count,body.micro #read-all{display:none}
@media(max-width:720px){
body.compact[data-mode="home"] .home-copy{top:60px;bottom:84px;transform:none}body.compact[data-mode="home"] h1{width:calc(100% - 130px);font-size:19px;line-height:1.6}
body.compact[data-mode="home"] .portal{top:60px;right:20px;width:115px;height:135px;min-height:100px}
body.compact[data-mode="home"] .left-note{position:absolute;bottom:58px;margin:0;padding:9px 12px}body.compact[data-mode="home"] .left-note .note-meta{margin:0}body.compact[data-mode="home"] .left-note .note-text{display:none}
body.compact[data-mode="home"] .small-link{display:none}body.compact[data-mode="home"] .home-actions{position:absolute;bottom:0;margin:0}
body.micro[data-mode="home"] .left-note{display:none}
}
/* ── Motion preferences and fallbacks ── */
html.no-motion *,html.no-motion *::before,html.no-motion *::after{animation:none!important;transition-duration:1ms!important}
body.suspended *{animation-play-state:paused!important}
/* Keyboard shortcuts (space.js snap): land on the target state with no transition; ambient loops keep running. */
body.snap *,body.snap *::before,body.snap *::after{transition:none!important}
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important}
.portal img,.home-copy,.conversation,.portal-aura,.depth-veil,.navigation,.nav-glow{transition:opacity 200ms linear!important}
}
/* Tier 2 (slow device): drop backdrop blur, the most expensive compositor effect. */
html[data-tier="2"] .glass,html[data-tier="2"] .sheet{backdrop-filter:none;-webkit-backdrop-filter:none;background:var(--glass-read)}
+478
View File
@@ -0,0 +1,478 @@
'use strict';
// Local fixtures only. CSP forbids connections; nothing is persisted.
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
const html = document.documentElement;
const sheet = $('#sheet'), content = $('#sheet-content'), stage = $('#stage'), portal = $('#portal');
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
const motionOff = () => reducedQuery.matches || html.classList.contains('no-motion');
const EASE = 'cubic-bezier(.22,1,.36,1)'; // enter / settle
const EASE_SPACE = 'cubic-bezier(.65,0,.25,1)'; // camera-like moves between spaces
// record = the full text / history beside her; pane = life, memories, settings with her window set back.
const DEPTH = {home: 0, talk: 1, record: .7, pane: -.6};
const state = {
mode: 'home', name: '晚晚', relation: '朋友', turn: 0, cursor: 0, head: 0,
flow: true, held: new Set(), busy: false, voice: false, token: 0, dialog: null, returnMode: 'home',
dwell: 0, dwellTotal: 0, shown: [], createdNew: false
};
let messages = [
{id: -3, turn: -2, role: 'user', text: '淡色的花看起来很安静。', status: 'complete', life: 0},
{id: -2, turn: -2, role: 'her', text: '楼下花店的洋桔梗开了。我挑了一小束,放在窗边。', status: 'complete', life: 0},
{id: -1, turn: -1, role: 'her', text: '我们就这样一起听一会儿雨吧。', status: 'complete', life: 1},
{id: 1, turn: 0, role: 'user', text: '你今天在读什么?', status: 'complete'},
{id: 2, turn: 0, role: 'her', text: '一本旧散文集。读到写雨的那页,突然很想和你说说话。', status: 'complete'}
];
let nextId = 3, streamTimer = 0, portalAnim = null, closing = null, trigger = null, toastTimer = 0;
const SHORT = '刚刚看完一小节,正好想歇一会儿。你呢,今天过得怎么样?';
const LONG = '今天读到一段写雨的文字,想到小时候放学回家的那条路。那时候总觉得路很长,下雨时又不舍得走快,想听雨落在不同屋檐上的声音。后来搬到高一点的地方,雨声变远了,能看见的灯却多了。我还是会在这种时候把书放一放,看看窗外,给自己泡一杯热茶。\n\n有时候,一天里也没有发生什么大事。可能只是花店换了橱窗,或者读到一个特别喜欢的句子。但如果能把这些细小的事情讲给你听,这一天就像多了一盏亮着的灯。\n\n我也想知道你那边的天气。如果你今天有点累,我们可以慢慢聊,不急着把所有的话一次说完。等你想说的时候,我就在这里。最后这一句也完整保留:愿今晚的雨声让你睡得安稳。';
const FIXTURES = [
{title: '窗边的洋桔梗', time: '今天 · 15:52', text: '楼下花店的洋桔梗开了。我挑了一小束,放在窗边。', memory: '你说,淡色的花看起来很安静。'},
{title: '一起听过的雨', time: '昨天 · 21:16', text: '昨晚我们都没有急着说话,只是听了一会儿雨。', memory: '没有话的时候,也可以一起待一会儿。'}
];
const escapeHTML = v => String(v).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const graphemes = text => typeof Intl.Segmenter === 'function' ? [...new Intl.Segmenter('zh', {granularity: 'grapheme'}).segment(text)].map(s => s.segment) : Array.from(text);
const who = m => m.role === 'user' ? '你' : state.name;
function toast(text) {
const t = $('#toast'); t.textContent = text; t.classList.add('show');
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
}
function announce(text) { $('#announcement').textContent = text; }
function setName(name) {
state.name = name;
$$('[data-name]').forEach(n => { n.textContent = name; n.title = name; });
present('jump');
}
// ── Space: one persistent portal moves between places; the canvas camera moves with it. ──
function portalBox() {
const r = portal.getBoundingClientRect(), w = $('#world').getBoundingClientRect(), cs = getComputedStyle(portal);
return {left: r.left - w.left, top: r.top - w.top, width: r.width, height: r.height, radius: cs.borderRadius, opacity: cs.opacity};
}
// Keyboard shortcuts land on the target state at once: CSS transitions are suspended for two frames
// and any finite animation already under way (geometry, line moves, ink) jumps to its end.
function snap() {
document.body.classList.add('snap');
for (const a of document.getAnimations()) if (a.effect && a.effect.getTiming().iterations !== Infinity) a.finish();
requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('snap')));
}
function setMode(mode, {instant = false} = {}) {
const before = portalBox();
if (portalAnim) { portalAnim.cancel(); portalAnim = null; }
if (instant) snap();
state.mode = mode; document.body.dataset.mode = mode;
Atmos.setDepth(DEPTH[mode], instant);
$('#scene-label').textContent = mode === 'talk' || mode === 'record' ? '交流近景' : '窗边看书';
$('.home-copy').inert = mode !== 'home'; $('#conversation').inert = mode !== 'talk';
portal.style.height = '';
if (mode === 'talk') fitTalkStage();
const after = portalBox();
if (!instant && !motionOff() && before.width && after.width) {
if (html.dataset.tier === '2') {
// Slow devices: no per-frame layout; she simply fades in at the new place.
portalAnim = portal.animate([{opacity: 0}, {opacity: after.opacity}], {duration: 320, easing: EASE});
} else {
// Animate real geometry (not a scale), so the arch → frame radius and the photo never distort.
// Measured (evidence/portal-ab.json): layout ~0.1ms/frame (absolute box, own subtree); repaint of the large desktop window is the real cost.
portalAnim = portal.animate([
{left: before.left + 'px', top: before.top + 'px', width: before.width + 'px', height: before.height + 'px', borderRadius: before.radius, opacity: before.opacity, right: 'auto', transform: 'none'},
{left: after.left + 'px', top: after.top + 'px', width: after.width + 'px', height: after.height + 'px', borderRadius: after.radius, opacity: after.opacity, right: 'auto', transform: 'none'}
], {duration: 720, easing: EASE_SPACE});
}
portalAnim.finished.then(() => { portalAnim = null; }).catch(() => {});
}
$$('.navigation button').forEach(n => n.dataset.nav === (mode === 'pane' || mode === 'record' ? state.dialogNav : mode) ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current'));
moveNavGlow();
if (mode === 'talk') { present(instant ? 'instant' : 'jump'); }
}
// The glow only slides (transform); its width is set without transition — all nav items share one width.
function moveNavGlow() {
const cur = $('.navigation [aria-current]'), glow = $('.nav-glow');
if (!cur) { glow.style.opacity = 0; return; }
glow.style.opacity = 1; glow.style.width = cur.offsetWidth + 'px'; glow.style.transform = `translateX(${cur.offsetLeft}px)`;
}
function openTalk(focus = false) { if (state.mode !== 'talk') setMode('talk'); if (focus) $('#draft').focus({preventScroll: true}); }
function fitTalkStage() {
if (state.mode !== 'talk') return;
const conv = $('#conversation').getBoundingClientRect(), w = $('#world').getBoundingClientRect();
const top = parseFloat(getComputedStyle(portal).top) || 0;
const gap = innerWidth <= 720 ? 10 : 18;
portal.style.height = Math.max(56, Math.floor(conv.top - w.top - top - gap)) + 'px';
}
// ── Reading: replies flow on by themselves, one or two lines at a time. ──
function chunks(text) {
const compact = document.body.classList.contains('compact');
const width = Math.max(220, stage.clientWidth - 24), font = compact ? 15 : innerWidth <= 720 ? 17 : 21;
const perLine = Math.floor(width / font), max = Math.max(14, Math.min(64, Math.floor(perLine * (compact ? 1.6 : 2.2))));
const units = graphemes(text), out = []; let line = '';
for (const ch of units) {
if (ch === '\n') { if (line.trim()) { out.push(line); line = ''; } continue; }
// Keep closing punctuation with its sentence; never produce an empty line.
if (!line && out.length && /[。!?!?,、;:,;:」』))”]/.test(ch)) { out[out.length - 1] += ch; continue; }
line += ch;
const n = graphemes(line).length;
if (/[。!?!?]/.test(ch) && n >= 4) { out.push(line); line = ''; }
else if (n >= max) {
// Prefer to break after a comma within the last third of the line.
const cut = Math.max(line.lastIndexOf(','), line.lastIndexOf('、'), line.lastIndexOf(';'));
if (cut > max * .55) { out.push(line.slice(0, cut + 1)); line = line.slice(cut + 1); } else { out.push(line); line = ''; }
}
}
if (line.trim()) out.push(line);
return out;
}
function pages() {
const list = [];
for (const m of messages.filter(m => m.turn === state.turn)) {
const parts = chunks(m.text);
// A still-growing sentence is held back until it is complete, so nothing re-animates mid-stream.
if (m.status === 'generating' && parts.length && !/[。!?!?\n]\s*$/.test(m.text)) parts.pop();
parts.forEach((text, index) => list.push({message: m, text, index, key: `${m.id}:${index}`}));
}
return list;
}
function dwellFor(page) {
const n = graphemes(page.text).length;
return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150));
}
function lineEl(page, current, animate = true) {
const el = document.createElement('div');
el.className = 'line' + (current ? ' is-current' : ' is-prev');
el.dataset.role = page.message.role; el.dataset.key = page.key;
const label = page.message.role === 'user' ? '你' : state.name;
const chars = graphemes(page.text);
const ink = current && animate && !motionOff() && page.message.role === 'her';
const body = ink ? chars.map((c, i) => `<span style="--i:${Math.min(i, 48)}">${escapeHTML(c)}</span>`).join('') : escapeHTML(page.text);
el.innerHTML = `<span class="who"><i></i><b>${escapeHTML(label)}</b><em class="state"></em></span><p class="${ink ? 'ink' : ''}">${body}</p><span class="dwell"><i></i></span>`;
return el;
}
function present(how = 'advance') {
const list = pages();
state.cursor = Math.max(0, Math.min(state.cursor, list.length - 1));
state.head = Math.max(state.head, state.cursor);
updateControls(list);
const page = list[state.cursor];
if (!page) { stage.replaceChildren(); return; }
const prev = list[state.cursor - 1];
const want = [prev, page].filter(Boolean);
const have = [...stage.querySelectorAll('.line:not(.leaving)')];
const keys = have.map(n => n.dataset.key);
state.dwell = 0; state.dwellTotal = dwellFor(page);
announce(`${who(page.message)}:${page.text}`);
if (how === 'instant') {
// Keyboard review: show the target lines as they are — no slide, no re-played ink.
stage.replaceChildren(...want.map(p => lineEl(p, p === page, false)));
refreshState(); return;
}
if (keys.join() === want.map(p => p.key).join()) { refreshState(); return; }
const animate = !motionOff() && state.mode === 'talk';
// Forward by one: the finished line lifts toward the "previous" slot while a small copy settles there; the oldest drifts away.
// Both copies keep a fixed font size (no per-frame re-typesetting); only transform/opacity move.
if (how === 'advance' && animate && keys[keys.length - 1] === (prev && prev.key)) {
const first = new Map(have.map(n => [n, n.getBoundingClientRect()]));
const stageRect = stage.getBoundingClientRect();
for (const n of have) if (n.dataset.key !== prev.key) leave(n, first.get(n), stageRect);
const keep = have.find(n => n.dataset.key === prev.key), was = first.get(keep);
const settled = lineEl(prev, false), fresh = lineEl(page, true);
pin(keep, was, stageRect);
stage.append(settled, fresh);
const to = settled.getBoundingClientRect();
if (to.height) {
const shrink = parseFloat(getComputedStyle(settled.querySelector('p')).fontSize) / parseFloat(getComputedStyle(keep.querySelector('p')).fontSize);
leave(keep, was, stageRect, false, to.top - was.top, shrink);
settled.animate([{opacity: 0, transform: `translateY(${Math.round((was.top - to.top) * .35)}px)`}, {opacity: getComputedStyle(settled).opacity, transform: 'none'}], {duration: 620, easing: EASE});
} else leave(keep, was, stageRect); // compact screens hide the previous line
fresh.animate([{opacity: 0, transform: 'translateY(16px)', filter: 'blur(8px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 680, easing: EASE});
} else {
// Jumps (review, new turn, resize) crossfade instead of sliding, so direction never lies.
const stageRect = stage.getBoundingClientRect();
for (const n of have) animate ? leave(n, n.getBoundingClientRect(), stageRect, true) : n.remove();
want.forEach(p => {
const el = lineEl(p, p === page); stage.append(el);
if (animate) el.animate([{opacity: 0, filter: 'blur(6px)'}, {opacity: 1, filter: 'blur(0)'}], {duration: 520, delay: 90, easing: EASE, fill: 'backwards'});
});
}
refreshState();
}
// Take a line out of the flow at its current place, so the remaining lines lay out once at their final size.
function pin(node, rect, stageRect) {
node.classList.add('leaving');
Object.assign(node.style, {position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px', transformOrigin: '50% 0'});
}
function leave(node, rect, stageRect, quick = false, lift = quick ? -4 : -14, shrink = 1) {
pin(node, rect, stageRect);
node.animate([{opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: `translateY(${lift}px) scale(${shrink})`, filter: 'blur(5px)'}],
{duration: quick ? 240 : 520, easing: EASE, fill: 'forwards'}).finished.then(() => node.remove()).catch(() => node.remove());
}
function refreshState() {
const cur = stage.querySelector('.line.is-current:not(.leaving)'); if (!cur) return;
const list = pages(), atEnd = state.cursor >= list.length - 1;
const page = list[state.cursor];
const s = cur.querySelector('.state');
s.textContent = page && page.message.status === 'cancelled' && page.index === chunks(page.message.text).length - 1 ? '未完成 · 已取消' : atEnd && state.busy ? '还在说' : '';
cur.classList.toggle('waiting', atEnd && state.busy);
}
function updateControls(list = pages()) {
const n = list.length;
$('#previous').disabled = state.cursor <= 0;
$('#next').disabled = state.cursor >= n - 1;
$('#page-count').textContent = n ? `${state.cursor + 1} / ${n}${state.busy ? '…' : ''}` : '0 / 0';
$('#stop').hidden = !state.busy;
const t = $('#toggle-flow');
t.setAttribute('aria-pressed', String(state.flow));
t.innerHTML = state.flow ? '<svg><use href="#i-pause"/></svg><span>暂停</span>' : '<svg><use href="#i-play"/></svg><span>继续</span>';
const unread = n - 1 - state.cursor;
const latest = $('#latest');
latest.hidden = state.flow || state.cursor >= state.head;
latest.querySelector('span').textContent = unread > 0 ? `回到读到的地方 · 后面还有 ${unread} 句` : '回到读到的地方';
document.body.classList.toggle('reading-paused', !state.flow);
$('#portal-state').textContent = state.busy ? '正在回应 · 演示' : state.voice ? '语音界面演示' : '在你面前';
}
function go(delta, manual = true, instant = false) {
const n = pages().length;
const to = Math.max(0, Math.min(state.cursor + delta, n - 1));
if (to === state.cursor) return;
state.cursor = to;
present(instant ? 'instant' : delta === 1 ? 'advance' : 'jump');
}
function back(instant = false) { if (state.flow) setFlow(false, true); go(-1, true, instant); updateControls(); }
function setFlow(on, quiet = false) {
state.flow = on; state.dwell = 0;
updateControls();
if (!quiet) toast(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看');
}
// Reading clock. Holds (hover, selection, open panels, hidden tab) freeze it without changing the user's choice.
let lastTick = 0;
function tick(now) {
const dt = lastTick ? Math.min(100, now - lastTick) : 0; lastTick = now;
if (state.mode === 'talk' && state.flow && !state.held.size && !document.hidden) {
const list = pages(), page = list[state.cursor];
if (page) {
if (state.dwell < state.dwellTotal) state.dwell += dt;
if (state.dwell >= state.dwellTotal && state.cursor < list.length - 1) go(1, false);
}
}
const bar = stage.querySelector('.line.is-current:not(.leaving) .dwell i');
if (bar) bar.style.transform = `scaleX(${state.dwellTotal ? Math.min(1, state.dwell / state.dwellTotal) : 0})`;
document.body.classList.toggle('reading-held', state.held.size > 0 && state.flow);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
const hold = (why, on) => { on ? state.held.add(why) : state.held.delete(why); };
// ── Simulated reply stream (sentence by sentence; not a real SSE connection). ──
function cancelReply(silent = false) {
const wasBusy = state.busy;
state.token++; clearTimeout(streamTimer); state.busy = false;
messages.filter(m => m.status === 'generating').forEach(m => { m.status = 'cancelled'; if (!m.text) m.text = '(回复在内容出现前取消)'; });
present('refresh');
if (wasBusy && !silent) toast('已停止。已出现的部分保留,并标为未完成');
}
function beginReply(text, reply = SHORT, after = null) {
cancelReply(true);
const resumed = !state.flow;
state.turn++; state.flow = true; state.cursor = 0; state.head = 0;
messages.push({id: nextId++, turn: state.turn, role: 'user', text, status: 'complete'});
present('jump');
if (resumed) toast('新的一轮会自动呈现,随时可以暂停');
streamReply(reply, after);
}
function streamReply(reply, after = null) {
const token = ++state.token;
const message = {id: nextId++, turn: state.turn, role: 'her', text: '', status: 'generating'};
messages.push(message); state.busy = true; updateControls(); refreshState();
const parts = reply.match(/[^。!?!?\n]+[。!?!?]*|\n+/gu) || [reply]; let index = 0;
function append() {
if (token !== state.token) return;
message.text += parts[index++];
if (index >= parts.length) { state.busy = false; message.status = 'complete'; }
updateControls(); refreshState();
if (state.dialog === 'history') renderHistory();
if (state.busy) streamTimer = setTimeout(append, 520); else if (after) after();
}
streamTimer = setTimeout(append, 900);
}
// ── Panels. Native dialog restores focus; the space pulls back while one is open. ──
function showSheet(kind, title, kicker, markup, nav = state.dialogNav) {
hold('sheet', true);
if (closing) { closing.cancel(); closing = null; }
state.dialogNav = nav;
const target = (kind === 'history' || kind === 'full') && (state.mode === 'talk' || state.mode === 'record') ? 'record' : 'pane';
if (!sheet.open) { trigger = document.activeElement; state.returnMode = state.mode; sheet.showModal(); setMode(target); }
else if (state.mode !== target) setMode(target);
else { $$('.navigation button').forEach(n => n.dataset.nav === nav ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current')); moveNavGlow(); }
state.dialog = kind; sheet.dataset.kind = kind;
$('#sheet-title').textContent = title; $('#sheet-kicker').textContent = kicker; content.innerHTML = markup; content.scrollTop = 0;
if (!motionOff()) sheet.animate([{opacity: 0, transform: 'translateY(18px) scale(.985)', filter: 'blur(6px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 460, easing: EASE});
$('#close-sheet').focus({preventScroll: true});
}
function finishClose(instant = false) {
sheet.getAnimations().forEach(a => a.cancel()); if (sheet.open) sheet.close();
state.dialog = null; closing = null; hold('sheet', false); state.dialogNav = null;
setMode(['pane', 'record'].includes(state.returnMode) ? 'home' : state.returnMode, {instant});
if (trigger && trigger.isConnected && !trigger.inert) trigger.focus({preventScroll: true});
}
function closeSheet(instant = false) {
if (!sheet.open) return;
if (instant) { if (closing) closing.cancel(); finishClose(true); return; }
if (closing) return;
if (motionOff()) { finishClose(); return; }
closing = sheet.animate([{opacity: 1, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: 'translateY(14px) scale(.985)', filter: 'blur(4px)'}], {duration: 240, easing: EASE, fill: 'forwards'});
closing.finished.then(() => finishClose()).catch(() => {});
}
function renderHistory() {
const keep = content.scrollTop;
content.innerHTML = '<p class="guide">本小样中的文字均为虚构。内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>' + messages.map(m => `<article class="message" data-role="${m.role}" data-life="${m.life ?? ''}"><div class="author"><i></i>${escapeHTML(who(m))}</div><p>${escapeHTML(m.text || '正在回应…')}</p>${m.status === 'cancelled' ? '<span class="incomplete">已停止 · 这是未完成片段</span>' : ''}</article>`).join('');
content.scrollTop = keep;
}
function showHistory() { showSheet('history', '你们说过的话', '完整记录 · 演示', '', 'talk'); renderHistory(); }
function showFull() {
const cur = pages()[state.cursor]; if (!cur) return;
const m = cur.message;
showSheet('full', '这一段,慢慢看', `${who(m)} · 演示全文`, `<p class="guide">整段完整可读。关闭后回到刚才那一句。</p><article class="message" data-role="${m.role}"><div class="author"><i></i>${escapeHTML(who(m))}</div><p>${escapeHTML(m.text)}</p>${m.status === 'cancelled' ? '<span class="incomplete">这段回复已停止,内容未完成。</span>' : ''}</article>`, 'talk');
}
function showDays(memory = false) {
if (state.createdNew) { showSheet('days', memory ? '我们的回忆' : '她的日子', '初次见面 · 演示', `<div class="row"><button class="pill" data-action="days" aria-pressed="${!memory}">她的日子</button><button class="pill" data-action="memories" aria-pressed="${memory}">我们的回忆</button></div><p>刚刚见面,还没有分享过的日子与共同回忆。</p><p class="guide">真实生活和记忆尚未接入,小样不会自动编出她未分享的经历。</p>`, 'days'); return; }
showSheet('days', '留在日子里的微光', '生活与共同回忆 · 演示', `<div class="row"><button class="pill" data-action="days" aria-pressed="${!memory}">她的日子</button><button class="pill" data-action="memories" aria-pressed="${memory}">我们的回忆</button></div><p class="guide">只放她已经向你分享的事。以下两条都是虚构的演示记录。</p>${FIXTURES.map((f, i) => `<button class="item" data-event="${i}"><div class="date">${f.time} · 已分享</div><h3>${memory ? f.memory : f.title}</h3><small>${memory ? '与这一天相连' : f.text}</small></button>`).join('')}<p class="subtle">更多真实生活、记忆和成长,等待后续功能接入。</p>`, 'days');
}
function eventDetail(i) {
const f = FIXTURES[i]; if (!f) return;
showSheet('event', f.title, '已分享的生活 · 演示', `<button class="small-link" data-action="days">‹ 回到她的日子</button><p class="subtle">${f.time}</p><div class="quote">${f.text}</div><h3>与你有关的回忆</h3><p>${f.memory}</p><button class="pill" data-event-history="${i}">看那时说的话</button><p class="guide">演示故事,不是真实的后台生活。</p>`, 'days');
}
function showManage() {
showSheet('manage', '慢慢安排你们的世界', '设置 · 演示', `<p class="guide">这里只体验入口和操作关系。所有更改只在这次小样中保留。</p><button class="item" data-action="profile"><h3>她与最初的底色</h3><small>出生底色只读,成长另行积累</small></button><button class="item" data-action="relationship"><h3>你们的关系</h3><small>称呼与相处边界,可以慢慢调整</small></button><button class="item" data-action="city"><h3>城市与天气</h3><small>跟随你选择的城市 · 本小样天气为示例</small></button><button class="item" data-action="appearance"><h3>外观素材</h3><small>换外观,保留同一个她</small></button><button class="item" data-action="backup"><h3>备份与恢复</h3><small>完整导出与每日备份 · 尚未接入</small></button><button class="item" data-action="services"><h3>语音与视觉观察</h3><small>开关、状态与时效 · 尚未接入</small></button><button class="item" data-action="display"><h3>空间与显示</h3><small>两套主题、动效强度</small></button>`, 'manage');
}
function showInfo(kind) {
const info = {
profile: ['她与最初的底色', '出生资料在创建后保留为只读底色,每轮重新提供给模型。个性的诠释、少量初始喜好与后来的成长分别保留。', '本小样不生成排盘、性格或成长,也不读取真实档案。相关真实能力仍须后续补齐。'],
appearance: ['外观素材', '替换外观前应先检查素材、预览新旧效果,再明确启用。记忆、生日、关系和成长仍属于同一个她。', '本小样只使用已认可的两张参考静帧,没有导入素材或实时口型。'],
backup: ['备份与恢复', '产品仍需每日自动备份,并支持包含人物、场景、设定与记忆的完整导出和恢复。', '备份尚未接入。本小样没有执行或伪装任何导出、备份、恢复操作。'],
services: ['语音与视觉观察', '语音由你主动开启,结束后停止收音。视觉观察可关闭,过期观察不再当作眼前事实。', '本小样不申请麦克风或摄像头权限,不采集声音与影像。']
}[kind];
showSheet(kind, info[0], '入口示意 · 尚未接入', `<button class="small-link" data-action="manage">‹ 返回设置</button><p>${info[1]}</p><p class="notice">${info[2]}</p>${kind === 'services' ? '<button class="pill" data-action="voice">体验语音界面演示</button>' : ''}`, 'manage');
}
function showRelationship() {
showSheet('relationship', '你们的关系', '演示表单', `<button class="small-link" data-action="manage">‹ 返回设置</button><form id="relation-form"><label for="relation">现在的关系</label><select id="relation"><option ${state.relation === '朋友' ? 'selected' : ''}>朋友</option><option ${state.relation === '恋人' ? 'selected' : ''}>恋人</option></select><label for="boundaries">相处边界</label><textarea id="boundaries" maxlength="500">${escapeHTML(state.boundaries || '彼此保留自己的时间,有想法可以直接说。')}</textarea><p class="guide">修改关系不会改写出生底色、过去的记忆与信任。</p><div class="row"><button class="primary" type="submit">确认本次演示选择</button><button class="pill" type="button" data-action="manage">取消</button></div><p id="form-status" class="status" role="status"></p></form>`, 'manage');
}
function showCity() {
showSheet('city', '她窗外的城市', '城市选择 · 演示', `<button class="small-link" data-action="manage">‹ 返回设置</button><form id="city-form"><label for="city">选择一个城市</label><select id="city"><option>上海</option><option>杭州</option><option>成都</option></select><p class="guide">正式产品将按城市跟随当地天气与作息。这里仅演示选择反馈,没有查询天气。</p><button class="primary" type="submit">保存演示选择</button><p id="form-status" class="status" role="status"></p></form>`, 'manage');
}
function showDisplay() {
showSheet('display', '空间与显示', '显示偏好 · 小样', `<button class="small-link" data-action="manage">‹ 返回设置</button><p>保留同一布局,切换整片空间的光、雾、景物与材质。</p><div class="row"><button class="pill" data-theme="moon" aria-pressed="${html.dataset.theme === 'moon'}">靛蓝月光</button><button class="pill" data-theme="fog" aria-pressed="${html.dataset.theme === 'fog'}">蓝紫雾光</button></div><button class="pill" data-action="motion">${html.classList.contains('no-motion') ? '恢复空间动效' : '减少动效'}</button><p class="guide">系统“减少动态效果”同样生效:背景几乎静止,只保留淡入淡出。设备较慢时会自动降低背景分辨率与粒子数量。</p>`, 'manage');
}
function showRehearsal() {
showSheet('rehearsal', '试试这些相处片段', '小样试用 · 全部为演示', `<p>回复会自动一句接一句呈现。想停下来时点「暂停」,往回看用左右箭头,「全文」看整段。</p><div class="row"><button class="pill" data-scenario="long">一段长回复</button><button class="pill" data-scenario="short">短句回复</button><button class="pill" data-scenario="incoming">连续来两段话</button><button class="pill" data-scenario="waiting">生成中与停止</button></div><p class="guide">每句停留随长度变化(约 2.4–8 秒),鼠标停在文字上会先停住计时,移开继续。新发消息总会自动呈现。</p><details><summary>试试初次见面与长名字</summary><p class="guide">只创建临时演示身份,不连接或重置现有伴侣。</p><button class="pill" data-action="create">重新演示初次创建</button></details><details><summary>验证说明与边界</summary><p class="guide">人物为获批参考静帧;文字、生活与回忆都是虚构。没有连接模型、语音、摄像头、天气或备份服务。背景中的雨、城市灯光与雾是界面氛围,不代表真实天气数据。</p></details>`);
}
function showCreation() {
showSheet('create', '先给她一个名字', '初次创建 · 临时演示', `<form id="create-form"><label for="new-name">你想怎么叫她</label><input id="new-name" maxlength="40" autocomplete="off" required placeholder="由你起名,1–20 字"><label for="new-relation">你们从什么关系开始</label><select id="new-relation" required><option value="" selected disabled>由你选择</option><option value="朋友">朋友</option><option value="恋人">恋人</option></select><p class="guide">出生资料、排盘与底色说明沿用既有创建流程,本次不模拟生成。确认仅进入这次虚构小样,不会接触真实档案。</p><div class="row"><button class="primary" type="submit">以这个名字体验小样</button><button class="pill" type="button" data-action="dismiss">取消</button></div><p id="creation-error" class="error" role="status"></p></form>`);
}
function showVoice() {
showSheet('voice', '体验语音界面', '没有收音的演示', `<p>这里仅演示语音开启、结束的界面。不会申请麦克风权限,也不会播放或识别声音。</p><div class="row"><button class="primary" data-action="start-voice">开始界面演示</button><button class="pill" data-action="dismiss">暂不开启</button></div>`, 'talk');
}
function runScenario(kind) {
if (sheet.open) { state.returnMode = 'talk'; finishClose(); }
openTalk();
if (kind === 'incoming') beginReply('你刚才想和我说什么?', '窗边的花开了一点。', () => streamReply('还有,今天的雨声很轻。我想把这片安静也分一点给你。'));
else if (kind === 'long') beginReply('慢慢讲,我想听听你今天的想法。', LONG);
else if (kind === 'waiting') beginReply('可以和我多说一会儿吗?', LONG);
else beginReply('你在做什么?', SHORT);
}
function applyTheme(theme, instant = false) {
if (!['moon', 'fog'].includes(theme)) return;
html.dataset.theme = theme; Atmos.setTheme(theme, instant || motionOff());
$$('button[data-theme]').forEach(n => n.setAttribute('aria-pressed', String(n.dataset.theme === theme)));
}
document.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.theme) { applyTheme(b.dataset.theme); return; }
if (b.dataset.nav) {
const mode = b.dataset.nav;
if (mode === 'home' || mode === 'talk') { if (sheet.open) { state.returnMode = mode; closeSheet(); } else mode === 'home' ? setMode('home') : openTalk(); }
else if (mode === 'days') showDays(); else showManage();
return;
}
if (b.dataset.event !== undefined) { eventDetail(Number(b.dataset.event)); return; }
if (b.dataset.eventHistory !== undefined) {
const i = Number(b.dataset.eventHistory); showHistory();
content.insertAdjacentHTML('afterbegin', `<button class="small-link" data-event="${i}">‹ 返回这一天</button>`);
content.querySelector(`[data-life="${i}"]`)?.scrollIntoView({block: 'center'}); return;
}
if (b.dataset.scenario) { runScenario(b.dataset.scenario); return; }
const actions = {
days: () => showDays(), memories: () => showDays(true), manage: showManage, relationship: showRelationship, city: showCity, display: showDisplay, create: showCreation, voice: showVoice, dismiss: closeSheet,
motion: () => { html.classList.toggle('no-motion'); showDisplay(); },
'start-voice': () => { state.returnMode = 'talk'; finishClose(); openTalk(); state.voice = true; document.body.classList.add('has-voice'); $('#voice-row').hidden = false; fitTalkStage(); updateControls(); toast('语音界面演示已开启 · 没有收音'); }
};
const a = b.dataset.action;
if (actions[a]) actions[a](); else if (['profile', 'appearance', 'backup', 'services'].includes(a)) showInfo(a);
});
$('#start-talk').onclick = () => openTalk();
$('#shared-note').onclick = () => { openTalk(); showHistory(); };
$('#days-link').onclick = () => showDays();
$('#close-talk').onclick = () => setMode('home');
$('#history').onclick = showHistory; $('#read-all').onclick = showFull; $('#close-sheet').onclick = () => closeSheet(); $('#rehearse').onclick = showRehearsal;
$('#home-voice').onclick = showVoice; $('#voice').onclick = showVoice;
$('#end-voice').onclick = () => { state.voice = false; document.body.classList.remove('has-voice'); $('#voice-row').hidden = true; cancelReply(true); fitTalkStage(); toast('语音界面演示已结束'); };
$('#previous').onclick = () => back();
$('#next').onclick = () => go(1);
$('#toggle-flow').onclick = () => setFlow(!state.flow);
$('#latest').onclick = () => { state.cursor = state.head; present('jump'); setFlow(true, true); };
$('#stop').onclick = () => cancelReply();
$('#composer').addEventListener('submit', e => { e.preventDefault(); const text = $('#draft').value.trim(); if (!text) return; $('#draft').value = ''; autoSize(); beginReply(text, /长|故事|慢慢|完整|多说/.test(text) ? LONG : SHORT); });
function autoSize() { const d = $('#draft'); d.style.height = 'auto'; d.style.height = Math.min(d.scrollHeight, 92) + 'px'; }
$('#draft').addEventListener('input', autoSize);
$('#draft').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); $('#composer').requestSubmit(); } });
$('#draft').addEventListener('focus', () => document.body.classList.add('typing'));
$('#draft').addEventListener('blur', () => document.body.classList.remove('typing'));
stage.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hold('hover', true); });
stage.addEventListener('pointerleave', () => hold('hover', false));
document.addEventListener('selectionchange', () => { const s = getSelection(); hold('select', !!(String(s).length && stage.contains(s.anchorNode))); });
sheet.addEventListener('cancel', e => { e.preventDefault(); closeSheet(true); }); // Escape
document.addEventListener('submit', e => {
if (e.target.id === 'relation-form') { e.preventDefault(); state.relation = $('#relation').value; state.boundaries = $('#boundaries').value; $('#form-status').textContent = '演示选择已确认。记忆和底色保持原样。'; }
else if (e.target.id === 'city-form') { e.preventDefault(); $('.weather').innerHTML = `${escapeHTML($('#city').value)} · 小雨 14° <small>示例</small>`; $('#form-status').textContent = '已保存演示选择;这里没有查询真实天气。'; }
else if (e.target.id === 'create-form') {
e.preventDefault(); const name = $('#new-name').value.trim(), relation = $('#new-relation').value;
if (!name || Array.from(name).length > 20 || /[<>\x00-\x1f\x7f]/.test(name) || !relation) { $('#creation-error').textContent = '名字请用 1–20 字,不含尖括号和控制符,并选择关系。'; return; }
cancelReply(true); messages = []; state.createdNew = true; state.turn++; state.cursor = 0; state.head = 0; state.flow = true;
messages.push({id: nextId++, turn: state.turn, role: 'her', text: '你好。很高兴在这里见到你。', status: 'complete'});
state.relation = relation; state.returnMode = 'talk'; setName(name); finishClose(); openTalk(); toast('临时演示身份已建立 · 未接触真实档案');
}
});
// Keyboard shortcuts are frequent, deliberate jumps: they show the target state at once (snap), unlike clicks and auto-flow.
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !sheet.open && state.mode === 'talk') { setMode('home', {instant: true}); $('#start-talk').focus(); }
if (state.mode === 'talk' && !sheet.open && document.activeElement !== $('#draft')) {
if (e.key === 'ArrowLeft') { snap(); back(true); } else if (e.key === 'ArrowRight') { snap(); go(1, true, true); } else if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); setFlow(!state.flow); }
}
});
document.addEventListener('visibilitychange', () => document.body.classList.toggle('suspended', document.hidden));
// Keyboard / short screens: the visual viewport decides how much room her window and the lines get.
function fitViewport() {
const vv = window.visualViewport, h = vv ? vv.height : innerHeight, t = vv ? vv.offsetTop : 0;
html.style.setProperty('--view-h', `${h}px`); html.style.setProperty('--view-top', `${t}px`);
document.body.classList.toggle('compact', h < 610);
document.body.classList.toggle('micro', h < 420);
// Re-chunk without losing the reading position: keep the same character offset in the same message.
const cur = pages()[state.cursor];
const anchor = cur ? {id: cur.message.id, offset: chunks(cur.message.text).slice(0, cur.index).join('').length} : null;
requestAnimationFrame(() => {
if (anchor) { const list = pages(); let used = 0; for (let i = 0; i < list.length; i++) { if (list[i].message.id !== anchor.id) continue; if (used + list[i].text.length > anchor.offset) { state.cursor = i; break; } used += list[i].text.length; } }
stage.replaceChildren(); present('jump'); fitTalkStage(); moveNavGlow();
});
}
window.visualViewport?.addEventListener('resize', fitViewport); window.visualViewport?.addEventListener('scroll', fitViewport); addEventListener('resize', fitViewport);
// Optional start parameters for review links and recordings: ?theme=fog&mode=talk&scene=long
const params = new URLSearchParams(location.search);
applyTheme(params.get('theme') || 'moon', true);
if (params.get('tier')) Atmos.setTier(+params.get('tier'));
fitViewport(); present('jump'); moveNavGlow();
if (params.get('mode') === 'talk') openTalk();
if (params.get('scene')) setTimeout(() => runScenario(params.get('scene')), 600);
window.__proto = {state, pages, runScenario, setMode, applyTheme, showDays, showManage, showHistory, closeSheet, setFlow, go, atmos: () => Atmos.info()};
+93
View File
@@ -0,0 +1,93 @@
# Minimal Chrome DevTools Protocol driver for headless Edge (Windows PowerShell 5.1).
$ErrorActionPreference = 'Stop'
$script:edge = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
$script:id = 0
$script:events = New-Object System.Collections.ArrayList
function Start-Browser([int]$port = 9333) {
$profile = Join-Path $env:TEMP "edge-hel793-$port"
$script:proc = Start-Process $script:edge -ArgumentList "--headless=new", "--remote-debugging-port=$port", "--user-data-dir=$profile", "--no-first-run", "--hide-scrollbars", "--force-color-profile=srgb", "--autoplay-policy=no-user-gesture-required", "about:blank" -PassThru
for ($i = 0; $i -lt 50; $i++) {
try { $targets = Invoke-RestMethod "http://127.0.0.1:$port/json" -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 }
}
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.Options.KeepAliveInterval = [TimeSpan]::FromSeconds(30)
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
$script:proc.Id
}
function Stop-Browser { try { $script:ws.Dispose() } catch {}; if ($script:proc -and -not $script:proc.HasExited) { Stop-Process -Id $script:proc.Id -Force } }
function Receive-Message {
$buffer = New-Object byte[] 1048576
$ms = New-Object System.IO.MemoryStream
do {
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $buffer)
$r = $script:ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
$ms.Write($buffer, 0, $r.Count)
} while (-not $r.EndOfMessage)
[Text.Encoding]::UTF8.GetString($ms.ToArray()) | ConvertFrom-Json
}
function Send-Cdp([string]$method, $params = @{}) {
$script:id++
$myId = $script:id
$json = @{ id = $myId; method = $method; params = $params } | ConvertTo-Json -Depth 10 -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
while ($true) {
$m = Receive-Message
if ($m.id -eq $myId) { if ($m.error) { throw "$method failed: $($m.error.message)" }; return $m.result }
if ($m.method) { Handle-Event $m }
}
}
function Handle-Event($m) {
if ($m.method -eq 'Page.screencastFrame') {
if ($script:recording) { [void]$script:frames.Add(@{ data = $m.params.data; t = $m.params.metadata.timestamp }) }
Send-Ack $m.params.sessionId
} else { [void]$script:events.Add($m.method) }
}
function Send-Ack($sid) {
$script:id++
$json = @{ id = $script:id; method = 'Page.screencastFrameAck'; params = @{ sessionId = $sid } } | ConvertTo-Json -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
}
function Invoke-Js([string]$expr, [switch]$await) {
$r = Send-Cdp 'Runtime.evaluate' @{ expression = $expr; returnByValue = $true; awaitPromise = [bool]$await }
if ($r.exceptionDetails) { throw "JS error: $($r.exceptionDetails.exception.description)" }
$r.result.value
}
function Set-View([int]$w, [int]$h, [switch]$mobile, [double]$dpr = 1) {
[void](Send-Cdp 'Emulation.setDeviceMetricsOverride' @{ width = $w; height = $h; deviceScaleFactor = $dpr; mobile = [bool]$mobile })
if ($mobile) { [void](Send-Cdp 'Emulation.setTouchEmulationEnabled' @{ enabled = $true; maxTouchPoints = 5 }) }
else { [void](Send-Cdp 'Emulation.setTouchEmulationEnabled' @{ enabled = $false }) }
}
function Open-Page([string]$url) {
[void](Send-Cdp 'Page.enable')
[void](Send-Cdp 'Page.navigate' @{ url = $url })
Wait-Ms 1500
}
# Keep pumping events (screencast frames) while waiting.
function Wait-Ms([int]$ms) {
$until = [DateTime]::Now.AddMilliseconds($ms)
while ([DateTime]::Now -lt $until) {
[void](Send-Cdp 'Runtime.evaluate' @{ expression = '0'; returnByValue = $true })
Start-Sleep -Milliseconds 40
}
}
function Save-Shot([string]$path) {
$r = Send-Cdp 'Page.captureScreenshot' @{ format = 'png' }
[IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($r.data))
}
function Start-Rec([int]$maxW = 1440, [int]$maxH = 900) {
$script:frames = New-Object System.Collections.ArrayList
$script:recording = $true
[void](Send-Cdp 'Page.startScreencast' @{ format = 'jpeg'; quality = 82; maxWidth = $maxW; maxHeight = $maxH; everyNthFrame = 1 })
}
function Stop-Rec {
[void](Send-Cdp 'Page.stopScreencast')
$script:recording = $false
$script:frames
}
+36
View File
@@ -0,0 +1,36 @@
param([string]$video, [string]$png, [int]$cols = 4, [int]$count = 12, [int]$thumbW = 480)
# Decodes the finished WebM in a browser and tiles evenly spaced frames, proving the file plays and showing motion over time.
. "$PSScriptRoot\cdp.ps1"
$dir = Split-Path $video
$page = Join-Path $dir 'contact.html'
Set-Content -Path $page -Encoding utf8 -Value '<!doctype html><body style="margin:0;background:#000"><video id=v muted playsinline></video><canvas id=c></canvas></body>'
$script:proc = Start-Process $script:edge -ArgumentList '--headless=new', '--remote-debugging-port=9337', "--user-data-dir=$env:TEMP\edge-hel793-contact", '--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required', 'about:blank' -PassThru
for ($i = 0; $i -lt 50; $i++) { try { $targets = Invoke-RestMethod 'http://127.0.0.1:9337/json' -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 } }
$t = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$t.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
try {
Open-Page ('file:///' + ($page -replace '\\', '/'))
$src = 'file:///' + ((Resolve-Path $video).Path -replace '\\', '/')
$js = @"
(async () => {
const v = document.getElementById('v'); v.src = '$src';
await new Promise((ok, no) => { v.onloadeddata = ok; v.onerror = () => no(new Error('decode failed')); });
// WebM from MediaRecorder has no duration header; seek to the end once to learn it.
if (!isFinite(v.duration)) { v.currentTime = 1e6; await new Promise(ok => v.ondurationchange = ok); await new Promise(ok => { v.onseeked = ok; v.currentTime = 0; }); }
const n = $count, cols = $cols, tw = $thumbW, th = Math.round(tw * v.videoHeight / v.videoWidth), rows = Math.ceil(n / cols);
const c = document.getElementById('c'); c.width = cols * tw; c.height = rows * (th + 22); const x = c.getContext('2d');
x.fillStyle = '#000'; x.fillRect(0, 0, c.width, c.height); x.font = '14px sans-serif';
for (let i = 0; i < n; i++) {
const t = v.duration * (i + .5) / n;
await new Promise(ok => { v.onseeked = ok; v.currentTime = t; });
const cx = (i % cols) * tw, cy = Math.floor(i / cols) * (th + 22);
x.drawImage(v, cx, cy + 22, tw, th); x.fillStyle = '#fff'; x.fillText(t.toFixed(1) + 's', cx + 6, cy + 16);
}
return JSON.stringify({duration: v.duration, w: v.videoWidth, h: v.videoHeight, png: c.toDataURL('image/png').split(',')[1]});
})()
"@
$r = Invoke-Js $js -await | ConvertFrom-Json
[IO.File]::WriteAllBytes($png, [Convert]::FromBase64String($r.png))
"duration=$([math]::Round($r.duration,1))s size=$($r.w)x$($r.h)"
} finally { Stop-Browser; Remove-Item $page -ErrorAction SilentlyContinue }
+98
View File
@@ -0,0 +1,98 @@
param([string]$html = "$PSScriptRoot\..\index.html", [string]$out = "$PSScriptRoot\..\evidence\motion-probe.json", [int]$port = 9341)
# HEL-795 motion probe: what is still animating 80ms after a keyboard action, vs. the same action by mouse / auto-flow,
# and how much layout work each transition costs (CDP Performance metrics, headless Edge, software compositing).
. "$PSScriptRoot\cdp.ps1"
$url = 'file:///' + ((Resolve-Path $html).Path -replace '\\', '/')
$results = New-Object System.Collections.ArrayList
function Rec([string]$name, $data) { [void]$results.Add([ordered]@{ probe = $name; data = $data }); "{0} {1}" -f $name, ($data | ConvertTo-Json -Compress -Depth 6) }
function J($e) { Invoke-Js $e | ConvertFrom-Json }
function JA($e) { Invoke-Js $e -await | ConvertFrom-Json }
function Key([string]$key, [int]$vk) {
[void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'rawKeyDown'; key = $key; code = $key; windowsVirtualKeyCode = $vk })
[void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'keyUp'; key = $key; code = $key; windowsVirtualKeyCode = $vk })
}
# Finite animations still running, grouped; ambient loops (infinite) are excluded on purpose.
$lib = @'
window.__anims = () => { const g = {}; for (const a of document.getAnimations()) { if (a.playState !== 'running') continue; const t = a.effect.getTiming(); if (t.iterations === Infinity) continue;
const ct = a.effect.getComputedTiming(); const left = Math.round(ct.endTime - (a.currentTime || 0)); if (left <= 0) continue; const el = a.effect.target;
const k = (el.id || String(el.className).split(' ')[0] || el.tagName) + ' | ' + (a.transitionProperty || a.animationName || 'script') + ' | ' + Math.round(t.duration) + 'ms';
g[k] = (g[k] || 0) + 1; } return g; };
window.__after = ms => new Promise(r => setTimeout(() => r(JSON.stringify(__anims())), ms));
window.__props = () => { const s = new Set(); for (const a of document.getAnimations()) if (a.transitionProperty) s.add(a.transitionProperty); return [...s]; };
'@
$null = Start-Browser $port
try {
Set-View 1440 900
[void](Send-Cdp 'Performance.enable' @{ timeDomain = 'timeTicks' })
function Metrics { $m = @{}; foreach ($x in (Send-Cdp 'Performance.getMetrics').metrics) { $m[$x.name] = $x.value }; $m }
function Cost([scriptblock]$act, [int]$ms) {
$a = Metrics; & $act; Wait-Ms $ms; $b = Metrics
[ordered]@{ layouts = $b.LayoutCount - $a.LayoutCount; layoutMs = [math]::Round(($b.LayoutDuration - $a.LayoutDuration) * 1000, 2); styleRecalcs = $b.RecalcStyleCount - $a.RecalcStyleCount; styleMs = [math]::Round(($b.RecalcStyleDuration - $a.RecalcStyleDuration) * 1000, 2); windowMs = $ms }
}
Open-Page "${url}?theme=moon"; [void](Invoke-Js $lib); Wait-Ms 1200
# 1. Mouse/click open + close keeps the natural geometry expansion (must stay animated).
[void](Invoke-Js "document.querySelector('#start-talk').click()")
Rec 'click-open-talk +80ms' (JA '__after(80)')
Wait-Ms 1500
Rec 'keyboard Escape +80ms' $(Key 'Escape' 27; JA '__after(80)')
Rec 'keyboard Escape: mode/portal' (J 'JSON.stringify({mode:document.body.dataset.mode,z:__proto.atmos().z,portalW:Math.round(document.querySelector("#portal").getBoundingClientRect().width)})')
Wait-Ms 1200
[void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 1500
[void](Invoke-Js "document.querySelector('#close-talk').click()")
Rec 'click close-talk +80ms' (JA '__after(80)')
Wait-Ms 1500
# 2. Reading: keyboard review vs. auto flow.
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 9000
Rec 'keyboard ArrowRight +80ms' $(Key 'ArrowRight' 39; JA '__after(80)')
Rec 'keyboard ArrowRight: ink spans' (J 'JSON.stringify({ink:document.querySelectorAll("#stage .is-current .ink span").length,lines:document.querySelectorAll("#stage .line:not(.leaving)").length,cursor:__proto.state.cursor})')
Wait-Ms 300
Rec 'keyboard ArrowLeft +80ms' $(Key 'ArrowLeft' 37; JA '__after(80)')
Rec 'keyboard ArrowLeft: state' (J 'JSON.stringify({flow:__proto.state.flow,cursor:__proto.state.cursor,latestHidden:document.querySelector("#latest").hidden})')
Wait-Ms 200
Rec 'keyboard ArrowLeft x3 rapid +80ms' $(Key 'ArrowLeft' 37; Key 'ArrowLeft' 37; Key 'ArrowLeft' 37; JA '__after(80)')
[void](Invoke-Js "document.querySelector('#next').click()")
Rec 'click next +80ms' (JA '__after(80)')
Wait-Ms 900
[void](Invoke-Js "document.querySelector('#latest').click()"); Wait-Ms 600
# Catch the moment auto-flow advances on its own, then look 80ms later.
$auto = JA "new Promise(r=>{const c=__proto.state.cursor;const t=setInterval(()=>{if(__proto.state.cursor!==c){clearInterval(t);setTimeout(()=>r(JSON.stringify({anims:__anims(),props:__props()})),80)}},5)})"
Rec 'auto advance +80ms' $auto
# Transitions on layout properties seen across an auto advance (font-size / width must be absent).
$seen = JA "new Promise(r=>{const s=new Set();const t=setInterval(()=>{for(const a of document.getAnimations())if(a.transitionProperty)s.add(a.transitionProperty)},16);setTimeout(()=>{clearInterval(t);r(JSON.stringify([...s]))},9000)})"
Rec 'css transitions seen over 9s of auto flow' $seen
Rec 'layout cost: auto flow 9s' (Cost {} 9000)
# 3. Nav glow: move between items by click; width must not be a transition.
[void](Invoke-Js "__proto.setMode('home')"); Wait-Ms 900
[void](Invoke-Js "document.querySelector('[data-nav=talk]').click()")
Rec 'nav click +80ms transitions' (JA "new Promise(r=>setTimeout(()=>r(JSON.stringify(document.getAnimations().filter(a=>a.effect.target.classList&&a.effect.target.classList.contains('nav-glow')).map(a=>a.transitionProperty))),80))")
Wait-Ms 1200
# 4. Portal geometry cost: click open/close (720ms) vs. keyboard Escape.
[void](Invoke-Js "__proto.setMode('home')"); Wait-Ms 1500
Rec 'layout cost: idle 1s (baseline)' (Cost {} 1000)
Rec 'layout cost: click open talk 1s' (Cost { [void](Invoke-Js "document.querySelector('#start-talk').click()") } 1000)
Rec 'layout cost: keyboard Escape 1s' (Cost { Key 'Escape' 27 } 1000)
Rec 'layout cost: click open + close-talk 2s' (Cost { [void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 1000; [void](Invoke-Js "document.querySelector('#close-talk').click()") } 1000)
# 5. Frame pacing during the 720ms geometry move (main-thread rAF intervals; headless = software raster, not a GPU number).
$fp = "new Promise(r=>{const ts=[];let s=performance.now();(function f(t){ts.push(t);if(t-s<900)requestAnimationFrame(f);else{const d=ts.slice(1).map((x,i)=>x-ts[i]).sort((a,b)=>a-b);r(JSON.stringify({frames:d.length,p50:+d[Math.floor(d.length*.5)].toFixed(1),p95:+d[Math.floor(d.length*.95)].toFixed(1),max:+d[d.length-1].toFixed(1)}))}})(s)})"
Wait-Ms 1500
Rec 'frames: idle home 900ms' (JA $fp)
[void](Invoke-Js "document.querySelector('#start-talk').click()"); Rec 'frames: click open talk 900ms' (JA $fp); Wait-Ms 600
[void](Invoke-Js "document.querySelector('#close-talk').click()"); Rec 'frames: click close talk 900ms' (JA $fp); Wait-Ms 600
# 6. Escape inside the history sheet returns to talk at once.
[void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 1200
[void](Invoke-Js "document.querySelector('#history').click()"); Wait-Ms 1200
Rec 'keyboard Escape in sheet +80ms' $(Key 'Escape' 27; JA '__after(80)')
Rec 'keyboard Escape in sheet: state' (J 'JSON.stringify({open:document.querySelector("#sheet").open,mode:document.body.dataset.mode})')
# 7. Forced slow tier: geometry is replaced by a short fade.
Open-Page "${url}?theme=moon&tier=2"; [void](Invoke-Js $lib); Wait-Ms 1200
[void](Invoke-Js "document.querySelector('#start-talk').click()")
Rec 'tier2 click open talk +80ms' (JA '__after(80)')
Rec 'atmos' (J 'JSON.stringify(__proto.atmos())')
} finally { Stop-Browser }
$results | ConvertTo-Json -Depth 8 | Set-Content $out -Encoding utf8
+27
View File
@@ -0,0 +1,27 @@
param([string]$html = "$PSScriptRoot\..\index.html", [string]$out = "$PSScriptRoot\..\evidence\portal-ab.json", [int]$port = 9343, [int]$runs = 6)
# HEL-795 A/B: frame pacing of the click open/close transition with the real portal geometry vs. the same
# transition with the geometry tween removed (everything else identical). Headless Edge, software raster - relative only.
. "$PSScriptRoot\cdp.ps1"
$url = 'file:///' + ((Resolve-Path $html).Path -replace '\\', '/')
$fp = "new Promise(r=>{const ts=[];let s=performance.now();(function f(t){ts.push(t);if(t-s<900)requestAnimationFrame(f);else{const d=ts.slice(1).map((x,i)=>x-ts[i]);r(JSON.stringify({frames:d.length,long:d.filter(x=>x>25).length,max:+Math.max(...d).toFixed(1)}))}})(s)})"
$noGeo = "(()=>{const p=document.querySelector('#portal'),a=Element.prototype.animate;p.animate=function(k,o){return a.call(this,[{opacity:getComputedStyle(this).opacity},{opacity:getComputedStyle(this).opacity}],o)};return 1})()"
$res = [ordered]@{}
$null = Start-Browser $port
try {
foreach ($size in @(@(1440, 900, $false), @(390, 844, $true))) {
Set-View $size[0] $size[1] -mobile:$size[2]
foreach ($variant in @('geometry', 'no-geometry')) {
Open-Page "${url}?theme=moon"; Wait-Ms 1500
if ($variant -eq 'no-geometry') { [void](Invoke-Js $noGeo) }
$rows = @()
for ($i = 0; $i -lt $runs; $i++) {
[void](Invoke-Js "document.querySelector('#start-talk').click()"); $rows += (Invoke-Js $fp -await | ConvertFrom-Json); Wait-Ms 500
[void](Invoke-Js "document.querySelector('#close-talk').click()"); $rows += (Invoke-Js $fp -await | ConvertFrom-Json); Wait-Ms 500
}
$k = "$($size[0])x$($size[1]) $variant"
$res[$k] = [ordered]@{ transitions = $rows.Count; avgFrames = [math]::Round(($rows | Measure-Object frames -Average).Average, 1); avgLongFrames = [math]::Round(($rows | Measure-Object long -Average).Average, 2); worstFrameMs = ($rows | Measure-Object max -Maximum).Maximum }
"$k $($res[$k] | ConvertTo-Json -Compress)"
}
}
} finally { Stop-Browser }
$res | ConvertTo-Json -Depth 5 | Set-Content $out -Encoding utf8
+87
View File
@@ -0,0 +1,87 @@
param([string]$name, [int]$w = 1440, [int]$h = 900, [switch]$mobile, [string]$query = '', [string]$steps, [string]$out = "$PWD\rec")
# Records a scripted walkthrough as WebM. $steps: lines of "wait <ms>" | "tap <css>" | "js <expr>" | "type <css>|<text>" | "shot <file>".
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$frameDir = Join-Path $out "frames-$name"
if (Test-Path $frameDir) { Remove-Item $frameDir -Recurse -Force }
New-Item -ItemType Directory -Force $frameDir | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\proto\prototypes\hel-793\index.html").Path -replace '\\', '/') + $query
# Recording-only touch marker so viewers can see where a tap happened. Not part of the prototype.
$tapJs = @'
window.__tap = sel => { const el = document.querySelector(sel); if (!el) throw new Error('missing ' + sel);
const r = el.getBoundingClientRect(), d = document.createElement('div');
d.style.cssText = `position:fixed;z-index:99999;left:${r.left + r.width / 2 - 18}px;top:${r.top + r.height / 2 - 18}px;width:36px;height:36px;border-radius:50%;border:2px solid #fff;background:#ffffff40;pointer-events:none;transition:opacity .5s,transform .5s`;
document.body.append(d); requestAnimationFrame(() => { d.style.transform = 'scale(1.6)'; d.style.opacity = '0'; }); setTimeout(() => d.remove(), 700); el.click(); return 1; };
'@
$null = Start-Browser 9335
try {
Set-View $w $h -mobile:$mobile -dpr 1
Open-Page $url
Wait-Ms 1200
[void](Invoke-Js $tapJs)
Start-Rec 1280 900
foreach ($line in ($steps -split "`n")) {
$line = $line.Trim(); if (-not $line) { continue }
$cmd, $arg = $line -split ' ', 2
switch ($cmd) {
'wait' { Wait-Ms ([int]$arg) }
'tap' { [void](Invoke-Js "__tap('$arg')"); Wait-Ms 60 }
'js' { [void](Invoke-Js $arg) }
'type' { $sel, $text = $arg -split '\|', 2; [void](Invoke-Js "(()=>{const e=document.querySelector('$sel');e.focus();e.value='$text';e.dispatchEvent(new Event('input'));return 1})()") }
'shot' { Save-Shot (Join-Path $out $arg) }
'log' { Write-Output ("{0}: {1}" -f $arg, (Invoke-Js $arg)) }
}
}
$frames = Stop-Rec
} finally { Stop-Browser }
# Write frames + timing manifest.
$t0 = $frames[0].t; $list = @()
for ($i = 0; $i -lt $frames.Count; $i++) {
$f = $frames[$i]; $file = 'f{0:D5}.jpg' -f $i
[IO.File]::WriteAllBytes((Join-Path $frameDir $file), [Convert]::FromBase64String($f.data))
$list += @{ f = $file; t = [math]::Round(($f.t - $t0) * 1000) }
}
$dur = $list[-1].t
"frames=$($frames.Count) duration=${dur}ms fps=$([math]::Round($frames.Count / ($dur / 1000), 1))"
$manifest = ($list | ConvertTo-Json -Compress)
# Encode in a second headless browser: draw frames on a canvas at their real timing and capture with MediaRecorder.
$encoder = Join-Path $frameDir 'encode.html'
Set-Content -Path $encoder -Encoding utf8 -Value '<!doctype html><canvas id=c></canvas>'
$script:edgeArgs = $null
$profile = Join-Path $env:TEMP 'edge-hel793-enc'
$script:proc = Start-Process $script:edge -ArgumentList '--headless=new', '--remote-debugging-port=9336', "--user-data-dir=$profile", '--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required', 'about:blank' -PassThru
for ($i = 0; $i -lt 50; $i++) { try { $targets = Invoke-RestMethod 'http://127.0.0.1:9336/json' -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 } }
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
try {
Open-Page ('file:///' + ($encoder -replace '\\', '/'))
$js = @"
(async () => {
const list = $manifest, base = location.href.replace(/encode\.html.*$/, '');
const load = f => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = base + f; });
const first = await load(list[0].f), c = document.getElementById('c');
c.width = first.naturalWidth; c.height = first.naturalHeight; const x = c.getContext('2d');
x.drawImage(first, 0, 0);
const stream = c.captureStream(30), chunks = [];
const rec = new MediaRecorder(stream, {mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 3000000});
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
const done = new Promise(ok => rec.onstop = ok);
rec.start(1000);
const start = performance.now(); let next = await load(list[1] ? list[1].f : list[0].f);
for (let i = 1; i < list.length; i++) {
const img = next; const pre = list[i + 1] ? load(list[i + 1].f) : null;
const wait = list[i].t - (performance.now() - start); if (wait > 0) await new Promise(r => setTimeout(r, wait));
x.drawImage(img, 0, 0); if (pre) next = await pre;
}
await new Promise(r => setTimeout(r, 400)); rec.stop(); await done;
const blob = new Blob(chunks, {type: 'video/webm'});
return await new Promise(ok => { const fr = new FileReader(); fr.onload = () => ok(fr.result.split(',')[1]); fr.readAsDataURL(blob); });
})()
"@
$b64 = Invoke-Js $js -await
[IO.File]::WriteAllBytes((Join-Path $out "$name.webm"), [Convert]::FromBase64String($b64))
"wrote $name.webm ($([math]::Round((Get-Item (Join-Path $out "$name.webm")).Length / 1MB, 2)) MB)"
} finally { Stop-Browser }
+30
View File
@@ -0,0 +1,30 @@
param([string]$out = "$PWD\shots", [string]$only = '')
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\proto\prototypes\hel-793\index.html").Path -replace '\\', '/')
$null = Start-Browser 9334
try {
$plans = @(
@{ n = 'desktop'; w = 1440; h = 900; m = $false },
@{ n = 'mobile'; w = 390; h = 844; m = $true }
)
foreach ($p in $plans) {
if ($only -and $p.n -ne $only) { continue }
Set-View $p.w $p.h -mobile:$p.m -dpr ($(if ($p.m) { 2 } else { 1 }))
foreach ($theme in 'moon', 'fog') {
Open-Page "${url}?theme=$theme"
Wait-Ms 3000
Save-Shot "$out\$($p.n)-home-$theme.png"
[void](Invoke-Js "__proto.runScenario('long')")
Wait-Ms 9000
Save-Shot "$out\$($p.n)-talk-$theme.png"
[void](Invoke-Js "__proto.showDays()")
Wait-Ms 1500
Save-Shot "$out\$($p.n)-days-$theme.png"
[void](Invoke-Js "__proto.showHistory()")
Wait-Ms 1500
Save-Shot "$out\$($p.n)-history-$theme.png"
}
}
Invoke-Js 'JSON.stringify(__proto.atmos())'
} finally { Stop-Browser }
+107
View File
@@ -0,0 +1,107 @@
param([string]$out = "$PWD\verify")
# Behavioural checks in a real browser. Each check records pass/fail with observed values.
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\proto\prototypes\hel-793\index.html").Path -replace '\\', '/')
$results = New-Object System.Collections.ArrayList
function Check([string]$name, [bool]$ok, $detail) { [void]$results.Add([ordered]@{ check = $name; pass = $ok; detail = $detail }); "{0} {1} {2}" -f ($(if ($ok) { 'PASS' } else { 'FAIL' })), $name, ($detail | ConvertTo-Json -Compress) }
function J($e) { Invoke-Js $e | ConvertFrom-Json }
$null = Start-Browser 9338
try {
Set-View 1440 900
Open-Page "${url}?theme=moon"
Wait-Ms 1500
# 1. Long reply flows by itself to the end, no clicks.
[void](Invoke-Js "__proto.runScenario('long')")
$seen = @()
for ($i = 0; $i -lt 60; $i++) { Wait-Ms 1000; $s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy})'; $seen += "$($s.c + 1)/$($s.n)"; if (-not $s.busy -and $s.c -eq $s.n - 1) { break } }
$s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy,flow:__proto.state.flow})'
Check 'auto-continues-long-reply-to-last-line' ($s.c -eq $s.n - 1 -and -not $s.busy -and $s.flow) @{ seconds = $i + 1; final = $s; trail = ($seen -join ' ') }
# Full text completeness: concatenated pages equal the message.
$c = J 'JSON.stringify((()=>{const p=__proto.pages();const m=p[p.length-1].message;return {same:p.filter(x=>x.message===m).map(x=>x.text).join("")===m.text.replace(/\n/g,""),lines:p.length}})())'
Check 'all-text-preserved-in-lines' $c.same $c
Save-Shot "$out\desktop-long-end.png"
# 2. Pause stops advancing; back shows the return chip; return resumes.
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 4500
[void](Invoke-Js "document.querySelector('#toggle-flow').click()"); $a = J '__proto.state.cursor'; Wait-Ms 6000; $b = J '__proto.state.cursor'
Check 'pause-holds-position' ($a -eq $b) @{ before = $a; after6s = $b }
[void](Invoke-Js "document.querySelector('#previous').click()"); Wait-Ms 800
$l = J 'JSON.stringify({hidden:document.querySelector("#latest").hidden,text:document.querySelector("#latest").textContent,c:__proto.state.cursor,head:__proto.state.head})'
Check 'review-back-offers-return' (-not $l.hidden) $l
Save-Shot "$out\desktop-review.png"
[void](Invoke-Js "document.querySelector('#latest').click()"); Wait-Ms 9000
$r = J 'JSON.stringify({c:__proto.state.cursor,flow:__proto.state.flow})'
Check 'return-resumes-flow' ($r.flow -and $r.c -gt $l.head) @{ head = $l.head; now = $r }
# 3. Sending while paused resumes auto presentation.
[void](Invoke-Js "__proto.setFlow(false,true)")
[void](Invoke-Js "(()=>{const d=document.querySelector('#draft');d.value='你在做什么?';document.querySelector('#composer').requestSubmit();return 1})()"); Wait-Ms 6000
$r = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,flow:__proto.state.flow,toast:document.querySelector("#toast").textContent})'
Check 'send-resumes-auto' ($r.flow -and $r.c -ge 1) $r
# 4. Identity labels on every visible line.
$who = J 'JSON.stringify([...document.querySelectorAll("#stage .line:not(.leaving)")].map(l=>({role:l.dataset.role,label:l.querySelector(".who b").textContent})))'
Check 'identity-labelled' (@($who | Where-Object { ($_.role -eq 'user' -and $_.label -eq '你') -or ($_.role -eq 'her' -and $_.label -eq '晚晚') }).Count -eq @($who).Count) $who
# 5. Cancel keeps partial text marked incomplete.
[void](Invoke-Js "__proto.runScenario('waiting')"); Wait-Ms 2500; [void](Invoke-Js "document.querySelector('#stop').click()"); Wait-Ms 600
$x = J 'JSON.stringify((()=>{const m=__proto.state&&window.__proto.pages().map(p=>p.message).pop();return {status:m.status,len:m.text.length,busy:__proto.state.busy}})())'
Check 'cancel-marks-incomplete' ($x.status -eq 'cancelled' -and -not $x.busy) $x
# 6. History open/close keeps the reading position.
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 7000; $a = J '__proto.state.cursor'
[void](Invoke-Js "document.querySelector('#history').click()"); Wait-Ms 4000; $b = J '__proto.state.cursor'
$mode = J 'JSON.stringify(document.body.dataset.mode)'
[void](Invoke-Js "document.querySelector('#close-sheet').click()"); Wait-Ms 1200
$c = J 'JSON.stringify({c:__proto.state.cursor,mode:document.body.dataset.mode})'
Check 'history-holds-then-returns' ($a -eq $b -and $c.mode -eq 'talk' -and $mode -eq 'record') @{ before = $a; whileOpen = $b; after = $c }
# 7. Space depth changes per mode.
$z = J 'JSON.stringify(__proto.atmos())'
Check 'camera-depth-talk' ([math]::Abs($z.z - 1) -lt .05) $z
# 8. Long name & phone keyboard viewport.
Set-View 390 844 -mobile -dpr 2
Open-Page "${url}?theme=fog"; Wait-Ms 1200
[void](Invoke-Js "__proto.state.createdNew=false")
[void](Invoke-Js "document.querySelector('#rehearse').click()"); Wait-Ms 600
[void](Invoke-Js "document.querySelector('[data-action=create]').click()"); Wait-Ms 600
[void](Invoke-Js "(()=>{document.querySelector('#new-name').value='星河与晚风之间慢慢醒来的小月亮';document.querySelector('#new-relation').value='朋友';document.querySelector('#create-form').requestSubmit();return 1})()")
Wait-Ms 2500
Save-Shot "$out\mobile-long-name.png"
$ov = J 'JSON.stringify((()=>{const b=document.querySelector("#stage .is-current .who b");return {name:b.textContent,overflow:b.scrollWidth>b.clientWidth,fits:b.getBoundingClientRect().right<=innerWidth}})())'
Check 'long-name-contained' $ov.fits $ov
# Simulated keyboard: shrink the visual viewport to 390x430 with the composer focused.
[void](Invoke-Js "document.querySelector('#draft').focus()")
Set-View 390 430 -mobile -dpr 2; Wait-Ms 1500
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 6000
[void](Invoke-Js "document.querySelector('#draft').focus()"); Wait-Ms 800
Save-Shot "$out\mobile-keyboard-430.png"
$k = J 'JSON.stringify((()=>{const r=e=>e.getBoundingClientRect();const cur=document.querySelector("#stage .is-current");const comp=r(document.querySelector("#composer"));const p=r(document.querySelector("#portal"));const c=cur&&r(cur);return {compact:document.body.classList.contains("compact"),composerBottom:comp.bottom,vh:innerHeight,lineTop:c&&c.top,lineBottom:c&&c.bottom,portalBottom:p.bottom,composerTop:comp.top}})())'
Check 'keyboard-viewport-no-overlap' ($k.compact -and $k.composerBottom -le $k.vh -and $k.portalBottom -le $k.lineTop -and $k.lineBottom -le $k.composerTop) $k
# 9. Small phone 320x568.
Set-View 320 568 -mobile -dpr 2; Wait-Ms 1500
Save-Shot "$out\mobile-320.png"
$k = J 'JSON.stringify((()=>{const r=e=>e.getBoundingClientRect();const cur=document.querySelector("#stage .is-current");const comp=r(document.querySelector("#composer"));const nav=r(document.querySelector(".navigation"));const c=r(cur);return {lineBottom:c.bottom,composerTop:comp.top,composerBottom:comp.bottom,navTop:nav.top,w:innerWidth,lineRight:c.right}})())'
Check 'small-phone-layout' ($k.lineBottom -le $k.composerTop -and $k.composerBottom -le $k.navTop -and $k.lineRight -le $k.w) $k
# 10. Reduced motion: emulate and confirm camera snaps / no CSS animations.
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @(@{ name = 'prefers-reduced-motion'; value = 'reduce' }) })
Set-View 1440 900; Open-Page "${url}?theme=moon"; Wait-Ms 1000
[void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 300
$rm = J 'JSON.stringify({z:__proto.atmos().z,anims:document.getAnimations().filter(a=>a.playState==="running"&&!["opacity","color","background-color","box-shadow"].includes(a.transitionProperty)).length})'
Check 'reduced-motion-respected' ($rm.z -eq 1 -and $rm.anims -eq 0) $rm
# 11. Tier fallback works (forced).
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @() })
Open-Page "${url}?theme=fog&tier=2"; Wait-Ms 1500
$t = J 'JSON.stringify(__proto.atmos())'
Check 'low-tier-fallback' ($t.tier -eq 2 -and $t.scale -eq .5) $t
Save-Shot "$out\desktop-tier2-fog.png"
# 12. Phone, normal height: while a long reply flows, lines never overlap her window or the composer.
foreach ($size in @(@(390, 844), @(360, 740), @(1440, 900), @(1280, 720))) {
$mob = $size[0] -lt 720
Set-View $size[0] $size[1] -mobile:$mob -dpr 1; Open-Page "${url}?theme=moon"; Wait-Ms 1000
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 1500
$worst = 999
for ($i = 0; $i -lt 24; $i++) {
Wait-Ms 1500
$g = J 'JSON.stringify((()=>{const ls=[...document.querySelectorAll("#stage .line:not(.leaving)")];if(!ls.length)return null;const top=Math.min(...ls.map(l=>l.getBoundingClientRect().top));const bottom=Math.max(...ls.map(l=>l.getBoundingClientRect().bottom));const p=document.querySelector("#portal").getBoundingClientRect();const c=document.querySelector("#composer").getBoundingClientRect();return {gapTop:top-p.bottom,gapBottom:c.top-bottom}})())'
if ($g) { $worst = [math]::Min($worst, [math]::Min($g.gapTop, $g.gapBottom)) }
}
Check "no-overlap-while-flowing-$($size[0])x$($size[1])" ($worst -ge 0) @{ minGapPx = [math]::Round($worst, 1) }
}
} finally { Stop-Browser }
$results | ConvertTo-Json -Depth 6 | Set-Content "$out\verification.json" -Encoding utf8