HEL-834: apply visual director memories page source package (6 modified + 9 new, verified against MANIFEST)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
zonggong
2026-10-24 13:35:48 +08:00
co-authored by multica-agent
parent 89d53b6c53
commit 2288471445
16 changed files with 637 additions and 22 deletions
+24
View File
@@ -0,0 +1,24 @@
# HEL-834 源码包(视觉总监)
- 基于提交:`89d53b6c53fdfc26af8b1a2fa0244ac41efc731b`(后台分支 `agent/agent/4d31c9a5ed04`,PR#9)
- 产品目标:`origin/agent/agent/de8874a3803b`;请在新分支入库(如 `agent/hel-834-memories`),不要覆盖其他作者的分支
- 用法:把本目录下的文件按相同相对路径覆盖到 89d53b6 的检出上;没有删除任何文件
- 共 15 个文件:新增 9 个,修改 6 个,删除 0 个
| 状态 | 路径 | SHA256(新) | SHA256(89d53b6 原文件) |
| --- | --- | --- | --- |
| 新增 | `docs/r3/browser-HEL-834.txt` | `7461febb6d266920eca34425c1d3d951cd2ea7fadb8f8a2a53310612b4a91bcc` | — |
| 新增 | `docs/r3/handoff-HEL-834.json` | `cde71b060455bdc5ebf0971dfc92f875182ae7e1aadf429439f5888e849a79a7` | — |
| 新增 | `docs/r3/REPORT-HEL-834.md` | `77740766ccfc3d0858e3f4af7660b54b36eef8c2e0841b38251baf8b0eb3f189` | — |
| 新增 | `docs/r3/web-build-HEL-834.txt` | `16362d2b7d4ad61e4b5a8bafedb063e9ff139fda53f3950c7b8ee84812918594` | — |
| 新增 | `docs/r3/web-lint-HEL-834.txt` | `6ca7a482e2298ce889fc7d80f367f59e778737d31f6660f348edf029ebb42cc0` | — |
| 新增 | `docs/r3/web-test-HEL-834.txt` | `d150d765b4cd7ab1c067f5b0d1118d4542a84813c31198c63d3aa2f8dea93cb7` | — |
| 修改 | `web/src/adapters/real.ts` | `c87b8456f9334fb55fc7e50a4f6ef86d7d94ee8b507c408effecb642f79e35a5` | `1e769de6183d24ccc822118175e1c2128cbb8f70f3f21ff15376c2ae43124cc2` |
| 修改 | `web/src/app/RealApp.tsx` | `cd455d7a2147e8cb3a4ec0e2fb87934ca8bfe69e6f17ffe13a6e72d1705d8942` | `72df8344f8acdf192d8b0c661ced3a9f3ef71d41ae92587cb5cf4678261eb5e1` |
| 修改 | `web/src/chat/ChatPanel.tsx` | `550c3705e6697341d2a0c15820b51da179db48418b341c678a0da52d60a01f34` | `de31238b37e0643c1922a154ca1d6445ade35b7432571644ec83e3d0d6d9ac9b` |
| 修改 | `web/src/domain/model.ts` | `88d76909fc771ca4572fd6097ead70beb347e6ba17b97eab913757a1bc193ec5` | `03dbcd3ec04770b81bbb2f1be6663464e13cec00a7e58299b1508984cdf4e6a0` |
| 修改 | `web/src/life/LifePanel.tsx` | `a91fe08695a856c8e02b9a08b0c6c89427155243b52c300e7d6ce2ded3272192` | `14b32114a2896377d4e1cb799654847dee6edf42f10639665202e9969009caed` |
| 修改 | `web/src/styles/components.css` | `5e831103d798c75ef717b28e87c139da7d8d4479ac45a270bd2d02b4d6e8911b` | `6cd91dda1c0fb64bfd79e3e402ee570b641666a8dbeb135d733f6d9557588b48` |
| 新增 | `web/tests/hel834_mock.mjs` | `303079e4811f7ea62d52ea95c95b1b225259db1e19bbba914e9c70de31ffbe5f` | — |
| 新增 | `web/tests/hel834_shots.mjs` | `cb1984c50bae5da46f370292efbba6bb04f7d861bda3af572f2b45733843bc2c` | — |
| 新增 | `web/tests/r3memory.test.ts` | `166b963bafed1ac00d76506f74dc554c4b9e24c3719a055e02b94e72e95f609e` | — |
+59
View File
@@ -0,0 +1,59 @@
# HEL-834 R3 回忆页接线(视觉总监,待独立审核)
完整基准 v1.10/59项;读取的代码是后台交付提交 `89d53b6c53fdfc26af8b1a2fa0244ac41efc731b`(分支 `agent/agent/4d31c9a5ed04`,PR#9)。按这份精确源码续作,没有回到旧 main。产品目标是 `origin/agent/agent/de8874a3803b`。需求变更:无。
开工前读了 AGENTS.md、需求入口 README、current.md(CG-006、CG-018、AM-001、UX-006、DEL-001 等原文)、coverage.md、changes.md、交接模板、REPORT-HEL-832 §1.6/§3/§5、STATUS-HEL-833、handoff-HEL-833.json,以及后台 `api/app.py` 中的 `/api/memories*` 和 `memory/episodic.py` 中 `dto()`/`page()`/`source()` 的实际实现。
## 本机环境与交付方式
运行机器上没有 git、python 或 node,所以无法按卡要求推独立分支、开 PR,也跑不了 `scripts/check_requirements.py`(它需要 python3 和 git 引用)。补救办法:
- 源码:从 Gitea 下载 89d53b6 的归档包(HTTP 200),在副本上修改。交付用源码包加 MANIFEST,由总工入库(同 HEL-828 的做法)。
- 构建与测试:从 nodejs.org 下载官方 Node v22.11.0 便携版,放在任务工作目录,SHA256 已对照官方 SHASUMS256 核实。`npm ci` 按仓库 package-lock 安装,没有新增依赖。build、lint、test 都用仓库自带脚本跑,并保存了输出。
- 没有改动队员运行时、模型或认证,也没有改系统环境。
## 改了什么(只改前端)
| 文件 | 改动 |
| --- | --- |
| `web/src/adapters/real.ts` | 新增 `serverMemory` 校验器(照 serverLife 的写法),以及 `memoryPage`(limit=30,带 before 游标)和 `memoryDetail`(详情页;伪造、非法或隐藏的 id 统一按 404 处理,非法 id 根本不发请求)。404/405 映射为 `NotWired`(老服务没有这个路由),与失败分开处理。新增 `memoryNote`:服务器报 `capabilities.memory===true` 时首页才说"回忆保存在服务器",否则保持原来更保守的说法。 |
| `web/src/domain/model.ts` | `Memory` 增加可选字段 `messageIds`(只有真实模式会有)。 |
| `web/src/life/LifePanel.tsx` | 真实模式的回忆页接入服务端数据,生命周期与"她的日子"相同:每次打开都重新读取,失败时显示中性提示并可重试,用游标看更早的回忆,伪造的 `#memory=` 旧链接按 id 去取。接收确认后在 15 秒和 45 秒各重新看一次(伴侣来源同样受确认门禁限制)。详情页的"那时候的聊天"只显示 messageIds 对应的消息,按实际说话人标注"你"或她的名字。"来自这些日子"只用服务端裁剪过的 lifeIds,没加载到的日子按 id 读取 `/api/life/{id}`。"她的日子"详情里的"和这件事有关的回忆"由服务端 lifeIds 反查。 |
| `web/src/app/RealApp.tsx` | 新增 `openSaid(messageIds)`:从回忆跳到交谈记录时直接定位到那几条消息,不往消息上写 lifeIds,避免回忆 id 和日子 id 混用。首页那一行改用 `memoryNote`。 |
| `web/src/chat/ChatPanel.tsx` | 新增可选参数 `focusIds`:给每条消息加 `data-mid`,命中的消息用原有的 `hl` 高亮并滚动到可见处。原来的 focusLife 逻辑不变。 |
| `web/src/styles/components.css` | 修正一个原有样式缺陷:回忆列表和详情抬头里的圆点 `i.k.mem` 被 `.mem` 卡片规则误套,变成了整行加内边距的空框(演示模式里同样存在)。现在只把圆点恢复成既有规范的 9px 描边圆点,没有改其他样式。 |
| `web/tests/r3memory.test.ts` | 新增 5 项单元测试:合同字段映射与游标、坏行丢弃、空列表/未接通/失败三态、详情 404 与非法 id、首页说法。 |
| `web/tests/hel834_mock.mjs`、`hel834_shots.mjs` | 本地隔离的合同 mock 和 Edge CDP 浏览器检查脚本,可以重跑。 |
### 视觉决定(沿用已认可的回忆页,没有新建组件)
- 真实模式下,"我们一起的"一组展示服务端回忆。"你们的小习惯"和"她知道的你"后台没有生成能力,不画成看起来会自己填满的空组。若以后服务端返回其他分组,会按原组件自动出现。
- "她的成长"仍然只来自 `/api/persona`,放在设置页。这里只保留分组标题,加一句说明和"在设置里看底色与成长"的入口,不把成长当成回忆来计数或合流。
- 后台的 title 和 text 截自同一段 gist,直接显示会重复。处理方式:服务端截短的标题加"…";列表第二行和详情引文只在提供了新内容时才显示。
- 状态文案分开写:
- 读取中:"正在翻你们的回忆…"
- 失败:"回忆一时没翻出来",并提供重试。
- 老服务未接通:"这台服务的长期记忆尚未接通"。
- 空列表:"还没有可以展示的回忆",并说明整理需要一段时间,还没整理好的或来源不能公开的都不会出现。这里不断言"从未聊过",也不伪造压缩进度。
- 隐藏或旧链接:统一显示"这一条现在打不开"。
- 布局、动效、两套主题和手机推入式详情都沿用原有实现。
## 隐私边界(AM-001)
真实模式只渲染服务端已经裁剪好的字段。前端没有补任何关联,也没有放宽门禁:没有把同一轮的伴侣回复拼进回忆,没有补 lifeIds,没有改 told 状态,也不发送任何确认。"那时候的聊天"取自用户本人的 `/api/messages` 记录,按 messageIds 精确匹配,排除取消和失败的消息。若某条消息在本地找不到,显示中性的"现在打不开"。本次自测的 mock 里没有出现未分享生活、未发送消息或未知的私密引用。真实后台的来源门禁以 HEL-833 的自测和总工审核为准,本卡只验证前端如实渲染。
## 实际验证
- `docs/r3/web-build-HEL-834.txt`:`tsc --noEmit && vite build` 通过。
- `docs/r3/web-lint-HEL-834.txt`:0 个错误,1 个警告,就是原有的 ChatPanel ref 警告。
- `docs/r3/web-test-HEL-834.txt`:vitest 42 项通过(原 37 项加新增 5 项)。
- `docs/r3/browser-HEL-834.txt`:headless Edge 对本地 mock 跑 23/23 项检查全部通过。覆盖:首页 30 条、坏行被丢弃、next_before 翻页到 32 条、详情字段、messageIds 对应的聊天和说话人、已加载与未加载日子的关联、按 id 打开更早的日子、用户自己的话标为"你"、"在交谈里看这段"精确高亮、日子详情反查回忆、重新打开时重读并出现新回忆、隐藏旧链接显示中性提示、可见回忆的深链、空/失败/未接通三种状态互不混淆、失败后重试、首页说法两种情况、真实文字聊天收发正常、演示路径零 `/api` 请求且零外部请求。
- 截图(随交付评论附上,未入仓库):月光和雾光两套主题,各有电脑 1440×900 和手机 390×844(DPR2,触控模拟)的列表与详情;另有月光主题的电脑和手机空/失败/未接通状态、雾光主题的电脑空/失败/未接通状态、隐藏链接和演示入口。手机图是等效视口,**没有在真机或 Safari 上测试**。
- 真实模型调用 0 次。没有部署,没有合并,没有动 8300/8310/8320/8340 等现网端口和真实档案。
## 未做和未验证(交总工 HEL-835 补充)
- 没有推独立分支、没有开 PR、没有跑 `check_requirements.py` 双目标检查:本机没有 git/python。请总工入库到新分支,开 PR 指向 `agent/agent/de8874a3803b` 后补跑:`--base-ref origin/main` 和 `--base-ref origin/agent/agent/de8874a3803b`,两次都加 `--handoff docs/r3/handoff-HEL-834.json`。
- 只用 mock 测试过前端;没有与真实 R3 后台(ControlledLLM 隔离库)做端到端联调。原有的 Python 浏览器检查(`web/tests/*_browser_check.py`)没有运行。
- 真实模型的压缩质量、隔天能否取回、服务器部署、手机真机和 Safari 都没有验收。用户还没有体验过本页。
- HEL-831 的三天观察,以及 R4–R7、真实视频口型,仍由主卡 HEL-797 追踪。阶段完成不等于产品完成。
+24
View File
@@ -0,0 +1,24 @@
PASS first page renders 30 valid memories, malformed rows dropped — 30 items
PASS unbuilt groups not drawn; 她的成长 points to settings
PASS cursor paging appends the older page — 32 items
PASS paging used next_before=12
PASS detail shows server fields and source
PASS 那时候的聊天 is exactly messageIds under the real speaker
PASS linked days: loaded one by title, older one by id placeholder
PASS older day opens by id through /api/life/90
PASS a memory from your words shows them as yours
PASS 在交谈里看这段 opens the record at that message
PASS life detail lists the memory linked by server lifeIds
PASS reopening re-reads /api/memories and shows the new memory first
PASS old/hidden memory link is one neutral can't-open
PASS deep link to a visible memory opens it
PASS empty state is its own honest state
PASS fail state is its own honest state
PASS unwired state is its own honest state
PASS retry after failure loads the list
PASS home note stays conservative when memory is not on
PASS home note says memory is saved only when the server reports it
PASS real text chat sends and receives
PASS demo path makes zero /api and zero off-host requests — 7 requests, off-host 0, api 0
PASS demo stays its own local path (wizard, no real profile)
23/23 checks passed
+51
View File
@@ -0,0 +1,51 @@
{
"baseline_version": "1.10",
"read_commit": "89d53b6c53fdfc26af8b1a2fa0244ac41efc731b",
"requirement_ids": [
"CG-006",
"CG-018",
"AM-001",
"UX-001",
"UX-003",
"UX-005",
"UX-006",
"VIS-001",
"VIS-011",
"VIS-013",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"DEL-001"
],
"deliverable": "R3回忆页前端接线(视觉总监亲自写):真实模式接GET /api/memories游标分页与/api/memories/{id}详情,serverMemory校验丢弃坏行;每次打开重读、确认后有界重看;失败/未接通/空列表三态分开且诚实;详情按messageIds显示那时候的聊天并按真实说话人标注,lifeIds关联日子并可按id打开;回忆跳交谈按消息id精确定位;她的成长仍来自/api/persona只做指引;修正i.k.mem圆点被卡片样式误套的既有缺陷。",
"acceptance": [
"web build通过;lint 0错1既有ChatPanel警告;vitest 42项通过(原37+新增5)",
"headless Edge对本地合同mock 23/23项检查通过:分页/坏行丢弃/详情/关联/空/失败/未接通/重试/重开重读/隐藏旧链接中性404/用户原话标注为你/交谈精确定位/首页说法/真实文字聊天/演示零/api零外发",
"两主题×电脑1440×900与手机390×844等效视口截图:列表、详情及各状态",
"前端未补任何关联或权限:不拼同轮回复、不补lifeIds、不改told、不发确认;演示路径零改动"
],
"deferred": [
"本机无git/python:未推分支、未开PR、未运行check_requirements双目标检查,交总工HEL-835入库后补跑",
"未与真实R3后台(ControlledLLM隔离库)端到端联调;既有Python浏览器检查未运行",
"真实模型gist质量、隔天真实取回、服务器部署、手机真机/Safari、用户体验验收均未做",
"你们的小习惯/她知道的你等语义分组后台未实现,真实模式不画空组;成长与回忆合流未做,仍在设置页",
"HEL-831三天观察继续;R4主动联系、R5语音、R6天气素材完整备份、R7授权观察及真实视频口型仍HEL-797追踪"
],
"requirement_changes": [],
"verification": [
"docs/r3/web-build-HEL-834.txt、web-lint-HEL-834.txt、web-test-HEL-834.txt:仓库脚本实际输出",
"docs/r3/browser-HEL-834.txt:23项浏览器检查逐条PASS;脚本web/tests/hel834_mock.mjs与hel834_shots.mjs可重跑",
"docs/r3/REPORT-HEL-834.md:改动、视觉决定、隐私边界、环境限制与未验证项",
"截图随HEL-834交付评论附件提供;手机为等效视口,未测真机/Safari",
"零真实模型调用;未部署、未合并;未动现网端口、真实档案、运行时/模型/认证"
],
"product_target": "origin/agent/agent/de8874a3803b",
"base_commit": "89d53b6c53fdfc26af8b1a2fa0244ac41efc731b",
"branch": null,
"status": "in_review",
"deployed": false,
"real_model_calls": 0,
"pr_url": null
}
+13
View File
@@ -0,0 +1,13 @@
> cybergril-web@0.1.0 build
> tsc --noEmit && vite build
vite v6.3.6 building for production...
transforming...
✓ 54 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-DdDYzR5S.css 81.48 kB │ gzip: 17.15 kB
dist/assets/index-QAD5A-Zm.js 287.46 kB │ gzip: 95.21 kB
✓ built in 695ms
+10
View File
@@ -0,0 +1,10 @@
> cybergril-web@0.1.0 lint
> eslint src
C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-834-e2774ff5d9e2\workdir\src\cybergril\web\src\chat\ChatPanel.tsx
164:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)
+18
View File
@@ -0,0 +1,18 @@
> cybergril-web@0.1.0 test
> vitest run
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-834-e2774ff5d9e2/workdir/src/cybergril/web
✓ tests/reading.test.ts (11 tests) 49ms
✓ tests/domain.test.ts (12 tests) 11ms
✓ tests/r2wiring.test.ts (11 tests) 47ms
✓ tests/r3memory.test.ts (5 tests) 22ms
✓ tests/real.test.ts (3 tests) 24ms
Test Files 5 passed (5)
Tests 42 passed (42)
Start at 01:25:12
Duration 567ms (transform 188ms, setup 0ms, collect 444ms, tests 155ms, environment 1ms, prepare 379ms)
+44 -1
View File
@@ -1,5 +1,5 @@
import type { ChatAdapter } from './demo';
import type { DemoState, Identity, LifeEvent, Message, ServerEvent } from '../domain/model';
import type { DemoState, Identity, LifeEvent, Memory, Message, ServerEvent } from '../domain/model';
import { defaultSettings } from '../domain/model';
export interface Color {
@@ -15,6 +15,14 @@ export interface Health {
// R2 servers only. backend.life is the configured switch; life_runtime.state is what the loop is actually doing.
backend?: { life: boolean; persona: boolean; receipt: boolean; web_connected: boolean };
life_runtime?: LifeRuntime;
// capabilities.memory is true only on an R3 server with chat memory writing and recall both on.
capabilities?: Record<string, boolean>;
}
// The home line under the window. Memory is only called connected when the server says so; when it is off or unknown
// this keeps the older, more conservative words rather than guessing.
export function memoryNote(health: Health | undefined, life: LifeStatus) {
if (health?.capabilities?.memory === true) return life.wired ? `资料、聊天与回忆保存在服务器。生活:${life.label}。语音尚未接通。` : '资料、聊天与回忆保存在服务器。生活与语音尚未接通。';
return life.wired ? `资料与聊天已保存在服务器。生活:${life.label}。长期记忆与语音尚未接通。` : '资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。';
}
// What the pages may honestly say about her life right now. Never "running" from the switch alone (HEL-828 item 6).
export interface LifeStatus { wired: boolean; on: boolean; now: string; label: string; note: string }
@@ -68,6 +76,41 @@ export async function lifeDetail(id: string, signal: AbortSignal): Promise<LifeE
if (!serverLife(data?.event)) throw new NotFound();
return toLifeEvent(data.event);
}
// ── Real chat memories (GET /api/memories, R3). The server only returns compressed memories whose chat source passed
// its gate (AM-001); uncompressed or non-public ones are simply absent, so an empty list proves nothing either way. ──
export interface ServerMemory { id: string; title: string; text: string; group: string; source: string; lifeIds: number[]; messageIds: number[]; ts: number }
function serverMemory(v: unknown): v is ServerMemory {
const m = v as Record<string, unknown>;
return !!m && typeof m === 'object' && typeof m.id === 'string' && /^[1-9]\d{0,18}$/u.test(m.id) && typeof m.title === 'string' && !!m.title.trim() && typeof m.text === 'string' && !!m.text.trim()
&& typeof m.group === 'string' && !!m.group && typeof m.source === 'string' && ints(m.lifeIds) && ints(m.messageIds) && m.messageIds.length > 0 && Number.isFinite(m.ts);
}
export const toMemory = (m: ServerMemory): Memory => ({ id: m.id, title: m.title, text: m.text, group: m.group, source: m.source, lifeIds: m.lifeIds.map(String), messageIds: m.messageIds.map(String) });
// A server from before R3 has no such route: that is "not wired", not a failure and not "nothing to show".
export class NotWired extends Error {}
export async function memoryPage(before: string | null, signal: AbortSignal): Promise<{ memories: Memory[]; next: string | null }> {
const s = within(signal, 10000);
let response: Response;
try { response = await fetch(`/api/memories?limit=30${before ? `&before=${encodeURIComponent(before)}` : ''}`, { signal: s }); }
catch (e) { if (signal.aborted) throw e; throw new Error('暂时连不上真实服务,请检查连接后重试。'); }
if (response.status === 404 || response.status === 405) throw new NotWired();
if (!response.ok) throw new Error('回忆暂时读不出来,请稍后重试。');
const page = await response.json().catch(() => null) as { memories?: unknown; next_before?: unknown } | null;
if (!page || !Array.isArray(page.memories)) throw new Error('真实服务暂时没有正确响应,请稍后重试。');
// Malformed rows are dropped, never patched up into something that looks real.
return { memories: page.memories.filter(serverMemory).map(toMemory), next: Number.isSafeInteger(page.next_before) && (page.next_before as number) > 0 ? String(page.next_before) : null };
}
export async function memoryDetail(id: string, signal: AbortSignal): Promise<Memory> {
if (!/^[1-9]\d{0,18}$/u.test(id)) throw new NotFound();
let response: Response;
try { response = await fetch(`/api/memories/${id}`, { signal: within(signal, 10000) }); }
catch (e) { if (signal.aborted) throw e; throw new Error('暂时连不上真实服务,请检查连接后重试。'); }
// Unknown, hidden, uncompressed, forged and malformed ids all come back as the same 404.
if (response.status === 404) throw new NotFound();
if (!response.ok) throw new Error('这一条暂时读不出来,请稍后重试。');
const data = await response.json().catch(() => null);
if (!serverMemory(data?.memory)) throw new NotFound();
return toMemory(data.memory);
}
// ── Persona layers (GET /api/persona). Birth facts stay on the identity; these are the separate model layers. ──
export interface PersonaView {
state: 'ready' | 'loading' | 'failed' | 'disabled' | 'not_initialized';
+9 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, lifeStatus, loadReal, type Health } from '../adapters/real';
import { api, lifeStatus, loadReal, memoryNote, type Health } from '../adapters/real';
import type { DemoState, Message, Theme } from '../domain/model';
import { Presenter } from '../stage/Presenter';
import { CreationWizard } from '../manage/CreationWizard';
@@ -32,16 +32,18 @@ export function RealApp() {
// Status words follow the server, re-read when you open a page that shows them; a failed read keeps the last one.
const life = lifeStatus(health);
const refreshHealth = useCallback(() => { api<Health>('health').then(setHealth, () => {}); }, []);
const [focusLife, setFocusLife] = useState(''), [ackAt, setAckAt] = useState(0);
const [focusLife, setFocusLife] = useState(''), [focusIds, setFocusIds] = useState<string[]>([]), [ackAt, setAckAt] = useState(0);
const acknowledged = useCallback(() => setAckAt(Date.now()), []);
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setArea(next); setLog(false); setFocusLife(''); }); if (next === 'days' || next === 'settings') refreshHealth(); }, [refreshHealth]);
const navigate = useCallback((next: string) => { leaveSettingsThen(() => { setArea(next); setLog(false); setFocusLife(''); setFocusIds([]); }); if (next === 'days' || next === 'settings') refreshHealth(); }, [refreshHealth]);
// "在交谈里看这段": open the record at the turn that told it. The record's server links are authoritative, so the
// local copy of those messages learns them even if they were told after this page loaded.
const openTold = useCallback((id: string, messageIds: string[] = []) => {
const told = new Set(messageIds);
if (latest.current && told.size) changeMessages(latest.current.messages.map(m => told.has(m.id) && !m.lifeIds.includes(id) ? { ...m, lifeIds: [...m.lifeIds, id] } : m));
leaveSettingsThen(() => { setArea('talk'); setLog(true); setFocusLife(id); });
leaveSettingsThen(() => { setArea('talk'); setLog(true); setFocusLife(id); setFocusIds([]); });
}, [changeMessages]);
// A memory opens the record at exactly the messages the server says it was made from; no life links are added.
const openSaid = useCallback((messageIds: string[]) => { leaveSettingsThen(() => { setArea('talk'); setLog(true); setFocusLife(''); setFocusIds(messageIds); }); }, []);
// Esc peels one layer at a time: the record first, then you are back across the room at once.
useEffect(() => { const key = (e: KeyboardEvent) => { if (e.key !== 'Escape' || creating) return; if (log) { setLog(false); return; } if (area === 'talk') { snap(); setInstant(i => i + 1); setArea('now'); return; } if (area !== 'now') navigate('now'); }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, [creating, log, navigate, area]);
const lastReply = state ? [...state.messages].reverse().find(m => m.role === 'companion') : undefined;
@@ -51,9 +53,9 @@ export function RealApp() {
{!ready ? <main className="entry"><div className="card glass"><h1>一扇亮着的窗。</h1><Status text={error || '正在读取服务器档案…'} error={!!error}/>{error && <><p>请检查真实服务是否可达。资料仍在服务器保存。</p><Button primary onClick={() => void initialize()}>重新连接</Button></>}</div></main> : <>
{creating && <CreationWizard real fail={false} onCreate={setState} onEnter={() => setCreating(false)}/>}
{!creating && state && <>
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><button className="away" onClick={() => navigate('days')}>日子与回忆 · {life.label} <Icon name="next"/></button>{lastReply && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">{life.wired ? `资料与聊天已保存在服务器。生活:${life.label}。长期记忆与语音尚未接通。` : '资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。'}</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onRecord={setRecord} offline={false} fail={false} log={log} setLog={setLog} focusLife={focusLife} onReceipt={acknowledged}/>}
<LifePanel real state={state} open={area === 'days'} now={life.now} life={life} ackAt={ackAt} onClose={() => navigate('now')} onChat={openTold} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/>
{area === 'now' && <main className="home"><div className="eyebrow">一扇亮着的窗</div><h1 className="whisper">慢慢说,我在这里。</h1><div className="meta"><span><Icon name="heart"/>{state.identity.relation} · 慢慢熟悉</span></div><button className="away" onClick={() => navigate('days')}>日子与回忆 · {life.label} <Icon name="next"/></button>{lastReply && <button className="letter glass" onClick={() => { navigate('talk'); setLog(true); }}><span className="lh"><b>最近说过的话</b><span>打开完整记录</span></span><p>{lastReply.text}</p></button>}<div className="cta"><Button primary small={false} onClick={() => navigate('talk')}><Icon name="chat"/>和她说话</Button></div><p className="first-note">{memoryNote(health, life)}</p>{health && !health.llm.ok && <Status error text="模型暂时不可达,档案和历史仍可查看。"/>}</main>}
{area === 'talk' && <ChatPanel real state={state} onChange={changeMessages} onPresence={setPresence} onVoice={ignoreVoice} onObservation={ignoreVoice} onRecord={setRecord} offline={false} fail={false} log={log} setLog={setLog} focusLife={focusLife} focusIds={focusIds} onReceipt={acknowledged}/>}
<LifePanel real state={state} open={area === 'days'} now={life.now} life={life} ackAt={ackAt} onClose={() => navigate('now')} onChat={openTold} onSaid={openSaid} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/>
<ManagePanel real state={state} open={area === 'settings'} now={life.now} life={life} focus={herFocus} onClose={() => navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/>
<nav className="nav glass" aria-label="主导航">{[['now','moon','此刻'],['talk','chat','交谈'],['days','book','日子'],['settings','gear','设置']].map(([id, icon, label]) => <button key={id} className="nav-item" aria-current={area === id ? 'page' : undefined} onClick={() => navigate(id)}><Icon name={icon}/>{label}</button>)}</nav></>}
</>}</>;
+6 -3
View File
@@ -15,7 +15,8 @@ const START: Anchor = { mi: 0, offset: 0 };
const metricsFor = (width: number): Metrics => { const compact = document.documentElement.classList.contains('compact'); return { width, font: compact ? 16 : innerWidth <= 720 ? 18 : 23, compact }; };
const typingTarget = (el: Element | null) => !!el && (el.matches('textarea,input,select,[contenteditable="true"]'));
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, real = false, onReceipt }: {
const NO_IDS: string[] = [];
export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, onRecord, offline, fail, log, setLog, focusLife, focusIds = NO_IDS, real = false, onReceipt }: {
real?: boolean;
// Real only: the server accepted a complete-receipt confirmation (the life it may unlock is decided later).
onReceipt?: () => void;
@@ -31,6 +32,8 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
log: boolean;
setLog: (b: boolean) => void;
focusLife: string;
// Real only: exact message ids to open the record at (a memory's server-given sources), apart from life ids.
focusIds?: string[];
}) {
const draftKey = real ? 'cg.real.draft' : 'cg.draft', readKey = real ? 'cg.real.reading' : 'cg.reading';
const [draft, setDraft] = useState(() => { try { return sessionStorage.getItem(draftKey) || ''; } catch { return ''; } }), [reply, setReply] = useState(''), [busy, setBusy] = useState(false), [voice, setVoice] = useState(false), [permission, setPermission] = useState<'' | 'mic' | 'vision'>(''), [notice, setNotice] = useState(''), [flash, setFlash] = useState(''), [denied, setDenied] = useState(false), [vision, setVision] = useState(false);
@@ -80,7 +83,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
useEffect(() => { if (!flash) return; const t = setTimeout(() => setFlash(''), 2600); return () => clearTimeout(t); }, [flash]);
useEffect(() => { onRecord(log || full); }, [log, full, onRecord]);
useEffect(() => () => onRecord(false), [onRecord]);
useEffect(() => { if (log) { const el = focusLife ? logRef.current?.querySelector(`[data-life~="${CSS.escape(focusLife)}"]`) : null; if (el) el.scrollIntoView({ block: 'center' }); else if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log, focusLife, view.messages.length]);
useEffect(() => { if (log) { const el = focusLife ? logRef.current?.querySelector(`[data-life~="${CSS.escape(focusLife)}"]`) : focusIds.length ? logRef.current?.querySelector(`[data-mid="${CSS.escape(focusIds[0])}"]`) : null; if (el) el.scrollIntoView({ block: 'center' }); else if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; } }, [log, focusLife, focusIds, view.messages.length]);
// ── Stage ──
useLayoutEffect(() => { if (stageRef.current) lines.current = new LineStage(stageRef.current); return () => { lines.current = null; }; }, []);
@@ -245,7 +248,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
</div>
</section>
<aside className="record log-panel glass read" hidden={!log} role="dialog" aria-modal="false" aria-labelledby="record-title"><header><div><p className="eyebrow">{real ? '服务器聊天记录' : '完整记录 · 本机演示'}</p><h2 id="record-title">你们说过的话</h2></div><button className="btn-round sm" ref={logClose} aria-label="关闭记录" onClick={() => setLog(false)}><Icon name="x"/></button></header>
<div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds.join(' ') || undefined} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${focusLife && m.lifeIds.includes(focusLife) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}</div></aside>
<div className="scroll" ref={logRef}><p className="guide">内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>{record.map(m => <article key={m.id} data-life={m.lifeIds.join(' ') || undefined} data-mid={m.id} data-role={m.role} className={`message ${m.status === 'failed' ? 'fail' : ''} ${(focusLife && m.lifeIds.includes(focusLife)) || focusIds.includes(m.id) ? 'hl' : ''}`}><div className="author"><i/>{m.role === 'user' ? '你' : name}{m.proactive && <span className="tagm">她主动说的</span>}</div><p>{m.text}</p>{m.status === 'failed' && <button className="btn-text" onClick={() => void send(m)}>没发出去 · 重试</button>}{m.status === 'cancelled' && <span className="incomplete">已取消</span>}{m.live === 'cancelled' && <span className="incomplete">已停止 · 这是未完成片段,未存入聊天记录</span>}</article>)}{reply && <article className="message" data-role="companion"><div className="author"><i/>{name}</div><p>{reply}</p><span className="incomplete">还在说…</span></article>}</div></aside>
{full && fullMessage && <aside className="record full glass read" role="dialog" aria-modal="false" aria-labelledby="full-title"><header><div><p className="eyebrow">{fullMessage.role === 'user' ? '你' : name} · 全文</p><h2 id="full-title">这一段,慢慢看</h2></div><button className="btn-round sm" ref={fullClose} aria-label="关闭全文" onClick={() => setFull(false)}><Icon name="x"/></button></header>
<div className="scroll"><p className="guide">整段完整可读。关闭后回到刚才那一句。</p><article className="message" data-role={fullMessage.role}><div className="author"><i/>{fullMessage.role === 'user' ? '你' : name}</div><p>{fullMessage.text}</p>{fullMessage.live === 'generating' && <span className="incomplete">还在说…</span>}{fullMessage.live === 'cancelled' && <span className="incomplete">这段回复已停止,内容未完成,未存入聊天记录。</span>}</article></div></aside>}
{permission && <Modal title={permission === 'mic' ? '开启模拟语音?' : '打开模拟视觉窗?'} onClose={() => { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') { setDenied(false); setVoice(true); onVoice(true); voiceController.current = new AbortController(); onPresence('在听你说(模拟)'); setNotice('模拟语音没有收音,可继续用文字输入。'); } else { if (fail) { onObservation('模拟 · 看不清,视觉服务未接通'); setNotice('模拟观察失败,文字仍可使用。'); } else setVision(true); } setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。</Modal>}
+2
View File
@@ -36,6 +36,8 @@ export interface Memory {
group: string;
source: string;
lifeIds: string[];
// Real memories only: the (already server-trimmed) sent chat messages the memory was made from.
messageIds?: string[];
}
export interface Message {
id: string;
+104 -11
View File
@@ -3,7 +3,7 @@ import type { DemoState, LifeEvent, Memory } from '../domain/model';
import { publicView } from '../domain/privacy';
import { Button, Icon } from '../shared/ui';
import { delay } from '../adapters/demo';
import { lifeDetail, lifePage, NotFound, type LifeStatus } from '../adapters/real';
import { lifeDetail, lifePage, memoryDetail, memoryPage, NotFound, NotWired, type LifeStatus } from '../adapters/real';
// HEL-815 page design: a page lying on your side of the table while the room steps back. Only what she has already
// told you is ever rendered (AM-001), so there is no "not told yet" label, filter or switch anywhere; any link the
// client cannot resolve opens one neutral "can't open" state that reveals nothing about whether the record exists.
@@ -11,10 +11,17 @@ const KIND = { plan: '打算', now: '正在', done: '已经发生' } as const;
const MEMORY_GROUPS = ['我们一起的', '你们的小习惯', '她知道的你', '她的成长'];
const phone = () => matchMedia('(max-width:720px)').matches;
type Load = 'ok' | 'loading' | 'error';
// Real memories also distinguish a server that has no memory route at all (before R3) from one that failed.
type MemLoad = Load | 'unwired';
// A real detail that is not in the loaded pages is asked for by id; anything the server will not show is one 404.
type Lookup = { id: string; state: 'loading' | 'missing' | 'error'; event?: undefined } | { id: string; state: 'ok'; event: LifeEvent };
type MemLookup = { id: string; state: 'loading' | 'missing' | 'error'; memory?: undefined } | { id: string; state: 'ok'; memory: Memory };
const RE_READ_MS = [15000, 45000];
export function LifePanel({ state, open, onClose, onChat, onSettings, now = '', fail = false, real = false, life, ackAt = 0 }: {
const byId = <T extends { id: string }>(a: T, b: T) => Number(b.id) - Number(a.id);
// Real titles and texts are both cut from one server summary; a title the server shortened says so, and a second line
// or quote that would only repeat it is left out.
const heading = (m: Memory) => m.text.length > m.title.length && m.text.startsWith(m.title) ? `${m.title}…` : m.title;
export function LifePanel({ state, open, onClose, onChat, onSaid, onSettings, now = '', fail = false, real = false, life, ackAt = 0 }: {
real?: boolean;
state: DemoState;
open: boolean;
@@ -22,23 +29,55 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
fail?: boolean;
onClose: () => void;
onChat: (id: string, messageIds?: string[]) => void;
// Real only: open the conversation at the exact (server-given) messages a memory was made from.
onSaid?: (messageIds: string[]) => void;
onSettings?: () => void;
// Real only: what the life backend is actually doing, and when the server last accepted a complete receipt.
life?: LifeStatus;
ackAt?: number;
}) {
const [realEvents, setRealEvents] = useState<LifeEvent[]>([]), [next, setNext] = useState<string | null>(null), [lookup, setLookup] = useState<Lookup | null>(null), [attempt, setAttempt] = useState(0), [moreError, setMoreError] = useState(false);
const [realMemories, setRealMemories] = useState<Memory[]>([]), [memNext, setMemNext] = useState<string | null>(null), [memLoad, setMemLoad] = useState<MemLoad>(real ? 'loading' : 'ok'), [memMore, setMemMore] = useState(false), [memMoreError, setMemMoreError] = useState(false), [memLookup, setMemLookup] = useState<MemLookup | null>(null);
// Real records come from the server already trimmed (AM-001); the demo filters its local fixtures the same way.
const view: DemoState = real ? { ...state, events: realEvents, memories: [] } : publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18), [load, setLoad] = useState<Load>(real ? 'loading' : 'ok'), [more, setMore] = useState(false);
const view: DemoState = real ? { ...state, events: realEvents, memories: realMemories } : publicView(state), [tab, setTab] = useState('life'), [selection, setSelection] = useState(''), [limit, setLimit] = useState(18), [load, setLoad] = useState<Load>(real ? 'loading' : 'ok'), [more, setMore] = useState(false);
const list = useRef<HTMLDivElement>(null), detail = useRef<HTMLElement>(null), scroll = useRef<Record<string, number>>({}), wasOpen = useRef(false), request = useRef<AbortController | null>(null), nextRef = useRef(next), loadRef = useRef(load); nextRef.current = next; loadRef.current = load;
useEffect(() => { if (open) {
const params = new URLSearchParams(location.hash.slice(1));
const id = params.get('event');
const id = params.get('event'), memoryId = params.get('memory');
if (id) {
setTab('life');
setSelection(id);
} else if (memoryId) {
setTab('memory');
setSelection(memoryId);
}
} }, [open]);
// Memories load on their own track, so a slow or missing memory route never holds up her days (and vice versa).
const memRequest = useRef<AbortController | null>(null), memQuiet = useRef<AbortController | null>(null), memNextRef = useRef(memNext), memLoadRef = useRef(memLoad); memNextRef.current = memNext; memLoadRef.current = memLoad;
const reloadMemories = useCallback(async () => {
memQuiet.current?.abort(); memRequest.current?.abort(); const c = new AbortController(); memRequest.current = c;
setMemLoad('loading');
try { const page = await memoryPage(null, c.signal); if (c.signal.aborted) return; setRealMemories(page.memories); setMemNext(page.next); setMemLoad('ok'); }
catch (e) { if (!c.signal.aborted) setMemLoad(e instanceof NotWired ? 'unwired' : 'error'); }
finally { if (!c.signal.aborted) setMemMore(false); }
}, []);
const lookAgainMemories = useCallback(async () => {
if (memLoadRef.current !== 'ok') return;
memQuiet.current?.abort(); const c = new AbortController(); memQuiet.current = c;
try {
const page = await memoryPage(null, c.signal); if (c.signal.aborted) return;
setRealMemories(old => { const known = new Set(old.map(m => m.id)), added = page.memories.filter(m => !known.has(m.id)); return added.length ? [...added, ...old].sort(byId) : old; });
} catch { /* Stays as it was; reopening the page reads again. */ }
}, []);
async function earlierMemories() {
memRequest.current?.abort(); const c = new AbortController(); memRequest.current = c; setMemMore(true); setMemMoreError(false);
try {
if (!memNextRef.current) return;
const page = await memoryPage(memNextRef.current, c.signal);
if (c.signal.aborted) return;
setRealMemories(old => [...old, ...page.memories.filter(m => !old.some(o => o.id === m.id))]); setMemNext(page.next);
} catch { if (!c.signal.aborted) setMemMoreError(true); } finally { if (!c.signal.aborted) setMemMore(false); }
}
// Real: every opening re-reads the newest page, so what she told you since (and only that) shows up. Nothing is
// marked told on this side.
const reload = useCallback(async () => {
@@ -60,15 +99,16 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
} catch { /* Stays as it was; reopening the page reads again. */ }
}, []);
// Demo only: the failure switch shows how a list that could not be read looks; nothing real is requested.
useEffect(() => { if (open && !wasOpen.current) { if (real) void reload(); else setLoad(fail ? 'error' : 'ok'); } wasOpen.current = open; if (!open) { request.current?.abort(); quiet.current?.abort(); setMore(false); } }, [open, fail, real, reload]);
useEffect(() => { if (open && !wasOpen.current) { if (real) { void reload(); void reloadMemories(); } else setLoad(fail ? 'error' : 'ok'); } wasOpen.current = open; if (!open) { request.current?.abort(); quiet.current?.abort(); memRequest.current?.abort(); memQuiet.current?.abort(); setMore(false); setMemMore(false); } }, [open, fail, real, reload, reloadMemories]);
// A receipt the server just accepted may unlock a record once her telling is judged, which can finish a little
// later. While the page is open, look again a bounded number of times; leaving and coming back always re-reads.
useEffect(() => {
if (!real || !open || !ackAt) return;
const timers = RE_READ_MS.map(ms => ackAt + ms - Date.now()).filter(ms => ms > 0).map(ms => setTimeout(() => void lookAgain(), ms));
// Her side of a chat memory also waits on that same judged receipt, so memories are looked at again too.
const timers = RE_READ_MS.map(ms => ackAt + ms - Date.now()).filter(ms => ms > 0).map(ms => setTimeout(() => { void lookAgain(); void lookAgainMemories(); }, ms));
return () => timers.forEach(clearTimeout);
}, [real, open, ackAt, lookAgain]);
useEffect(() => () => { request.current?.abort(); quiet.current?.abort(); }, []);
}, [real, open, ackAt, lookAgain, lookAgainMemories]);
useEffect(() => () => { request.current?.abort(); quiet.current?.abort(); memRequest.current?.abort(); memQuiet.current?.abort(); }, []);
async function retry() { if (real) { void reload(); return; } request.current?.abort(); const c = new AbortController(); request.current = c; setLoad('loading'); try { await delay(600, c.signal); setLoad(fail ? 'error' : 'ok'); } catch { /* Closed or replaced. */ } }
async function earlier() {
request.current?.abort(); const c = new AbortController(); request.current = c; setMore(true);
@@ -93,6 +133,15 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
lifeDetail(selection, c.signal).then(event => { if (!c.signal.aborted) setLookup({ id: selection, state: 'ok', event }); }, e => { if (!c.signal.aborted) setLookup({ id: selection, state: e instanceof NotFound ? 'missing' : 'error' }); });
return () => c.abort();
}, [real, open, tab, selection, loaded, load, attempt]);
// Same for a memory: an old link or a memory outside the loaded pages is asked for by id, and stays one neutral 404.
const memLoaded = view.memories.some(m => m.id === selection);
useEffect(() => {
if (!real || !open || tab !== 'memory' || !selection || memLoaded || memLoad === 'loading') return;
const c = new AbortController();
setMemLookup({ id: selection, state: 'loading' });
memoryDetail(selection, c.signal).then(memory => { if (!c.signal.aborted) setMemLookup({ id: selection, state: 'ok', memory }); }, e => { if (!c.signal.aborted) setMemLookup({ id: selection, state: e instanceof NotFound ? 'missing' : 'error' }); });
return () => c.abort();
}, [real, open, tab, selection, memLoaded, memLoad, attempt]);
// Each tab keeps its own reading position; switching back lands where you left it.
// A hidden panel is display:none, which drops its scroll offset; reopening (e.g. "结束交谈 · 回到日子") puts it back.
useLayoutEffect(() => { if (list.current && open) list.current.scrollTop = scroll.current[tab] || 0; }, [tab, open]);
@@ -105,7 +154,8 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
const ordered = [...view.events.filter(e => e.kind === 'plan'), ...view.events.filter(e => e.kind !== 'plan')], shown = real ? ordered : ordered.slice(0, limit);
const day = (e: LifeEvent) => e.kind === 'plan' ? '她说过的打算' : e.time.split(' ')[0];
const fetched = lookup?.id === selection && lookup.state === 'ok' ? lookup.event : undefined;
const event = tab === 'life' ? view.events.find(e => e.id === selection) || fetched : undefined, memory = tab === 'memory' ? view.memories.find(m => m.id === selection) : undefined;
const memFetched = memLookup?.id === selection && memLookup.state === 'ok' ? memLookup.memory : undefined;
const event = tab === 'life' ? view.events.find(e => e.id === selection) || fetched : undefined, memory = tab === 'memory' ? view.memories.find(m => m.id === selection) || memFetched : undefined;
const name = view.identity.name, demoTag = !real && <span className="tag-demo">演示数据</span>;
// Real: the messages the server says told it, plus what you said in those same turns.
const lines = (e: LifeEvent) => {
@@ -115,7 +165,8 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
const said = (who: string, text: string) => <blockquote className="quote"><span className="who"><i/>{who}</span>{text}</blockquote>;
const linkCard = (key: string, title: string, sub: string, go: () => void) => <button className="link-card" key={key} aria-describedby={`lc-${key}`} onClick={go}><span><b>{title}</b><small id={`lc-${key}`}>{sub}</small></span><Icon name="next"/></button>;
function eventView(e: LifeEvent) {
const mems = e.memoryIds.map(id => view.memories.find(m => m.id === id)).filter((m): m is Memory => !!m), chat = lines(e);
// Real: a memory points at a day only through the server-trimmed lifeIds; nothing is linked on this side.
const mems = real ? view.memories.filter(m => m.lifeIds.includes(e.id)) : e.memoryIds.map(id => view.memories.find(m => m.id === id)).filter((m): m is Memory => !!m), chat = lines(e);
return <>
<div className="d-kicker"><i className={`k ${e.kind}`}/>{KIND[e.kind]} · {e.time} · {e.place}</div>
<h3 className="d-title">{e.title}</h3>
@@ -129,6 +180,7 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
</>;
}
function memoryView(m: Memory) {
if (real) return realMemoryView(m);
const from = m.lifeIds.map(id => view.events.find(e => e.id === id)).filter((e): e is LifeEvent => !!e);
return <>
<div className="d-kicker"><i className={`k ${m.group === '她的成长' ? 'grow' : 'mem'}`}/>{m.group} · {m.source}</div>
@@ -141,6 +193,23 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
<div className="d-foot">{demoTag}回忆由她在相处中整理,只引用她讲过的日子。</div>
</>;
}
// Real memory: only what the server returned. "那时候的聊天" is exactly its messageIds from your own chat history,
// each under who actually said it, so your words are never shown as hers. A day it names but this page has not
// loaded is opened by id and can still only come back as the one neutral "can't open".
function realMemoryView(m: Memory) {
const ids = new Set(m.messageIds || []), chat = view.messages.filter(x => ids.has(x.id) && x.status !== 'failed' && x.status !== 'cancelled');
return <>
<div className="d-kicker"><i className="k mem"/>{m.group} · {m.source}</div>
<h3 className="d-title">{heading(m)}</h3>
<p className="d-told">她是这样记得的</p>
{m.text !== m.title && said(name, m.text)}
{m.lifeIds.length > 0 && <><div className="d-sec">来自这些日子</div><div className="link-list">{m.lifeIds.map(id => { const e = view.events.find(x => x.id === id); return linkCard(id, e ? e.title : '她讲给你听过的一件事', e ? e.time : '在她的日子里打开', () => select(id, 'life')); })}</div></>}
<div className="d-sec">那时候的聊天</div>
{chat.length ? <div className="said-then">{chat.map(x => <p key={x.id} data-role={x.role}><span className="who"><i/>{x.role === 'user' ? '你' : name}</span>{x.text}</p>)}</div> : <p className="d-dim">那时候的聊天现在打不开。</p>}
{chat.length > 0 && onSaid && <Button onClick={() => onSaid(chat.map(x => x.id))}>在交谈里看这段<Icon name="next"/></Button>}
<div className="d-foot">这是她从你们说出口的聊天里整理的回忆,由服务器保存;只有来源可以公开的才会显示。它和她的日子分开,也不会改写她最初的底色。</div>
</>;
}
const failed = <div className="state-card" role="alert"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这几天的事一时没翻出来。</p><p>不是你的问题,记录也没有丢。</p><Button onClick={() => void retry()}>再试一次</Button></div>;
const skeleton = (n: number) => <div className="skel" aria-hidden="true">{Array.from({ length: n }, (_, i) => <i key={i} style={{ width: `${[78, 62, 70, 54, 66][i % 5]}%` }}/>)}</div>;
function lifeList() {
@@ -150,17 +219,41 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
return <>{shown.map((e, i) => <div key={e.id}>{(i === 0 || day(shown[i - 1]) !== day(e)) && <div className="grp">{day(e)}</div>}<button className={`ev told ${e.kind}`} aria-current={selection === e.id} onClick={() => select(e.id)}><time>{e.kind === 'plan' ? e.time : e.time.split(' ')[1]}</time><i className={`k ${e.kind}`}/><span><span className="tt">{e.title}</span><span className="sn">{e.text}</span>{(e.kind !== 'done' || e.memoryIds.length > 0) && <span className="sub">{e.kind === 'plan' && <span className="tag">计划可能会变</span>}{e.kind === 'now' && <span className="tag accent">正在</span>}{e.memoryIds.length > 0 && <span className="tag">和你有关</span>}</span>}</span></button></div>)}
{more ? <p className="more-busy" role="status"><i className="spin"/>正在翻更早的日子…</p> : (real ? next : limit < ordered.length) ? <div className="more">{moreError && <p className="d-dim" role="alert">更早的日子一时没翻出来,记录没有丢。</p>}<Button onClick={() => void earlier()}>{moreError ? '再试一次' : '看更早的日子'}</Button></div> : <p className="list-end">再往前,就是她刚住进来的那天了。</p>}</>;
}
const memItem = (m: Memory) => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}><i className={`k ${m.group === '她的成长' ? 'grow' : 'mem'}`}/><span><small className="src">{m.source}</small><b>{heading(m)}</b>{!m.text.startsWith(m.title) && <span className="sn">{m.text}</span>}</span></button>;
// Real: the server only produces chat memories ("我们一起的"). Unbuilt groups are not drawn as if they could fill;
// "她的成长" stays in Settings (from /api/persona) and is only pointed to here, never counted as a memory.
function realMemoryList() {
if (memLoad === 'loading') return <><p className="grp" role="status">正在翻你们的回忆…</p>{skeleton(5)}</>;
if (memLoad === 'error') return <div className="state-card" role="alert"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">回忆一时没翻出来。</p><p>不是你的问题,聊天和回忆都没有丢。</p><Button onClick={() => void reloadMemories()}>再试一次</Button></div>;
const groups = ['我们一起的', ...new Set(view.memories.map(m => m.group).filter(g => g !== '我们一起的' && g !== '她的成长'))];
return <>
{memLoad === 'unwired' ? <><div className="grp">我们一起的</div><div className="empty">暂无可核实的回忆<small>这台服务的长期记忆尚未接通;文字聊天记录独立保留。</small></div></> : <>
{groups.map(group => { const items = view.memories.filter(m => m.group === group); return <div key={group}><div className="grp">{group}</div>{items.length ? items.map(memItem) : <div className="empty">还没有可以展示的回忆<small>聊过的话,她要过一阵才会整理成回忆;还没整理好的、或来源不能公开的,都不会出现在这里。你们说过的话都在交谈的完整记录里。</small></div>}</div>; })}
{memMore ? <p className="more-busy" role="status"><i className="spin"/>正在翻更早的回忆…</p> : memNext ? <div className="more">{memMoreError && <p className="d-dim" role="alert">更早的回忆一时没翻出来,记录没有丢。</p>}<Button onClick={() => void earlierMemories()}>{memMoreError ? '再试一次' : '看更早的回忆'}</Button></div> : view.memories.length > 0 && <p className="list-end">再往前的聊天,还没有整理成可以展示的回忆。</p>}
</>}
<div className="grp">她的成长</div>
<div className="empty">她后来慢慢有的变化另外保存<small>成长记在「设置 · 她」里,和回忆分开,不改写她最初的底色。</small>{onSettings && <button className="btn-text" onClick={onSettings}>在设置里看底色与成长<Icon name="next"/></button>}</div>
</>;
}
function memoryList() {
if (real) return realMemoryList();
if (load === 'loading') return skeleton(5);
if (load === 'error') return failed;
if (!real && !view.memories.length) return <div className="empty">还没有共同的回忆。<small>聊着聊着就会有了。</small></div>;
return MEMORY_GROUPS.map(group => { const items = view.memories.filter(m => m.group === group); return <div key={group}><div className="grp">{group}{!real && <span>{items.length}</span>}</div>{real && <div className="empty">暂无可核实的回忆<small>长期记忆尚未接通;文字聊天记录独立保留。</small></div>}{items.map(m => <button className="mem" key={m.id} aria-current={selection === m.id} onClick={() => select(m.id)}><i className={`k ${group === '她的成长' ? 'grow' : 'mem'}`}/><span><small className="src">{m.source}</small><b>{m.title}</b><span className="sn">{m.text}</span></span></button>)}</div>; });
}
const blank = load !== 'ok' || (tab === 'life' ? !view.events.length : !view.memories.length);
// Real detail still being looked up by id, or that lookup failed (not a 404), for whichever tab is showing.
const tabLoad = tab === 'life' ? load : memLoad, found = tab === 'life' ? lookup : memLookup;
const pending = real && !!selection && (tabLoad === 'loading' || !found || found.id !== selection || found.state === 'loading');
const lookupFailed = real && !!selection && found?.id === selection && found.state === 'error';
const idle = real ? (tab === 'life' ? (view.events.length ? '选一件她讲给你听过的事,慢慢读。' : life?.wired ? '她讲给你听过的事会出现在左侧;这里是日子详情和那时候的聊天。' : '后台接通后,她讲给你听过的事会出现在左侧;这里保留日子详情、回忆与聊天的关联。')
: view.memories.length ? '选一条,看她怎么记得。' : '她整理好的回忆会出现在左侧;这里是回忆详情和那时候的聊天。')
: blank ? (tab === 'life' ? '选一天,看她那天跟你讲过的事。' : '选一条,看她怎么记得。') : tab === 'life' ? '选一件小事,慢慢读。' : '选一条,看她怎么记得。';
return <section className={`pane glass read ${open ? 'open' : ''} ${selection ? 'has-detail' : ''}`} hidden={!open} aria-label="日子"><header><div className="ttl"><small className="kicker"><span className="k-wide">日子 · {real ? (life?.wired ? '服务器记录' : '后台待接入') : '演示数据'}</span><span className="k-near"><i/>她此刻 · {now || '安静待着'}</span></small><h2>{tab === 'life' ? '她的日子' : '我们的回忆'}</h2></div><span className="grow"/>{real ? <span className="tag-demo">{life?.label || '后台待接入'}</span> : demoTag}<button className="btn-round sm" aria-label="关闭日子" onClick={onClose}><Icon name="x"/></button></header>
<div className="pane-tabs"><div className="tabs" role="tablist">{[['life', '她的日子'], ['memory', '我们的回忆']].map(([t, l]) => <button role="tab" key={t} aria-selected={tab === t} onClick={() => switchTab(t)}>{l}</button>)}</div><span className="tabnote">{tab === 'life' ? '只有她讲给你听过的事,才会出现在这里' : '她记得的你们,和她后来慢慢有的'}</span></div>
<div className="cols"><div className="list" ref={list} onScroll={e => { scroll.current[tab] = e.currentTarget.scrollTop; }}>{tab === 'life' ? lifeList() : memoryList()}</div>
<article className="detail" ref={detail}><button className="btn-text back" onClick={() => setSelection('')}><Icon name="back"/>返回{tab === 'life' ? '她的日子' : '我们的回忆'}</button>
{event ? <div className="d-in" key={`e-${event.id}`}>{eventView(event)}</div> : memory ? <div className="d-in" key={`m-${memory.id}`}>{memoryView(memory)}</div> : real && tab === 'life' && selection && (load === 'loading' || !lookup || lookup.id !== selection || lookup.state === 'loading') ? skeleton(5) : real && tab === 'life' && selection && lookup?.state === 'error' ? <div className="state-card" role="alert"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这一条一时没翻出来。</p><p>不是你的问题,记录也没有丢。</p><Button onClick={() => setAttempt(n => n + 1)}>再试一次</Button></div> : selection ? <div className="state-card"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这一条现在打不开。</p><p>可能是链接已经过期。</p><Button onClick={() => setSelection('')}>回到{tab === 'life' ? '她的日子' : '我们的回忆'}</Button></div> : load === 'loading' ? skeleton(5) : <div className="d-empty"><div><Icon name="book"/>{real ? (view.events.length ? '选一件她讲给你听过的事,慢慢读。' : life?.wired ? '她讲给你听过的事会出现在左侧;这里是日子详情和那时候的聊天。' : '后台接通后,她讲给你听过的事会出现在左侧;这里保留日子详情、回忆与聊天的关联。') : blank ? (tab === 'life' ? '选一天,看她那天跟你讲过的事。' : '选一条,看她怎么记得。') : tab === 'life' ? '选一件小事,慢慢读。' : '选一条,看她怎么记得。'}</div></div>}
{event ? <div className="d-in" key={`e-${event.id}`}>{eventView(event)}</div> : memory ? <div className="d-in" key={`m-${memory.id}`}>{memoryView(memory)}</div> : pending ? skeleton(5) : lookupFailed ?<div className="state-card" role="alert"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这一条一时没翻出来。</p><p>不是你的问题,记录也没有丢。</p><Button onClick={() => setAttempt(n => n + 1)}>再试一次</Button></div> : selection ? <div className="state-card"><i className="state-mark"><Icon name="warn"/></i><p className="state-t">这一条现在打不开。</p><p>可能是链接已经过期。</p><Button onClick={() => setSelection('')}>回到{tab === 'life' ? '她的日子' : '我们的回忆'}</Button></div> : load === 'loading' ? skeleton(5) : <div className="d-empty"><div><Icon name="book"/>{idle}</div></div>}
</article></div></section>;
}
+3
View File
@@ -845,6 +845,9 @@ body:has(textarea:focus) .space *{animation-play-state:paused!important}
.mem .src{display:block;margin:0;font-size:12px;color:var(--text-3)}
.mem b{display:block;font-weight:400;font-size:15px}
.mem[aria-current="true"]{border-color:transparent;box-shadow:inset 2px 0 0 var(--accent)}
/* The memory dot also carries .mem; keep the card rules above from turning it into a padded block. */
i.k.mem,i.k.mem:hover{display:inline-block;width:9px;height:9px;padding:0;margin:7px 0 0;border-radius:50%;background:transparent;border:1.5px solid var(--accent);box-shadow:none}
.d-kicker i.k.mem{margin:0}
.ev[aria-current="true"]{box-shadow:inset 2px 0 0 var(--accent)}
.more-busy{display:flex;align-items:center;justify-content:center;gap:10px;margin:18px 0 6px;font-size:13px;color:var(--text-3)}
.more-busy .spin{width:14px;height:14px}
+85
View File
@@ -0,0 +1,85 @@
// HEL-834 isolated contract mock: serves web/dist and the real-mode API shapes from the HEL-833 backend
// (HEL-832 §5 memories, /api/life, /api/messages, /api/persona, /api/health). Synthetic data only, no model, no network.
// Usage: node tests/hel834_mock.mjs <port>; switch scenarios with GET /__mode/<full|full-new|empty|fail|unwired>.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const dist = fileURLToPath(new URL('../dist/', import.meta.url));
const port = Number(process.argv[2] || 8834);
let mode = 'full';
const log = [];
const T0 = Date.UTC(2026, 9, 23, 13, 0);
const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
// Sent turns, plus one cancelled and one failed message that must never be shown as a memory's chat.
const turns = [
['下雨天的时候,你会想听什么?', '会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累?'],
['今天加班到九点,好累。', '那先别急着做别的,喝口热水,慢慢跟我说。'],
['我下周想去杭州看看西湖。', '记得带伞,十月的湖边风有点凉。'],
['你昨天说的那段旋律,后来怎么样了?', '我给它换了一个结尾,像是把门轻轻带上。'],
];
const messages = [];
turns.forEach(([u, c], i) => {
const turn = `00000000-0000-4000-8000-00000000000${i}`, ts = T0 + i * 3600e3;
messages.push({ id: i * 2 + 1, ts, role: 'user', text: u, turn_id: turn, status: 'sent', lifeIds: [] });
messages.push({ id: i * 2 + 2, ts: ts + 4000, role: 'companion', text: c, turn_id: turn, status: 'sent', lifeIds: i === 3 ? [101] : [] });
});
messages.push({ id: 9, ts: T0 + 5 * 3600e3, role: 'user', text: '这句被我取消了', turn_id: '00000000-0000-4000-8000-000000000009', status: 'cancelled', lifeIds: [] });
const life = [
{ id: 101, ts: T0 - 86400e3, kind: 'thought', content: '我试着给一段旋律换个结尾。', place: '房间', salience: .7, source: 'generated', day_key: '2026-10-22', tz: 'Asia/Shanghai', told: true, told_at: T0 + 3 * 3600e3, messageIds: [8] },
{ id: 100, ts: T0 - 2 * 86400e3, kind: 'scene', content: '傍晚下了一阵雨,窗台上积了一小洼水。', place: '窗边', salience: .5, source: 'generated', day_key: '2026-10-21', tz: 'Asia/Shanghai', told: true, told_at: T0, messageIds: [2] },
];
// Told, but older than the loaded page: only reachable by id.
const olderLife = { id: 90, ts: T0 - 9 * 86400e3, kind: 'encounter', content: '楼下新开了一家修钟表的小店。', place: '街角', salience: .4, source: 'generated', day_key: '2026-10-14', tz: 'Asia/Shanghai', told: true, told_at: T0 - 8 * 86400e3, messageIds: [] };
const stamp = ts => { const d = new Date(ts + 8 * 3600e3); return `来自 ${d.getUTCMonth() + 1}月${d.getUTCDate()}日 ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} 的聊天`; };
const mem = (id, gist, messageId, lifeIds = []) => { const m = messages.find(x => x.id === messageId); return { id: String(id), title: gist.slice(0, 40), text: gist.slice(0, 200), group: '我们一起的', source: stamp(m.ts), lifeIds, messageIds: [messageId], ts: m.ts }; };
const filler = Array.from({ length: 28 }, (_, i) => mem(10 + i, `第 ${i + 1} 条合成回忆:你们聊到窗外的天色和晚饭吃了什么。`, 1 + (i % 8)));
const base = [
mem(48, '他下周想去杭州看西湖,她提醒他带伞,十月的湖边风有点凉;她还记得他说过想在断桥边等一场日落,看完再慢慢走回去。', 5),
mem(47, '他说今天加班到九点很累,她让他先喝口热水再慢慢说。', 3),
mem(46, '她把那段旋律换了个结尾,像把门轻轻带上——他问起过。', 8, [101, 90]),
mem(45, '他问她下雨天想听什么,她说想听雨落在铁皮檐上。', 1),
...filler.reverse(),
];
const fresh = mem(50, '他提起下雨天,她记得他喜欢开一条窗缝听雨。', 2);
const malformed = [{ ...mem(49, '坏行:没有来源消息', 1), messageIds: [] }, { ...mem(44, '坏行:数字id', 1), id: 44 }];
const visible = () => (mode === 'full-new' ? [fresh, ...base] : base);
const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
const page = (rows, url, limit) => { const before = Number(url.searchParams.get('before') || Infinity), list = rows.filter(r => Number(r.id) < before), out = list.slice(0, limit); return { out, next: list.length > limit ? Number(out[out.length - 1].id) : null }; };
createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
log.push(`${req.method} ${url.pathname}${url.search}`);
const p = url.pathname;
if (p.startsWith('/__mode/')) { mode = p.slice(8); return send(res, 200, { mode }); }
if (p === '/__log') return send(res, 200, log.splice(0));
if (p === '/api/health') return send(res, 200, { version: 'R3-backend', epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, life: false, voice: false, vision: false, weather: false, materials: false, backup: false, memory: mode !== 'unwired' } });
if (p === '/api/companion') return send(res, 200, identity);
if (p === '/api/messages') return send(res, 200, { messages, next_before: null, epoch: 3 });
if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
if (p === '/api/life') return send(res, 200, { events: life, next_before: null, epoch: 3 });
const lifeId = p.match(/^\/api\/life\/(\d+)$/u);
if (lifeId) { const e = [...life, olderLife].find(x => String(x.id) === lifeId[1]); return e ? send(res, 200, { event: e, epoch: 3 }) : send(res, 404, { detail: '这一条现在打不开。' }); }
if (p === '/api/memories') {
if (mode === 'unwired') return send(res, 404, { detail: 'Not Found' });
if (mode === 'fail') return send(res, 500, { detail: 'Internal Server Error' });
if (mode === 'empty') return send(res, 200, { memories: [], next_before: null, epoch: 3 });
const limit = Number(url.searchParams.get('limit') || 30), { out, next } = page(visible(), url, limit);
return send(res, 200, { memories: [...out, ...(url.searchParams.get('before') ? [] : malformed)], next_before: next, epoch: 3 });
}
const memId = p.match(/^\/api\/memories\/(.+)$/u);
if (memId) { const m = mode === 'unwired' ? null : visible().find(x => x.id === memId[1]); return m ? send(res, 200, { memory: m, epoch: 3 }) : send(res, 404, { detail: '这一条现在打不开。' }); }
if (p === '/api/chat' && req.method === 'POST') {
let body = ''; for await (const chunk of req) body += chunk;
const { turn_id } = JSON.parse(body), text = '我在。今天的雨停了没有?';
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(`data: ${JSON.stringify({ t: 'chat.delta', epoch: 3, turn_id, delta: text })}\n\ndata: ${JSON.stringify({ t: 'chat.final', epoch: 3, turn_id, utterance: { text, speech_tags: [] }, receipt: null })}\n\n`);
return;
}
if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
const file = normalize(join(dist, p === '/' ? 'index.html' : p));
if (!file.startsWith(normalize(dist))) return send(res, 404, '');
try { send(res, 200, await readFile(file), { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml' }[extname(file)] || 'application/octet-stream'); }
catch { send(res, 404, ''); }
}).listen(port, '127.0.0.1', () => console.log(`mock on ${port}`));
+131
View File
@@ -0,0 +1,131 @@
// HEL-834 browser check: drives headless Edge over CDP against tests/hel834_mock.mjs.
// Usage: node tests/hel834_shots.mjs <edge.exe> <outDir> [port]. Prints one PASS/FAIL line per check, exits 1 on any FAIL.
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const [edge, out, portArg] = process.argv.slice(2);
const port = Number(portArg || 8834), base = `http://127.0.0.1:${port}`, cdpPort = 9434;
mkdirSync(out, { recursive: true });
const profile = join(tmpdir(), `hel834-edge-${Date.now()}`);
const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', 'about:blank'], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let targets;
for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r, { once: true }));
let seq = 0; const waiting = new Map(), requests = [];
ws.addEventListener('message', ({ data }) => {
const m = JSON.parse(data);
if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); }
else if (m.method === 'Network.requestWillBeSent') requests.push(m.params.request.url);
});
const cdp = (method, params = {}) => new Promise((ok, ko) => { const id = ++seq; waiting.set(id, { ok, ko }); ws.send(JSON.stringify({ id, method, params })); });
const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
const results = [];
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
const mode = m => fetch(`${base}/__mode/${m}`);
const serverLog = async () => (await fetch(`${base}/__log`)).json();
const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
const click = async (selector, text = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(text)})); if (!el) return false; el.click(); return true; })()`);
const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
const count = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).length`);
const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
async function boot(theme, path = '/') {
await cdp('Page.navigate', { url: `${base}/` }); await sleep(600);
await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`);
await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1800);
}
const openMemories = async () => { await click('.nav-item', '日子'); await sleep(1300); await click('[role=tab]', '我们的回忆'); await sleep(900); };
await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable');
// 1. Two themes × desktop/phone: list and detail.
for (const theme of ['moon', 'fog']) for (const v of ['desktop', 'phone']) {
await mode('full'); await view(v); await boot(theme);
await openMemories(); await shot(`${theme}-${v}-memories`);
await click('button.mem', '旋律'); await sleep(1000); await shot(`${theme}-${v}-detail`);
}
// 2. Behaviour (desktop, moon).
await view('desktop'); await mode('full'); await boot('moon'); await serverLog();
await openMemories();
let list = await text('.list');
check('first page renders 30 valid memories, malformed rows dropped', await count('button.mem') === 30 && !list.includes('坏行'), `${await count('button.mem')} items`);
check('unbuilt groups not drawn; 她的成长 points to settings', !list.includes('你们的小习惯') && !list.includes('她知道的你') && list.includes('她的成长') && list.includes('在设置里看底色与成长'));
await click('.more button', '看更早的回忆'); await sleep(900);
list = await text('.list');
check('cursor paging appends the older page', await count('button.mem') === 32 && list.includes('再往前的聊天'), `${await count('button.mem')} items`);
check('paging used next_before=12', (await serverLog()).includes('GET /api/memories?limit=30&before=12'));
await click('button.mem', '旋律'); await sleep(900);
let detail = await text('.detail');
check('detail shows server fields and source', /我们一起的 · 来自 \d+月\d+日 \d\d:\d\d 的聊天/u.test(detail) && detail.includes('她是这样记得的'));
check('那时候的聊天 is exactly messageIds under the real speaker', await count('.said-then p') === 1 && detail.includes('换了一个结尾'));
check('linked days: loaded one by title, older one by id placeholder', detail.includes('一个念头') && detail.includes('她讲给你听过的一件事'));
await click('.link-card', '她讲给你听过的一件事'); await sleep(1200);
detail = await text('.detail');
check('older day opens by id through /api/life/90', detail.includes('修钟表'));
await click('[role=tab]', '我们的回忆'); await sleep(600);
await click('button.mem', '加班'); await sleep(900);
detail = await text('.detail');
check('a memory from your words shows them as yours', /你\s*今天加班到九点/u.test(detail) && await count('.said-then p[data-role=user]') === 1 && await count('.said-then p[data-role=companion]') === 0);
await click('.detail button', '在交谈里看这段'); await sleep(1500);
check('在交谈里看这段 opens the record at that message', await js(`!!document.querySelector('article.message.hl[data-mid="3"]')`) && await count('article.message.hl') === 1);
await click('.nav-item', '日子'); await sleep(1000); await click('[role=tab]', '她的日子'); await sleep(500);
await click('button.ev', '旋律'); await sleep(900);
detail = await text('.detail');
check('life detail lists the memory linked by server lifeIds', detail.includes('和这件事有关的回忆') && detail.includes('她把那段旋律换了个结尾'));
// Reopen re-reads.
await click('.nav-item', '此刻'); await sleep(800); await mode('full-new'); await serverLog();
await click('.nav-item', '日子'); await sleep(1200); await click('[role=tab]', '我们的回忆'); await sleep(800);
check('reopening re-reads /api/memories and shows the new memory first', (await serverLog()).some(l => l.startsWith('GET /api/memories?limit=30')) && (await text('button.mem')).split('\n').some(l => l.includes('开一条窗缝')));
// Old link to a memory the server will not show.
await mode('full'); await boot('moon', '/#memory=77'); await click('.nav-item', '日子'); await sleep(1600);
detail = await text('.detail');
check('old/hidden memory link is one neutral can\'t-open', detail.includes('这一条现在打不开') && !detail.includes('77'));
await shot('moon-desktop-hidden-link');
await boot('moon', '/#memory=47'); await click('.nav-item', '日子'); await sleep(1600);
check('deep link to a visible memory opens it', (await text('.detail')).includes('加班到九点'));
// 3. States.
for (const [m, expect] of [['empty', '还没有可以展示的回忆'], ['fail', '回忆一时没翻出来'], ['unwired', '长期记忆尚未接通']]) {
for (const v of ['desktop', 'phone']) {
await mode(m); await view(v); await boot('moon'); await openMemories();
const t = await text('.list');
if (v === 'desktop') check(`${m} state is its own honest state`, t.includes(expect) && !(m !== 'empty' && t.includes('还没有可以展示的回忆')) && !(m !== 'fail' && t.includes('一时没翻出来')));
await shot(`moon-${v}-${m}`);
}
}
await view('desktop'); await mode('fail'); await boot('fog'); await openMemories(); await shot('fog-desktop-fail');
await mode('full'); await click('.list button', '再试一次'); await sleep(900);
check('retry after failure loads the list', await count('button.mem') === 30);
await mode('empty'); await boot('fog'); await openMemories(); await shot('fog-desktop-empty');
await mode('unwired'); await boot('fog'); await openMemories(); await shot('fog-desktop-unwired');
const home = async () => { await click('.nav-item', '此刻'); await sleep(700); return js(`document.querySelector('.first-note')?.textContent || ''`); };
check('home note stays conservative when memory is not on', (await home()).includes('长期记忆与语音尚未接通'));
await mode('full'); await boot('moon');
check('home note says memory is saved only when the server reports it', (await js(`document.querySelector('.first-note')?.textContent || ''`)).includes('资料、聊天与回忆保存在服务器'));
// 4. Real text chat still works.
await click('.nav-item', '交谈'); await sleep(1200);
await js(`document.querySelector('textarea').focus(); true`);
await cdp('Input.insertText', { text: '雨停了吗' });
await cdp('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' });
await cdp('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
await sleep(6000);
check('real text chat sends and receives', (await serverLog()).includes('POST /api/chat') && (await js('document.body.innerText')).includes('今天的雨停了没有'));
// 5. Demo path: separate, zero API and zero off-host requests.
requests.length = 0; await serverLog();
await view('desktop'); await cdp('Page.navigate', { url: `${base}/?demo=1` }); await sleep(2000);
await shot('moon-desktop-demo-entry');
const offHost = requests.filter(u => !u.startsWith(base) && !u.startsWith('data:') && !u.startsWith('blob:'));
const api = (await serverLog()).filter(l => l.includes('/api/'));
check('demo path makes zero /api and zero off-host requests', offHost.length === 0 && api.length === 0, `${requests.length} requests, off-host ${offHost.length}, api ${api.length}`);
check('demo stays its own local path (wizard, no real profile)', (await js('document.body.innerText')).includes('这是本机演示') && !(await js('document.body.innerText')).includes('栖'));
ws.close(); browser.kill(); await sleep(500); try { rmSync(profile, { recursive: true, force: true }); } catch { /* Edge may still hold the profile. */ }
const failed = results.filter(r => !r).length;
console.log(`${results.length - failed}/${results.length} checks passed`);
process.exit(failed ? 1 : 0);
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { memoryDetail, memoryNote, memoryPage, NotFound, NotWired, lifeStatus, type Health } from '../src/adapters/real';
// HEL-832 §5 MemoryDTO exactly as the HEL-833 backend serializes it (episodic.py dto()).
const dto = (id: string, over: Record<string, unknown> = {}) => ({ id, title: '一起听的那场雨', text: '你说下雨天想听她弹琴,她记得你那天很累。', group: '我们一起的', source: '来自 10月23日 21:04 的聊天', lifeIds: [], messageIds: [Number(id) * 2], ts: 1761224640000, ...over });
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
const signal = () => new AbortController().signal;
describe('real memories (GET /api/memories)', () => {
afterEach(() => { vi.unstubAllGlobals(); });
it('maps the contract fields, keeps the server cursor and asks for the next page with before', async () => {
const fetch = vi.fn().mockResolvedValueOnce(json({ memories: [dto('12', { lifeIds: [7] }), dto('9')], next_before: 9, epoch: 3 })).mockResolvedValueOnce(json({ memories: [dto('4')], next_before: null, epoch: 3 }));
vi.stubGlobal('fetch', fetch);
const first = await memoryPage(null, signal());
expect(first.next).toBe('9');
expect(first.memories[0]).toEqual({ id: '12', title: '一起听的那场雨', text: dto('12').text, group: '我们一起的', source: '来自 10月23日 21:04 的聊天', lifeIds: ['7'], messageIds: ['24'] });
const second = await memoryPage(first.next, signal());
expect(second).toMatchObject({ next: null, memories: [{ id: '4' }] });
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/memories?limit=30', '/api/memories?limit=30&before=9']);
});
it('drops malformed rows instead of patching them up', async () => {
const bad = [dto('0'), dto('x1'), { ...dto('5'), id: 5 }, dto('6', { title: '' }), dto('7', { text: ' ' }), dto('8', { messageIds: [] }), dto('10', { messageIds: ['3'] }), dto('11', { lifeIds: [-1] }), dto('13', { ts: 'yesterday' }), dto('14', { group: undefined }), null, 'row'];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ memories: [...bad, dto('20')], next_before: null, epoch: 1 })));
const page = await memoryPage(null, signal());
expect(page.memories.map(m => m.id)).toEqual(['20']);
});
it('an empty list is just empty, a missing route is "not wired", and a failure is a failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(json({ memories: [], next_before: null, epoch: 1 })).mockResolvedValueOnce(json({ detail: 'Not Found' }, 404)).mockResolvedValueOnce(json({ detail: 'boom' }, 500)).mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce(json({ nope: 1 })));
await expect(memoryPage(null, signal())).resolves.toEqual({ memories: [], next: null });
await expect(memoryPage(null, signal())).rejects.toBeInstanceOf(NotWired);
for (let i = 0; i < 3; i++) {
const e = await memoryPage(null, signal()).catch(x => x);
expect(e).toBeInstanceOf(Error);
expect(e).not.toBeInstanceOf(NotWired);
}
});
it('detail: a hidden, uncompressed or forged id is one neutral 404, malformed ids never reach the server', async () => {
const fetch = vi.fn().mockResolvedValueOnce(json({ memory: dto('12'), epoch: 2 })).mockResolvedValueOnce(json({ detail: '这一条现在打不开。' }, 404)).mockResolvedValueOnce(json({ memory: { ...dto('15'), messageIds: [] }, epoch: 2 })).mockResolvedValueOnce(json({ detail: 'x' }, 503));
vi.stubGlobal('fetch', fetch);
await expect(memoryDetail('12', signal())).resolves.toMatchObject({ id: '12', messageIds: ['24'] });
await expect(memoryDetail('13', signal())).rejects.toBeInstanceOf(NotFound);
await expect(memoryDetail('15', signal())).rejects.toBeInstanceOf(NotFound);
const e = await memoryDetail('16', signal()).catch(x => x);
expect(e).not.toBeInstanceOf(NotFound);
for (const id of ['', '0', '-1', '1e3', '../life/1', '12345678901234567890']) await expect(memoryDetail(id, signal())).rejects.toBeInstanceOf(NotFound);
expect(fetch).toHaveBeenCalledTimes(4);
});
it('the home note only calls memory connected when the server says so', () => {
const base: Health = { epoch: 1, llm: { ok: true, model: 'm' } };
expect(memoryNote(base, lifeStatus(base))).toContain('长期记忆与语音尚未接通');
expect(memoryNote({ ...base, capabilities: { memory: false } }, lifeStatus(base))).toContain('长期记忆与语音尚未接通');
expect(memoryNote({ ...base, capabilities: { memory: true } }, lifeStatus(base))).toBe('资料、聊天与回忆保存在服务器。生活与语音尚未接通。');
});
});