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