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:
co-authored by
multica-agent
parent
89d53b6c53
commit
2288471445
@@ -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` | — |
|
||||
@@ -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 追踪。阶段完成不等于产品完成。
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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></>}
|
||||
</>}</>;
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`));
|
||||
@@ -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);
|
||||
@@ -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('资料、聊天与回忆保存在服务器。生活与语音尚未接通。');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user