diff --git a/docs/r3/MANIFEST-HEL-834-source.md b/docs/r3/MANIFEST-HEL-834-source.md new file mode 100644 index 0000000..0bb4a5f --- /dev/null +++ b/docs/r3/MANIFEST-HEL-834-source.md @@ -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` | — | diff --git a/docs/r3/REPORT-HEL-834.md b/docs/r3/REPORT-HEL-834.md new file mode 100644 index 0000000..a00ba73 --- /dev/null +++ b/docs/r3/REPORT-HEL-834.md @@ -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 追踪。阶段完成不等于产品完成。 diff --git a/docs/r3/browser-HEL-834.txt b/docs/r3/browser-HEL-834.txt new file mode 100644 index 0000000..285901a --- /dev/null +++ b/docs/r3/browser-HEL-834.txt @@ -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 diff --git a/docs/r3/handoff-HEL-834.json b/docs/r3/handoff-HEL-834.json new file mode 100644 index 0000000..3f724d8 --- /dev/null +++ b/docs/r3/handoff-HEL-834.json @@ -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 +} diff --git a/docs/r3/web-build-HEL-834.txt b/docs/r3/web-build-HEL-834.txt new file mode 100644 index 0000000..182d2b8 --- /dev/null +++ b/docs/r3/web-build-HEL-834.txt @@ -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 diff --git a/docs/r3/web-lint-HEL-834.txt b/docs/r3/web-lint-HEL-834.txt new file mode 100644 index 0000000..1868d3f --- /dev/null +++ b/docs/r3/web-lint-HEL-834.txt @@ -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) + diff --git a/docs/r3/web-test-HEL-834.txt b/docs/r3/web-test-HEL-834.txt new file mode 100644 index 0000000..11ed653 --- /dev/null +++ b/docs/r3/web-test-HEL-834.txt @@ -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) + diff --git a/web/src/adapters/real.ts b/web/src/adapters/real.ts index 17f7777..8d58ea8 100644 --- a/web/src/adapters/real.ts +++ b/web/src/adapters/real.ts @@ -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; +} +// 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; + 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 { + 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'; diff --git a/web/src/app/RealApp.tsx b/web/src/app/RealApp.tsx index 7a30e6a..dccb1a9 100644 --- a/web/src/app/RealApp.tsx +++ b/web/src/app/RealApp.tsx @@ -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').then(setHealth, () => {}); }, []); - const [focusLife, setFocusLife] = useState(''), [ackAt, setAckAt] = useState(0); + const [focusLife, setFocusLife] = useState(''), [focusIds, setFocusIds] = useState([]), [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 ?

一扇亮着的窗。

{error && <>

请检查真实服务是否可达。资料仍在服务器保存。

}
: <> {creating && setCreating(false)}/>} {!creating && state && <> - {area === 'now' &&
一扇亮着的窗

慢慢说,我在这里。

{state.identity.relation} · 慢慢熟悉
{lastReply && }

{life.wired ? `资料与聊天已保存在服务器。生活:${life.label}。长期记忆与语音尚未接通。` : '资料与聊天已保存在服务器。生活、长期记忆与语音尚未接通。'}

{health && !health.llm.ok && }
} - {area === 'talk' && } - navigate('now')} onChat={openTold} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/> + {area === 'now' &&
一扇亮着的窗

慢慢说,我在这里。

{state.identity.relation} · 慢慢熟悉
{lastReply && }

{memoryNote(health, life)}

{health && !health.llm.ok && }
} + {area === 'talk' && } + navigate('now')} onChat={openTold} onSaid={openSaid} onSettings={() => { navigate('settings'); setHerFocus(n => n + 1); }}/> navigate('now')} onSave={setState} fail={false} theme={theme} setTheme={setTheme}/> } }; diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx index 9c7f16c..eb8d0e2 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -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, +

内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。

{record.map(m =>
{m.role === 'user' ? '你' : name}{m.proactive && 她主动说的}

{m.text}

{m.status === 'failed' && }{m.status === 'cancelled' && 已取消}{m.live === 'cancelled' && 已停止 · 这是未完成片段,未存入聊天记录}
)}{reply &&
{name}

{reply}

还在说…
}
{full && fullMessage && } {permission && { 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(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。} diff --git a/web/src/domain/model.ts b/web/src/domain/model.ts index 2f0ae85..846d9e7 100644 --- a/web/src/domain/model.ts +++ b/web/src/domain/model.ts @@ -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; diff --git a/web/src/life/LifePanel.tsx b/web/src/life/LifePanel.tsx index 841913c..3410853 100644 --- a/web/src/life/LifePanel.tsx +++ b/web/src/life/LifePanel.tsx @@ -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 = (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([]), [next, setNext] = useState(null), [lookup, setLookup] = useState(null), [attempt, setAttempt] = useState(0), [moreError, setMoreError] = useState(false); + const [realMemories, setRealMemories] = useState([]), [memNext, setMemNext] = useState(null), [memLoad, setMemLoad] = useState(real ? 'loading' : 'ok'), [memMore, setMemMore] = useState(false), [memMoreError, setMemMoreError] = useState(false), [memLookup, setMemLookup] = useState(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(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(real ? 'loading' : 'ok'), [more, setMore] = useState(false); const list = useRef(null), detail = useRef(null), scroll = useRef>({}), wasOpen = useRef(false), request = useRef(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(null), memQuiet = useRef(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 && 演示数据; // 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) =>
{who}{text}
; const linkCard = (key: string, title: string, sub: string, go: () => void) => ; 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 <>
{KIND[e.kind]} · {e.time} · {e.place}

{e.title}

@@ -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 <>
{m.group} · {m.source}
@@ -141,6 +193,23 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '',
{demoTag}回忆由她在相处中整理,只引用她讲过的日子。
; } + // 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 <> +
{m.group} · {m.source}
+

{heading(m)}

+

她是这样记得的

+ {m.text !== m.title && said(name, m.text)} + {m.lifeIds.length > 0 && <>
来自这些日子
{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')); })}
} +
那时候的聊天
+ {chat.length ?
{chat.map(x =>

{x.role === 'user' ? '你' : name}{x.text}

)}
:

那时候的聊天现在打不开。

} + {chat.length > 0 && onSaid && } +
这是她从你们说出口的聊天里整理的回忆,由服务器保存;只有来源可以公开的才会显示。它和她的日子分开,也不会改写她最初的底色。
+ ; + } const failed =

这几天的事一时没翻出来。

不是你的问题,记录也没有丢。

; const skeleton = (n: number) => ; function lifeList() { @@ -150,17 +219,41 @@ export function LifePanel({ state, open, onClose, onChat, onSettings, now = '', return <>{shown.map((e, i) =>
{(i === 0 || day(shown[i - 1]) !== day(e)) &&
{day(e)}
}
)} {more ?

正在翻更早的日子…

: (real ? next : limit < ordered.length) ?
{moreError &&

更早的日子一时没翻出来,记录没有丢。

}
:

再往前,就是她刚住进来的那天了。

}; } + const memItem = (m: Memory) => ; + // 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 <>

正在翻你们的回忆…

{skeleton(5)}; + if (memLoad === 'error') return

回忆一时没翻出来。

不是你的问题,聊天和回忆都没有丢。

; + const groups = ['我们一起的', ...new Set(view.memories.map(m => m.group).filter(g => g !== '我们一起的' && g !== '她的成长'))]; + return <> + {memLoad === 'unwired' ? <>
我们一起的
暂无可核实的回忆这台服务的长期记忆尚未接通;文字聊天记录独立保留。
: <> + {groups.map(group => { const items = view.memories.filter(m => m.group === group); return
{group}
{items.length ? items.map(memItem) :
还没有可以展示的回忆聊过的话,她要过一阵才会整理成回忆;还没整理好的、或来源不能公开的,都不会出现在这里。你们说过的话都在交谈的完整记录里。
}
; })} + {memMore ?

正在翻更早的回忆…

: memNext ?
{memMoreError &&

更早的回忆一时没翻出来,记录没有丢。

}
: view.memories.length > 0 &&

再往前的聊天,还没有整理成可以展示的回忆。

} + } +
她的成长
+
她后来慢慢有的变化另外保存成长记在「设置 · 她」里,和回忆分开,不改写她最初的底色。{onSettings && }
+ ; + } function memoryList() { + if (real) return realMemoryList(); if (load === 'loading') return skeleton(5); if (load === 'error') return failed; if (!real && !view.memories.length) return
还没有共同的回忆。聊着聊着就会有了。
; return MEMORY_GROUPS.map(group => { const items = view.memories.filter(m => m.group === group); return
{group}{!real && {items.length}}
{real &&
暂无可核实的回忆长期记忆尚未接通;文字聊天记录独立保留。
}{items.map(m => )}
; }); } 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 ; } diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 0671a45..cc1650f 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -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} diff --git a/web/tests/hel834_mock.mjs b/web/tests/hel834_mock.mjs new file mode 100644 index 0000000..8ddbf81 --- /dev/null +++ b/web/tests/hel834_mock.mjs @@ -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 ; switch scenarios with GET /__mode/. +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}`)); diff --git a/web/tests/hel834_shots.mjs b/web/tests/hel834_shots.mjs new file mode 100644 index 0000000..de71f55 --- /dev/null +++ b/web/tests/hel834_shots.mjs @@ -0,0 +1,131 @@ +// HEL-834 browser check: drives headless Edge over CDP against tests/hel834_mock.mjs. +// Usage: node tests/hel834_shots.mjs [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); diff --git a/web/tests/r3memory.test.ts b/web/tests/r3memory.test.ts new file mode 100644 index 0000000..dd57ce2 --- /dev/null +++ b/web/tests/r3memory.test.ts @@ -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 = {}) => ({ 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('资料、聊天与回忆保存在服务器。生活与语音尚未接通。'); + }); +});