From 53e233b8c79b76686f68d1ed8012f708cbfac790 Mon Sep 17 00:00:00 2001 From: SternChiri <103919801+SternChiri@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:45:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(dsh-go):=20=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E8=AE=B0=E5=BF=86=E5=BA=93=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh-go 是 DSH 的 Android 客户端(Flutter 原生界面),看不到 Web 面板。新增 src/dsh-go/ 域随插件一起发布:面板在引擎进程内直接派发 src/api.js 已注册的 路由 handler,读写与 Web 面板同一批口径。 - package.json 增 dshGo 清单;src/index.js 在 webServer 块内 ctx.inject(["dshGo"]) 里动态导入面板,没有桥的宿主整块不加载;cfg.apiToken 交给面板,进程内写请求 带 x-dsh-mneme-token,不给自家路由开白名单。 - 视图用 v2 协议:顶层原生 tabs(记忆/实体/设置),设置页内两格 segmented 切「设置 | 状态」;文案取自 Web 面板 zh i18n。 - 编辑只提交改动过的字段,updated_at 对不上就中止;删除确认按面板 scope 核对, 避免多面板并发时删错行。 - 跟进 0.11.0 的两块新面板面:「Agent 眼中的你」自动事实(读 / 逐条增改归档 / 立即维护轮)与代码库地志(仓库 → 模块骨架 → 页正文 / 重建骨架)。功能开关补 profileFactsEnabled、chorographyEnabled 与 chorographyInject 档位下拉,并新增 「其它」分组把上游新加、面板暂无中文名的布尔旗标动态列出。 - 为面板补两处读面:/entities?q=(名字过滤下推 store)、/external-api?token=omit (只读配置,不生成也不落库 Bearer)。 - 测试:test/dsh-go-{panel,panel-actions,host}.test.js 与 api-routes-gaps 的 q / token=omit 两例。 - 进程内派发的超时守卫改成显式 cancel(不再 unref 定时器):handler 不响应时 node 22 的 test runner 会判定事件循环已空,把用例记成 cancelled;成功路径也不再 白挂 5 秒定时器。 - 列表页的翻页用原生两段 segmented(一行等宽、不折行),页码挂在 label 上; 记忆与实体页的空态不再用结构分支表达,改由文案随数据走。 - 第一轮复核的六条:归档/删除都按面板 scope 核对;/list 支持按 id 取单条 (含归档行),编辑保存前回读最新值,冲突后按最新记忆重建比对基准;列表页的 动作只重读当前页那一块;pending 任务等真正跑完再推数据。 - 第二轮复核:/list?id= 只返回未遗忘的记忆;外部访问与向量配置读失败时不再 整体写回(避免覆盖 Web 面板配置);保存、冲突裁决与「Agent 眼中的你」的保存/归档都按面板 scope 核对; 实体搜索后重读实体清单;溯源 409 判据改用实际的小写错误码;设置项动作后 重读设置块,避免 App 本地切页签看到旧值。 --- AGENTS.md | 1 + dsh-mneme/CHANGELOG.md | 8 + dsh-mneme/docs/DSH-GO.md | 97 + dsh-mneme/lib/api.js | 34 +- dsh-mneme/lib/dsh-go/host.js | 162 ++ dsh-mneme/lib/dsh-go/panel.js | 2091 +++++++++++++++++++ dsh-mneme/lib/dsh-go/text.js | 342 +++ dsh-mneme/lib/dsh-go/views/common.js | 68 + dsh-mneme/lib/dsh-go/views/entity.js | 48 + dsh-mneme/lib/dsh-go/views/memory.js | 198 ++ dsh-mneme/lib/dsh-go/views/root.js | 30 + dsh-mneme/lib/dsh-go/views/settings.js | 398 ++++ dsh-mneme/lib/dsh-go/views/status.js | 51 + dsh-mneme/lib/index.js | 10 + dsh-mneme/lib/store.js | 17 +- dsh-mneme/package.json | 24 + dsh-mneme/src/api.js | 34 +- dsh-mneme/src/dsh-go/host.js | 162 ++ dsh-mneme/src/dsh-go/panel.js | 2091 +++++++++++++++++++ dsh-mneme/src/dsh-go/text.js | 342 +++ dsh-mneme/src/dsh-go/views/common.js | 68 + dsh-mneme/src/dsh-go/views/entity.js | 48 + dsh-mneme/src/dsh-go/views/memory.js | 198 ++ dsh-mneme/src/dsh-go/views/root.js | 30 + dsh-mneme/src/dsh-go/views/settings.js | 398 ++++ dsh-mneme/src/dsh-go/views/status.js | 51 + dsh-mneme/src/index.js | 10 + dsh-mneme/src/store.js | 17 +- dsh-mneme/test/api-routes-gaps.test.js | 46 + dsh-mneme/test/api.test.js | 27 + dsh-mneme/test/dsh-go-host.test.js | 134 ++ dsh-mneme/test/dsh-go-panel-actions.test.js | 854 ++++++++ dsh-mneme/test/dsh-go-panel.test.js | 251 +++ 33 files changed, 8322 insertions(+), 18 deletions(-) create mode 100644 dsh-mneme/docs/DSH-GO.md create mode 100644 dsh-mneme/lib/dsh-go/host.js create mode 100644 dsh-mneme/lib/dsh-go/panel.js create mode 100644 dsh-mneme/lib/dsh-go/text.js create mode 100644 dsh-mneme/lib/dsh-go/views/common.js create mode 100644 dsh-mneme/lib/dsh-go/views/entity.js create mode 100644 dsh-mneme/lib/dsh-go/views/memory.js create mode 100644 dsh-mneme/lib/dsh-go/views/root.js create mode 100644 dsh-mneme/lib/dsh-go/views/settings.js create mode 100644 dsh-mneme/lib/dsh-go/views/status.js create mode 100644 dsh-mneme/src/dsh-go/host.js create mode 100644 dsh-mneme/src/dsh-go/panel.js create mode 100644 dsh-mneme/src/dsh-go/text.js create mode 100644 dsh-mneme/src/dsh-go/views/common.js create mode 100644 dsh-mneme/src/dsh-go/views/entity.js create mode 100644 dsh-mneme/src/dsh-go/views/memory.js create mode 100644 dsh-mneme/src/dsh-go/views/root.js create mode 100644 dsh-mneme/src/dsh-go/views/settings.js create mode 100644 dsh-mneme/src/dsh-go/views/status.js create mode 100644 dsh-mneme/test/dsh-go-host.test.js create mode 100644 dsh-mneme/test/dsh-go-panel-actions.test.js create mode 100644 dsh-mneme/test/dsh-go-panel.test.js diff --git a/AGENTS.md b/AGENTS.md index ae233fd4..8b6e3cc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ dsh-mneme 是 DSH 宿主的记忆插件(蒸馏 / 注入 / 检索 / 巩固 / sc | 配置 | `src/config.js`(schema + lightMode)、`src/settings.js`(feature flags 白名单) | 一切行为开关的家 | | API 面 | `src/api.js`(宿主内 /api/dsh-mneme/*)、`src/api-standalone.js`(Bearer 数据面)、`bin/dsh-mneme-mcp.mjs`(MCP stdio)、`src/serve.js` + `bin/dsh-mneme-serve.mjs`(独立服务 daemon,无 LLM 数据面) | 对外四张脸 | | 面板 | `lib/client.js` | 面板侧产物,无 src 对应物 | +| 手机面板 | `src/dsh-go/**` + `package.json` 的 `dshGo` | dsh-go 原生面板:进程内派发同一批路由,只有宿主提供 `dshGo` 服务时才加载([docs/DSH-GO.md](dsh-mneme/docs/DSH-GO.md)) | | 运行时 | `src/runtime/*` | 模型下载(断点续传)/ 校验 / adopt | | 命令 | `src/commands.js` | 斜杠命令注册与派发 | | 双语 | `src/lang.js` | 文案与语言解析 | diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 8d598329..ba5390a1 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -4,6 +4,14 @@ ## 🆕 新增 +- **dsh-go 原生记忆库面板:手机 App 里也能看、能改这份记忆库**:`dsh-go`(DSH 的 Android 客户端,[仓库](https://github.com/ccch1mneyyy/dsh-Go))是 Flutter 原生界面,看不到 Web 面板。新增 `src/dsh-go/` 域随插件一起发布——不另开包,也不另起端口:面板在引擎进程内直接派发 `src/api.js` 已注册的路由 handler,读写与 Web 面板同一批口径(「两张皮只许剩鉴权与路径不同」)。 + + - **按宿主能力挂载**:`package.json` 增 `dshGo` 清单(一个面板 `mneme`,placement `session.tab` / `features`);`src/index.js` 在 webServer 块内 `ctx.inject(["dshGo"], …)` 里动态导入 `src/dsh-go/panel.js`,没有桥的宿主(桌面 / headless)整块不加载。`cfg.apiToken` 一并交给面板:进程内写请求带 `x-dsh-mneme-token`,不给自家路由开白名单。 + - **视图**:v2 视图协议,顶层是 App 原生 `tabs`(记忆 / 实体 / 设置);设置页内两格 `segmented` 切「设置 | 状态」——电脑端是四个页签,但 App 页签条每格有 80dp 下限,四格会折行。文案取自 Web 面板的 zh i18n,不自造解释性文字。记忆 / 实体页的树不随数据变(宿主的 `view()` 早于 `data()`,按数据分支的地方首屏会定形、之后只填绑定),空状态提示改成绑定文案;翻页用一行 `chips`(原生按钮组每列至少 136dp×字号,两个按钮在窄屏会折成上下两行),页码兼作 chips 的标签。 + - **写回不覆盖别人的写入**:编辑页记住打开时的快照,只提交改动过的字段;`updated_at` 对不上就中止并重新载入。删除确认不携带参数(`confirm` 节点只有 id 与按钮文案),所以按面板 scope 记下「这个面板打开了哪条记忆」,确认时核对。 + - **为面板补了两处读面**:`/entities` 支持 `?q=`(名字过滤下推到 store,`%` / `_` 转义);`/external-api` 支持 `?token=omit`(只读配置,不生成也不落库 Bearer——默认 GET 会顺手铸造一个)。 + - **验证**:`test/dsh-go-panel.test.js`(19 屏视图预算 + 页签 / 开关 / 文案锁)、`test/dsh-go-panel-actions.test.js`(点按之后发给宿主的请求、scope 核对、只发差集、视图切换)、`test/dsh-go-host.test.js`(请求体 flush、异步响应、令牌、401、超时);`test/api-routes-gaps.test.js` 补 `q` 与 `token=omit` 两例。 + - **「Agent 眼中的你」按段化第一期:注入渲染函数产出段边界(`segments`),面板从此能按段读(#600)**。`renderUserSettingsBlock` 原先只回一整串 `text`,面板那张卡只能整段只读显示——想改注入文本里的某一句,得走「整段提取 → 复制 → 粘进画像框」的剪贴板中转,而画像段是手写原文、人格段是结构化卡、事实段是逐条维护的行,三者写回语义完全不同。段边界是这三条路能分开走的前提。 - **纯加法,注入字节零变化**:返回值多一个 `segments`,每行带 `kind` 与写回定位信息;`text` 仍是原来的 `escapePromptVars(config, lines.join("\n"))`。段文本存**未转义**原文(面板要显示给人看),转义只发生在 `text` 这一层——转义只动连续花括号、换行不参与,逐段转义与整串转义等价,所以注入行为与改前逐字节相同,老用户零变化。 diff --git a/dsh-mneme/docs/DSH-GO.md b/dsh-mneme/docs/DSH-GO.md new file mode 100644 index 00000000..47b9b3e0 --- /dev/null +++ b/dsh-mneme/docs/DSH-GO.md @@ -0,0 +1,97 @@ +# dsh-go 原生面板 + +> 客户端仓库:[ccch1mneyyy/dsh-Go](https://github.com/ccch1mneyyy/dsh-Go) + +> dsh-mneme 一直是「Web 面板 + 11 个记忆工具 + 自动注入」。手机 App **[dsh-go](https://github.com/ccch1mneyyy/dsh-Go)**(DSH 的 Android 客户端:Flutter 原生壳,手机本机跑引擎,也能遥控电脑)是 Flutter 原生界面,看不到 Web 面板——这个域就是给它的原生面板:与电脑端同一套功能划分(记忆 / 实体 / 设置 / 状态)、同一批后端路由。 +> +> 记忆库是**按宿主独立存放**的:面板读写的是它所在那台宿主的 `memory.db`,与另一台设备上的库不互通。目前没有在线同步,只能通过分享包 `mneme.share/v1` 手动迁移,且不是实时同步。 + +## 职责边界 + +- **只做展示与转发**:取数、写回都走 `src/api.js` 已注册的 HTTP 路由 handler(进程内派发),不新增路由、不直连 SQLite、不写第二套口径。 +- **不碰记忆本体**:面板不参与蒸馏、注入、巩固、冲突判定;它只是这些能力的一个操作面(和 Web 面板同级)。 +- **可选能力**:桌面 / headless 宿主没有 [dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 桥,这个域整块不加载(`ctx.inject(["dshGo"], …)` 的回调不会被调度)。 +- **不重复 Web 面板**:`lib/client.js`(浏览器 bundle)照旧是 Web 面板的唯一实现;两边共享的是**后端**,不是视图代码。 + +## 对外接口 + +### 1. 包清单 + +`package.json` 的 `dshGo` 字段([dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 桥扫描已启用插件的 package.json,见 `dsh-go-bridge/lib/native/manifest.js`): + +```json +"dshGo": { "v": 1, "title": {...}, "icon": "book", "contributes": { "panels": [{ "id": "mneme", ... }] } } +``` + +面板的 `pkg` 由桥按「注册调用所在 fiber 所属的 loader entry」反查,所以**不需要独立包**:在 mneme 自己的插件上下文里调 `ctx.dshGo.panel("mneme", …)`,归谁一目了然。 + +### 2. 挂载点 + +`src/index.js` 在 webServer 块内、`createApi` 之后: + +```js +ctx.inject(["dshGo"], (dshCtx) => { + import("./dsh-go/panel.js") + .then((panel) => panel.mountDshGoPanel(dshCtx, { apiToken: cfg.apiToken })) + .catch((error) => ctx.logger?.warn?.(`[dsh-mneme] dsh-go 面板未加载:${error?.message ?? error}`)); +}); +``` + +两处细节都是刻意的: + +- **动态 import**:静态导入会在插件加载时就把面板模块跑一遍,「没有 dsh-go 就不加载这个域」这句承诺就不成立了。 +- **把 `cfg.apiToken` 交给面板**:`createApi` 给 token / token-write 路由包了鉴权,进程内调用不带令牌会被自家路由拦成 401。令牌以 `x-dsh-mneme-token` 头随请求走(`src/dsh-go/host.js`),不给进程内调用开白名单。 + +### 3. 视图协议(v2) + +`buildView(state)` 返回 `{v:2, type:"stack", accent:"app", children:[…]}`,顶层是 App 原生 `tabs`:记忆 / 实体 / 设置。设置页顶部两格 `segmented`(设置 | 状态)——电脑端是四个页签,但 App 的页签条每格有 80dp 下限,四格在手机上会折行,所以放三格。 + +### 4. 0.11.0 补上的两块面板面 + +mneme 0.11.0 把「Agent 眼中的你」自动事实(`profile_facts` 三表)和 chorography 地志搬上了 Web 面板。手机面板跟着补,仍走同一批路由: + +| 面板位置 | 路由 | 说明 | +|---|---|---| +| 设置 › 个性化 › Agent 眼中的你 | `GET /profile-facts?agent=` | 一次拿到 `cards` / `facts`(含归档)/ `journal` / `runs`;flag 关着也照读,历史上攒下的事实要能看见 | +| 同上(增 / 改 / 归档) | `POST /profile-facts/apply` | body `{agent, ops}`,op 形状与自动轮共用:`add` 必须带 `content`,`update`/`archive` 必须带 `id` | +| 同上(立即更新) | `POST /profile-facts/run` | 一次 LLM 维护轮,慢且串行,面板按 pending 处理(60s);`409` = 功能关着,`503` = 没配模型,`502` = 模型答得不成形 | +| 设置 › 记忆引擎 › 代码库地志 | `GET /choro/repos`、`/choro/modules?repo=`、`/choro/page?repo=&slug=` | 仓库 → 模块骨架 → 页正文;都受 `chorographyEnabled` 闸门(关着回 `409 disabled`) | +| 同上(重建骨架) | `POST /choro/run` `{repo}` | 只跑 T0 apply(秒级);T1 富化仍需命令行 `--t1`,所以重建会把已富化的语义段退回骨架 | + +功能开关这块也换了一半口径: + +- 枚举型旗标(`chorographyInject` 的 `light/full/off`)用下拉,`PUT /features` 收字符串值——同一个动作 id 既服务开关也服务下拉。 +- 新增「其它」分组:把 `/features` 的 `effective` 里**我们还没给中文名**的布尔项动态列成数据行(`list` + `items` 绑定),点一行翻转一项。上游每加一个开关,手机面板不会再静默漏掉它;数值/字符串键(模型名、预算)不放这里,它们要输入框。 + +## 内部文件 + +| 文件 | 一句话 | +|---|---| +| `src/dsh-go/panel.js` | 状态机、取数、动作(`mountDshGoPanel`)。动作跑完自己把新数据推给所有打开的 feed | +| `src/dsh-go/host.js` | 进程内派发:`ctx.get("webServer").match(path)` 拿到 mneme 自己的 handler,伪造 req/res 调用 | +| `src/dsh-go/text.js` | 类型标签、开关分组、时间与 scope 格式化 | +| `src/dsh-go/views/root.js` | `buildView`:顶层页签组合 | +| `src/dsh-go/views/{common,memory,entity,settings,status}.js` | 各分区视图;`common.js` 是小组件构造器 | + +文案取自 Web 面板的 zh i18n(`lib/client.js` 里的 `memory.*` 键),面板不再自造解释性文案。 + +## 已知坑 + +- **feed 是推送式的**:`data()` 每个面板实例只跑一次,`refresh` 效果只让 App 重画视图、不会重跑 `data()`。所以每个动作结尾都要 `broadcast()`(向所有 emit 推新 payload),否则点了按钮界面还是旧数据。推送节流 4/s。 +- **不能走 HTTP**:[dsh-go](https://github.com/ccch1mneyyy/dsh-Go) 桥的路由守卫(`installFallbackGuard`)对没有合法签名的请求直接 401(`x-dsh-go-guard: reject`),浏览器 cookie 是宿主签的、进程内也拿不到。进程内 `webServer.match()` 绕开这一层,且复用同一批 handler。 +- **`/external-api` 的 GET 有副作用**:不带参数时会顺手生成 Bearer token 并落库。面板读配置用 `?token=omit`(只回 `enabled/port/host`,不铸造也不落库),不读令牌。 +- **进程内调用也要带令牌**:宿主配了 `apiToken` 时,`/update`、`/delete`、`/features` 这些 token 路由对进程内请求同样校验;令牌走 `x-dsh-mneme-token` 头。 +- **两种响应节奏都要等**:多数路由在返回的 promise 里 `res.end`,少数(`/version-check`)立即 `return` 再在回调里写响应。`host.js` 同时等 handler 的返回值和响应结束,只 race 的话后者会被当成空结果。 +- **`/search` 不认识 `type`**:它是全库语义检索,面板在拿到结果后再按所选类型收一遍。 +- **`/entities` 的 `q`**:名字过滤必须下推到 store(`LIKE`,`%`/`_` 转义)。面板若自己取 limit 条再本地过滤,排在 limit 之外的实体永远搜不到。 +- **删除只能按状态认人**:`confirm` 节点没有参数位(只有 id 与两个按钮文案),所以「删哪一条」只能靠状态;状态是所有面板共用的,因此面板按 `scope` 记下自己打开的那条,确认时与本打开的条目核对,不一致就拒绝。 +- **`/update` 不接受 `type`**:编辑已有记忆时类型只读展示,新建才给选择器。 +- **保存只发差集**:`/update` 没有版本号校验,整份提交会把别处(工具、Web 面板)刚写进去的新值盖掉。面板在编辑页记住打开时的快照,只提交改动过的字段,`updated_at` 对不上就中止并让用户再看一眼。 +- **`/create` 不接受 scope**:归属 Agent / 工作区两个输入只在编辑已有记忆时出现。 +- **节点预算**:面板 120 节点 / 8 层 / 32 KiB,`data` 32 KiB。列表行只下发 `summary`(`content` 截 120 字),全文留在详情取,否则条目一多就超预算。 +- **地志与事实都有闸门**:`/choro/*` 在 `chorographyEnabled` 关闭时回 `409 {error:"disabled"}`,`/profile-facts/run` 在 `profileFactsEnabled` 关闭时同样 `409`;两个读口(`/profile-facts`、`/choro/repos` 之外的模块/页)不受开关影响,面板要能展示历史数据。 +- **事实的 op 形状是硬约定**:`add` 缺 `content` 会把 `"undefined"` 写进事实正文并从此每帧注入;面板在本地就拦「正文不能空」,不把脏 op 递过去。 +- **重建骨架只到 T0**:`POST /choro/run` 是同步秒级回执,不带 T1 富化;面板文案要说明「语义段退回骨架」,否则用户会以为富化丢了。 +- **`refresh` 会重建视图树,页签选择会丢**:顶层 `tabs` 的选中项是 App 的本地状态(视图协议没有绑定口),App 收到 `refresh` 重新拉一遍视图,树一重建就回到第一个页签。所以「只改一个绑定值」的动作(开关、档位下拉、外部访问开关、事实增改、地志翻页)不要申请重画,只推数据;只有真的改变了「哪些节点存在」的动作(进子页、换分区、换分组、翻页、搜索)才要 refresh。 +- **`nestFlags` 不能把值压成布尔**:枚举型旗标(`chorographyInject` = light/full/off)以前被写成 `value === true`,下拉永远只拿到 true/false。视图绑定要原样透传。 +- **不要用 `metric.caption` 放单位**:`caption` 固定渲染在数值下方一行;v2 的 metric 整串数值同一字号,所以单位拼进 value(`57 条`),App 侧只有自带模型卡才会拆小字单位。 diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index 711f3bde..d21b7c5b 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -392,7 +392,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // merge/update verdict ∪ source=dream 直写)——状态页「查看全部」 // 与记忆库的「沉淀」筛选 chip 共用这个视图。 const depositedOnly = url.searchParams.get("deposited") === "only"; - const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }); + // id: 按主键取单条(含归档行)。面板打开/保存前要拿「这一条」的最新值, + // 分页扫列表最多只能看到前 1000 条,已归档的更不在默认视图里;扫不到就 + // 只能拿陈旧缓存去比对 updated_at,等于没有版本检查。 + const idFilter = (url.searchParams.get("id") ?? "").trim(); + const rows = idFilter === "" + ? service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) + // getById 是裸 SELECT,没有 forgotten 谓词;这条路由是 open 的, + // 不过滤等于让本机任意进程按 id 读到已遗忘记忆的全文(评审 #1)。 + // 归档行仍要放行:归档 ≠ 遗忘,面板保存前要用它比对 updated_at。 + : [service.getById(idFilter)].filter((memo) => memo && !memo.forgotten); // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出 // schema 严格复用 toApiList,扩展只发生在 HTTP 层。 // heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度 @@ -415,8 +424,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = })); // Total honors the same filters as the rows, or the pager's // has-more math breaks whenever minImportance/source/updated-at - // bounds are active. - sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) }); + // bounds are active. 按 id 取单条时总数就是命中的行数。 + const total = idFilter === "" + ? service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) + : rows.length; + sendJson(res, 200, { items, total }); } catch { sendJson(res, 500, { error: "internal" }); } @@ -1689,7 +1701,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = try { const url = new URL(req.url, "http://localhost"); const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500)); - const entities = service.listEntities?.({ limit }) ?? []; + // q = 名字过滤。必须下推到 store:面板此前取回 limit 条再本地过滤, + // 排在 limit 之后的实体永远搜不到(评审 #9)。老宿主没有这个形参时 + // 多传一个键不会报错,只是过滤失效——与 limit 的处理一致。 + const q = url.searchParams.get("q") ?? ""; + const entities = service.listEntities?.({ limit, q }) ?? []; // total 是**全库**实体数,不是本次切片长度:目录带 limit,面板拿它当 // 「一共抽出多少实体」,填切片长度会让 1.6 万实体永远显示成 500。 // 老宿主(service 上没有 countEntities)回落到切片长度,不抛。计数本身失败也 @@ -2287,11 +2303,17 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // materialize and persist one here. 只有通过上面那道门才会走到这里, // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。 const kv = settings.getExternalApi() ?? {}; - if (!kv.token) { + // ?token=omit:只读配置、不生成也不落库 Bearer。 + // 手机端面板(src/dsh-go)只需要 enabled/port/host 来渲染表单,而默认的 + // GET 会顺手 materialize 一个 token 写进 user_settings —— 一次纯读请求 + // 变成写路径,既污染审计也违反「面板不显示明文令牌」的承诺。带上这个 + // 参数时 token 字段一律留空;不传的老客户端行为完全不变。 + const omitToken = new URL(req.url, "http://localhost").searchParams.get("token") === "omit"; + if (!omitToken && !kv.token) { kv.token = randomBytes(24).toString("base64url"); settings.setExternalApi({ token: kv.token }); } - sendJson(res, 200, { config: fullExternalConfig(kv) }); + sendJson(res, 200, { config: fullExternalConfig(omitToken ? { ...kv, token: "" } : kv) }); } catch { sendJson(res, 500, { error: "internal" }); } diff --git a/dsh-mneme/lib/dsh-go/host.js b/dsh-mneme/lib/dsh-go/host.js new file mode 100644 index 00000000..f230a5c4 --- /dev/null +++ b/dsh-mneme/lib/dsh-go/host.js @@ -0,0 +1,162 @@ +// In-process call into the mneme HTTP API. +// +// The panel runs inside the engine process, exactly where mneme registered its +// routes. Going through real HTTP would hit the dsh-go bridge guard (401 unless +// the request carries the process launch token, which is never written to disk), +// so we dispatch the registered route handler directly and never leave the +// process. Everything stays on the local web server table: no sockets. +// +// Auth is not skipped: mneme's route registrar wraps every route with a policy +// check, so a write route answers 401 unless the request carries the configured +// apiToken. We forward it as the x-dsh-mneme-token header (one of the two the +// registrar accepts) instead of weakening the policy for in-process callers. +import { EventEmitter } from "node:events"; + +const PREFIX = "/api/dsh-mneme"; +const DEFAULT_TIMEOUT = 5000; + +function makeRequest(url, method, body, apiToken) { + const req = new EventEmitter(); + req.url = url; + req.method = method; + req.headers = { + host: "127.0.0.1", + "content-type": "application/json", + "content-length": String(body === undefined ? 0 : Buffer.byteLength(body)), + }; + if (typeof apiToken === "string" && apiToken.length > 0) { + req.headers["x-dsh-mneme-token"] = apiToken; + } + req.socket = { remoteAddress: "127.0.0.1", encrypted: false }; + req.resume = () => {}; + req.pause = () => {}; + req.setEncoding = () => {}; + // The handler attaches its body listeners synchronously when invoked, so the + // body is only released afterwards by flushRequest(); emitting during + // construction would fire 'data'/'end' with no listener attached and the + // handler's readBody() would wait forever. + req.flush = () => { + queueMicrotask(() => { + if (body !== undefined) req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + }; + return req; +} + +function makeResponse() { + const chunks = []; + let settled; + const done = new Promise((resolve) => { + settled = resolve; + }); + const headers = {}; + const res = { + statusCode: 200, + headersSent: false, + done, + writeHead(status, extra) { + res.statusCode = status; + res.headersSent = true; + if (extra !== undefined && extra !== null) { + for (const key of Object.keys(extra)) headers[key.toLowerCase()] = extra[key]; + } + return res; + }, + setHeader(key, value) { + headers[key.toLowerCase()] = value; + return res; + }, + getHeader(key) { + return headers[key.toLowerCase()]; + }, + removeHeader(key) { + delete headers[key.toLowerCase()]; + }, + write(chunk) { + if (chunk !== undefined && chunk !== null && typeof chunk !== "function") { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + return true; + }, + end(chunk) { + if (chunk !== undefined && chunk !== null && typeof chunk !== "function") { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + settled(); + return res; + }, + flushHeaders() {}, + }; + res.text = () => Buffer.concat(chunks).toString("utf8"); + res.bytes = () => Buffer.concat(chunks); + return res; +} + +// 超时定时器不能被 unref():handler 永不响应时,它是唯一还挂在事件循环上的 +// 东西;unref 之后 node 22 的 test runner 会判定循环已空,把用例记成 cancelled +// (CI 的 node22 两腿就是这么红的)。改成显式 cancel —— 成功路径也不白挂 5 秒。 +function timeout(ms) { + let timer; + const promise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("mneme 接口超时")), ms); + }); + return { promise, cancel: () => clearTimeout(timer) }; +} + +/** + * Resolve the mneme route handler and run it in-process. + * @param {object} ctx - the plugin context (must expose get()). + * @param {{apiToken?: string}} [options] - the host's configured API token; the + * route registrar requires it for every token / token-write route. + */ +export function createHostCall(ctx, options = {}) { + const apiToken = options.apiToken; + return async function callMneme(path, options = {}) { + const method = (options.method ?? "GET").toUpperCase(); + const body = options.body === undefined ? undefined : JSON.stringify(options.body); + const url = new URL(PREFIX + path, "http://127.0.0.1"); + const webServer = ctx.get?.("webServer") ?? ctx.reflect?.get?.("webServer"); + if (webServer === undefined || typeof webServer.match !== "function") { + throw new Error("宿主 webServer 不可用"); + } + const route = webServer.match(url.pathname); + if (route === undefined || route === null) throw new Error(`mneme 未注册 ${url.pathname}`); + const req = makeRequest(url.pathname + url.search, method, body, apiToken); + const res = makeResponse(); + let run; + try { + run = Promise.resolve(route.handler(req, res)); + } finally { + req.flush(); + } + // Wait for both: most routes return a promise and end the response inside it, + // but a few (version-check) return undefined and end later from a callback. + // Racing the three would return the moment the synchronous call returns and + // hand an empty body to the caller, which then looks like a failed fetch. + const guard = timeout(options.timeoutMs ?? DEFAULT_TIMEOUT); + try { + await Promise.race([ + Promise.all([run, res.done]).then(() => undefined), + guard.promise, + ]); + } finally { + guard.cancel(); + } + const text = res.text(); + if (res.statusCode >= 400) { + const detail = text.length > 200 ? `${text.slice(0, 200)}…` : text; + const error = new Error(`mneme ${method} ${path} → ${res.statusCode} ${detail}`); + error.status = res.statusCode; + error.body = text; + throw error; + } + if (options.raw === true) return text; + if (text === "") return {}; + try { + return JSON.parse(text); + } catch { + return { text }; + } + }; +} diff --git a/dsh-mneme/lib/dsh-go/panel.js b/dsh-mneme/lib/dsh-go/panel.js new file mode 100644 index 00000000..1ce4779a --- /dev/null +++ b/dsh-mneme/lib/dsh-go/panel.js @@ -0,0 +1,2091 @@ +// local-mneme-panel — a dsh-go native panel over the installed dsh-mneme store. +// +// The panel never talks HTTP: src/dsh-go/host.js dispatches mneme's own registered +// route handlers inside this engine process. Reads are mneme's own GET routes; +// writes go through mneme's own routes (create/update/delete/features/profile/ +// rules/mode/conflicts/commands/vector/external/reclaim), so its apiToken policy +// still applies if one is ever set. +import { createHostCall } from "./host.js"; +import { buildView } from "./views/root.js"; +import { + FLAG_LABELS, + RANGE_DAYS, + TYPE_LABELS, + clip, + errorText, + flagAction, + fmtDay, + fmtMonth, + fmtTime, + joinTags, + scopeText, + splitTags, + entityIcon, + entityTypeLabel, + typeIcon, + typeLabel, + typeTone, +} from "./text.js"; + +const PAGE_SIZE = 20; +const cache = new Map(); +// Live panel feeds, one emit per open panel/locale. dsh-go feeds are push-based: +// data() runs once and later state changes must be pushed through these emits +// (a `refresh` effect only re-renders the view; it never re-runs data()). +const emitters = new Set(); +// 每个面板 scope 打开过哪条记忆。删除确认(confirm 节点)不携带任何参数, +// 只能靠状态决定删谁;状态又是所有面板共用的,所以按 scope 记一份,删除前 +// 与本面板打开的那条核对,不匹配就拒绝(评审 #5)。 +const openedBy = new Map(); +// 冲突页的裁决同样改数据,而 state.conflictId 是模块级共享的:两个面板 +// 各看一条冲突时,后开的会盖掉前一个的 id。按面板 scope 记一份再核对 +// (评审 #3 的 conflict-win 部分)。 +const conflictBy = new Map(); +// 「Agent 眼中的你」的编辑态 state.factEdit 同样是共享的;按面板 scope 记 +// 一份打开的事实 id,保存与归档前核对(评审 #3 的 facts 部分)。 +const factBy = new Map(); +// 上一次推给 App 的完整数据:未重新读取的块从这里并回去,保证每份 data 都 +// 带着全部绑定键(见 load 的注释)。 +let lastPayload = null; +const scopeKey = (scope) => [scope?.sessionId ?? "", scope?.turnId ?? "", scope?.callId ?? ""].join("|"); + +const defaultState = () => ({ + page: "list", // list|detail|editor|entity|conflict|settings|profile + flagGroup: "basic", + extraRows: [], // 上游新增、面板还没中文名的布尔旗标 + factsCards: [], + factsAgent: "", + factsRows: [], + factsItems: [], + factEdit: null, + factsRunText: "", + choroRepos: [], + choroRepo: "", + choroRows: [], + choroNote: "", + choroPageText: "", + settingsView: "settings", // settings|status + sPanel: "menu", // menu|workbench|commands|vector|external|help|facts|choro + profileTab: "profile", + q: "", + type: "all", + range: "all", + minImportance: 0, + scope: "all", + semantic: false, + viewMode: "flat", + heatSort: false, + filtersOpen: false, + offset: 0, + size: PAGE_SIZE, + current: null, + form: {}, + // 编辑页打开时的那份快照:只有和它不同的字段才会写回 /update, + // 并且用它挡住「别处已经改过这条记忆」(评审 #4)。 + editBase: null, + entityTotal: -1, + entityTypeTotal: 0, + entityQuery: "", + entityName: "", + entityDepth: 1, + entityRelationCount: 0, + entityMemoryCount: 0, + flags: {}, + flagOverrides: {}, + pendingRestart: [], + pendingConflicts: 0, + pendingIds: new Set(), + rowsCount: 0, + total: 0, // 当前筛选下的总条数,翻页时用它算页数 + monthGroups: [], + detailEntities: [], + detailEntityCount: 0, + detailConflict: false, + provenanceText: "", + conflictCount: 0, + conflictId: "", + conflictPick: "", + conflictA: null, + conflictB: null, + conflictReady: false, + wbKv: [], + wbDeposited: [], + wbArchived: [], + commands: [], + cmdCount: 0, + sCmdPick: "", + sCmdName: "", + sCmdDesc: "", + sCmdIns: "", + vector: { enabled: false, baseUrl: "", model: "" }, + external: { enabled: false, port: 8790, host: "127.0.0.1" }, + // 读成功才允许整体写回:读失败时上面的 vector/external 会落成默认值, + // 此时 PUT 会把用户在 Web 面板里配好的地址/端口覆盖掉(评审 #2)。 + vectorLoaded: false, + externalLoaded: false, + // 实体搜索要重读的是「实体清单」而不是整页;load() 按这个标记补读 + // (评审 #4)。 + entityDirty: false, + // 顶层页签由 App 本地切换,state.page 可能还停在 "list";设置项的动作 + // 改完数据后也要重读设置块,否则推到面板上的仍是旧值(评审 #6)。 + settingsDirty: false, + versionNote: "", + extractText: "", + injectPreviewText: "", + tokenCategories: [], + tokenSeries: [], + tokenKv: [], + heatSeries: [], + heatNote: "", + reclaimText: "尚未检查。", + reclaimConfirm: false, + notice: "", + noticeTone: "ok", + err: "", +}); + +const state = defaultState(); + +function resetNotice() { + state.notice = ""; + state.err = ""; +} + +function notice(text, tone = "ok") { + state.notice = clip(text, 160); + state.noticeTone = tone; +} + +/** Read the resolved action arguments/values the app sends with an invoke. */ +function valueOf(event, keys, fallback) { + const value = event?.value; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const key of keys) { + if (value[key] !== undefined && value[key] !== null) return value[key]; + } + } + if (typeof value === "string" && value !== "") return value; + if (typeof value === "boolean") return value; + return fallback; +} + +function num(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function rowOf(row) { + const heat = typeof row.heat === "number" ? row.heat : Number.NaN; + const pending = state.pendingIds.has(row.id); + const marks = []; + if (row.archived === true) marks.push("已归档"); + if (pending) marks.push("待确认"); + return { + title: clip(row.title, 60), + subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at ?? row.created_at)}${marks.length === 0 ? "" : ` · ${marks.join(" · ")}`}`, + trailing: Number.isFinite(heat) ? `★${row.importance ?? 1} · 热度 ${heat.toFixed(2)}` : `★${row.importance ?? 1}`, + ...(typeIcon(row.type) === undefined ? {} : { icon: typeIcon(row.type) }), + tone: typeTone(row.type), + action: { id: "open", args: { id: row.id } }, + }; +} + +/** Flat dotted flag keys -> nested object so `data.flags.a.b` bindings resolve. */ +function nestFlags(effective) { + const out = {}; + for (const [key, value] of Object.entries(effective ?? {})) { + const parts = key.split("."); + let node = out; + for (const part of parts.slice(0, -1)) { + if (typeof node[part] !== "object" || node[part] === null) node[part] = {}; + node = node[part]; + } + // 原样透传:开关是布尔,枚举型旗标(chorographyInject 的 light/full/off) + // 是字符串。以前这里一律写成 `value === true`,下拉永远只看到 true/false。 + node[parts[parts.length - 1]] = value; + } + return out; +} + +const enc = (value) => encodeURIComponent(String(value ?? "")); + +/** + * Mount the panel on a context that already provides the dsh-go `dshGo` service. + * Called by src/index.js from `ctx.inject(['dshGo'], ...)`: without the dsh-go + * bridge (desktop / headless hosts) this module never runs. + */ +export function mountDshGoPanel(ctx, options = {}) { + // options.apiToken 由 src/index.js 传入:路由注册器给 token / token-write + // 路由包了鉴权,进程内调用不带这个头就会被自家 API 拦成 401(评审 #2)。 + const call = createHostCall(ctx, options); + + // ---------------------------------------------------------------- loaders + function groupByMonth(rows, raw) { + const buckets = new Map(); + rows.forEach((row, index) => { + const label = fmtMonth(raw[index]?.updated_at ?? raw[index]?.created_at); + if (!buckets.has(label)) buckets.set(label, []); + buckets.get(label).push(row); + }); + return [...buckets.entries()].map(([label, list]) => ({ label, rows: list })); + } + + async function loadMemory() { + const statsPromise = call("/recall-stats?window=30").catch(() => ({})); + const conflictsPromise = call("/conflicts").catch(() => ({})); + const dreamPromise = call("/dream-status").catch(() => ({})); + const payload = { + query: state.q, + filter: state.type, + fRange: state.range, + fMin: String(state.minImportance), + fScope: state.scope, + fSemantic: state.semantic, + fView: state.viewMode, + fHeat: state.heatSort, + }; + let items = []; + if (state.q !== "") { + const mode = state.semantic ? "hybrid" : "auto"; + const res = await call(`/search?q=${enc(state.q)}&topK=20&mode=${mode}`); + // /search 没有 type 参数(它是全库语义检索),类型筛选只能在拿到结果后 + // 再收一遍,否则搜索会把用户选好的类型丢掉(评审 #8)。 + const hits = res?.items ?? []; + items = state.type === "all" || state.type === "" ? hits : hits.filter((item) => item?.type === state.type); + payload.total = String(items.length); + // 空结果不再靠一块 emptyState 表达:那个结构在首屏就定了,数据晚一步 + // 到达时就会和真实列表同时出现。文案跟着数据走,结构不跟着动。 + payload.pageText = + items.length === 0 + ? "搜索无结果" + : `搜索 · ${items.length} 条`; + } else { + const query = new URLSearchParams({ + limit: String(state.size), + offset: String(state.offset), + order: "chrono", + }); + if (state.type !== "all" && state.type !== "") query.set("type", state.type); + if (state.minImportance > 0) query.set("minImportance", String(state.minImportance)); + const days = RANGE_DAYS[state.range]; + if (days !== undefined) query.set("updatedFrom", new Date(Date.now() - days * 86400000).toISOString()); + if (state.scope === "deposited") query.set("deposited", "only"); + if (state.scope === "archived") query.set("archived", "only"); + if (state.scope === "foreign") query.set("source", "imported"); + const res = await call(`/list?${query.toString()}`); + items = res?.items ?? []; + const total = num(res?.total, items.length); + const pages = Math.max(1, Math.ceil(total / state.size)); + const pageNo = Math.min(pages, Math.floor(state.offset / state.size) + 1); + payload.total = String(total); + // 这行文案同时是左胶囊的标签,列宽只有约 142dp,越长越容易折行。 + payload.pageText = total === 0 ? "没有匹配的记忆" : `${total} 条 · ${pageNo}/${pages} 页`; + } + for (const row of items) cache.set(row.id, row); + const dream = await dreamPromise; + state.pendingIds = new Set(Array.isArray(dream?.pendingMemoryIds) ? dream.pendingMemoryIds : []); + const ordered = state.heatSort ? [...items].sort((a, b2) => num(b2.heat, -1) - num(a.heat, -1)) : items; + payload.rows = ordered.map(rowOf); + state.monthGroups = groupByMonth(ordered, ordered); + const [stats, conflicts] = await Promise.all([statsPromise, conflictsPromise]); + payload.injected = String(stats?.injection?.injectedCount ?? 0); + payload.slotRate = String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10); + payload.conflicts = String(conflicts?.total ?? 0); + // metric values render in one style, so units ride along in the value + payload.totalText = `${payload.total ?? "0"} 条`; + payload.injectedText = `${payload.injected} 次`; + payload.slotRateText = `${payload.slotRate}%`; + payload.conflictsText = `${payload.conflicts} 条`; + state.pendingConflicts = num(conflicts?.total, 0); + payload.pendingConflicts = state.pendingConflicts; + // 翻页用的是 chips(原生最小 48dp,两个一定同行),点完立刻把绑定清空, + // 免得选中的那枚一直带着勾。 + payload.pagePick = ""; + state.rowsCount = (payload.rows ?? []).length; + state.total = num(payload.total, state.rowsCount); + return payload; + } + + async function loadDetail() { + const memo = state.current ?? {}; + const entities = await call(`/memories/entities?memoryId=${enc(memo.id ?? "")}`).then((res) => res?.entities ?? []); + state.detailEntityCount = entities.length; + state.detailConflict = state.pendingIds.has(memo.id); + return { + detailTitle: clip(memo.title ?? "(无标题)", 120), + detailMeta: [ + { label: "类型", value: typeLabel(memo.type) }, + { label: "重要度", value: `★${memo.importance ?? 1}` }, + { label: "归属", value: scopeText(memo) }, + { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) }, + { label: "标签", value: clip(joinTags(memo.tags) || "—", 40) }, + { label: "来源", value: clip(memo.source ?? "—", 40) }, + { label: "状态", value: memo.archived ? "已归档" : "正常", tone: memo.archived ? "muted" : "good" }, + ], + detailContent: clip(memo.content ?? "", 2000), + detailType: typeLabel(memo.type), + detailTypeTone: typeTone(memo.type) ?? "info", + detailStars: `重要度 ★${memo.importance ?? 1}`, + detailEntities: entities.slice(0, 8).map((item) => ({ + title: clip(item.name ?? item.title ?? item.id ?? "—", 40), + subtitle: clip(item.type ?? "实体", 20), + icon: "user", + })), + detailEntityCount: entities.length, + detailConflict: state.detailConflict, + provenanceText: state.provenanceText, + }; + } + + function loadEditor() { + return { + form: { + id: state.form.id ?? "", + title: state.form.title ?? "", + content: state.form.content ?? "", + type: state.form.type ?? "project", + importance: num(state.form.importance, 3), + tags: state.form.tags ?? "", + agentScope: state.form.agentScope ?? "", + workspaceScope: state.form.workspaceScope ?? "", + }, + }; + } + + async function loadEntity() { + const needle = state.entityQuery.trim(); + // 名字过滤交给宿主(/entities?q=):本地过滤只能看见 limit 那一条切片, + // 排在后面的实体搜不到(评审 #9)。类型数只在不搜索时重算——它代表整库 + // 有几种类型,不该随搜索词一起抖动。 + const [directory, matched] = await Promise.all([ + needle === "" ? null : call("/entities?limit=200").catch(() => null), + call(`/entities?limit=${needle === "" ? 200 : 30}${needle === "" ? "" : `&q=${enc(needle)}`}`), + ]); + const all = directory?.entities ?? matched?.entities ?? []; + state.entityTotal = num(matched?.total, all.length); + if (needle === "") { + const types = new Set(); + for (const item of all) { + if (typeof item?.type === "string" && item.type !== "") types.add(item.type); + } + state.entityTypeTotal = types.size; + } + const shown = (needle === "" ? all : (matched?.entities ?? [])).slice(0, 30); + return { + entityTotal: String(state.entityTotal), + entityTypeTotal: String(state.entityTypeTotal), + entityTotalText: `${String(state.entityTotal)} 个`, + entityTypeTotalText: `${String(state.entityTypeTotal)} 类`, + entityQuery: state.entityQuery, + // 结构固定,空库的提示走这一行文案(见 views/entity.js 的注释)。 + entityStatus: + shown.length === 0 + ? needle === "" + ? "还没有实体 · 在「设置 › 记忆引擎」的功能开关里打开「抽取人物与项目」" + : `没有名字含「${clip(needle, 20)}」的实体` + : needle === "" + ? `共 ${String(state.entityTotal)} 个实体` + : `搜索「${clip(needle, 20)}」· ${String(shown.length)} 个`, + entities: shown.map((item) => ({ + title: clip(item.name ?? item.title ?? item.id ?? "—", 40), + subtitle: clip(entityTypeLabel(item.type ?? "—"), 20), + trailing: item.mention_count === undefined ? undefined : `${item.mention_count} 次`, + ...(entityIcon(item.type) === undefined ? {} : { icon: entityIcon(item.type) }), + action: { id: "open-entity", args: { name: String(item.name ?? "") } }, + })), + }; + } + + async function loadEntityDetail() { + const name = state.entityName; + const ego = await call(`/semantic/graph/ego?entity=${enc(name)}&depth=${state.entityDepth}&limit=40`); + const attrs = await call(`/semantic/graph/entity-attrs?entity=${enc(name)}`).catch(() => null); + const nodes = Array.isArray(ego?.nodes) ? ego.nodes : []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const root = ego?.root ?? {}; + const relations = (Array.isArray(ego?.edges) ? ego.edges : []).slice(0, 30).map((edge) => { + const from = byId.get(edge.from_entity); + const to = byId.get(edge.to_entity); + const other = edge.from_entity === root.id ? edge.to_entity : edge.from_entity; + const otherNode = byId.get(other); + return { + title: `${clip(from?.name ?? "—", 16)} → ${clip(to?.name ?? "—", 16)}`, + subtitle: clip(edge.relation_type ?? "关系", 24), + tone: "info", + ...(otherNode === undefined + ? {} + : { action: { id: "open-entity", args: { name: String(otherNode.name ?? "") } } }), + }; + }); + const memories = new Map(); + for (const edge of Array.isArray(ego?.edges) ? ego.edges : []) { + if (typeof edge.memory_id === "string" && edge.memory_id !== "") { + const memo = cache.get(edge.memory_id); + memories.set(edge.memory_id, { + title: clip(memo?.title ?? `记忆 ${edge.memory_id.slice(0, 8)}`, 40), + subtitle: memo === undefined ? "未在本地缓存" : typeLabel(memo.type), + action: { id: "open", args: { id: edge.memory_id } }, + }); + } + } + state.entityRelationCount = relations.length; + state.entityMemoryCount = memories.size; + return { + entityName: clip(root.name ?? name, 40), + entityType: clip(entityTypeLabel(root.type ?? "实体"), 20), + entityMentionText: `被提到 ${String(root.mention_count ?? 0)} 次 · 关系 ${relations.length} 条`, + entityDepth: String(state.entityDepth), + entityAttrs: (Array.isArray(attrs?.attrs) ? attrs.attrs : []).slice(0, 24).map((attr) => ({ + label: clip(attr.key ?? "—", 30), + value: clip(String(attr.value ?? "—"), 60), + })), + entityRelations: relations, + entityMemories: [...memories.values()].slice(0, 20), + entityRelationCount: relations.length, + entityMemoryCount: memories.size, + entityHint: "点关系可以跳到相邻实体;点记忆可以跳到详情。", + }; + } + + async function loadSettings() { + const [features, mode] = await Promise.all([call("/features"), call("/mode")]); + const effective = features?.effective ?? {}; + state.flags = effective; + state.flagOverrides = features?.overrides ?? {}; + state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : []; + const changed = Object.keys(state.flagOverrides).length; + const pending = state.pendingRestart.length; + // 上游不断加旗标:没给中文名的布尔项仍要出现在面板上,否则新功能在手机上 + // 静默消失。数值/字符串键(模型名、预算)不放这里——它们需要输入框,不是开关。 + state.extraRows = Object.keys(effective) + .filter((key) => !FLAG_LABELS.has(key) && typeof effective[key] === "boolean") + .sort() + .map((key) => ({ + title: key, + subtitle: effective[key] === true ? "已开启 · 点一下关闭" : "已关闭 · 点一下开启", + icon: effective[key] === true ? "toggle-on" : "toggle-off", + action: { id: "flag-set", args: { key } }, + })); + const payload = { + extraRows: state.extraRows, + settingsView: state.settingsView, + sPanel: state.sPanel, + flagGroup: state.flagGroup, + flags: nestFlags(effective), + mode: String(mode?.mode ?? "standard"), + flagsMeta: `已改 ${changed} 项 · 待重启生效 ${pending} 项(改动即时写回 mneme,重启引擎后彻底生效)`, + }; + if (state.sPanel === "menu") return payload; + if (state.sPanel === "workbench") { + const [deposited, archived] = await Promise.all([ + call("/list?deposited=only&limit=8&order=chrono").catch(() => ({})), + call("/list?archived=only&limit=5&order=chrono").catch(() => ({})), + ]); + const depItems = deposited?.items ?? []; + const arcItems = archived?.items ?? []; + for (const row of [...depItems, ...arcItems]) cache.set(row.id, row); + state.wbDeposited = depItems.map((row) => ({ + title: clip(row.title, 50), + subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at)}`, + action: { id: "open", args: { id: row.id } }, + })); + state.wbArchived = arcItems.map((row) => ({ + title: clip(row.title, 50), + subtitle: fmtTime(row.updated_at), + trailing: "点按恢复", + action: { id: "wb-restore", args: { id: row.id } }, + })); + Object.assign(payload, { + wbKv: [ + { label: "已沉淀", value: `${String(deposited?.total ?? depItems.length)} 条` }, + { label: "已归档", value: `${String(archived?.total ?? arcItems.length)} 条` }, + ], + wbDeposited: state.wbDeposited, + wbArchived: state.wbArchived, + }); + } else if (state.sPanel === "commands") { + const res = await call("/commands").catch(() => ({ commands: [] })); + state.commands = Array.isArray(res?.commands) ? res.commands : []; + state.cmdCount = state.commands.length; + Object.assign(payload, { + cmdRows: state.commands.map((cmd) => ({ + title: clip(cmd.name ?? cmd.id ?? "—", 30), + subtitle: clip(cmd.description ?? "", 60), + })), + cmdCount: state.cmdCount, + sCmdPick: state.sCmdPick, + sCmdName: state.sCmdName, + sCmdDesc: state.sCmdDesc, + sCmdIns: state.sCmdIns, + }); + } else if (state.sPanel === "vector") { + const res = await call("/vector-config").catch(() => null); + // 读到才算 loaded:读失败时下面会落成默认值,不能再拿它整体覆盖 + // 服务端配置(评审 #2)。 + state.vectorLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object"; + const config = res?.config ?? {}; + state.vector = { + enabled: config.enabled === true, + baseUrl: String(config.baseUrl ?? ""), + model: String(config.model ?? ""), + }; + const keySet = typeof config.apiKey === "string" && config.apiKey !== ""; + Object.assign(payload, { + sVecEnabled: state.vector.enabled, + sVecUrl: state.vector.baseUrl, + sVecModel: state.vector.model, + vectorKv: [ + { + label: "状态", + value: state.vector.enabled ? "已启用" : "未启用", + tone: state.vector.enabled ? "good" : "muted", + }, + { label: "接口", value: state.vector.baseUrl === "" ? "未配置" : clip(state.vector.baseUrl, 60) }, + { label: "模型", value: state.vector.model === "" ? "未配置" : state.vector.model }, + { label: "密钥", value: keySet ? "已配置(面板不显示)" : "未配置" }, + ], + vectorNote: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。", + }); + } else if (state.sPanel === "external") { + // 读一次真实配置,否则面板永远拿默认值渲染,保存时又把默认值写回去, + // 把用户已经在 Web 面板里配好的端口/地址覆盖掉(评审 #6)。 + // 必须带 token=omit:不带的话这条 GET 会顺手生成并落库一个 Bearer。 + const res = await call("/external-api?token=omit").catch(() => null); + state.externalLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object"; + const config = res?.config ?? {}; + state.external = { + enabled: config.enabled === true, + port: num(config.port, 8790), + host: typeof config.host === "string" && config.host !== "" ? config.host : "127.0.0.1", + }; + Object.assign(payload, { + sExtEnabled: state.external.enabled, + sExtPort: String(state.external.port), + sExtHost: state.external.host, + }); + } else if (state.sPanel === "facts") { + // 「Agent 眼中的你」:一次请求喂饱这张卡(facts / journal / runs)。flag 关着 + // 也照读——历史上开过又关掉的库里事实还在,得让人看得见。 + const query = state.factsAgent === "" ? "" : `?agent=${encodeURIComponent(state.factsAgent)}`; + const facts = await call(`/profile-facts${query}`).catch(() => ({})); + state.factsCards = Array.isArray(facts?.cards) ? facts.cards.map(String) : []; + const rows = Array.isArray(facts?.facts) ? facts.facts : []; + state.factsItems = rows; + const active = rows.filter((fact) => fact.status !== "archived"); + state.factsRows = rows.slice(0, 20).map((fact) => ({ + title: clip(String(fact.content ?? ""), 60), + subtitle: + fact.status === "archived" + ? `已归档 · ${fmtTime(fact.updated_at)}` + : `更新于 ${fmtTime(fact.updated_at)}`, + trailing: fact.status === "archived" ? "已归档" : "点一下修改", + action: { id: "facts-edit", args: { id: String(fact.id ?? "") } }, + })); + const runs = Array.isArray(facts?.runs) ? facts.runs : []; + const last = runs[0]; + state.factsRunText = + last === undefined + ? "还没跑过维护轮。点「立即更新」会让模型读最近的记忆补事实。" + : `最近一轮:${String(last.status ?? "?")} · 改 ${String(last.ops_applied ?? 0)} 条 · 读 ${String(last.input_count ?? 0)} 条记忆 · ${fmtTime(last.started_at)}`; + Object.assign(payload, { + factsAgent: state.factsAgent, + factsCardOptions: [ + { label: "默认", value: "" }, + // 宿主给的 cards 里常含空串(默认卡),别再和「默认」重复一次。 + ...state.factsCards.filter((key) => key !== "").map((key) => ({ label: key, value: key })), + ], + factsRows: state.factsRows, + factsRunText: state.factsRunText, + sFactNew: state.form.factNew ?? "", + sFactEdit: state.factEdit?.content ?? "", + factsEditHint: + state.factEdit === null + ? "" + : `正在修改:${clip(String(state.factEdit.content ?? ""), 40)}`, + }); + if (active.length === 0) state.factsRows.push({ title: "还没有事实", subtitle: "点下面的「立即更新」让模型生成一批。" }); + } else if (state.sPanel === "choro") { + // 地志是「按仓库」的:先列已登记仓库,再列该仓库的模块骨架页。 + const repos = await call("/choro/repos").catch(() => ({})); + state.choroRepos = (Array.isArray(repos?.repos) ? repos.repos : []).map((repo) => ({ + id: String(repo.id ?? ""), + name: clip(String(repo.name ?? repo.root_path ?? repo.id ?? ""), 40), + })); + if (state.choroRepo === "" && state.choroRepos.length > 0) state.choroRepo = state.choroRepos[0].id; + const known = state.choroRepos.some((repo) => repo.id === state.choroRepo); + if (!known) state.choroRepo = state.choroRepos[0]?.id ?? ""; + let note = "先在地志里登记仓库(命令行 dsh-mneme-choro --apply)。"; + let rows = []; + if (state.choroRepo !== "") { + const map = await call(`/choro/modules?repo=${encodeURIComponent(state.choroRepo)}`).catch(() => ({})); + const modules = Array.isArray(map?.modules) ? map.modules : []; + rows = modules.slice(0, 30).map((item) => ({ + title: clip(String(item.title ?? item.slug ?? ""), 40), + subtitle: `${String(item.file_count ?? 0)} 个文件 · ${String(item.page_status ?? "无页")}`, + trailing: "看页", + action: { id: "choro-page", args: { slug: String(item.slug ?? "") } }, + })); + const repo = state.choroRepos.find((item) => item.id === state.choroRepo); + const baseline = map?.repo?.gitBaseline; + note = + modules.length === 0 + ? "这个仓库还没有骨架页:点下面的「重建骨架」生成一次。" + : `${modules.length} 个模块${baseline ? ` · 基线 ${String(baseline).slice(0, 7)}` : ""} · ${repo?.name ?? ""}`; + } + state.choroRows = rows; + state.choroNote = note; + Object.assign(payload, { + choroRepo: state.choroRepo, + choroRepoOptions: + state.choroRepos.length === 0 + ? [{ label: "还没有登记任何仓库", value: "" }] + : state.choroRepos.map((repo) => ({ label: repo.name, value: repo.id })), + choroRows: state.choroRows, + choroNote: state.choroNote, + choroPageText: state.choroPageText, + }); + } else if (state.sPanel === "help") { + const info = await call("/info").catch(() => ({})); + Object.assign(payload, { + helpKv: [ + { label: "插件", value: `@modusensus/dsh-mneme ${String(info?.version ?? "—")}` }, + { label: "记忆库", value: "本机 SQLite(与本机 Web 面板同一份,不跨设备同步)" }, + { label: "面板", value: "dsh-go 原生面板" }, + ], + versionNote: state.versionNote, + }); + } + return payload; + } + + async function loadProfile() { + if (state.profileTab === "persona") { + const res = await call("/persona"); + const persona = res?.persona ?? {}; + const preview = res?.preview ?? {}; + return { + personaText: clip( + JSON.stringify({ default: persona.default ?? {}, byAgent: persona.byAgent ?? {} }, null, 2), + 700, + ), + previewItems: [ + { label: "生效来源", value: String(preview.source ?? "—") }, + { label: "注入内容", value: clip(preview.text === "" ? "(空)" : preview.text, 300) }, + ], + }; + } + if (state.profileTab === "rules") { + const res = await call("/rules"); + return { editText: clip(JSON.stringify(res?.rules ?? [], null, 2), 2000) }; + } + const res = await call("/profile"); + return { editText: clip(res?.profile ?? "", 2000), extractText: state.extractText }; + } + + async function loadConflict() { + const res = await call("/conflicts"); + const items = Array.isArray(res?.items) ? res.items : []; + state.conflictCount = num(res?.total, items.length); + const rows = items.slice(0, 20).map((item) => ({ + title: clip(item.a?.title ?? item.b?.title ?? item.id ?? "—", 40), + subtitle: `A:${clip(item.a?.title ?? "—", 20)} · B:${clip(item.b?.title ?? "—", 20)}`, + tone: "warn", + action: { id: "conflict-pick-row", args: { id: String(item.id ?? "") } }, + })); + const selected = items.find((item) => item.id === state.conflictId) ?? null; + state.conflictA = selected?.a ?? null; + state.conflictB = selected?.b ?? null; + state.conflictReady = selected !== null; + const side = (memo) => + memo === null + ? [] + : [ + { label: "类型", value: typeLabel(memo.type) }, + { label: "重要度", value: `★${memo.importance ?? 1}` }, + { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) }, + ]; + return { + conflictRows: rows, + conflictCount: state.conflictCount, + conflictReady: state.conflictReady, + conflictPick: state.conflictPick, + conflictATitle: clip(state.conflictA?.title ?? "", 120), + conflictAMeta: side(state.conflictA), + conflictAContent: clip(state.conflictA?.content ?? "", 800), + conflictBTitle: clip(state.conflictB?.title ?? "", 120), + conflictBMeta: side(state.conflictB), + conflictBContent: clip(state.conflictB?.content ?? "", 800), + pendingConflicts: state.conflictCount, + }; + } + + async function loadStatus() { + const [info, health, dream, inject, stats, conflicts, list, entities, mode, preview, audit, features, recent] = + await Promise.all([ + call("/info").catch(() => ({})), + call("/health").catch(() => ({})), + call("/dream-status").catch(() => ({})), + call("/inject-status").catch(() => ({})), + call("/recall-stats?window=30").catch(() => ({})), + call("/conflicts").catch(() => ({})), + call("/list?limit=1").catch(() => ({})), + call("/entities?limit=1").catch(() => ({})), + call("/mode").catch(() => ({})), + call("/inject-preview").catch(() => ({})), + call("/semantic/llm-audit/stats?days=28").catch(() => ({})), + call("/features").catch(() => ({})), + call("/list?limit=200&order=chrono").catch(() => ({})), + ]); + const run = dream?.lastRun ?? {}; + const mirror = health?.mirror ?? {}; + const snapshot = preview?.snapshot ?? null; + state.injectPreviewText = + snapshot === null + ? "还没有渲染过注入预览:下一轮对话开始时 mneme 会写一份快照。" + : clip(typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot), 500); + const byDay = Array.isArray(audit?.by_day) ? audit.by_day : []; + state.tokenCategories = byDay.map((day) => fmtDay(day.day)); + state.tokenSeries = byDay.length === 0 ? [] : [{ name: "调用", values: byDay.map((day) => num(day.count, 0)) }]; + state.tokenKv = [ + { label: "调用次数", value: `${String(audit?.total_calls ?? 0)} 次` }, + { label: "Token", value: String(audit?.total_tokens ?? 0) }, + { label: "估算成本", value: `$${Number(audit?.total_cost_usd ?? 0).toFixed(4)}` }, + ]; + const heatEnabled = (features?.effective ?? {}).heatEnabled === true; + const heated = (recent?.items ?? []).filter((row) => typeof row.heat === "number"); + if (heatEnabled && heated.length > 0) { + const hot = heated.filter((row) => row.heat >= 0.7).length; + const warm = heated.filter((row) => row.heat >= 0.3 && row.heat < 0.7).length; + state.heatSeries = [{ name: "条数", values: [hot, warm, heated.length - hot - warm] }]; + state.heatNote = `统计最近 ${heated.length} 条记忆的热度。`; + } else { + state.heatSeries = []; + state.heatNote = "mneme 没有开启“热度衰减”,所以看不到热度分布。"; + } + state.pendingConflicts = num(conflicts?.total, 0); + return { + // metric cards live on 状态, mirroring mneme's own Status page + totalText: `${String(list?.total ?? 0)} 条`, + injectedText: `${String(stats?.injection?.injectedCount ?? 0)} 次`, + slotRateText: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%`, + conflictsText: `${String(conflicts?.total ?? 0)} 条`, + statusKv: [ + { label: "mneme 版本", value: String(info?.version ?? "—") }, + { label: "记忆", value: `${String(list?.total ?? 0)} 条` }, + { label: "实体", value: `${String(entities?.total ?? 0)} 个` }, + { label: "运行模式", value: String(mode?.mode ?? "—") }, + { label: "镜像", value: String(mirror.status ?? "—"), tone: mirror.status === "ok" ? "good" : "warn" }, + { label: "上次成功", value: fmtTime(mirror.success_at) }, + ], + injectKv: [ + { + label: "自动注入", + value: inject?.autoInject ? "已开启" : "已关闭", + tone: inject?.autoInject ? "good" : "muted", + }, + { label: "注入抑制", value: inject?.suppressed ? "是" : "否" }, + { label: "会话预设", value: String(inject?.agentPreset ?? "—") }, + { label: "近 30 天注入", value: `${String(stats?.injection?.injectedCount ?? 0)} 次` }, + { label: "命中率", value: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%` }, + ], + topRows: (stats?.topRecalled ?? []).slice(0, 5).map((row) => ({ + title: clip(row.title, 40), + subtitle: typeLabel(row.type), + trailing: `${row.count} 次`, + })), + dreamKv: [ + { label: "上次巩固", value: fmtTime(dream?.lastConsolidation) }, + { label: "上次运行", value: `${fmtTime(run.created_at)} ${run.status ?? "—"}` }, + { + label: "待确认", + value: `${String(conflicts?.total ?? 0)} 条`, + tone: num(conflicts?.total, 0) > 0 ? "warn" : "good", + }, + ], + runRows: (dream?.runs ?? []).slice(0, 5).map((row) => ({ + title: `${fmtTime(row.created_at)} · ${String(row.status ?? "—")}`, + subtitle: clip(row.summary ?? row.mode ?? "", 40), + })), + injectPreviewText: state.injectPreviewText, + tokenCategories: state.tokenCategories, + tokenSeries: state.tokenSeries, + tokenKv: state.tokenKv, + heatSeries: state.heatSeries, + heatNote: state.heatNote, + reclaimText: state.reclaimText, + reclaimConfirm: state.reclaimConfirm, + pendingConflicts: state.pendingConflicts, + }; + } + + // The app's `tabs` bar switches locally, so the payload always carries every + // section (a binding whose key is missing renders as 「—」). That does not mean + // every section must be re-read after every tap: 实体 / 设置 / 状态 only change + // while their own page is open, so a tap on the list used to cost four reads + // (评审 #14). Load the active page's section, keep the rest from the previous + // payload; a full read happens on mount. + async function load(options = {}) { + const full = options.full === true; + const payload = { + page: state.page, + profileTab: state.profileTab, + notice: state.notice, + err: state.err, + }; + const jobs = []; + const merge = (promise) => jobs.push(promise.then((part) => Object.assign(payload, part))); + const page = state.page; + const memoryPage = !["entity", "settings", "profile"].includes(page); + if (full || memoryPage) { + const memory = page === "detail" ? loadDetail() : page === "editor" ? loadEditor() : loadMemory(); + merge(Promise.resolve(memory)); + } + const entityPage = page === "entity"; + // 实体搜索要重读实体清单;但搜索后 state.page 仍是 "list"(视图里实体 + // 区就是列表),旧条件只看页面,于是搜索完推的仍是搜索前那份(评审 #4)。 + if (full || entityPage || state.entityDirty) merge(entityPage ? loadEntityDetail() : loadEntity()); + state.entityDirty = false; + if (full || page === "settings" || page === "profile" || state.settingsDirty) { + merge(loadSettings()); + merge(loadStatus()); + } + state.settingsDirty = false; + if (full || (memoryPage && page === "conflict")) merge(loadConflict()); + if (full || page === "profile") merge(loadProfile()); + const settled = await Promise.allSettled(jobs); + const failed = settled.find((item) => item.status === "rejected"); + if (failed !== undefined) payload.err = errorText(failed.reason); + // 未重新读取的块沿用上一次的完整结果;刷新过的那块覆盖上去。 + const merged = lastPayload === null ? payload : { ...lastPayload, ...payload }; + lastPayload = merged; + return merged; + } + + /** Re-run the loaders and push the result into every live panel feed. */ + async function broadcast() { + if (emitters.size === 0) return; + const payload = await load(); + for (const emit of [...emitters]) { + try { + emit(payload); + } catch { + // a closing panel detaches its emitter; nothing to do + } + } + } + + // ---------------------------------------------------------------- actions + function goMemory(page) { + state.page = page; + state.err = ""; + } + + function nextValue(list, current, value) { + const picked = typeof value === "string" && list.includes(value) ? value : null; + if (picked !== null) return picked; + const index = list.indexOf(current); + return list[(index + 1) % list.length]; + } + + async function applyArchived(archived, event, scope) { + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + // 归档/恢复改的是「正在看」的那一条,参数只能从状态里拿;状态是模块级 + // 共享的,两个面板各开一条时后开的会盖掉前一个的 current。与删除同一条 + // 规矩:只动本面板自己打开的那条,对不上就拒绝(评审 #10)。 + const mine = openedBy.get(scopeKey(scope)); + if (mine === undefined || mine !== memo.id) { + return { toast: "这条不是当前打开的记忆,已取消" }; + } + try { + const res = await call("/update", { method: "POST", body: { id: mine, archived } }); + if (res?.memory !== null && typeof res.memory === "object") { + state.current = res.memory; + openedBy.set(scopeKey(scope), String(res.memory.id ?? mine)); + } + notice(archived ? "已归档。" : "已恢复。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + } + + // 编辑页的比对基准:点「编辑」时看到的那一份快照。冲突后必须用最新的 + // 记忆重建它,否则下一次保存会因为 base 为 null 而整份提交、直接跳过版本 + // 检查(评审 #11)。 + function editBaseOf(memo) { + return { + title: String(memo.title ?? ""), + content: String(memo.content ?? ""), + importance: num(memo.importance, 3), + tags: joinTags(memo.tags), + agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "", + workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "", + updatedAt: typeof memo.updated_at === "string" ? memo.updated_at : "", + }; + } + + // 每次打开都回宿主重读,而不是先看 cache:cache 是列表加载时写下的, + // 可能已经几分钟没更新;编辑页会拿它预填表单,随后整份提交回 /update, + // 用陈旧内容覆盖掉工具或 Web 面板刚写进去的新值(评审 #4)。 + // /list 支持按 id 取单条(归档行也取得到),不再用 limit=500 扫列表再拿缓存 + // 兜底:500 条之外或已归档的条目根本扫不到,兜底等于拿旧值去比对 updated_at + // (评审 #12)。网络失败就让它抛,调用处会报错,绝不悄悄退回缓存。 + async function openMemoryById(id) { + const res = await call(`/list?id=${encodeURIComponent(String(id))}`); + const row = (res?.items ?? [])[0]; + if (row === undefined) { + cache.delete(id); + return undefined; + } + cache.set(row.id, row); + return row; + } + + const actions = { + "switch-group"(scope, event) { + resetNotice(); + const keys = ["basic", "record", "write", "workflow", "retrieval", "scope", "extra"]; + state.flagGroup = nextValue(keys, state.flagGroup, valueOf(event, ["group", "value", "k"], "")); + state.page = "settings"; + return {}; + }, + + "switch-panel"(scope, event) { + resetNotice(); + const keys = ["menu", "workbench", "commands", "vector", "external", "help", "facts", "choro"]; + state.sPanel = nextValue(keys, state.sPanel, valueOf(event, ["value", "panel", "k"], "")); + state.page = "settings"; + return {}; + }, + + "open-panel"(scope, event) { + resetNotice(); + // 功能开关已经铺在设置页正文里,不再有 flags 子页面(#dsh-go 面板对齐电脑端分组) + const keys = ["workbench", "commands", "vector", "external", "help", "facts", "choro"]; + const wanted = String(valueOf(event, ["k", "panel", "value"], "") ?? ""); + state.sPanel = keys.includes(wanted) ? wanted : "menu"; + state.page = "settings"; + // 子页面画在「设置」视图里:从「状态」视图点过来时若不把视图切回去, + // 看到的还是状态页,什么都没有发生(评审 #14)。 + state.settingsView = "settings"; + return {}; + }, + + "switch-settings-view"(scope, event) { + resetNotice(); + state.settingsView = nextValue(["settings", "status"], state.settingsView, valueOf(event, ["value"], "")); + if (state.settingsView === "settings") state.page = "settings"; + return {}; + }, + + "open-profile"(scope, event) { + resetNotice(); + const wanted = String(valueOf(event, ["k", "tab", "value"], state.profileTab) ?? ""); + state.profileTab = ["profile", "rules", "persona"].includes(wanted) ? wanted : "profile"; + state.page = "profile"; + return {}; + }, + + "open-conflict"() { + resetNotice(); + state.page = "conflict"; + // 冲突页同样属于「设置」视图(入口在设置页的状态区里),视图停在状态 + // 视图时切不到它(评审 #14)。 + state.settingsView = "settings"; + return {}; + }, + + "clear-filter"() { + resetNotice(); + state.q = ""; + state.type = "all"; + state.range = "all"; + state.minImportance = 0; + state.scope = "all"; + state.semantic = false; + state.heatSort = false; + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-range"(scope, event) { + resetNotice(); + state.range = nextValue(["all", "7d", "30d", "90d"], state.range, valueOf(event, ["value"], "")); + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-minimp"(scope, event) { + resetNotice(); + const picked = String(valueOf(event, ["value"], "0") ?? "0"); + state.minImportance = ["0", "3", "4", "5"].includes(picked) ? Number(picked) : 0; + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-scope"(scope, event) { + resetNotice(); + state.scope = nextValue(["all", "deposited", "archived", "foreign"], state.scope, valueOf(event, ["value"], "")); + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-view"(scope, event) { + resetNotice(); + state.viewMode = nextValue(["flat", "month"], state.viewMode, valueOf(event, ["value"], "")); + return {}; + }, + + "toggle-semantic"(scope, event) { + resetNotice(); + const value = event?.value; + state.semantic = typeof value === "boolean" ? value : !state.semantic; + return {}; + }, + + "toggle-heat"(scope, event) { + resetNotice(); + const value = event?.value; + state.heatSort = typeof value === "boolean" ? value : !state.heatSort; + return {}; + }, + + async provenance() { + resetNotice(); + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + try { + const res = await call(`/memory/provenance?id=${enc(memo.id)}&limit=5`); + const windows = Array.isArray(res?.windows) ? res.windows : []; + if (windows.length === 0) { + state.provenanceText = "mneme 里没有这条记忆的蒸馏来源记录。"; + } else { + const first = windows[0] ?? {}; + state.provenanceText = clip( + `${windows.length} 个来源窗口 · 最近来自会话 ${String(first.session_id ?? "—").slice(0, 12)}(${fmtTime(first.created_at)})`, + 300, + ); + } + } catch (error) { + state.provenanceText = errorText(error).includes("distill-records-disabled") + ? "mneme 没开「保留蒸馏原文」,所以这条记忆没有可查的来源窗口。" + : errorText(error); + } + return {}; + }, + + refresh() { + resetNotice(); + return {}; + }, + + back() { + resetNotice(); + state.err = ""; + if (state.page === "profile" || state.page === "conflict") { + state.page = "settings"; + state.sPanel = "menu"; + } else if (state.page === "entity") { + state.page = "list"; + } else if (state.page === "settings") { + if (state.sPanel !== "menu") state.sPanel = "menu"; + else state.page = "list"; + } else if (state.page === "detail" || state.page === "editor") { + state.page = "list"; + } + return {}; + }, + + search(scope, event) { + resetNotice(); + state.q = String(valueOf(event, ["query", "q", "text"], state.q) ?? "").trim(); + state.offset = 0; + goMemory("list"); + return {}; + }, + + page(scope, event) { + resetNotice(); + // chips 送来的是 "prev" / "next";早期的按钮组送 { d: ±1 },两条都认。 + const raw = valueOf(event, ["value", "d", "delta", "page"], 0); + const delta = raw === "prev" ? -1 : raw === "next" ? 1 : num(raw, 0); + const pages = Math.max(1, Math.ceil(num(state.total, 0) / state.size)); + const next = Math.min(pages, Math.max(1, Math.floor(state.offset / state.size) + 1 + delta)); + state.offset = (next - 1) * state.size; + goMemory("list"); + return {}; + }, + + async open(scope, event) { + resetNotice(); + const id = String(valueOf(event, ["id"], "") ?? ""); + if (id === "") return { toast: "这条记录没有 id" }; + let found; + try { + found = await openMemoryById(id); + } catch (error) { + state.err = errorText(error); + return {}; + } + if (found === undefined) { + state.err = "这条记忆已经不在列表里了。"; + return {}; + } + state.current = found; + openedBy.set(scopeKey(scope), found.id); + state.provenanceText = "尚未查看。点上面的按钮可向 mneme 查这条记忆的蒸馏来源。"; + goMemory("detail"); + return {}; + }, + + "open-entity"(scope, event) { + resetNotice(); + const name = String(valueOf(event, ["name", "entity", "value"], "") ?? ""); + if (name === "") return { toast: "没有选中实体" }; + state.entityName = name; + state.entityDepth = 1; + state.page = "entity"; + return {}; + }, + + "entity-search"(scope, event) { + resetNotice(); + state.entityQuery = String(valueOf(event, ["entityQuery", "query", "q"], "") ?? "").trim(); + state.page = "list"; + // 让下一次 load() 补读实体清单(页面仍是列表,见 load 里的注释)。 + state.entityDirty = true; + return {}; + }, + + "entity-depth"(scope, event) { + resetNotice(); + const picked = String(valueOf(event, ["value", "entityDepth"], "1") ?? "1"); + state.entityDepth = picked === "2" ? 2 : 1; + return {}; + }, + + edit(scope) { + resetNotice(); + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + // 记下本面板打开的那条:save 也要按它核对(评审 #3)。 + openedBy.set(scopeKey(scope), memo.id); + state.form = { + id: memo.id, + title: memo.title ?? "", + content: memo.content ?? "", + type: memo.type ?? "project", + importance: num(memo.importance, 3), + tags: joinTags(memo.tags), + agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "", + workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "", + }; + state.editBase = editBaseOf(memo); + goMemory("editor"); + return {}; + }, + + "type-filter"(scope, event) { + resetNotice(); + const next = String(valueOf(event, ["value", "type"], "all") ?? "all"); + state.type = next === "all" || TYPE_LABELS[next] !== undefined ? next : "all"; + state.offset = 0; + goMemory("list"); + return {}; + }, + + "form-type"(scope, event) { + const next = String(valueOf(event, ["value", "type"], state.form.type ?? "project") ?? "project"); + state.form.type = TYPE_LABELS[next] === undefined ? "project" : next; + return {}; + }, + + new() { + resetNotice(); + state.current = null; + state.form = { + id: "", + title: "", + content: "", + type: "project", + importance: 3, + tags: "", + agentScope: "", + workspaceScope: "", + }; + goMemory("editor"); + return {}; + }, + + async save(scope, event) { + resetNotice(); + const value = event?.value; + const source = + value !== null && typeof value === "object" && !Array.isArray(value) + ? { ...state.form, ...value } + : { ...state.form }; + const title = String(source.title ?? "").trim(); + const content = String(source.content ?? "").trim(); + const type = TYPE_LABELS[String(source.type ?? "")] === undefined ? "project" : String(source.type); + const importance = Math.min(5, Math.max(1, Math.round(num(source.importance, 3)))); + const tags = splitTags(String(source.tags ?? "")); + if (title === "" || content === "") return { toast: "标题和正文都不能空" }; + const id = state.form.id === "" || state.form.id === undefined ? undefined : String(state.form.id); + // 编辑既有条目也要核对面板 scope:state.form 是模块级共享的,两个面板 + // 各开一条时,A 面板点保存会把内容写进 B 刚打开的那条(评审 #3)。 + // 新建(id 为空)不涉及已有行,不核对。 + if (id !== undefined) { + const mine = openedBy.get(scopeKey(scope)); + if (mine === undefined || mine !== id) { + return { toast: "这条不是当前打开的记忆,已取消" }; + } + } + + if (id === undefined) { + try { + const res = await call("/create", { method: "POST", body: { title, content, importance, tags, type } }); + state.form = {}; + state.editBase = null; + if (res !== null && typeof res === "object" && typeof res.id === "string") { + state.current = res; + openedBy.set(scopeKey(scope), res.id); + goMemory("detail"); + notice(res.action === "merged" ? "已并入同标题的既有条目。" : "已新建一条记忆。"); + } else { + goMemory("list"); + notice("已保存。"); + } + } catch (error) { + state.err = errorText(error); + } + return {}; + } + + // 编辑既有条目:先回宿主要一份当前值。盘点窗口够长(跨了几分钟甚至几次 + // 对话)时,缓存里的那份可能已经被工具或 Web 面板改过,而这里回填的表单 + // 一旦整份提交就会把对方的写入盖掉;/update 没有版本号校验,所以这层 + // 比较只能放在面板侧(评审 #4)。 + let fresh; + try { + fresh = await openMemoryById(id); + } catch (error) { + state.err = errorText(error); + return {}; + } + if (fresh === undefined) { + state.current = null; + state.form = {}; + state.editBase = null; + goMemory("list"); + notice("这条记忆已经不在了。", "warn"); + return {}; + } + const base = state.editBase; + if (base !== null && base.updatedAt !== "" && base.updatedAt !== String(fresh.updated_at ?? "")) { + state.current = fresh; + state.form = { + id: fresh.id, + title: fresh.title ?? "", + content: fresh.content ?? "", + type: fresh.type ?? "project", + importance: num(fresh.importance, 3), + tags: joinTags(fresh.tags), + agentScope: typeof fresh.agent_scope === "string" ? fresh.agent_scope : "", + workspaceScope: typeof fresh.workspace_scope === "string" ? fresh.workspace_scope : "", + }; + state.editBase = editBaseOf(fresh); + state.err = "这条记忆刚在别处被改过,已换成最新内容,请再确认一次。"; + return {}; + } + + // 只提交真正改动的字段:把未改的 title/content 一起发回去,等于用旧值 + // 覆盖对方的写入(同一问题的一半)。 + const body = { id }; + if (base === null || title !== base.title) body.title = title; + if (base === null || content !== base.content) body.content = content; + if (base === null || importance !== base.importance) body.importance = importance; + if (base === null || joinTags(tags) !== base.tags) body.tags = tags; + const agentScope = String(source.agentScope ?? "").trim(); + const workspaceScope = String(source.workspaceScope ?? "").trim(); + if (base === null || agentScope !== base.agentScope) body.agent_scope = agentScope === "" ? null : agentScope; + if (base === null || workspaceScope !== base.workspaceScope) { + body.workspace_scope = workspaceScope === "" ? null : workspaceScope; + } + if (Object.keys(body).length === 1) return { toast: "没有改动" }; + try { + const res = await call("/update", { method: "POST", body }); + const memo = res?.memory; + state.form = {}; + state.editBase = null; + if (memo !== null && typeof memo === "object" && typeof memo.id === "string") { + state.current = memo; + openedBy.set(scopeKey(scope), memo.id); + goMemory("detail"); + } else { + goMemory("list"); + } + notice("已保存修改。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async archive(scope, event) { + resetNotice(); + return applyArchived(true, event, scope); + }, + + async unarchive(scope, event) { + resetNotice(); + return applyArchived(false, event, scope); + }, + + async delete(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + // 确认框本身不带参数(confirm 节点只有 id 与两个按钮文案),所以「删哪 + // 一条」只能靠状态。状态是模块级的、被所有面板共用:两个面板各开一条时 + // 后开的会盖掉前一个的 current,此时点确认就会删错行(评审 #5)。 + // 打开记忆时按面板 scope 记一份(见 open)。只显示共享详情、没在本面板 + // 走过 open 的(mine 为空)也不能删:state.current 可能是另一个面板的 + // 条目(评审 #13)。 + const mine = openedBy.get(scopeKey(scope)); + if (mine === undefined || mine !== memo.id) { + return { toast: "这条不是当前打开的记忆,已取消" }; + } + const id = mine; + try { + await call("/delete", { method: "POST", body: { id } }); + cache.delete(id); + openedBy.delete(scopeKey(scope)); + state.current = null; + goMemory("list"); + notice("已删除。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + copy() { + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + return { copy: clip(`# ${memo.title}\n\n${memo.content}`, 8000) }; + }, + + insert() { + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + return { insert: clip(`【记忆】${memo.title}\n${memo.content}`, 2000) }; + }, + + "profile-tab"(scope, event) { + resetNotice(); + const next = nextValue( + ["profile", "rules", "persona"], + state.profileTab, + valueOf(event, ["k", "tab", "value"], ""), + ); + state.profileTab = next; + state.page = "profile"; + return {}; + }, + + async "profile-save"(scope, event) { + resetNotice(); + const body = String(valueOf(event, ["editText", "text", "value"], "") ?? ""); + try { + if (state.profileTab === "rules") { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + state.err = "规则必须是合法 JSON 数组。"; + return {}; + } + // 非数组({} / null / "text")不能落成空数组:这条路由是整份覆盖, + // 静默写 [] 会把用户已有的规则一次清空(评审 #10)。 + if (!Array.isArray(parsed)) { + state.err = "规则必须是合法 JSON 数组。"; + return {}; + } + await call("/rules", { method: "PUT", body: { rules: parsed } }); + } else { + await call("/profile", { method: "PUT", body: { profile: body } }); + } + notice("已保存。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async "profile-extract"() { + resetNotice(); + if (state.profileTab !== "profile") return { toast: "先切到画像" }; + try { + const res = await call("/profile/extract", { method: "POST", body: {} }); + const draft = res?.draft ?? res ?? {}; + const text = + typeof draft.profile === "string" + ? draft.profile + : typeof draft.text === "string" + ? draft.text + : JSON.stringify(draft); + state.extractText = clip(`草稿:${text}`, 600); + } catch (error) { + const raw = errorText(error); + state.extractText = raw.includes("disabled") + ? "mneme 没开「画像提取草稿」,先到「设置 › 功能开关 › 写入」里打开它。" + : raw.includes("no-route") + ? "没有可用的模型路由,提取不了草稿。" + : raw.includes("no-input") + ? "记忆还太少,提取不出画像。" + : raw; + } + return {}; + }, + + async mode(scope, event) { + resetNotice(); + const next = String(valueOf(event, ["mode", "value"], "standard") ?? "standard"); + if (next !== "standard" && next !== "light") { + state.err = "未知的模式。"; + return {}; + } + try { + await call("/mode", { method: "PUT", body: { mode: next } }); + notice(`已切到${next === "light" ? "轻量" : "标准"}模式。`); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async reclaim() { + resetNotice(); + try { + const res = await call("/maintenance/reclaim"); + const report = res?.report ?? res ?? {}; + const rows = Array.isArray(report.candidates) ? report.candidates.length : undefined; + const total = rows ?? report.reclaimable ?? report.total ?? report.rows ?? null; + state.reclaimText = + total === null ? "已检查(dry-run,未改动)。" : `可回收 ${String(total)} 条(dry-run,未改动)。`; + } catch (error) { + state.reclaimText = errorText(error); + } + return {}; + }, + + "reclaim-prepare"() { + resetNotice(); + state.reclaimConfirm = true; + state.reclaimText = "回收会丢弃无法再召回的条目,确认后再执行。"; + return {}; + }, + + async "reclaim-run"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + try { + const res = await call("/maintenance/reclaim", { + method: "POST", + body: { confirm: true, vacuum: true }, + }); + const report = res?.report ?? res ?? {}; + state.reclaimText = `已回收 ${String(report.reclaimed ?? report.total ?? 0)} 条。`; + state.reclaimConfirm = false; + } catch (error) { + state.reclaimText = errorText(error); + } + return {}; + }, + + "conflict-pick-row"(scope, event) { + resetNotice(); + state.conflictId = String(valueOf(event, ["id"], "") ?? ""); + conflictBy.set(scopeKey(scope), state.conflictId); + state.conflictPick = ""; + state.page = "conflict"; + return {}; + }, + + "conflict-pick"(scope, event) { + resetNotice(); + const picked = String(valueOf(event, ["winner", "value"], "") ?? ""); + state.conflictPick = picked === "a" || picked === "b" ? picked : "none"; + state.page = "conflict"; + return {}; + }, + + async "conflict-win"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const id = state.conflictId; + if (id === "") return { toast: "先选一条冲突" }; + // 冲突 id 也是共享状态:只裁决本面板自己选中的那条(评审 #3)。 + if (conflictBy.get(scopeKey(scope)) !== id) { + return { toast: "这条不是当前打开的冲突,已取消" }; + } + const winner = state.conflictPick === "a" ? "a" : state.conflictPick === "b" ? "b" : null; + try { + await call("/conflicts/resolve", { method: "POST", body: { id, winner, apply: true } }); + conflictBy.delete(scopeKey(scope)); + state.conflictId = ""; + state.conflictPick = ""; + notice("已按裁决合并。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async "wb-restore"(scope, event) { + resetNotice(); + const id = String(valueOf(event, ["id"], "") ?? ""); + if (id === "") return {}; + try { + await call("/update", { method: "POST", body: { id, archived: false } }); + notice("已恢复这条记忆。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + "cmd-pick"(scope, event) { + resetNotice(); + state.sCmdPick = String(valueOf(event, ["value"], "") ?? ""); + state.page = "settings"; + return {}; + }, + + "cmd-add"(scope, event) { + resetNotice(); + const value = event?.value; + const source = + value !== null && typeof value === "object" && !Array.isArray(value) + ? { sCmdName: value.sCmdName, sCmdDesc: value.sCmdDesc, sCmdIns: value.sCmdIns } + : {}; + const name = String(source.sCmdName ?? state.sCmdName ?? "").trim(); + const description = String(source.sCmdDesc ?? state.sCmdDesc ?? "").trim(); + const instruction = String(source.sCmdIns ?? state.sCmdIns ?? "").trim(); + if (name === "" || instruction === "") return { toast: "命令名和指令正文都不能空" }; + return call("/commands", { method: "POST", body: { name, description, instruction } }).then( + () => { + state.sCmdName = ""; + state.sCmdDesc = ""; + state.sCmdIns = ""; + notice(`已添加命令 ${name}。`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + async "cmd-delete"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const id = state.sCmdPick; + if (id === "") return { toast: "先选一条命令" }; + try { + await call(`/commands?id=${enc(id)}`, { method: "DELETE" }); + state.sCmdPick = ""; + notice("已删除命令。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + "vector-toggle"(scope, event) { + resetNotice(); + // 没读到真实配置时 state.vector 只是默认值,整体 PUT 会覆盖服务端(评审 #2)。 + if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" }; + const value = event?.value; + state.vector.enabled = typeof value === "boolean" ? value : !state.vector.enabled; + return call("/vector-config", { + method: "PUT", + body: { enabled: state.vector.enabled, baseUrl: state.vector.baseUrl, model: state.vector.model }, + }).then( + () => { + notice(`向量检索:${state.vector.enabled ? "已开启" : "已关闭"}`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "vector-save"(scope, event) { + resetNotice(); + if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" }; + const value = event?.value; + const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; + const baseUrl = String(source.sVecUrl ?? state.vector.baseUrl ?? "").trim(); + const model = String(source.sVecModel ?? state.vector.model ?? "").trim(); + state.vector.baseUrl = baseUrl; + state.vector.model = model; + return call("/vector-config", { + method: "PUT", + body: { enabled: state.vector.enabled, baseUrl, model }, + }).then( + () => { + notice("已保存向量配置。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "vector-reindex"() { + resetNotice(); + const run = call("/vector-reindex", { timeoutMs: 60000 }).then( + (res) => { + notice(`索引完成:新增 ${String(res?.indexed ?? 0)} 条,跳过 ${String(res?.skipped ?? 0)} 条。`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + return { + pending: { label: "正在重建向量索引", timeoutMs: 60000 }, + result: async () => run, + }; + }, + + "external-toggle"(scope, event) { + resetNotice(); + if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" }; + const value = event?.value; + state.external.enabled = typeof value === "boolean" ? value : !state.external.enabled; + return call("/external-api", { + method: "PUT", + body: { enabled: state.external.enabled, port: state.external.port, host: state.external.host }, + }).then( + () => { + notice(`外部访问:${state.external.enabled ? "已开启" : "已关闭"}`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "external-save"(scope, event) { + resetNotice(); + if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" }; + const value = event?.value; + const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; + const port = Math.round(num(source.sExtPort ?? state.external.port, 8790)); + const host = String(source.sExtHost ?? state.external.host ?? "").trim(); + if (port < 1 || port > 65535) return { toast: "端口要在 1–65535 之间" }; + if (host === "" || host.includes("://")) return { toast: "监听地址不能带协议前缀" }; + state.external.port = port; + state.external.host = host; + return call("/external-api", { + method: "PUT", + body: { enabled: state.external.enabled, port, host }, + }).then( + () => { + notice("已保存外部访问配置。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + async "check-version"() { + resetNotice(); + try { + const res = await call("/version-check"); + const status = String(res?.status ?? "unknown"); + state.versionNote = + status === "outdated" + ? `有新版本 ${String(res?.latest ?? "")}(当前 ${String(res?.version ?? "")})` + : status === "latest" + ? `已是最新版本 ${String(res?.version ?? "")}` + : "没能连上 npm registry,稍后再试。"; + if (status === "outdated" && res?.latest) notice(`mneme 有新版本 ${res.latest}`); + } catch (error) { + state.versionNote = errorText(error); + } + return {}; + }, + }; + + // One action per feature switch: the app tells us which action fired, and the + // new value arrives as `event.value` (boolean) or is derived by flipping the + // current effective value. + for (const key of FLAG_LABELS.keys()) { + actions[flagAction(key)] = async (scope, event) => { + resetNotice(); + const value = event?.value; + // state.flags 是宿主原样的扁平点号键(scopeState.enabled);嵌套形态只 + // 出现在视图绑定里(payload.flags = nestFlags(effective))。这里若按嵌套 + // 去取值会恒为 undefined,于是「已经打开的开关」永远翻转不回去(评审 #7)。 + const current = state.flags?.[key]; + // 布尔项翻转、枚举项(chorographyInject 的 light/full/off)直接用宿主给的 + // 字符串值:同一个动作 id 同时服务开关与下拉。 + const next = + typeof value === "boolean" || typeof value === "string" + ? value + : typeof current === "string" + ? current + : !(current === true); + try { + await call("/features", { method: "PUT", body: { [key]: next } }); + const features = await call("/features"); + state.flagOverrides = features?.overrides ?? {}; + state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : []; + state.flags = features?.effective ?? state.flags; + state.settingsDirty = true; + notice( + `${FLAG_LABELS.get(key) ?? key}:${ + typeof next === "string" ? next : next ? "已开启" : "已关闭" + }`, + ); + } catch (error) { + state.err = errorText(error); + } + // 开关只改一个绑定值:数据已经推过去了,别让 App 重建视图(否则设置页 + // 会被弹回第一个页签)。 + return { refresh: false }; + }; + } + + // ---- 「Agent 眼中的你」自动事实(0.11.0 的面板卡) ---------------------- + // 写入只有一个入口:POST /profile-facts/apply,op 形状与自动轮共用(add 必须 + // 带 content、update/archive 必须带 id)。这里不自己拼 SQL,也不碰 user_settings + // ——手填画像仍然优先。 + const applyFacts = (ops) => + call("/profile-facts/apply", { method: "POST", body: { agent: state.factsAgent, ops } }); + + Object.assign(actions, { + "facts-agent"(scope, event) { + resetNotice(); + state.factsAgent = String(valueOf(event, ["value", "agent"], "") ?? ""); + state.factEdit = null; + factBy.delete(scopeKey(scope)); + return {}; + }, + + "facts-edit"(scope, event) { + resetNotice(); + const id = String(valueOf(event, ["id"], "") ?? ""); + const fact = state.factsItems.find((item) => String(item.id) === id); + if (fact === undefined) return { toast: "这条事实已经不在了" }; + state.factEdit = { id, content: String(fact.content ?? "") }; + factBy.set(scopeKey(scope), id); + return {}; + }, + + "facts-add"(scope, event) { + resetNotice(); + const value = event?.value; + const source = value !== null && typeof value === "object" ? value : {}; + const text = String(source.sFactNew ?? state.form.factNew ?? "").trim(); + if (text === "") return { toast: "写一句话再添加" }; + return applyFacts([{ op: "add", content: text }]).then( + (result) => { + state.form.factNew = ""; + notice(`已加 ${String(result?.applied ?? 1)} 条事实。`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "facts-save"(scope, event) { + resetNotice(); + const target = state.factEdit; + if (target === null) return { toast: "先点一条事实" }; + if (factBy.get(scopeKey(scope)) !== target.id) { + return { toast: "这条不是当前打开的事实,已取消" }; + } + const value = event?.value; + const source = value !== null && typeof value === "object" ? value : {}; + const text = String(source.sFactEdit ?? target.content ?? "").trim(); + if (text === "") return { toast: "事实正文不能空" }; + if (text === target.content) return { toast: "没有改动" }; + return applyFacts([{ op: "update", id: target.id, content: text }]).then( + (result) => { + const failed = Array.isArray(result?.failures) ? result.failures : []; + if (failed.length > 0) { + state.err = "这条事实已被改过或删掉了,刷新后再试。"; + return {}; + } + state.factEdit = null; + factBy.delete(scopeKey(scope)); + notice("事实已更新。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "facts-archive"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const target = state.factEdit; + if (target === null) return { toast: "先点一条事实" }; + if (factBy.get(scopeKey(scope)) !== target.id) { + return { toast: "这条不是当前打开的事实,已取消" }; + } + return applyFacts([{ op: "archive", id: target.id }]).then( + () => { + state.factEdit = null; + factBy.delete(scopeKey(scope)); + notice("已归档这条事实。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "facts-run"(scope, event) { + resetNotice(); + // 维护轮要跑一次 LLM(模型读最近记忆、提 op、落库),慢且不可并发,所以 + // 走 pending:面板先挂一条「正在更新」,最长等 60 秒。 + const run = call("/profile-facts/run", { + method: "POST", + body: { agent: state.factsAgent }, + timeoutMs: 60000, + }).then( + (receipt) => { + const status = String(receipt?.status ?? "ok"); + state.factsRunText = + status === "ok" + ? `本轮改了 ${String(receipt?.opsApplied ?? 0)} 条事实。` + : status === "noop" + ? "没有新记忆可读,这轮没动。" + : `这轮结束了:${status}`; + notice("事实维护轮已结束。"); + return { toast: "事实已更新" }; + }, + (error) => { + const message = errorText(error); + // 409 = 功能关着,503 = 没配模型,502 = 模型答得不成形:三种都得说人话。 + state.err = /\b409\b/u.test(message) + ? "「Agent 眼中的你」还关着:先在上面把开关打开。" + : /\b503\b/u.test(message) + ? "还没配后台模型:先在 mneme 设置里选一个 dream 模型。" + : message; + return { toast: "这轮没成功" }; + }, + ); + return { pending: { label: "正在维护事实", timeoutMs: 60000 }, result: async () => run }; + }, + + // ---- 代码库地志(chorography)----------------------------------------- + "choro-repo"(scope, event) { + resetNotice(); + state.choroRepo = String(valueOf(event, ["value"], "") ?? ""); + state.choroPageText = ""; + return {}; + }, + + "choro-page"(scope, event) { + resetNotice(); + const slug = String(valueOf(event, ["slug"], "") ?? ""); + if (slug === "" || state.choroRepo === "") return { toast: "先选一个仓库" }; + return call(`/choro/page?repo=${encodeURIComponent(state.choroRepo)}&slug=${encodeURIComponent(slug)}`).then( + (res) => { + const page = res?.page; + state.choroPageText = + page === undefined + ? "这一页读不出来,可能刚被重建过。" + : clip(String(page?.content ?? page?.relPath ?? ""), 1600); + notice(`已读取 ${String(page?.title ?? slug)} 的骨架页。`); + return {}; + }, + (error) => { + const message = errorText(error); + state.err = /\b409\b/u.test(message) ? "索引还在、盘上没了:点「重建骨架」重跑一次。" : message; + return {}; + }, + ); + }, + + "choro-run"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + if (state.choroRepo === "") return { toast: "还没有登记任何仓库" }; + const run = call("/choro/run", { + method: "POST", + body: { repo: state.choroRepo }, + timeoutMs: 60000, + }).then( + (res) => { + const receipt = res?.receipt ?? {}; + state.choroPageText = ""; + notice( + `骨架已重建:${String(receipt.pagesWritten ?? receipt.modules ?? 0)} 页` + + (receipt.t1Loss === true ? "(已富化的语义段被退回骨架)" : ""), + ); + return { toast: "重建完成" }; + }, + (error) => { + state.err = errorText(error); + return { toast: "重建失败" }; + }, + ); + return { pending: { label: "正在重建骨架", timeoutMs: 60000 }, result: async () => run }; + }, + }); + + // 未收录的布尔旗标(「其它」组里的数据行):行里带着 key,点一下翻转。 + actions["flag-set"] = async (scope, event) => { + resetNotice(); + const key = String(valueOf(event, ["key", "value"], "") ?? ""); + if (key === "") { + state.err = "没有拿到要切换的键。"; + return {}; + } + const next = state.flags?.[key] !== true; + try { + await call("/features", { method: "PUT", body: { [key]: next } }); + const features = await call("/features"); + state.flagOverrides = features?.overrides ?? {}; + state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : []; + state.flags = features?.effective ?? state.flags; + state.settingsDirty = true; + notice(`${key}:${next ? "已开启" : "已关闭"}`); + } catch (error) { + state.err = errorText(error); + } + return { refresh: false }; + }; + + // 只改绑定值的动作不申请重画视图。App 收到 refresh 会重新拉一次视图树, + // 而顶层 tabs 的选中页签是 App 的本地状态:树一重建就回到第一个页签,用户在 + // 设置页翻开关会被弹回记忆页。这些动作的结果都在 data 里(开关值、列表行、 + // 文案),推一次数据就够了。 + const VALUE_ONLY = new Set([ + "mode", + "vector-toggle", + "vector-save", + "external-toggle", + "external-save", + "profile-save", + "facts-agent", + "facts-edit", + "facts-add", + "facts-save", + "facts-archive", + "facts-run", + "flag-set", + "choro-repo", + "choro-page", + "choro-run", + ]); + + // Every action may change what the panel shows, so push fresh data before + // asking the app to re-render (the refresh effect alone would re-render a + // stale feed). + const wrapped = {}; + for (const [id, handler] of Object.entries(actions)) { + wrapped[id] = async (scope, event) => { + let result; + try { + result = await handler(scope, event); + } catch (error) { + state.err = errorText(error); + result = undefined; + } + // 默认重画视图:多数动作会改变「哪些节点存在」(进子页、空态、列表)。 + // 只改一个绑定值的动作(开关、档位、外部访问的开关)要显式关掉重画: + // App 的 refresh 会让它重新拉一次视图树,而页签选择是 App 的本地状态, + // 重建后回到第一个页签——用户正在设置页翻开关,会被弹回记忆页。 + const wanted = result ?? {}; + const skip = wanted.refresh === false || VALUE_ONLY.has(id) || id.startsWith("flag."); + const effect = { ...wanted, refresh: !skip }; + // pending 动作(维护轮、地志重建)把真正的活儿放在 result 里,handler + // 返回时它还没跑:此时立刻 broadcast 推出去的仍是这一轮之前的事实与 + // 地志列表。改成等 result 跑完再推(评审 #15)。 + if (typeof wanted.result === "function") { + const inner = wanted.result; + effect.result = async (...args) => { + try { + return await inner(...args); + } finally { + await broadcast(); + } + }; + return effect; + } + await broadcast(); + return effect; + }; + } + + // ----------------------------------------------------------------- panel + ctx.dshGo.panel("mneme", { + view() { + return buildView(state); + }, + data(scope, emit) { + emitters.add(emit); + // 挂载时读全:之后每个动作只重读当前页那一块(见 load 的注释)。 + load({ full: true }).then( + (payload) => { + if (emitters.has(emit)) emit(payload); + }, + (error) => { + if (emitters.has(emit)) emit({ notice: state.notice, err: errorText(error) }); + }, + ); + return () => { + emitters.delete(emit); + }; + }, + actions: wrapped, + }); +} + +/** + * 清掉模块级状态与缓存。在引擎重新挂载面板、以及测试之间切换场景时用它: + * 这些表(列表缓存、按 scope 打开的记忆、提示语)是模块级的,跨 mount 复用会 + * 把上一个场景的残留带进下一个。 + */ +export function resetPanelState() { + cache.clear(); + emitters.clear(); + openedBy.clear(); + conflictBy.clear(); + factBy.clear(); + lastPayload = null; + Object.assign(state, defaultState()); +} diff --git a/dsh-mneme/lib/dsh-go/text.js b/dsh-mneme/lib/dsh-go/text.js new file mode 100644 index 00000000..87832363 --- /dev/null +++ b/dsh-mneme/lib/dsh-go/text.js @@ -0,0 +1,342 @@ +// Labels, flag metadata and small formatting helpers shared by the panel pages. +export const TYPE_LABELS = { + preference: "偏好", + project: "项目", + decision: "决策", + history: "历史", + rejected_solution: "弃用方案", + pitfall: "教训", + constraint: "约束", + summary: "摘要", + document: "文档", + fact: "事实", + pattern: "模式", +}; + +// 编辑器只提供常用的几类(写库层接受更多,但新建表单不必铺满)。 +export const TYPE_OPTIONS = [ + "preference", + "project", + "decision", + "constraint", + "pitfall", + "history", + "rejected_solution", + "summary", +]; + +// 筛选要覆盖写库层接受的全部类型(store.TYPES 的 10 个):只给 8 个的话, +// 库里已有的 pattern / document 条目在面板上筛不出来(评审 #11)。 +export const FILTER_TYPE_OPTIONS = [ + "preference", + "project", + "decision", + "history", + "summary", + "pattern", + "rejected_solution", + "pitfall", + "constraint", + "document", +]; + +const TYPE_TONES = { + preference: "info", + decision: "good", + constraint: "warn", + pitfall: "bad", + project: "info", + history: "muted", +}; + +const TYPE_ICONS = { + preference: "star", + project: "folder", + decision: "check", + constraint: "flag", + pitfall: "warning", + history: "clock", + rejected_solution: "remove", + summary: "note", + document: "book", + fact: "pin", + pattern: "repeat", +}; + +// Entity types come from extraction, so this is a best-effort mapping with a +// neutral fallback rather than a closed enum. +const ENTITY_ICONS = { + person: "user", + people: "user", + agent: "robot", + project: "folder", + workspace: "workspace", + repo: "code", + technology: "code", + tech: "code", + tool: "wrench", + library: "puzzle", + concept: "lightbulb", + topic: "note", + event: "calendar", + place: "globe", + location: "globe", + organization: "team", + org: "team", + team: "team", + document: "book", +}; + +// Entity types are free-form (the extractor invents them), so unknown ones fall +// back to what the store returned instead of being hidden. +const ENTITY_TYPE_LABELS = { + person: "人物", + people: "人物", + agent: "智能体", + project: "项目", + workspace: "工作区", + repo: "仓库", + technology: "技术", + tech: "技术", + tool: "工具", + library: "库", + concept: "概念", + topic: "主题", + event: "事件", + place: "地点", + location: "地点", + organization: "组织", + org: "组织", + team: "团队", + document: "文档", +}; + +export const entityIcon = (type) => ENTITY_ICONS[String(type ?? "").toLowerCase()] ?? "link"; +export const entityTypeLabel = (type) => ENTITY_TYPE_LABELS[String(type ?? "").toLowerCase()] ?? String(type ?? "实体"); + +export const typeLabel = (type) => TYPE_LABELS[type] ?? String(type ?? "其它"); +export const typeTone = (type) => TYPE_TONES[type]; +export const typeIcon = (type) => TYPE_ICONS[type]; + +const TIME = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", +}); + +export function fmtTime(iso) { + if (typeof iso !== "string" || iso === "") return "—"; + const when = new Date(iso); + if (Number.isNaN(when.getTime())) return "—"; + return TIME.format(when); +} + +export function clip(text, max) { + const str = typeof text === "string" ? text : String(text ?? ""); + if (str.length <= max) return str; + return `${str.slice(0, max - 1)}…`; +} + +export function splitTags(value) { + if (typeof value !== "string") return []; + return value + .split(/[,,]/u) + .map((t) => t.trim()) + .filter((t) => t !== ""); +} + +export function joinTags(tags) { + return Array.isArray(tags) ? tags.join(", ") : ""; +} + +/** Error text short enough for a 32 KiB panel payload. */ +export function errorText(error) { + const raw = error instanceof Error ? error.message : String(error); + return clip(raw, 600); +} + +/** True for a dotted flag key, used as a nested `data.flags.*` binding path. */ +export const flagPath = (key) => `data.flags.${key}`; + +/** One action id per switch: the app only tells us which action fired. */ +export const flagAction = (key) => `flag.${key.replace(/[^A-Za-z0-9_-]/gu, "_")}`; + +// Mirrors mneme's own settings page grouping (lib/client.js FEATURE_GROUPS), +// trimmed to the switches this panel can flip on a phone. +export const FLAG_GROUPS = [ + { + key: "basic", + label: "常用", + note: "先动这几个总开关。", + items: [ + ["autoInject", "每轮自动带上相关记忆"], + ["autoSummarize", "对话结束自动提炼成记忆"], + ["autoDream", "后台自动合并、去重"], + ], + }, + { + key: "record", + label: "记录", + note: "它记住什么、写多细。", + items: [ + ["hotMemoryEnabled", "带上最近几轮原话"], + ["injectTimePrefix", "注入当天日期"], + ["injectGuidanceEnabled", "教 Agent 怎么用记忆"], + ["continuityRescueEnabled", "压缩前先存快照"], + ["memoryQualityFilter.enabled", "滤掉低质量条目"], + ["sensitiveScanEnabled", "写入前扫描敏感内容"], + ["documentMemoryEnabled", "长文档只存摘要卡"], + ["profileFactsEnabled", "自动维护「Agent 眼中的你」"], + ["codingRetrospect", "编码过程也沉淀"], + ["distillRecords.enabled", "保留蒸馏原文"], + ], + }, + { + key: "write", + label: "写入", + note: "它什么时候主动写。", + items: [ + ["entityExtractionEnabled", "抽取人物与项目"], + ["writeAdmission.enabled", "写入准入判定"], + ["writeAdmission.enforce", "准入不通过就拦下"], + ["preInjectGate.enabled", "注入前判定"], + ["preInjectGate.enforce", "判定不通过就不注入"], + ["profileExtractEnabled", "画像提取草稿"], + ], + }, + { + key: "workflow", + label: "工作流", + note: "后台整理与人格。", + items: [ + ["sleepModeEnabled", "空闲时整理记忆"], + ["actionPipelineEnabled", "M.O. 动作图谱"], + ["actionNudgeEnabled", "自动沉淀工作流"], + ["personaEnabled", "注入人格卡"], + ["personaStrictAddressing", "严格人格寻址"], + ["llmAudit.enabled", "记录后台模型用量"], + ["rerankEnabled", "召回结果精排"], + ["chorographyEnabled", "代码库地志(chorography)"], + ], + }, + { + key: "retrieval", + label: "检索", + note: "它怎么把记忆找回来。", + items: [ + ["hybridInject", "关键词 + 语义两路"], + ["selectiveInjectEnabled", "选择性注入"], + ["adaptiveThresholdEnabled", "自适应阈值"], + ["bm25SearchEnabled", "BM25 检索"], + ["searchSemanticDedup", "语义去重"], + ["heatEnabled", "热度衰减"], + ["conflictFreezeEnabled", "矛盾记忆先冻结"], + ["trustEpistemicWeighting", "可信度加权"], + ["reflectionUpdateEnabled", "反思式更新"], + ], + }, + { + key: "scope", + label: "作用域", + note: "不同会话之间怎么隔离。", + items: [ + ["scopeEnabled", "按会话作用域标注"], + ["strictScope", "严格按作用域过滤"], + ["scopeState.enabled", "作用域级暂停"], + ["scopeState.enforce", "暂停后真的不注入"], + ], + }, + { + key: "extra", + label: "其它", + // 上游不断加开关:这里按 /features 的 effective 动态列出「我们还没给中文名」 + // 的布尔项,避免新功能在手机面板上静默消失(视图结构不能靠数据分支,所以 + // 用 list + 数据行,点一行翻转一项)。 + note: "上游新增的开关,面板还没有中文名;点一行即可切换。", + items: [], + }, +]; + +// 枚举型旗标(不是开关,得用下拉):PUT /features 认字符串值。 +export const ENUM_FLAGS = [ + { + key: "chorographyInject", + label: "地志注入档位", + hint: "每轮往系统提示里塞多少代码库提纲:轻量只放前 6 个模块,完整放全部,关闭则一轮都不放。", + options: [ + { label: "轻量", value: "light" }, + { label: "完整", value: "full" }, + { label: "关闭", value: "off" }, + ], + }, +]; + +export const FLAG_LABELS = new Map(); +for (const group of FLAG_GROUPS) { + for (const [key, label] of group.items) FLAG_LABELS.set(key, label); +} +for (const flag of ENUM_FLAGS) FLAG_LABELS.set(flag.key, flag.label); + +export const MODE_OPTIONS = [ + { label: "标准", value: "standard" }, + { label: "轻量", value: "light" }, +]; + +// ---- memory list filters (mirrors mneme's own filter bar) ------------------- +export const RANGE_OPTIONS = [ + { label: "全部时间", value: "all" }, + { label: "近 7 天", value: "7d" }, + { label: "近 30 天", value: "30d" }, + { label: "近 90 天", value: "90d" }, +]; + +export const MINIMP_OPTIONS = [ + { label: "不限重要度", value: "0" }, + { label: "★3 以上", value: "3" }, + { label: "★4 以上", value: "4" }, + { label: "仅 ★5", value: "5" }, +]; + +export const SCOPE_OPTIONS = [ + { label: "全部来源", value: "all" }, + { label: "仅已沉淀", value: "deposited" }, + { label: "仅已归档", value: "archived" }, + { label: "仅外部导入", value: "foreign" }, +]; + +export const VIEW_OPTIONS = [ + { label: "平铺", value: "flat" }, + { label: "按月", value: "month" }, +]; + +export const RANGE_DAYS = { "7d": 7, "30d": 30, "90d": 90 }; + +const MONTH = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit" }); +const DAY = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit" }); + +/** `2026-11` group label for the month view. */ +export function fmtMonth(iso) { + if (typeof iso !== "string" || iso === "") return "未知时间"; + const when = new Date(iso); + if (Number.isNaN(when.getTime())) return "未知时间"; + return MONTH.format(when).replace(/\//gu, "-"); +} + +/** `07-12` for charts. */ +export function fmtDay(iso) { + if (typeof iso !== "string" || iso === "") return "—"; + const when = new Date(iso); + if (Number.isNaN(when.getTime())) return "—"; + return DAY.format(when); +} + +/** 归属 line for a memory row (agent/workspace scope). */ +export function scopeText(memo) { + const parts = []; + if (typeof memo?.agent_scope === "string" && memo.agent_scope !== "") parts.push(`Agent ${memo.agent_scope}`); + if (typeof memo?.workspace_scope === "string" && memo.workspace_scope !== "") + parts.push(`工作区 ${memo.workspace_scope}`); + return parts.length === 0 ? "全局" : parts.join(" · "); +} diff --git a/dsh-mneme/lib/dsh-go/views/common.js b/dsh-mneme/lib/dsh-go/views/common.js new file mode 100644 index 00000000..849e24e2 --- /dev/null +++ b/dsh-mneme/lib/dsh-go/views/common.js @@ -0,0 +1,68 @@ +// Small builders shared by every panel section. +export const b = (path) => ({ $: path }); +export const text = (value, style) => ({ type: "text", text: value, ...(style === undefined ? {} : { style }) }); +export const heading = (value) => text(value, { size: "subtitle", weight: "semibold" }); +export const caption = (value) => text(value, { size: "caption" }); +export const mono = (value) => text(value, { size: "mono" }); +export const act = (label, id, args, icon) => ({ + label, + ...(icon === undefined ? {} : { icon }), + action: args === undefined ? { id } : { id, args }, +}); +export const actions = (...items) => ({ type: "actions", items }); +export const kv = (items) => ({ type: "keyValue", items }); +// 只接受两种来源:字面文案,或调用方显式构造好的绑定对象 { $: path }。 +// 曾经把 "data.err" 这样的路径当字符串传进来,这里又原样当字面量下发,于是 +// 面板把路径本身显示成一句提示(评审 #12)——绑定的构造统一由 b() 负责。 +export const callout = (src, tone) => ({ + type: "callout", + text: typeof src === "string" ? src : src, + tone, +}); +export const fold = (label, children, expanded = false) => ({ type: "collapsible", label, expanded, children }); +export const metric = (label, value, tone) => ({ + type: "metric", + label, + value: b(value), + ...(tone === undefined ? {} : { tone }), +}); +export const divider = () => ({ type: "divider" }); +// tone 只接受枚举值,不能绑定(schema 的 badgeV2.tone 是纯枚举),所以第二个 +// 参数只能是字面色,不能是 "data.xxx" 这样的路径;文案同理,绑定要传 b(path)。 +export const badge = (src, tone) => ({ + type: "badge", + text: src, + ...(tone === undefined ? {} : { tone }), +}); +export const empty = (title, message, icon, action) => ({ + type: "emptyState", + title, + ...(message === undefined ? {} : { message }), + ...(icon === undefined ? {} : { icon }), + ...(action === undefined ? {} : { action }), +}); +export const select = (label, bind, options, onChange) => ({ + type: "select", + label, + bind, + options, + onChange: { id: onChange }, +}); +export const toggle = (label, bind, onChange) => ({ type: "toggle", label, bind, onChange: { id: onChange } }); + +const NOTICE_TONES = { ok: "good", warn: "warn", bad: "bad" }; + +export function notices(state) { + if (state.err) return [callout(b("data.err"), "bad")]; + if (state.notice) return [callout(b("data.notice"), NOTICE_TONES[state.noticeTone] ?? "info")]; + return []; +} + +export const chart = (kind, categories, series, unit, captionText) => ({ + type: "chart", + kind, + categories, + series, + ...(unit === undefined ? {} : { unit }), + ...(captionText === undefined ? {} : { caption: captionText }), +}); diff --git a/dsh-mneme/lib/dsh-go/views/entity.js b/dsh-mneme/lib/dsh-go/views/entity.js new file mode 100644 index 00000000..6fde632e --- /dev/null +++ b/dsh-mneme/lib/dsh-go/views/entity.js @@ -0,0 +1,48 @@ +// 实体 section: entity catalogue with a search box, plus one entity's +// attributes and relations (mneme's graph ego network, without the SVG map). +import { act, actions, b, badge, caption, fold, heading, kv, metric, select, text } from "./common.js"; + +const DEPTH_OPTIONS = [ + { label: "只看直接关系", value: "1" }, + { label: "再看两层", value: "2" }, +]; + +function entityList() { + // 这里曾经按 state.entityTotal === 0 换成一块空状态:视图结构在首屏就 + // 定死了,而数据是随后才推来的,于是实体已经有了、空状态块还挂在上面。 + // 现在结构固定,空库的提示由 data.entityStatus 这一行文案承载。 + return [ + { + type: "grid", + columns: 2, + children: [metric("实体总数", "data.entityTotalText"), metric("类型数", "data.entityTypeTotalText")], + }, + { + type: "form", + submit: { id: "entity-search" }, + submitLabel: "搜索实体", + children: [{ type: "textField", label: "按名字找", bind: "data.entityQuery", placeholder: "留空显示全部" }], + }, + { type: "list", items: b("data.entities") }, + caption(b("data.entityStatus")), + actions(act("刷新", "refresh", undefined, "refresh")), + ]; +} + +function entityDetail(state) { + return [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + { type: "row", children: [heading(b("data.entityName")), badge(b("data.entityType"), "info")] }, + caption(b("data.entityMentionText")), + select("关系深度", "data.entityDepth", DEPTH_OPTIONS, "entity-depth"), + fold("属性", [kv(b("data.entityAttrs"))], true), + fold("关系", [{ type: "list", items: b("data.entityRelations") }], true), + fold("提及它的记忆", [{ type: "list", items: b("data.entityMemories") }], false), + text(b("data.entityHint"), { size: "caption" }), + ]; +} + +export function entitySection(state) { + if (state.page === "entity") return entityDetail(state); + return entityList(state); +} diff --git a/dsh-mneme/lib/dsh-go/views/memory.js b/dsh-mneme/lib/dsh-go/views/memory.js new file mode 100644 index 00000000..768915ca --- /dev/null +++ b/dsh-mneme/lib/dsh-go/views/memory.js @@ -0,0 +1,198 @@ +// 记忆 section: list (search + filters + month view), detail, editor, and the +// conflict queue that mneme's own drawer keeps one click away from a memory. +import { + FILTER_TYPE_OPTIONS, + MINIMP_OPTIONS, + RANGE_OPTIONS, + SCOPE_OPTIONS, + TYPE_OPTIONS, + VIEW_OPTIONS, + typeLabel, +} from "../text.js"; +import { act, actions, b, badge, caption, divider, empty, fold, heading, kv, select, text, toggle } from "./common.js"; + +function filterBar(state) { + return fold( + "筛选与视图", + [ + select( + "类型", + "data.filter", + [ + { label: "全部类型", value: "all" }, + ...FILTER_TYPE_OPTIONS.map((type) => ({ label: typeLabel(type), value: type })), + ], + "type-filter", + ), + select("时间范围", "data.fRange", RANGE_OPTIONS, "set-range"), + select("重要度", "data.fMin", MINIMP_OPTIONS, "set-minimp"), + select("来源", "data.fScope", SCOPE_OPTIONS, "set-scope"), + select("视图", "data.fView", VIEW_OPTIONS, "set-view"), + toggle("语义检索", "data.fSemantic", "toggle-semantic"), + toggle("热度优先", "data.fHeat", "toggle-heat"), + ], + state.filtersOpen, + ); +} + +function monthsView(state) { + const groups = Array.isArray(state.monthGroups) ? state.monthGroups : []; + if (groups.length === 0) return [{ type: "list", items: b("data.rows") }]; + return groups.map((group) => fold(group.label, [{ type: "list", items: group.rows }], true)); +} + +function memoryList(state) { + return [ + { + type: "form", + submit: { id: "search" }, + submitLabel: "搜索", + children: [{ type: "textField", label: "搜索记忆", bind: "data.query", placeholder: "标题或正文,留空返回全部" }], + }, + actions(act("新记忆", "new", undefined, "add"), act("刷新", "refresh", undefined, "refresh")), + filterBar(state), + ...(state.viewMode === "month" ? monthsView(state) : [{ type: "list", items: b("data.rows") }]), + // 翻页用 App 原生的两段 segmented:每段最少 80dp、两段正好铺满一行, + // 不会像按钮组那样折成上下两行(面板只有约 297dp 宽)。 + // 页码文案挂在 label 上(binding data.pageText),顺带替掉独立的说明行。 + // 空列表也走同一条路:视图结构在首屏就定死了,按数据决定「要不要这块」 + // 会留下错误的残影(列表已经推来 N 条,空状态块却还在),所以这里不放 + // 任何依赖数据的结构分支,只把「没有匹配」写进页码文案里。 + { + type: "segmented", + label: b("data.pageText"), + bind: "data.pagePick", + options: [ + { label: "上一页", value: "prev" }, + { label: "下一页", value: "next" }, + ], + onChange: { id: "page" }, + }, + ]; +} + +function memoryDetail(state) { + const archived = state.current?.archived === true; + return [ + actions( + act("返回", "back", undefined, "reply"), + act("编辑", "edit", undefined, "edit"), + archived ? act("恢复", "unarchive", undefined, "refresh") : act("归档", "archive", undefined, "archive"), + ), + text(b("data.detailTitle"), { size: "title", weight: "semibold" }), + { + type: "row", + children: [ + badge(b("data.detailType"), state.detailTypeTone ?? "info"), + text(b("data.detailStars"), { size: "caption", weight: "medium" }), + ], + }, + ...(state.detailConflict === true + ? [{ type: "callout", text: "这条记忆已进入冲突队列,可在「设置 › 状态 › 冲突队列」里裁决。", tone: "warn" }] + : []), + kv(b("data.detailMeta")), + fold("正文", [text(b("data.detailContent"))], true), + fold("相关实体", [{ type: "list", items: b("data.detailEntities") }], false), + fold( + "来源与溯源", + [ + actions(act("查看来源", "provenance", undefined, "history")), + text(b("data.provenanceText"), { size: "caption" }), + ], + false, + ), + actions(act("复制", "copy", undefined, "copy"), act("插入输入框", "insert", undefined, "paste")), + fold("删除这条记忆", [{ type: "confirm", id: "delete", confirmLabel: "删除", cancelLabel: "取消" }], false), + ]; +} + +function memoryEditor(state) { + return [ + actions(act("取消", "back")), + { + type: "form", + submit: { id: "save" }, + submitLabel: "保存", + children: [ + { type: "textField", label: "标题", bind: "data.form.title", required: true, maxLength: 200 }, + { type: "textField", label: "正文", bind: "data.form.content", multiline: true, maxLines: 6, maxLength: 2000 }, + ...(state.form?.id === "" || state.form?.id === undefined + ? [ + select( + "类型", + "data.form.type", + TYPE_OPTIONS.map((type) => ({ label: typeLabel(type), value: type })), + "form-type", + ), + ] + : [text(`类型:${typeLabel(state.form?.type)}(保存后不能改)`, { size: "caption" })]), + { type: "stepper", label: "重要度", bind: "data.form.importance", min: 1, max: 5 }, + { type: "textField", label: "标签", bind: "data.form.tags", placeholder: "逗号分隔" }, + ...(state.form?.id === "" || state.form?.id === undefined + ? [] + : [ + { type: "textField", label: "归属 Agent", bind: "data.form.agentScope", placeholder: "留空 = 全局" }, + { type: "textField", label: "归属工作区", bind: "data.form.workspaceScope", placeholder: "留空 = 全局" }, + ]), + ], + }, + ]; +} + +// ------------------------------------------------------------- conflict queue +export function conflictPage(state) { + const nodes = [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + heading("冲突队列"), + caption("冻结的矛盾记忆,等待人工确认"), + ]; + if (state.conflictCount === 0) { + nodes.push(empty("没有待裁决的冲突", undefined, "check")); + return nodes; + } + nodes.push({ type: "list", items: b("data.conflictRows") }); + if (state.conflictReady !== true) return nodes; + nodes.push(divider()); + nodes.push( + fold( + "版本 A", + [ + text(b("data.conflictATitle"), { size: "subtitle", weight: "semibold" }), + kv(b("data.conflictAMeta")), + text(b("data.conflictAContent")), + ], + true, + ), + ); + nodes.push( + fold( + "版本 B", + [ + text(b("data.conflictBTitle"), { size: "subtitle", weight: "semibold" }), + kv(b("data.conflictBMeta")), + text(b("data.conflictBContent")), + ], + false, + ), + ); + nodes.push( + actions( + act("采用 A", "conflict-pick", { winner: "a" }, "check"), + act("采用 B", "conflict-pick", { winner: "b" }, "check"), + act("都不采用", "conflict-pick", { winner: "none" }, "remove"), + ), + ); + if (state.conflictPick !== "") { + const label = state.conflictPick === "a" ? "采用 A" : state.conflictPick === "b" ? "采用 B" : "都不采用"; + nodes.push({ type: "confirm", id: "conflict-win", confirmLabel: label, cancelLabel: "取消" }); + nodes.push(caption("确认后:保留方正文追加已否决注记,另一方归档。")); + } + return nodes; +} + +export function memorySection(state) { + if (state.page === "conflict") return conflictPage(state); + if (state.page === "detail") return memoryDetail(state); + if (state.page === "editor") return memoryEditor(state); + return memoryList(state); +} diff --git a/dsh-mneme/lib/dsh-go/views/root.js b/dsh-mneme/lib/dsh-go/views/root.js new file mode 100644 index 00000000..a432e429 --- /dev/null +++ b/dsh-mneme/lib/dsh-go/views/root.js @@ -0,0 +1,30 @@ +// View builder — a phone-first rebuild of mneme's own panel. +// +// Top level: three sections in the app's own `tabs` bar (equal columns, one +// line, local switching). The app's tab bar needs 80dp per tab, so four tabs +// wrap on a phone-width panel; three fit. 运行状态 lives inside the settings tab, +// and so does the conflict queue (that is where its entry point sits). +// Because tabs switch locally, every section's data is loaded and pushed together. +import { entitySection } from "./entity.js"; +import { memorySection } from "./memory.js"; +import { settingsTab } from "./settings.js"; +import { notices } from "./common.js"; + +export function buildView(state) { + return { + v: 2, + type: "stack", + accent: "app", + children: [ + ...notices(state), + { + type: "tabs", + items: [ + { label: "记忆", children: memorySection(state) }, + { label: "实体", children: entitySection(state) }, + { label: "设置", children: settingsTab(state) }, + ], + }, + ], + }; +} diff --git a/dsh-mneme/lib/dsh-go/views/settings.js b/dsh-mneme/lib/dsh-go/views/settings.js new file mode 100644 index 00000000..acd9833d --- /dev/null +++ b/dsh-mneme/lib/dsh-go/views/settings.js @@ -0,0 +1,398 @@ +// 设置 / 状态: two sibling areas, mirroring mneme's own panel where 设置 and +// 状态 are separate top-level tabs. The app's tab bar only fits three tabs at +// 80dp on a phone, so the third tab holds a two-item switch (设置 | 状态) and +// each area keeps its own inner navigation: 设置 is a card menu (like mneme's +// Settings cards) and 状态 is mneme's Status page with its grouped cards. +import { ENUM_FLAGS, FLAG_GROUPS, MODE_OPTIONS, flagAction, flagPath } from "../text.js"; +import { + act, + actions, + b, + caption, + divider, + empty, + fold, + heading, + kv, + metric, + mono, + select, + text, + toggle, +} from "./common.js"; +import { conflictPage } from "./memory.js"; +import { statusSection } from "./status.js"; + +const SETTINGS_VIEWS = ["settings", "status"]; + +// 设置页的顺序与分组对齐 mneme 电脑端面板(个性化 / 记忆引擎 / 搜索 / 连接与 +// 安全 / 帮助)。功能开关是记忆引擎的一部分,所以不再藏进子页面,直接铺在设置 +// 页上;只有需要表单的子页(命令 / 向量 / 外部访问 / 工作动态 / 关于)才进入 +// 「点一行 → 子页面」的导航。 +const PAGES = [ + { key: "commands", title: "自定义指令", hint: "把常用提示词存成 / 命令,聊天时直接键入调用。", icon: "command" }, + { + key: "vector", + title: "向量检索", + hint: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。", + icon: "chart", + }, + { + key: "external", + title: "外部访问", + hint: "开放一个本机接口,让其他工具(插件、命令行、桌面应用)也能读写这份记忆库,默认只允许本机连接。改动保存后重启 DSH 生效。", + icon: "globe", + }, + { + key: "choro", + title: "代码库地志", + hint: "给常用仓库生成一份代码骨架:模块划分、文件数、约定与构建方式,开会话时按档位注入。", + icon: "code", + }, + { key: "help", title: "关于", icon: "info" }, +]; + +const pageRow = (page) => ({ + title: page.title, + ...(page.hint === undefined ? {} : { subtitle: page.hint }), + icon: page.icon, + action: { id: "open-panel", args: { k: page.key } }, +}); + +const rows = (keys) => ({ + type: "list", + items: PAGES.filter((page) => keys.includes(page.key)).map(pageRow), +}); + +function personalRows() { + return { + type: "list", + items: [ + { + title: "用户画像", + subtitle: "写一段自我介绍(角色、背景、偏好),Agent 每轮对话都会自动带上。", + icon: "user", + action: { id: "open-profile", args: { k: "profile" } }, + }, + { + title: "规则", + subtitle: "给 Agent 立下必须遵守的规矩,随时增删,下一轮即生效。", + icon: "list", + action: { id: "open-profile", args: { k: "rules" } }, + }, + { + title: "Agent 眼中的你", + subtitle: "dream 巩固后自动维护的一份逐条事实清单;这里可以逐条改、加、归档。", + icon: "sparkle", + action: { id: "open-panel", args: { k: "facts" } }, + }, + { + title: "人格定义(beta)", + subtitle: "给这位 Agent 一张角色卡:名字、性格、回复要求。", + icon: "robot", + action: { id: "open-profile", args: { k: "persona" } }, + }, + ], + }; +} + +function settingsMenu(state) { + const group = FLAG_GROUPS.find((g) => g.key === state.flagGroup) ?? FLAG_GROUPS[0]; + return [ + heading("个性化"), + personalRows(), + heading("记忆引擎"), + { + type: "segmented", + label: "\u200b", + bind: "data.mode", + options: MODE_OPTIONS, + onChange: { id: "mode" }, + }, + caption( + "轻量模式只保留核心能力:记忆读写、自动注入、热记忆;巩固、实体抽取、语义搜索等全部关闭,适合低配机器或只想「记住偏好」的场景。标准模式开启全部功能。", + ), + rows(["choro"]), + heading("功能开关"), + select( + "分组", + "data.flagGroup", + FLAG_GROUPS.map((g) => ({ label: g.label, value: g.key })), + "switch-group", + ), + caption(group.note), + ...group.items.map(([key, label]) => toggle(label, flagPath(key), flagAction(key))), + // 枚举项不是开关:给一组下拉(目前只有地志注入档位,挂在工作流组下)。 + ...ENUM_FLAGS.filter((flag) => group.key === "workflow").flatMap((flag) => [ + select(flag.label, flagPath(flag.key), flag.options, flagAction(flag.key)), + caption(flag.hint), + ]), + // 「其它」组按 /features 的 effective 动态列出未收录的布尔旗标:视图结构 + // 不能靠数据分支,所以用 list + 数据行(点一行翻转一侧)。 + ...(group.key === "extra" ? [{ type: "list", items: b("data.extraRows") }] : []), + caption(b("data.flagsMeta")), + caption( + "控制插件在后台做哪些事。改动即时保存,重启 DSH 后生效;嵌套开关(如「质量过滤 · 启用」)在电脑端面板里按点号归类。", + ), + heading("搜索"), + rows(["vector"]), + heading("连接与安全"), + rows(["external"]), + heading("帮助"), + rows(["commands", "help"]), + divider(), + actions(act("刷新", "refresh", undefined, "refresh")), + ]; +} + +function workbenchPanel() { + const nodes = [ + kv(b("data.wbKv")), + heading("已沉淀"), + { type: "list", items: b("data.wbDeposited") }, + caption("沉淀 = 被 mneme 当成工作流或项目状态写下来的条目。"), + heading("归档"), + ]; + nodes.push({ type: "list", items: b("data.wbArchived") }); + nodes.push(caption("点按任意一条可以恢复它。")); + return nodes; +} + +function commandsPanel(state) { + const nodes = [{ type: "list", items: b("data.cmdRows") }]; + if (state.cmdCount === 0) nodes.push(empty("还没有自定义命令", "在下面填一条,之后就能随时叫它。", "command")); + nodes.push({ + type: "form", + submit: { id: "cmd-add" }, + submitLabel: "新增命令", + children: [ + { type: "textField", label: "命令名", bind: "data.sCmdName", required: true, maxLength: 40 }, + { type: "textField", label: "说明", bind: "data.sCmdDesc", maxLength: 120 }, + { type: "textField", label: "指令正文", bind: "data.sCmdIns", multiline: true, maxLines: 4, maxLength: 2000 }, + ], + }); + if (state.cmdCount > 0) { + nodes.push( + select( + "要删除的命令", + "data.sCmdPick", + [ + { label: "不删除", value: "" }, + ...state.commands.map((cmd) => ({ label: String(cmd.name ?? cmd.id), value: String(cmd.id) })), + ], + "cmd-pick", + ), + ); + if (state.sCmdPick !== "") { + nodes.push({ type: "confirm", id: "cmd-delete", confirmLabel: "删除命令", cancelLabel: "取消" }); + } + } + return nodes; +} + +function vectorPanel() { + return [ + kv(b("data.vectorKv")), + toggle("启用向量检索", "data.sVecEnabled", "vector-toggle"), + { + type: "form", + submit: { id: "vector-save" }, + submitLabel: "保存向量配置", + children: [ + { type: "textField", label: "接口地址", bind: "data.sVecUrl", placeholder: "https://…/v1", maxLength: 200 }, + { + type: "textField", + label: "模型", + bind: "data.sVecModel", + placeholder: "text-embedding-3-small", + maxLength: 80, + }, + ], + }, + actions(act("重建索引", "vector-reindex", undefined, "refresh")), + caption(b("data.vectorNote")), + ]; +} + +function externalPanel() { + return [ + toggle("开启外部访问", "data.sExtEnabled", "external-toggle"), + { + type: "form", + submit: { id: "external-save" }, + submitLabel: "保存外部访问", + children: [ + { type: "textField", label: "端口", bind: "data.sExtPort", placeholder: "8790", maxLength: 5 }, + { type: "textField", label: "监听地址", bind: "data.sExtHost", placeholder: "127.0.0.1", maxLength: 60 }, + ], + }, + caption("令牌由 mneme 自己管理,面板不读取也不显示明文;需要令牌时到电脑端的 mneme 设置里看。"), + ]; +} + +function helpPanel() { + return [ + kv(b("data.helpKv")), + actions(act("检测新版本", "check-version", undefined, "download")), + caption(b("data.versionNote")), + ]; +} + +const PANEL_TITLES = { + facts: "Agent 眼中的你", + choro: "代码库地志", + workbench: "工作台", + commands: "自定义命令", + vector: "向量检索", + external: "外部访问", + help: "关于", +}; + +function factsPanel() { + // 结构必须与数据无关(宿主先给视图、后推数据;按数据分支的地方会在首屏定形)。 + // 所以「编辑框 / 归档按钮」永远在树里,点的哪条事实由 data.factEditHint 说明、 + // 内容由 data.sFactEdit 填。 + return [ + select( + "哪张卡", + "data.factsAgent", + b("data.factsCardOptions"), + "facts-agent", + ), + { type: "list", items: b("data.factsRows") }, + { type: "textField", label: "修改这条事实", bind: "data.sFactEdit", multiline: true, maxLines: 3, maxLength: 300 }, + text(b("data.factsEditHint"), { size: "caption" }), + actions(act("保存修改", "facts-save", undefined, "save")), + { type: "confirm", id: "facts-archive", confirmLabel: "归档这条", cancelLabel: "取消" }, + { + type: "form", + submit: { id: "facts-add" }, + submitLabel: "添加一条", + children: [ + { type: "textField", label: "新事实(一句话)", bind: "data.sFactNew", required: true, maxLength: 200 }, + ], + }, + actions(act("立即更新", "facts-run", undefined, "sparkle")), + caption(b("data.factsRunText")), + caption("事实由模型在 dream 巩固后自己维护;手填的画像永远优先,事实不会覆盖它。"), + ]; +} + +function choroPanel(state) { + return [ + select("仓库", "data.choroRepo", b("data.choroRepoOptions"), "choro-repo"), + { type: "list", items: b("data.choroRows") }, + text(b("data.choroNote"), { size: "caption" }), + fold("模块页", [mono(b("data.choroPageText"))], false), + { type: "confirm", id: "choro-run", confirmLabel: "重建骨架", cancelLabel: "取消" }, + caption("重建会把已富化的模块页退回骨架;语义段要命令行 --t1 重新生成。"), + ]; +} + +function panelBody(state) { + switch (state.sPanel) { + case "facts": + return factsPanel(state); + case "choro": + return choroPanel(state); + case "workbench": + return workbenchPanel(); + case "commands": + return commandsPanel(state); + case "vector": + return vectorPanel(); + case "external": + return externalPanel(); + case "help": + return helpPanel(); + default: + return []; + } +} + +function panelPage(state) { + return [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + heading(PANEL_TITLES[state.sPanel] ?? "设置"), + ...panelBody(state), + ]; +} + +function settingsBody(state) { + if (state.page === "profile") return profilePage(state); + if (state.page === "conflict") return conflictPage(state); + if (state.sPanel !== "menu") return panelPage(state); + return settingsMenu(state); +} + +function statusBody(state) { + return [ + { + type: "grid", + columns: 2, + children: [ + metric("记忆总数", "data.totalText"), + metric("近 30 天注入", "data.injectedText"), + metric("注入命中率", "data.slotRateText", "good"), + metric("待确认冲突", "data.conflictsText", state.pendingConflicts > 0 ? "warn" : undefined), + ], + }, + ...statusSection(state), + actions(act("刷新", "refresh", undefined, "refresh")), + ]; +} + +export function settingsTab(state) { + const view = SETTINGS_VIEWS.includes(state.settingsView) ? state.settingsView : "settings"; + return [ + { + type: "segmented", + label: "\u200b", + bind: "data.settingsView", + options: [ + { label: "设置", value: "settings" }, + { label: "状态", value: "status" }, + ], + onChange: { id: "switch-settings-view" }, + }, + ...(view === "status" ? statusBody(state) : settingsBody(state)), + ]; +} + +export function profilePage(state) { + const nodes = [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + { + type: "segmented", + label: "\u200b", + bind: "data.profileTab", + options: [ + { label: "画像", value: "profile" }, + { label: "规则", value: "rules" }, + { label: "人格", value: "persona" }, + ], + onChange: { id: "profile-tab" }, + }, + ]; + if (state.profileTab === "persona") { + nodes.push(kv(b("data.previewItems"))); + nodes.push(fold("人格设定", [mono(b("data.personaText"))], false)); + return nodes; + } + nodes.push({ + type: "textField", + label: state.profileTab === "rules" ? "规则(JSON 数组)" : "用户画像", + bind: "data.editText", + multiline: true, + maxLines: 8, + maxLength: 4000, + }); + nodes.push(actions(act("保存", "profile-save", undefined, "save"))); + if (state.profileTab === "profile") { + nodes.push(actions(act("从记忆提取草稿", "profile-extract", undefined, "sparkle"))); + if (state.extractText !== "") nodes.push(text(b("data.extractText"), { size: "caption" })); + } + return nodes; +} + +export { panelPage }; diff --git a/dsh-mneme/lib/dsh-go/views/status.js b/dsh-mneme/lib/dsh-go/views/status.js new file mode 100644 index 00000000..27bf9b5b --- /dev/null +++ b/dsh-mneme/lib/dsh-go/views/status.js @@ -0,0 +1,51 @@ +// 运行状态: the runtime picture mneme's own Status tab shows, grouped into +// collapsible blocks so the settings tab stays short. +import { act, actions, b, caption, chart, fold, heading, kv } from "./common.js"; + +export function statusSection(state) { + return [ + fold("库内一览", [kv(b("data.statusKv"))], true), + fold("注入与召回", [kv(b("data.injectKv")), heading("常被召回"), { type: "list", items: b("data.topRows") }], true), + fold("注入预览", [caption(b("data.injectPreviewText"))], false), + fold("后台巩固", [kv(b("data.dreamKv")), { type: "list", items: b("data.runRows") }], false), + fold( + "模型用量", + [ + { + type: "chart", + kind: "bar", + categories: b("data.tokenCategories"), + series: b("data.tokenSeries"), + unit: "次", + caption: "近 28 天", + }, + kv(b("data.tokenKv")), + ], + false, + ), + fold( + "热度分布", + [ + { type: "chart", kind: "bar", categories: ["热", "温", "冷"], series: b("data.heatSeries"), unit: "条" }, + caption(b("data.heatNote")), + ], + false, + ), + fold("冲突队列", [actions(act("打开冲突队列", "open-conflict", undefined, "warning"))], false), + fold("工作动态", [actions(act("打开工作动态", "open-panel", { k: "workbench" }, "todo"))], false), + fold( + "维护", + [ + actions( + act("检查可回收", "reclaim", undefined, "clear"), + act("真跑回收", "reclaim-prepare", undefined, "delete"), + ), + ...(state.reclaimConfirm === true + ? [{ type: "confirm", id: "reclaim-run", confirmLabel: "真跑回收", cancelLabel: "取消" }] + : []), + caption(b("data.reclaimText")), + ], + false, + ), + ]; +} diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index eb0baa0d..221bf74b 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -687,6 +687,16 @@ export const apply = (ctx, config) => { list: () => [] }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro, profileFacts); disposers.push(api.dispose); + + // dsh-go 原生面板:只有宿主提供 dshGo 服务(dsh-go 手机 App)时才挂载。 + // 动态 import 是刻意的——静态导入会让「没有桥就整块不加载」这句承诺落空。 + // cfg.apiToken 一并交给面板:createApi 给 token / token-write 路由包了鉴权, + // 进程内调用不带令牌会被自家路由拦成 401,不为此在服务端开白名单。 + ctx.inject(["dshGo"], (dshCtx) => { + import("./dsh-go/panel.js") + .then((panel) => panel.mountDshGoPanel(dshCtx, { apiToken: cfg.apiToken })) + .catch((error) => ctx.logger?.warn?.(`[dsh-mneme] dsh-go 面板未加载:${error?.message ?? error}`)); + }); } // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条 diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 686864f8..6e904d45 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -4501,10 +4501,21 @@ export function createStore(path) { /** All entities (optionally name-filtered, newest first). Used by sleep phase 4 * orphan detection: an entity with zero relations is a candidate for relation * completion. */ - function listEntities({ limit = 1000 } = {}) { + function listEntities({ limit = 1000, q } = {}) { + const needle = typeof q === "string" ? q.trim() : ""; + if (needle === "") { + const rows = db.prepare( + "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?" + ).all(limit); + return rows.map(toEntity); + } + // 名字过滤放在 SQL 侧:调用方若在 LIMIT 之后过滤,排在 limit 之外的实体 + // 就永远搜不到(面板按 limit=200 取回再本地过滤,1.6 万实体时命中率约 1%)。 + // LIKE 的 % / _ 用 ESCAPE 转义,否则用户输入一个 "%" 就会把整库匹配出来。 + const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`); const rows = db.prepare( - "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?" - ).all(limit); + "SELECT * FROM entities WHERE name LIKE ? ESCAPE '\\' ORDER BY last_seen DESC, name ASC LIMIT ?" + ).all(`%${escaped}%`, limit); return rows.map(toEntity); } diff --git a/dsh-mneme/package.json b/dsh-mneme/package.json index b3bc0464..338ea2de 100644 --- a/dsh-mneme/package.json +++ b/dsh-mneme/package.json @@ -57,6 +57,30 @@ "patch": "./cordis.patch.yml" } }, + "dshGo": { + "v": 1, + "title": { + "zh": "记忆库", + "en": "Memory" + }, + "icon": "book", + "contributes": { + "panels": [ + { + "id": "mneme", + "title": { + "zh": "记忆库", + "en": "Memory" + }, + "icon": "book", + "placement": [ + "session.tab", + "features" + ] + } + ] + } + }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6 <0.2.0 || >=0.1.5-rc.0 <0.3.0 || >=0.2.0-rc.0 <0.3.0", diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index 711f3bde..d21b7c5b 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -392,7 +392,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // merge/update verdict ∪ source=dream 直写)——状态页「查看全部」 // 与记忆库的「沉淀」筛选 chip 共用这个视图。 const depositedOnly = url.searchParams.get("deposited") === "only"; - const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }); + // id: 按主键取单条(含归档行)。面板打开/保存前要拿「这一条」的最新值, + // 分页扫列表最多只能看到前 1000 条,已归档的更不在默认视图里;扫不到就 + // 只能拿陈旧缓存去比对 updated_at,等于没有版本检查。 + const idFilter = (url.searchParams.get("id") ?? "").trim(); + const rows = idFilter === "" + ? service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) + // getById 是裸 SELECT,没有 forgotten 谓词;这条路由是 open 的, + // 不过滤等于让本机任意进程按 id 读到已遗忘记忆的全文(评审 #1)。 + // 归档行仍要放行:归档 ≠ 遗忘,面板保存前要用它比对 updated_at。 + : [service.getById(idFilter)].filter((memo) => memo && !memo.forgotten); // 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出 // schema 严格复用 toApiList,扩展只发生在 HTTP 层。 // heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度 @@ -415,8 +424,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = })); // Total honors the same filters as the rows, or the pager's // has-more math breaks whenever minImportance/source/updated-at - // bounds are active. - sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) }); + // bounds are active. 按 id 取单条时总数就是命中的行数。 + const total = idFilter === "" + ? service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) + : rows.length; + sendJson(res, 200, { items, total }); } catch { sendJson(res, 500, { error: "internal" }); } @@ -1689,7 +1701,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = try { const url = new URL(req.url, "http://localhost"); const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500)); - const entities = service.listEntities?.({ limit }) ?? []; + // q = 名字过滤。必须下推到 store:面板此前取回 limit 条再本地过滤, + // 排在 limit 之后的实体永远搜不到(评审 #9)。老宿主没有这个形参时 + // 多传一个键不会报错,只是过滤失效——与 limit 的处理一致。 + const q = url.searchParams.get("q") ?? ""; + const entities = service.listEntities?.({ limit, q }) ?? []; // total 是**全库**实体数,不是本次切片长度:目录带 limit,面板拿它当 // 「一共抽出多少实体」,填切片长度会让 1.6 万实体永远显示成 500。 // 老宿主(service 上没有 countEntities)回落到切片长度,不抛。计数本身失败也 @@ -2287,11 +2303,17 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = // materialize and persist one here. 只有通过上面那道门才会走到这里, // 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。 const kv = settings.getExternalApi() ?? {}; - if (!kv.token) { + // ?token=omit:只读配置、不生成也不落库 Bearer。 + // 手机端面板(src/dsh-go)只需要 enabled/port/host 来渲染表单,而默认的 + // GET 会顺手 materialize 一个 token 写进 user_settings —— 一次纯读请求 + // 变成写路径,既污染审计也违反「面板不显示明文令牌」的承诺。带上这个 + // 参数时 token 字段一律留空;不传的老客户端行为完全不变。 + const omitToken = new URL(req.url, "http://localhost").searchParams.get("token") === "omit"; + if (!omitToken && !kv.token) { kv.token = randomBytes(24).toString("base64url"); settings.setExternalApi({ token: kv.token }); } - sendJson(res, 200, { config: fullExternalConfig(kv) }); + sendJson(res, 200, { config: fullExternalConfig(omitToken ? { ...kv, token: "" } : kv) }); } catch { sendJson(res, 500, { error: "internal" }); } diff --git a/dsh-mneme/src/dsh-go/host.js b/dsh-mneme/src/dsh-go/host.js new file mode 100644 index 00000000..f230a5c4 --- /dev/null +++ b/dsh-mneme/src/dsh-go/host.js @@ -0,0 +1,162 @@ +// In-process call into the mneme HTTP API. +// +// The panel runs inside the engine process, exactly where mneme registered its +// routes. Going through real HTTP would hit the dsh-go bridge guard (401 unless +// the request carries the process launch token, which is never written to disk), +// so we dispatch the registered route handler directly and never leave the +// process. Everything stays on the local web server table: no sockets. +// +// Auth is not skipped: mneme's route registrar wraps every route with a policy +// check, so a write route answers 401 unless the request carries the configured +// apiToken. We forward it as the x-dsh-mneme-token header (one of the two the +// registrar accepts) instead of weakening the policy for in-process callers. +import { EventEmitter } from "node:events"; + +const PREFIX = "/api/dsh-mneme"; +const DEFAULT_TIMEOUT = 5000; + +function makeRequest(url, method, body, apiToken) { + const req = new EventEmitter(); + req.url = url; + req.method = method; + req.headers = { + host: "127.0.0.1", + "content-type": "application/json", + "content-length": String(body === undefined ? 0 : Buffer.byteLength(body)), + }; + if (typeof apiToken === "string" && apiToken.length > 0) { + req.headers["x-dsh-mneme-token"] = apiToken; + } + req.socket = { remoteAddress: "127.0.0.1", encrypted: false }; + req.resume = () => {}; + req.pause = () => {}; + req.setEncoding = () => {}; + // The handler attaches its body listeners synchronously when invoked, so the + // body is only released afterwards by flushRequest(); emitting during + // construction would fire 'data'/'end' with no listener attached and the + // handler's readBody() would wait forever. + req.flush = () => { + queueMicrotask(() => { + if (body !== undefined) req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + }; + return req; +} + +function makeResponse() { + const chunks = []; + let settled; + const done = new Promise((resolve) => { + settled = resolve; + }); + const headers = {}; + const res = { + statusCode: 200, + headersSent: false, + done, + writeHead(status, extra) { + res.statusCode = status; + res.headersSent = true; + if (extra !== undefined && extra !== null) { + for (const key of Object.keys(extra)) headers[key.toLowerCase()] = extra[key]; + } + return res; + }, + setHeader(key, value) { + headers[key.toLowerCase()] = value; + return res; + }, + getHeader(key) { + return headers[key.toLowerCase()]; + }, + removeHeader(key) { + delete headers[key.toLowerCase()]; + }, + write(chunk) { + if (chunk !== undefined && chunk !== null && typeof chunk !== "function") { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + return true; + }, + end(chunk) { + if (chunk !== undefined && chunk !== null && typeof chunk !== "function") { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + settled(); + return res; + }, + flushHeaders() {}, + }; + res.text = () => Buffer.concat(chunks).toString("utf8"); + res.bytes = () => Buffer.concat(chunks); + return res; +} + +// 超时定时器不能被 unref():handler 永不响应时,它是唯一还挂在事件循环上的 +// 东西;unref 之后 node 22 的 test runner 会判定循环已空,把用例记成 cancelled +// (CI 的 node22 两腿就是这么红的)。改成显式 cancel —— 成功路径也不白挂 5 秒。 +function timeout(ms) { + let timer; + const promise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("mneme 接口超时")), ms); + }); + return { promise, cancel: () => clearTimeout(timer) }; +} + +/** + * Resolve the mneme route handler and run it in-process. + * @param {object} ctx - the plugin context (must expose get()). + * @param {{apiToken?: string}} [options] - the host's configured API token; the + * route registrar requires it for every token / token-write route. + */ +export function createHostCall(ctx, options = {}) { + const apiToken = options.apiToken; + return async function callMneme(path, options = {}) { + const method = (options.method ?? "GET").toUpperCase(); + const body = options.body === undefined ? undefined : JSON.stringify(options.body); + const url = new URL(PREFIX + path, "http://127.0.0.1"); + const webServer = ctx.get?.("webServer") ?? ctx.reflect?.get?.("webServer"); + if (webServer === undefined || typeof webServer.match !== "function") { + throw new Error("宿主 webServer 不可用"); + } + const route = webServer.match(url.pathname); + if (route === undefined || route === null) throw new Error(`mneme 未注册 ${url.pathname}`); + const req = makeRequest(url.pathname + url.search, method, body, apiToken); + const res = makeResponse(); + let run; + try { + run = Promise.resolve(route.handler(req, res)); + } finally { + req.flush(); + } + // Wait for both: most routes return a promise and end the response inside it, + // but a few (version-check) return undefined and end later from a callback. + // Racing the three would return the moment the synchronous call returns and + // hand an empty body to the caller, which then looks like a failed fetch. + const guard = timeout(options.timeoutMs ?? DEFAULT_TIMEOUT); + try { + await Promise.race([ + Promise.all([run, res.done]).then(() => undefined), + guard.promise, + ]); + } finally { + guard.cancel(); + } + const text = res.text(); + if (res.statusCode >= 400) { + const detail = text.length > 200 ? `${text.slice(0, 200)}…` : text; + const error = new Error(`mneme ${method} ${path} → ${res.statusCode} ${detail}`); + error.status = res.statusCode; + error.body = text; + throw error; + } + if (options.raw === true) return text; + if (text === "") return {}; + try { + return JSON.parse(text); + } catch { + return { text }; + } + }; +} diff --git a/dsh-mneme/src/dsh-go/panel.js b/dsh-mneme/src/dsh-go/panel.js new file mode 100644 index 00000000..1ce4779a --- /dev/null +++ b/dsh-mneme/src/dsh-go/panel.js @@ -0,0 +1,2091 @@ +// local-mneme-panel — a dsh-go native panel over the installed dsh-mneme store. +// +// The panel never talks HTTP: src/dsh-go/host.js dispatches mneme's own registered +// route handlers inside this engine process. Reads are mneme's own GET routes; +// writes go through mneme's own routes (create/update/delete/features/profile/ +// rules/mode/conflicts/commands/vector/external/reclaim), so its apiToken policy +// still applies if one is ever set. +import { createHostCall } from "./host.js"; +import { buildView } from "./views/root.js"; +import { + FLAG_LABELS, + RANGE_DAYS, + TYPE_LABELS, + clip, + errorText, + flagAction, + fmtDay, + fmtMonth, + fmtTime, + joinTags, + scopeText, + splitTags, + entityIcon, + entityTypeLabel, + typeIcon, + typeLabel, + typeTone, +} from "./text.js"; + +const PAGE_SIZE = 20; +const cache = new Map(); +// Live panel feeds, one emit per open panel/locale. dsh-go feeds are push-based: +// data() runs once and later state changes must be pushed through these emits +// (a `refresh` effect only re-renders the view; it never re-runs data()). +const emitters = new Set(); +// 每个面板 scope 打开过哪条记忆。删除确认(confirm 节点)不携带任何参数, +// 只能靠状态决定删谁;状态又是所有面板共用的,所以按 scope 记一份,删除前 +// 与本面板打开的那条核对,不匹配就拒绝(评审 #5)。 +const openedBy = new Map(); +// 冲突页的裁决同样改数据,而 state.conflictId 是模块级共享的:两个面板 +// 各看一条冲突时,后开的会盖掉前一个的 id。按面板 scope 记一份再核对 +// (评审 #3 的 conflict-win 部分)。 +const conflictBy = new Map(); +// 「Agent 眼中的你」的编辑态 state.factEdit 同样是共享的;按面板 scope 记 +// 一份打开的事实 id,保存与归档前核对(评审 #3 的 facts 部分)。 +const factBy = new Map(); +// 上一次推给 App 的完整数据:未重新读取的块从这里并回去,保证每份 data 都 +// 带着全部绑定键(见 load 的注释)。 +let lastPayload = null; +const scopeKey = (scope) => [scope?.sessionId ?? "", scope?.turnId ?? "", scope?.callId ?? ""].join("|"); + +const defaultState = () => ({ + page: "list", // list|detail|editor|entity|conflict|settings|profile + flagGroup: "basic", + extraRows: [], // 上游新增、面板还没中文名的布尔旗标 + factsCards: [], + factsAgent: "", + factsRows: [], + factsItems: [], + factEdit: null, + factsRunText: "", + choroRepos: [], + choroRepo: "", + choroRows: [], + choroNote: "", + choroPageText: "", + settingsView: "settings", // settings|status + sPanel: "menu", // menu|workbench|commands|vector|external|help|facts|choro + profileTab: "profile", + q: "", + type: "all", + range: "all", + minImportance: 0, + scope: "all", + semantic: false, + viewMode: "flat", + heatSort: false, + filtersOpen: false, + offset: 0, + size: PAGE_SIZE, + current: null, + form: {}, + // 编辑页打开时的那份快照:只有和它不同的字段才会写回 /update, + // 并且用它挡住「别处已经改过这条记忆」(评审 #4)。 + editBase: null, + entityTotal: -1, + entityTypeTotal: 0, + entityQuery: "", + entityName: "", + entityDepth: 1, + entityRelationCount: 0, + entityMemoryCount: 0, + flags: {}, + flagOverrides: {}, + pendingRestart: [], + pendingConflicts: 0, + pendingIds: new Set(), + rowsCount: 0, + total: 0, // 当前筛选下的总条数,翻页时用它算页数 + monthGroups: [], + detailEntities: [], + detailEntityCount: 0, + detailConflict: false, + provenanceText: "", + conflictCount: 0, + conflictId: "", + conflictPick: "", + conflictA: null, + conflictB: null, + conflictReady: false, + wbKv: [], + wbDeposited: [], + wbArchived: [], + commands: [], + cmdCount: 0, + sCmdPick: "", + sCmdName: "", + sCmdDesc: "", + sCmdIns: "", + vector: { enabled: false, baseUrl: "", model: "" }, + external: { enabled: false, port: 8790, host: "127.0.0.1" }, + // 读成功才允许整体写回:读失败时上面的 vector/external 会落成默认值, + // 此时 PUT 会把用户在 Web 面板里配好的地址/端口覆盖掉(评审 #2)。 + vectorLoaded: false, + externalLoaded: false, + // 实体搜索要重读的是「实体清单」而不是整页;load() 按这个标记补读 + // (评审 #4)。 + entityDirty: false, + // 顶层页签由 App 本地切换,state.page 可能还停在 "list";设置项的动作 + // 改完数据后也要重读设置块,否则推到面板上的仍是旧值(评审 #6)。 + settingsDirty: false, + versionNote: "", + extractText: "", + injectPreviewText: "", + tokenCategories: [], + tokenSeries: [], + tokenKv: [], + heatSeries: [], + heatNote: "", + reclaimText: "尚未检查。", + reclaimConfirm: false, + notice: "", + noticeTone: "ok", + err: "", +}); + +const state = defaultState(); + +function resetNotice() { + state.notice = ""; + state.err = ""; +} + +function notice(text, tone = "ok") { + state.notice = clip(text, 160); + state.noticeTone = tone; +} + +/** Read the resolved action arguments/values the app sends with an invoke. */ +function valueOf(event, keys, fallback) { + const value = event?.value; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const key of keys) { + if (value[key] !== undefined && value[key] !== null) return value[key]; + } + } + if (typeof value === "string" && value !== "") return value; + if (typeof value === "boolean") return value; + return fallback; +} + +function num(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function rowOf(row) { + const heat = typeof row.heat === "number" ? row.heat : Number.NaN; + const pending = state.pendingIds.has(row.id); + const marks = []; + if (row.archived === true) marks.push("已归档"); + if (pending) marks.push("待确认"); + return { + title: clip(row.title, 60), + subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at ?? row.created_at)}${marks.length === 0 ? "" : ` · ${marks.join(" · ")}`}`, + trailing: Number.isFinite(heat) ? `★${row.importance ?? 1} · 热度 ${heat.toFixed(2)}` : `★${row.importance ?? 1}`, + ...(typeIcon(row.type) === undefined ? {} : { icon: typeIcon(row.type) }), + tone: typeTone(row.type), + action: { id: "open", args: { id: row.id } }, + }; +} + +/** Flat dotted flag keys -> nested object so `data.flags.a.b` bindings resolve. */ +function nestFlags(effective) { + const out = {}; + for (const [key, value] of Object.entries(effective ?? {})) { + const parts = key.split("."); + let node = out; + for (const part of parts.slice(0, -1)) { + if (typeof node[part] !== "object" || node[part] === null) node[part] = {}; + node = node[part]; + } + // 原样透传:开关是布尔,枚举型旗标(chorographyInject 的 light/full/off) + // 是字符串。以前这里一律写成 `value === true`,下拉永远只看到 true/false。 + node[parts[parts.length - 1]] = value; + } + return out; +} + +const enc = (value) => encodeURIComponent(String(value ?? "")); + +/** + * Mount the panel on a context that already provides the dsh-go `dshGo` service. + * Called by src/index.js from `ctx.inject(['dshGo'], ...)`: without the dsh-go + * bridge (desktop / headless hosts) this module never runs. + */ +export function mountDshGoPanel(ctx, options = {}) { + // options.apiToken 由 src/index.js 传入:路由注册器给 token / token-write + // 路由包了鉴权,进程内调用不带这个头就会被自家 API 拦成 401(评审 #2)。 + const call = createHostCall(ctx, options); + + // ---------------------------------------------------------------- loaders + function groupByMonth(rows, raw) { + const buckets = new Map(); + rows.forEach((row, index) => { + const label = fmtMonth(raw[index]?.updated_at ?? raw[index]?.created_at); + if (!buckets.has(label)) buckets.set(label, []); + buckets.get(label).push(row); + }); + return [...buckets.entries()].map(([label, list]) => ({ label, rows: list })); + } + + async function loadMemory() { + const statsPromise = call("/recall-stats?window=30").catch(() => ({})); + const conflictsPromise = call("/conflicts").catch(() => ({})); + const dreamPromise = call("/dream-status").catch(() => ({})); + const payload = { + query: state.q, + filter: state.type, + fRange: state.range, + fMin: String(state.minImportance), + fScope: state.scope, + fSemantic: state.semantic, + fView: state.viewMode, + fHeat: state.heatSort, + }; + let items = []; + if (state.q !== "") { + const mode = state.semantic ? "hybrid" : "auto"; + const res = await call(`/search?q=${enc(state.q)}&topK=20&mode=${mode}`); + // /search 没有 type 参数(它是全库语义检索),类型筛选只能在拿到结果后 + // 再收一遍,否则搜索会把用户选好的类型丢掉(评审 #8)。 + const hits = res?.items ?? []; + items = state.type === "all" || state.type === "" ? hits : hits.filter((item) => item?.type === state.type); + payload.total = String(items.length); + // 空结果不再靠一块 emptyState 表达:那个结构在首屏就定了,数据晚一步 + // 到达时就会和真实列表同时出现。文案跟着数据走,结构不跟着动。 + payload.pageText = + items.length === 0 + ? "搜索无结果" + : `搜索 · ${items.length} 条`; + } else { + const query = new URLSearchParams({ + limit: String(state.size), + offset: String(state.offset), + order: "chrono", + }); + if (state.type !== "all" && state.type !== "") query.set("type", state.type); + if (state.minImportance > 0) query.set("minImportance", String(state.minImportance)); + const days = RANGE_DAYS[state.range]; + if (days !== undefined) query.set("updatedFrom", new Date(Date.now() - days * 86400000).toISOString()); + if (state.scope === "deposited") query.set("deposited", "only"); + if (state.scope === "archived") query.set("archived", "only"); + if (state.scope === "foreign") query.set("source", "imported"); + const res = await call(`/list?${query.toString()}`); + items = res?.items ?? []; + const total = num(res?.total, items.length); + const pages = Math.max(1, Math.ceil(total / state.size)); + const pageNo = Math.min(pages, Math.floor(state.offset / state.size) + 1); + payload.total = String(total); + // 这行文案同时是左胶囊的标签,列宽只有约 142dp,越长越容易折行。 + payload.pageText = total === 0 ? "没有匹配的记忆" : `${total} 条 · ${pageNo}/${pages} 页`; + } + for (const row of items) cache.set(row.id, row); + const dream = await dreamPromise; + state.pendingIds = new Set(Array.isArray(dream?.pendingMemoryIds) ? dream.pendingMemoryIds : []); + const ordered = state.heatSort ? [...items].sort((a, b2) => num(b2.heat, -1) - num(a.heat, -1)) : items; + payload.rows = ordered.map(rowOf); + state.monthGroups = groupByMonth(ordered, ordered); + const [stats, conflicts] = await Promise.all([statsPromise, conflictsPromise]); + payload.injected = String(stats?.injection?.injectedCount ?? 0); + payload.slotRate = String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10); + payload.conflicts = String(conflicts?.total ?? 0); + // metric values render in one style, so units ride along in the value + payload.totalText = `${payload.total ?? "0"} 条`; + payload.injectedText = `${payload.injected} 次`; + payload.slotRateText = `${payload.slotRate}%`; + payload.conflictsText = `${payload.conflicts} 条`; + state.pendingConflicts = num(conflicts?.total, 0); + payload.pendingConflicts = state.pendingConflicts; + // 翻页用的是 chips(原生最小 48dp,两个一定同行),点完立刻把绑定清空, + // 免得选中的那枚一直带着勾。 + payload.pagePick = ""; + state.rowsCount = (payload.rows ?? []).length; + state.total = num(payload.total, state.rowsCount); + return payload; + } + + async function loadDetail() { + const memo = state.current ?? {}; + const entities = await call(`/memories/entities?memoryId=${enc(memo.id ?? "")}`).then((res) => res?.entities ?? []); + state.detailEntityCount = entities.length; + state.detailConflict = state.pendingIds.has(memo.id); + return { + detailTitle: clip(memo.title ?? "(无标题)", 120), + detailMeta: [ + { label: "类型", value: typeLabel(memo.type) }, + { label: "重要度", value: `★${memo.importance ?? 1}` }, + { label: "归属", value: scopeText(memo) }, + { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) }, + { label: "标签", value: clip(joinTags(memo.tags) || "—", 40) }, + { label: "来源", value: clip(memo.source ?? "—", 40) }, + { label: "状态", value: memo.archived ? "已归档" : "正常", tone: memo.archived ? "muted" : "good" }, + ], + detailContent: clip(memo.content ?? "", 2000), + detailType: typeLabel(memo.type), + detailTypeTone: typeTone(memo.type) ?? "info", + detailStars: `重要度 ★${memo.importance ?? 1}`, + detailEntities: entities.slice(0, 8).map((item) => ({ + title: clip(item.name ?? item.title ?? item.id ?? "—", 40), + subtitle: clip(item.type ?? "实体", 20), + icon: "user", + })), + detailEntityCount: entities.length, + detailConflict: state.detailConflict, + provenanceText: state.provenanceText, + }; + } + + function loadEditor() { + return { + form: { + id: state.form.id ?? "", + title: state.form.title ?? "", + content: state.form.content ?? "", + type: state.form.type ?? "project", + importance: num(state.form.importance, 3), + tags: state.form.tags ?? "", + agentScope: state.form.agentScope ?? "", + workspaceScope: state.form.workspaceScope ?? "", + }, + }; + } + + async function loadEntity() { + const needle = state.entityQuery.trim(); + // 名字过滤交给宿主(/entities?q=):本地过滤只能看见 limit 那一条切片, + // 排在后面的实体搜不到(评审 #9)。类型数只在不搜索时重算——它代表整库 + // 有几种类型,不该随搜索词一起抖动。 + const [directory, matched] = await Promise.all([ + needle === "" ? null : call("/entities?limit=200").catch(() => null), + call(`/entities?limit=${needle === "" ? 200 : 30}${needle === "" ? "" : `&q=${enc(needle)}`}`), + ]); + const all = directory?.entities ?? matched?.entities ?? []; + state.entityTotal = num(matched?.total, all.length); + if (needle === "") { + const types = new Set(); + for (const item of all) { + if (typeof item?.type === "string" && item.type !== "") types.add(item.type); + } + state.entityTypeTotal = types.size; + } + const shown = (needle === "" ? all : (matched?.entities ?? [])).slice(0, 30); + return { + entityTotal: String(state.entityTotal), + entityTypeTotal: String(state.entityTypeTotal), + entityTotalText: `${String(state.entityTotal)} 个`, + entityTypeTotalText: `${String(state.entityTypeTotal)} 类`, + entityQuery: state.entityQuery, + // 结构固定,空库的提示走这一行文案(见 views/entity.js 的注释)。 + entityStatus: + shown.length === 0 + ? needle === "" + ? "还没有实体 · 在「设置 › 记忆引擎」的功能开关里打开「抽取人物与项目」" + : `没有名字含「${clip(needle, 20)}」的实体` + : needle === "" + ? `共 ${String(state.entityTotal)} 个实体` + : `搜索「${clip(needle, 20)}」· ${String(shown.length)} 个`, + entities: shown.map((item) => ({ + title: clip(item.name ?? item.title ?? item.id ?? "—", 40), + subtitle: clip(entityTypeLabel(item.type ?? "—"), 20), + trailing: item.mention_count === undefined ? undefined : `${item.mention_count} 次`, + ...(entityIcon(item.type) === undefined ? {} : { icon: entityIcon(item.type) }), + action: { id: "open-entity", args: { name: String(item.name ?? "") } }, + })), + }; + } + + async function loadEntityDetail() { + const name = state.entityName; + const ego = await call(`/semantic/graph/ego?entity=${enc(name)}&depth=${state.entityDepth}&limit=40`); + const attrs = await call(`/semantic/graph/entity-attrs?entity=${enc(name)}`).catch(() => null); + const nodes = Array.isArray(ego?.nodes) ? ego.nodes : []; + const byId = new Map(nodes.map((node) => [node.id, node])); + const root = ego?.root ?? {}; + const relations = (Array.isArray(ego?.edges) ? ego.edges : []).slice(0, 30).map((edge) => { + const from = byId.get(edge.from_entity); + const to = byId.get(edge.to_entity); + const other = edge.from_entity === root.id ? edge.to_entity : edge.from_entity; + const otherNode = byId.get(other); + return { + title: `${clip(from?.name ?? "—", 16)} → ${clip(to?.name ?? "—", 16)}`, + subtitle: clip(edge.relation_type ?? "关系", 24), + tone: "info", + ...(otherNode === undefined + ? {} + : { action: { id: "open-entity", args: { name: String(otherNode.name ?? "") } } }), + }; + }); + const memories = new Map(); + for (const edge of Array.isArray(ego?.edges) ? ego.edges : []) { + if (typeof edge.memory_id === "string" && edge.memory_id !== "") { + const memo = cache.get(edge.memory_id); + memories.set(edge.memory_id, { + title: clip(memo?.title ?? `记忆 ${edge.memory_id.slice(0, 8)}`, 40), + subtitle: memo === undefined ? "未在本地缓存" : typeLabel(memo.type), + action: { id: "open", args: { id: edge.memory_id } }, + }); + } + } + state.entityRelationCount = relations.length; + state.entityMemoryCount = memories.size; + return { + entityName: clip(root.name ?? name, 40), + entityType: clip(entityTypeLabel(root.type ?? "实体"), 20), + entityMentionText: `被提到 ${String(root.mention_count ?? 0)} 次 · 关系 ${relations.length} 条`, + entityDepth: String(state.entityDepth), + entityAttrs: (Array.isArray(attrs?.attrs) ? attrs.attrs : []).slice(0, 24).map((attr) => ({ + label: clip(attr.key ?? "—", 30), + value: clip(String(attr.value ?? "—"), 60), + })), + entityRelations: relations, + entityMemories: [...memories.values()].slice(0, 20), + entityRelationCount: relations.length, + entityMemoryCount: memories.size, + entityHint: "点关系可以跳到相邻实体;点记忆可以跳到详情。", + }; + } + + async function loadSettings() { + const [features, mode] = await Promise.all([call("/features"), call("/mode")]); + const effective = features?.effective ?? {}; + state.flags = effective; + state.flagOverrides = features?.overrides ?? {}; + state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : []; + const changed = Object.keys(state.flagOverrides).length; + const pending = state.pendingRestart.length; + // 上游不断加旗标:没给中文名的布尔项仍要出现在面板上,否则新功能在手机上 + // 静默消失。数值/字符串键(模型名、预算)不放这里——它们需要输入框,不是开关。 + state.extraRows = Object.keys(effective) + .filter((key) => !FLAG_LABELS.has(key) && typeof effective[key] === "boolean") + .sort() + .map((key) => ({ + title: key, + subtitle: effective[key] === true ? "已开启 · 点一下关闭" : "已关闭 · 点一下开启", + icon: effective[key] === true ? "toggle-on" : "toggle-off", + action: { id: "flag-set", args: { key } }, + })); + const payload = { + extraRows: state.extraRows, + settingsView: state.settingsView, + sPanel: state.sPanel, + flagGroup: state.flagGroup, + flags: nestFlags(effective), + mode: String(mode?.mode ?? "standard"), + flagsMeta: `已改 ${changed} 项 · 待重启生效 ${pending} 项(改动即时写回 mneme,重启引擎后彻底生效)`, + }; + if (state.sPanel === "menu") return payload; + if (state.sPanel === "workbench") { + const [deposited, archived] = await Promise.all([ + call("/list?deposited=only&limit=8&order=chrono").catch(() => ({})), + call("/list?archived=only&limit=5&order=chrono").catch(() => ({})), + ]); + const depItems = deposited?.items ?? []; + const arcItems = archived?.items ?? []; + for (const row of [...depItems, ...arcItems]) cache.set(row.id, row); + state.wbDeposited = depItems.map((row) => ({ + title: clip(row.title, 50), + subtitle: `${typeLabel(row.type)} · ${fmtTime(row.updated_at)}`, + action: { id: "open", args: { id: row.id } }, + })); + state.wbArchived = arcItems.map((row) => ({ + title: clip(row.title, 50), + subtitle: fmtTime(row.updated_at), + trailing: "点按恢复", + action: { id: "wb-restore", args: { id: row.id } }, + })); + Object.assign(payload, { + wbKv: [ + { label: "已沉淀", value: `${String(deposited?.total ?? depItems.length)} 条` }, + { label: "已归档", value: `${String(archived?.total ?? arcItems.length)} 条` }, + ], + wbDeposited: state.wbDeposited, + wbArchived: state.wbArchived, + }); + } else if (state.sPanel === "commands") { + const res = await call("/commands").catch(() => ({ commands: [] })); + state.commands = Array.isArray(res?.commands) ? res.commands : []; + state.cmdCount = state.commands.length; + Object.assign(payload, { + cmdRows: state.commands.map((cmd) => ({ + title: clip(cmd.name ?? cmd.id ?? "—", 30), + subtitle: clip(cmd.description ?? "", 60), + })), + cmdCount: state.cmdCount, + sCmdPick: state.sCmdPick, + sCmdName: state.sCmdName, + sCmdDesc: state.sCmdDesc, + sCmdIns: state.sCmdIns, + }); + } else if (state.sPanel === "vector") { + const res = await call("/vector-config").catch(() => null); + // 读到才算 loaded:读失败时下面会落成默认值,不能再拿它整体覆盖 + // 服务端配置(评审 #2)。 + state.vectorLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object"; + const config = res?.config ?? {}; + state.vector = { + enabled: config.enabled === true, + baseUrl: String(config.baseUrl ?? ""), + model: String(config.model ?? ""), + }; + const keySet = typeof config.apiKey === "string" && config.apiKey !== ""; + Object.assign(payload, { + sVecEnabled: state.vector.enabled, + sVecUrl: state.vector.baseUrl, + sVecModel: state.vector.model, + vectorKv: [ + { + label: "状态", + value: state.vector.enabled ? "已启用" : "未启用", + tone: state.vector.enabled ? "good" : "muted", + }, + { label: "接口", value: state.vector.baseUrl === "" ? "未配置" : clip(state.vector.baseUrl, 60) }, + { label: "模型", value: state.vector.model === "" ? "未配置" : state.vector.model }, + { label: "密钥", value: keySet ? "已配置(面板不显示)" : "未配置" }, + ], + vectorNote: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。", + }); + } else if (state.sPanel === "external") { + // 读一次真实配置,否则面板永远拿默认值渲染,保存时又把默认值写回去, + // 把用户已经在 Web 面板里配好的端口/地址覆盖掉(评审 #6)。 + // 必须带 token=omit:不带的话这条 GET 会顺手生成并落库一个 Bearer。 + const res = await call("/external-api?token=omit").catch(() => null); + state.externalLoaded = res !== null && typeof res === "object" && res.config !== null && typeof res.config === "object"; + const config = res?.config ?? {}; + state.external = { + enabled: config.enabled === true, + port: num(config.port, 8790), + host: typeof config.host === "string" && config.host !== "" ? config.host : "127.0.0.1", + }; + Object.assign(payload, { + sExtEnabled: state.external.enabled, + sExtPort: String(state.external.port), + sExtHost: state.external.host, + }); + } else if (state.sPanel === "facts") { + // 「Agent 眼中的你」:一次请求喂饱这张卡(facts / journal / runs)。flag 关着 + // 也照读——历史上开过又关掉的库里事实还在,得让人看得见。 + const query = state.factsAgent === "" ? "" : `?agent=${encodeURIComponent(state.factsAgent)}`; + const facts = await call(`/profile-facts${query}`).catch(() => ({})); + state.factsCards = Array.isArray(facts?.cards) ? facts.cards.map(String) : []; + const rows = Array.isArray(facts?.facts) ? facts.facts : []; + state.factsItems = rows; + const active = rows.filter((fact) => fact.status !== "archived"); + state.factsRows = rows.slice(0, 20).map((fact) => ({ + title: clip(String(fact.content ?? ""), 60), + subtitle: + fact.status === "archived" + ? `已归档 · ${fmtTime(fact.updated_at)}` + : `更新于 ${fmtTime(fact.updated_at)}`, + trailing: fact.status === "archived" ? "已归档" : "点一下修改", + action: { id: "facts-edit", args: { id: String(fact.id ?? "") } }, + })); + const runs = Array.isArray(facts?.runs) ? facts.runs : []; + const last = runs[0]; + state.factsRunText = + last === undefined + ? "还没跑过维护轮。点「立即更新」会让模型读最近的记忆补事实。" + : `最近一轮:${String(last.status ?? "?")} · 改 ${String(last.ops_applied ?? 0)} 条 · 读 ${String(last.input_count ?? 0)} 条记忆 · ${fmtTime(last.started_at)}`; + Object.assign(payload, { + factsAgent: state.factsAgent, + factsCardOptions: [ + { label: "默认", value: "" }, + // 宿主给的 cards 里常含空串(默认卡),别再和「默认」重复一次。 + ...state.factsCards.filter((key) => key !== "").map((key) => ({ label: key, value: key })), + ], + factsRows: state.factsRows, + factsRunText: state.factsRunText, + sFactNew: state.form.factNew ?? "", + sFactEdit: state.factEdit?.content ?? "", + factsEditHint: + state.factEdit === null + ? "" + : `正在修改:${clip(String(state.factEdit.content ?? ""), 40)}`, + }); + if (active.length === 0) state.factsRows.push({ title: "还没有事实", subtitle: "点下面的「立即更新」让模型生成一批。" }); + } else if (state.sPanel === "choro") { + // 地志是「按仓库」的:先列已登记仓库,再列该仓库的模块骨架页。 + const repos = await call("/choro/repos").catch(() => ({})); + state.choroRepos = (Array.isArray(repos?.repos) ? repos.repos : []).map((repo) => ({ + id: String(repo.id ?? ""), + name: clip(String(repo.name ?? repo.root_path ?? repo.id ?? ""), 40), + })); + if (state.choroRepo === "" && state.choroRepos.length > 0) state.choroRepo = state.choroRepos[0].id; + const known = state.choroRepos.some((repo) => repo.id === state.choroRepo); + if (!known) state.choroRepo = state.choroRepos[0]?.id ?? ""; + let note = "先在地志里登记仓库(命令行 dsh-mneme-choro --apply)。"; + let rows = []; + if (state.choroRepo !== "") { + const map = await call(`/choro/modules?repo=${encodeURIComponent(state.choroRepo)}`).catch(() => ({})); + const modules = Array.isArray(map?.modules) ? map.modules : []; + rows = modules.slice(0, 30).map((item) => ({ + title: clip(String(item.title ?? item.slug ?? ""), 40), + subtitle: `${String(item.file_count ?? 0)} 个文件 · ${String(item.page_status ?? "无页")}`, + trailing: "看页", + action: { id: "choro-page", args: { slug: String(item.slug ?? "") } }, + })); + const repo = state.choroRepos.find((item) => item.id === state.choroRepo); + const baseline = map?.repo?.gitBaseline; + note = + modules.length === 0 + ? "这个仓库还没有骨架页:点下面的「重建骨架」生成一次。" + : `${modules.length} 个模块${baseline ? ` · 基线 ${String(baseline).slice(0, 7)}` : ""} · ${repo?.name ?? ""}`; + } + state.choroRows = rows; + state.choroNote = note; + Object.assign(payload, { + choroRepo: state.choroRepo, + choroRepoOptions: + state.choroRepos.length === 0 + ? [{ label: "还没有登记任何仓库", value: "" }] + : state.choroRepos.map((repo) => ({ label: repo.name, value: repo.id })), + choroRows: state.choroRows, + choroNote: state.choroNote, + choroPageText: state.choroPageText, + }); + } else if (state.sPanel === "help") { + const info = await call("/info").catch(() => ({})); + Object.assign(payload, { + helpKv: [ + { label: "插件", value: `@modusensus/dsh-mneme ${String(info?.version ?? "—")}` }, + { label: "记忆库", value: "本机 SQLite(与本机 Web 面板同一份,不跨设备同步)" }, + { label: "面板", value: "dsh-go 原生面板" }, + ], + versionNote: state.versionNote, + }); + } + return payload; + } + + async function loadProfile() { + if (state.profileTab === "persona") { + const res = await call("/persona"); + const persona = res?.persona ?? {}; + const preview = res?.preview ?? {}; + return { + personaText: clip( + JSON.stringify({ default: persona.default ?? {}, byAgent: persona.byAgent ?? {} }, null, 2), + 700, + ), + previewItems: [ + { label: "生效来源", value: String(preview.source ?? "—") }, + { label: "注入内容", value: clip(preview.text === "" ? "(空)" : preview.text, 300) }, + ], + }; + } + if (state.profileTab === "rules") { + const res = await call("/rules"); + return { editText: clip(JSON.stringify(res?.rules ?? [], null, 2), 2000) }; + } + const res = await call("/profile"); + return { editText: clip(res?.profile ?? "", 2000), extractText: state.extractText }; + } + + async function loadConflict() { + const res = await call("/conflicts"); + const items = Array.isArray(res?.items) ? res.items : []; + state.conflictCount = num(res?.total, items.length); + const rows = items.slice(0, 20).map((item) => ({ + title: clip(item.a?.title ?? item.b?.title ?? item.id ?? "—", 40), + subtitle: `A:${clip(item.a?.title ?? "—", 20)} · B:${clip(item.b?.title ?? "—", 20)}`, + tone: "warn", + action: { id: "conflict-pick-row", args: { id: String(item.id ?? "") } }, + })); + const selected = items.find((item) => item.id === state.conflictId) ?? null; + state.conflictA = selected?.a ?? null; + state.conflictB = selected?.b ?? null; + state.conflictReady = selected !== null; + const side = (memo) => + memo === null + ? [] + : [ + { label: "类型", value: typeLabel(memo.type) }, + { label: "重要度", value: `★${memo.importance ?? 1}` }, + { label: "更新", value: fmtTime(memo.updated_at ?? memo.created_at) }, + ]; + return { + conflictRows: rows, + conflictCount: state.conflictCount, + conflictReady: state.conflictReady, + conflictPick: state.conflictPick, + conflictATitle: clip(state.conflictA?.title ?? "", 120), + conflictAMeta: side(state.conflictA), + conflictAContent: clip(state.conflictA?.content ?? "", 800), + conflictBTitle: clip(state.conflictB?.title ?? "", 120), + conflictBMeta: side(state.conflictB), + conflictBContent: clip(state.conflictB?.content ?? "", 800), + pendingConflicts: state.conflictCount, + }; + } + + async function loadStatus() { + const [info, health, dream, inject, stats, conflicts, list, entities, mode, preview, audit, features, recent] = + await Promise.all([ + call("/info").catch(() => ({})), + call("/health").catch(() => ({})), + call("/dream-status").catch(() => ({})), + call("/inject-status").catch(() => ({})), + call("/recall-stats?window=30").catch(() => ({})), + call("/conflicts").catch(() => ({})), + call("/list?limit=1").catch(() => ({})), + call("/entities?limit=1").catch(() => ({})), + call("/mode").catch(() => ({})), + call("/inject-preview").catch(() => ({})), + call("/semantic/llm-audit/stats?days=28").catch(() => ({})), + call("/features").catch(() => ({})), + call("/list?limit=200&order=chrono").catch(() => ({})), + ]); + const run = dream?.lastRun ?? {}; + const mirror = health?.mirror ?? {}; + const snapshot = preview?.snapshot ?? null; + state.injectPreviewText = + snapshot === null + ? "还没有渲染过注入预览:下一轮对话开始时 mneme 会写一份快照。" + : clip(typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot), 500); + const byDay = Array.isArray(audit?.by_day) ? audit.by_day : []; + state.tokenCategories = byDay.map((day) => fmtDay(day.day)); + state.tokenSeries = byDay.length === 0 ? [] : [{ name: "调用", values: byDay.map((day) => num(day.count, 0)) }]; + state.tokenKv = [ + { label: "调用次数", value: `${String(audit?.total_calls ?? 0)} 次` }, + { label: "Token", value: String(audit?.total_tokens ?? 0) }, + { label: "估算成本", value: `$${Number(audit?.total_cost_usd ?? 0).toFixed(4)}` }, + ]; + const heatEnabled = (features?.effective ?? {}).heatEnabled === true; + const heated = (recent?.items ?? []).filter((row) => typeof row.heat === "number"); + if (heatEnabled && heated.length > 0) { + const hot = heated.filter((row) => row.heat >= 0.7).length; + const warm = heated.filter((row) => row.heat >= 0.3 && row.heat < 0.7).length; + state.heatSeries = [{ name: "条数", values: [hot, warm, heated.length - hot - warm] }]; + state.heatNote = `统计最近 ${heated.length} 条记忆的热度。`; + } else { + state.heatSeries = []; + state.heatNote = "mneme 没有开启“热度衰减”,所以看不到热度分布。"; + } + state.pendingConflicts = num(conflicts?.total, 0); + return { + // metric cards live on 状态, mirroring mneme's own Status page + totalText: `${String(list?.total ?? 0)} 条`, + injectedText: `${String(stats?.injection?.injectedCount ?? 0)} 次`, + slotRateText: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%`, + conflictsText: `${String(conflicts?.total ?? 0)} 条`, + statusKv: [ + { label: "mneme 版本", value: String(info?.version ?? "—") }, + { label: "记忆", value: `${String(list?.total ?? 0)} 条` }, + { label: "实体", value: `${String(entities?.total ?? 0)} 个` }, + { label: "运行模式", value: String(mode?.mode ?? "—") }, + { label: "镜像", value: String(mirror.status ?? "—"), tone: mirror.status === "ok" ? "good" : "warn" }, + { label: "上次成功", value: fmtTime(mirror.success_at) }, + ], + injectKv: [ + { + label: "自动注入", + value: inject?.autoInject ? "已开启" : "已关闭", + tone: inject?.autoInject ? "good" : "muted", + }, + { label: "注入抑制", value: inject?.suppressed ? "是" : "否" }, + { label: "会话预设", value: String(inject?.agentPreset ?? "—") }, + { label: "近 30 天注入", value: `${String(stats?.injection?.injectedCount ?? 0)} 次` }, + { label: "命中率", value: `${String(Math.round((stats?.injection?.slotFillRate ?? 0) * 1000) / 10)}%` }, + ], + topRows: (stats?.topRecalled ?? []).slice(0, 5).map((row) => ({ + title: clip(row.title, 40), + subtitle: typeLabel(row.type), + trailing: `${row.count} 次`, + })), + dreamKv: [ + { label: "上次巩固", value: fmtTime(dream?.lastConsolidation) }, + { label: "上次运行", value: `${fmtTime(run.created_at)} ${run.status ?? "—"}` }, + { + label: "待确认", + value: `${String(conflicts?.total ?? 0)} 条`, + tone: num(conflicts?.total, 0) > 0 ? "warn" : "good", + }, + ], + runRows: (dream?.runs ?? []).slice(0, 5).map((row) => ({ + title: `${fmtTime(row.created_at)} · ${String(row.status ?? "—")}`, + subtitle: clip(row.summary ?? row.mode ?? "", 40), + })), + injectPreviewText: state.injectPreviewText, + tokenCategories: state.tokenCategories, + tokenSeries: state.tokenSeries, + tokenKv: state.tokenKv, + heatSeries: state.heatSeries, + heatNote: state.heatNote, + reclaimText: state.reclaimText, + reclaimConfirm: state.reclaimConfirm, + pendingConflicts: state.pendingConflicts, + }; + } + + // The app's `tabs` bar switches locally, so the payload always carries every + // section (a binding whose key is missing renders as 「—」). That does not mean + // every section must be re-read after every tap: 实体 / 设置 / 状态 only change + // while their own page is open, so a tap on the list used to cost four reads + // (评审 #14). Load the active page's section, keep the rest from the previous + // payload; a full read happens on mount. + async function load(options = {}) { + const full = options.full === true; + const payload = { + page: state.page, + profileTab: state.profileTab, + notice: state.notice, + err: state.err, + }; + const jobs = []; + const merge = (promise) => jobs.push(promise.then((part) => Object.assign(payload, part))); + const page = state.page; + const memoryPage = !["entity", "settings", "profile"].includes(page); + if (full || memoryPage) { + const memory = page === "detail" ? loadDetail() : page === "editor" ? loadEditor() : loadMemory(); + merge(Promise.resolve(memory)); + } + const entityPage = page === "entity"; + // 实体搜索要重读实体清单;但搜索后 state.page 仍是 "list"(视图里实体 + // 区就是列表),旧条件只看页面,于是搜索完推的仍是搜索前那份(评审 #4)。 + if (full || entityPage || state.entityDirty) merge(entityPage ? loadEntityDetail() : loadEntity()); + state.entityDirty = false; + if (full || page === "settings" || page === "profile" || state.settingsDirty) { + merge(loadSettings()); + merge(loadStatus()); + } + state.settingsDirty = false; + if (full || (memoryPage && page === "conflict")) merge(loadConflict()); + if (full || page === "profile") merge(loadProfile()); + const settled = await Promise.allSettled(jobs); + const failed = settled.find((item) => item.status === "rejected"); + if (failed !== undefined) payload.err = errorText(failed.reason); + // 未重新读取的块沿用上一次的完整结果;刷新过的那块覆盖上去。 + const merged = lastPayload === null ? payload : { ...lastPayload, ...payload }; + lastPayload = merged; + return merged; + } + + /** Re-run the loaders and push the result into every live panel feed. */ + async function broadcast() { + if (emitters.size === 0) return; + const payload = await load(); + for (const emit of [...emitters]) { + try { + emit(payload); + } catch { + // a closing panel detaches its emitter; nothing to do + } + } + } + + // ---------------------------------------------------------------- actions + function goMemory(page) { + state.page = page; + state.err = ""; + } + + function nextValue(list, current, value) { + const picked = typeof value === "string" && list.includes(value) ? value : null; + if (picked !== null) return picked; + const index = list.indexOf(current); + return list[(index + 1) % list.length]; + } + + async function applyArchived(archived, event, scope) { + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + // 归档/恢复改的是「正在看」的那一条,参数只能从状态里拿;状态是模块级 + // 共享的,两个面板各开一条时后开的会盖掉前一个的 current。与删除同一条 + // 规矩:只动本面板自己打开的那条,对不上就拒绝(评审 #10)。 + const mine = openedBy.get(scopeKey(scope)); + if (mine === undefined || mine !== memo.id) { + return { toast: "这条不是当前打开的记忆,已取消" }; + } + try { + const res = await call("/update", { method: "POST", body: { id: mine, archived } }); + if (res?.memory !== null && typeof res.memory === "object") { + state.current = res.memory; + openedBy.set(scopeKey(scope), String(res.memory.id ?? mine)); + } + notice(archived ? "已归档。" : "已恢复。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + } + + // 编辑页的比对基准:点「编辑」时看到的那一份快照。冲突后必须用最新的 + // 记忆重建它,否则下一次保存会因为 base 为 null 而整份提交、直接跳过版本 + // 检查(评审 #11)。 + function editBaseOf(memo) { + return { + title: String(memo.title ?? ""), + content: String(memo.content ?? ""), + importance: num(memo.importance, 3), + tags: joinTags(memo.tags), + agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "", + workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "", + updatedAt: typeof memo.updated_at === "string" ? memo.updated_at : "", + }; + } + + // 每次打开都回宿主重读,而不是先看 cache:cache 是列表加载时写下的, + // 可能已经几分钟没更新;编辑页会拿它预填表单,随后整份提交回 /update, + // 用陈旧内容覆盖掉工具或 Web 面板刚写进去的新值(评审 #4)。 + // /list 支持按 id 取单条(归档行也取得到),不再用 limit=500 扫列表再拿缓存 + // 兜底:500 条之外或已归档的条目根本扫不到,兜底等于拿旧值去比对 updated_at + // (评审 #12)。网络失败就让它抛,调用处会报错,绝不悄悄退回缓存。 + async function openMemoryById(id) { + const res = await call(`/list?id=${encodeURIComponent(String(id))}`); + const row = (res?.items ?? [])[0]; + if (row === undefined) { + cache.delete(id); + return undefined; + } + cache.set(row.id, row); + return row; + } + + const actions = { + "switch-group"(scope, event) { + resetNotice(); + const keys = ["basic", "record", "write", "workflow", "retrieval", "scope", "extra"]; + state.flagGroup = nextValue(keys, state.flagGroup, valueOf(event, ["group", "value", "k"], "")); + state.page = "settings"; + return {}; + }, + + "switch-panel"(scope, event) { + resetNotice(); + const keys = ["menu", "workbench", "commands", "vector", "external", "help", "facts", "choro"]; + state.sPanel = nextValue(keys, state.sPanel, valueOf(event, ["value", "panel", "k"], "")); + state.page = "settings"; + return {}; + }, + + "open-panel"(scope, event) { + resetNotice(); + // 功能开关已经铺在设置页正文里,不再有 flags 子页面(#dsh-go 面板对齐电脑端分组) + const keys = ["workbench", "commands", "vector", "external", "help", "facts", "choro"]; + const wanted = String(valueOf(event, ["k", "panel", "value"], "") ?? ""); + state.sPanel = keys.includes(wanted) ? wanted : "menu"; + state.page = "settings"; + // 子页面画在「设置」视图里:从「状态」视图点过来时若不把视图切回去, + // 看到的还是状态页,什么都没有发生(评审 #14)。 + state.settingsView = "settings"; + return {}; + }, + + "switch-settings-view"(scope, event) { + resetNotice(); + state.settingsView = nextValue(["settings", "status"], state.settingsView, valueOf(event, ["value"], "")); + if (state.settingsView === "settings") state.page = "settings"; + return {}; + }, + + "open-profile"(scope, event) { + resetNotice(); + const wanted = String(valueOf(event, ["k", "tab", "value"], state.profileTab) ?? ""); + state.profileTab = ["profile", "rules", "persona"].includes(wanted) ? wanted : "profile"; + state.page = "profile"; + return {}; + }, + + "open-conflict"() { + resetNotice(); + state.page = "conflict"; + // 冲突页同样属于「设置」视图(入口在设置页的状态区里),视图停在状态 + // 视图时切不到它(评审 #14)。 + state.settingsView = "settings"; + return {}; + }, + + "clear-filter"() { + resetNotice(); + state.q = ""; + state.type = "all"; + state.range = "all"; + state.minImportance = 0; + state.scope = "all"; + state.semantic = false; + state.heatSort = false; + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-range"(scope, event) { + resetNotice(); + state.range = nextValue(["all", "7d", "30d", "90d"], state.range, valueOf(event, ["value"], "")); + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-minimp"(scope, event) { + resetNotice(); + const picked = String(valueOf(event, ["value"], "0") ?? "0"); + state.minImportance = ["0", "3", "4", "5"].includes(picked) ? Number(picked) : 0; + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-scope"(scope, event) { + resetNotice(); + state.scope = nextValue(["all", "deposited", "archived", "foreign"], state.scope, valueOf(event, ["value"], "")); + state.offset = 0; + goMemory("list"); + return {}; + }, + + "set-view"(scope, event) { + resetNotice(); + state.viewMode = nextValue(["flat", "month"], state.viewMode, valueOf(event, ["value"], "")); + return {}; + }, + + "toggle-semantic"(scope, event) { + resetNotice(); + const value = event?.value; + state.semantic = typeof value === "boolean" ? value : !state.semantic; + return {}; + }, + + "toggle-heat"(scope, event) { + resetNotice(); + const value = event?.value; + state.heatSort = typeof value === "boolean" ? value : !state.heatSort; + return {}; + }, + + async provenance() { + resetNotice(); + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + try { + const res = await call(`/memory/provenance?id=${enc(memo.id)}&limit=5`); + const windows = Array.isArray(res?.windows) ? res.windows : []; + if (windows.length === 0) { + state.provenanceText = "mneme 里没有这条记忆的蒸馏来源记录。"; + } else { + const first = windows[0] ?? {}; + state.provenanceText = clip( + `${windows.length} 个来源窗口 · 最近来自会话 ${String(first.session_id ?? "—").slice(0, 12)}(${fmtTime(first.created_at)})`, + 300, + ); + } + } catch (error) { + state.provenanceText = errorText(error).includes("distill-records-disabled") + ? "mneme 没开「保留蒸馏原文」,所以这条记忆没有可查的来源窗口。" + : errorText(error); + } + return {}; + }, + + refresh() { + resetNotice(); + return {}; + }, + + back() { + resetNotice(); + state.err = ""; + if (state.page === "profile" || state.page === "conflict") { + state.page = "settings"; + state.sPanel = "menu"; + } else if (state.page === "entity") { + state.page = "list"; + } else if (state.page === "settings") { + if (state.sPanel !== "menu") state.sPanel = "menu"; + else state.page = "list"; + } else if (state.page === "detail" || state.page === "editor") { + state.page = "list"; + } + return {}; + }, + + search(scope, event) { + resetNotice(); + state.q = String(valueOf(event, ["query", "q", "text"], state.q) ?? "").trim(); + state.offset = 0; + goMemory("list"); + return {}; + }, + + page(scope, event) { + resetNotice(); + // chips 送来的是 "prev" / "next";早期的按钮组送 { d: ±1 },两条都认。 + const raw = valueOf(event, ["value", "d", "delta", "page"], 0); + const delta = raw === "prev" ? -1 : raw === "next" ? 1 : num(raw, 0); + const pages = Math.max(1, Math.ceil(num(state.total, 0) / state.size)); + const next = Math.min(pages, Math.max(1, Math.floor(state.offset / state.size) + 1 + delta)); + state.offset = (next - 1) * state.size; + goMemory("list"); + return {}; + }, + + async open(scope, event) { + resetNotice(); + const id = String(valueOf(event, ["id"], "") ?? ""); + if (id === "") return { toast: "这条记录没有 id" }; + let found; + try { + found = await openMemoryById(id); + } catch (error) { + state.err = errorText(error); + return {}; + } + if (found === undefined) { + state.err = "这条记忆已经不在列表里了。"; + return {}; + } + state.current = found; + openedBy.set(scopeKey(scope), found.id); + state.provenanceText = "尚未查看。点上面的按钮可向 mneme 查这条记忆的蒸馏来源。"; + goMemory("detail"); + return {}; + }, + + "open-entity"(scope, event) { + resetNotice(); + const name = String(valueOf(event, ["name", "entity", "value"], "") ?? ""); + if (name === "") return { toast: "没有选中实体" }; + state.entityName = name; + state.entityDepth = 1; + state.page = "entity"; + return {}; + }, + + "entity-search"(scope, event) { + resetNotice(); + state.entityQuery = String(valueOf(event, ["entityQuery", "query", "q"], "") ?? "").trim(); + state.page = "list"; + // 让下一次 load() 补读实体清单(页面仍是列表,见 load 里的注释)。 + state.entityDirty = true; + return {}; + }, + + "entity-depth"(scope, event) { + resetNotice(); + const picked = String(valueOf(event, ["value", "entityDepth"], "1") ?? "1"); + state.entityDepth = picked === "2" ? 2 : 1; + return {}; + }, + + edit(scope) { + resetNotice(); + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + // 记下本面板打开的那条:save 也要按它核对(评审 #3)。 + openedBy.set(scopeKey(scope), memo.id); + state.form = { + id: memo.id, + title: memo.title ?? "", + content: memo.content ?? "", + type: memo.type ?? "project", + importance: num(memo.importance, 3), + tags: joinTags(memo.tags), + agentScope: typeof memo.agent_scope === "string" ? memo.agent_scope : "", + workspaceScope: typeof memo.workspace_scope === "string" ? memo.workspace_scope : "", + }; + state.editBase = editBaseOf(memo); + goMemory("editor"); + return {}; + }, + + "type-filter"(scope, event) { + resetNotice(); + const next = String(valueOf(event, ["value", "type"], "all") ?? "all"); + state.type = next === "all" || TYPE_LABELS[next] !== undefined ? next : "all"; + state.offset = 0; + goMemory("list"); + return {}; + }, + + "form-type"(scope, event) { + const next = String(valueOf(event, ["value", "type"], state.form.type ?? "project") ?? "project"); + state.form.type = TYPE_LABELS[next] === undefined ? "project" : next; + return {}; + }, + + new() { + resetNotice(); + state.current = null; + state.form = { + id: "", + title: "", + content: "", + type: "project", + importance: 3, + tags: "", + agentScope: "", + workspaceScope: "", + }; + goMemory("editor"); + return {}; + }, + + async save(scope, event) { + resetNotice(); + const value = event?.value; + const source = + value !== null && typeof value === "object" && !Array.isArray(value) + ? { ...state.form, ...value } + : { ...state.form }; + const title = String(source.title ?? "").trim(); + const content = String(source.content ?? "").trim(); + const type = TYPE_LABELS[String(source.type ?? "")] === undefined ? "project" : String(source.type); + const importance = Math.min(5, Math.max(1, Math.round(num(source.importance, 3)))); + const tags = splitTags(String(source.tags ?? "")); + if (title === "" || content === "") return { toast: "标题和正文都不能空" }; + const id = state.form.id === "" || state.form.id === undefined ? undefined : String(state.form.id); + // 编辑既有条目也要核对面板 scope:state.form 是模块级共享的,两个面板 + // 各开一条时,A 面板点保存会把内容写进 B 刚打开的那条(评审 #3)。 + // 新建(id 为空)不涉及已有行,不核对。 + if (id !== undefined) { + const mine = openedBy.get(scopeKey(scope)); + if (mine === undefined || mine !== id) { + return { toast: "这条不是当前打开的记忆,已取消" }; + } + } + + if (id === undefined) { + try { + const res = await call("/create", { method: "POST", body: { title, content, importance, tags, type } }); + state.form = {}; + state.editBase = null; + if (res !== null && typeof res === "object" && typeof res.id === "string") { + state.current = res; + openedBy.set(scopeKey(scope), res.id); + goMemory("detail"); + notice(res.action === "merged" ? "已并入同标题的既有条目。" : "已新建一条记忆。"); + } else { + goMemory("list"); + notice("已保存。"); + } + } catch (error) { + state.err = errorText(error); + } + return {}; + } + + // 编辑既有条目:先回宿主要一份当前值。盘点窗口够长(跨了几分钟甚至几次 + // 对话)时,缓存里的那份可能已经被工具或 Web 面板改过,而这里回填的表单 + // 一旦整份提交就会把对方的写入盖掉;/update 没有版本号校验,所以这层 + // 比较只能放在面板侧(评审 #4)。 + let fresh; + try { + fresh = await openMemoryById(id); + } catch (error) { + state.err = errorText(error); + return {}; + } + if (fresh === undefined) { + state.current = null; + state.form = {}; + state.editBase = null; + goMemory("list"); + notice("这条记忆已经不在了。", "warn"); + return {}; + } + const base = state.editBase; + if (base !== null && base.updatedAt !== "" && base.updatedAt !== String(fresh.updated_at ?? "")) { + state.current = fresh; + state.form = { + id: fresh.id, + title: fresh.title ?? "", + content: fresh.content ?? "", + type: fresh.type ?? "project", + importance: num(fresh.importance, 3), + tags: joinTags(fresh.tags), + agentScope: typeof fresh.agent_scope === "string" ? fresh.agent_scope : "", + workspaceScope: typeof fresh.workspace_scope === "string" ? fresh.workspace_scope : "", + }; + state.editBase = editBaseOf(fresh); + state.err = "这条记忆刚在别处被改过,已换成最新内容,请再确认一次。"; + return {}; + } + + // 只提交真正改动的字段:把未改的 title/content 一起发回去,等于用旧值 + // 覆盖对方的写入(同一问题的一半)。 + const body = { id }; + if (base === null || title !== base.title) body.title = title; + if (base === null || content !== base.content) body.content = content; + if (base === null || importance !== base.importance) body.importance = importance; + if (base === null || joinTags(tags) !== base.tags) body.tags = tags; + const agentScope = String(source.agentScope ?? "").trim(); + const workspaceScope = String(source.workspaceScope ?? "").trim(); + if (base === null || agentScope !== base.agentScope) body.agent_scope = agentScope === "" ? null : agentScope; + if (base === null || workspaceScope !== base.workspaceScope) { + body.workspace_scope = workspaceScope === "" ? null : workspaceScope; + } + if (Object.keys(body).length === 1) return { toast: "没有改动" }; + try { + const res = await call("/update", { method: "POST", body }); + const memo = res?.memory; + state.form = {}; + state.editBase = null; + if (memo !== null && typeof memo === "object" && typeof memo.id === "string") { + state.current = memo; + openedBy.set(scopeKey(scope), memo.id); + goMemory("detail"); + } else { + goMemory("list"); + } + notice("已保存修改。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async archive(scope, event) { + resetNotice(); + return applyArchived(true, event, scope); + }, + + async unarchive(scope, event) { + resetNotice(); + return applyArchived(false, event, scope); + }, + + async delete(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + // 确认框本身不带参数(confirm 节点只有 id 与两个按钮文案),所以「删哪 + // 一条」只能靠状态。状态是模块级的、被所有面板共用:两个面板各开一条时 + // 后开的会盖掉前一个的 current,此时点确认就会删错行(评审 #5)。 + // 打开记忆时按面板 scope 记一份(见 open)。只显示共享详情、没在本面板 + // 走过 open 的(mine 为空)也不能删:state.current 可能是另一个面板的 + // 条目(评审 #13)。 + const mine = openedBy.get(scopeKey(scope)); + if (mine === undefined || mine !== memo.id) { + return { toast: "这条不是当前打开的记忆,已取消" }; + } + const id = mine; + try { + await call("/delete", { method: "POST", body: { id } }); + cache.delete(id); + openedBy.delete(scopeKey(scope)); + state.current = null; + goMemory("list"); + notice("已删除。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + copy() { + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + return { copy: clip(`# ${memo.title}\n\n${memo.content}`, 8000) }; + }, + + insert() { + const memo = state.current; + if (memo === null) return { toast: "先选一条记忆" }; + return { insert: clip(`【记忆】${memo.title}\n${memo.content}`, 2000) }; + }, + + "profile-tab"(scope, event) { + resetNotice(); + const next = nextValue( + ["profile", "rules", "persona"], + state.profileTab, + valueOf(event, ["k", "tab", "value"], ""), + ); + state.profileTab = next; + state.page = "profile"; + return {}; + }, + + async "profile-save"(scope, event) { + resetNotice(); + const body = String(valueOf(event, ["editText", "text", "value"], "") ?? ""); + try { + if (state.profileTab === "rules") { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + state.err = "规则必须是合法 JSON 数组。"; + return {}; + } + // 非数组({} / null / "text")不能落成空数组:这条路由是整份覆盖, + // 静默写 [] 会把用户已有的规则一次清空(评审 #10)。 + if (!Array.isArray(parsed)) { + state.err = "规则必须是合法 JSON 数组。"; + return {}; + } + await call("/rules", { method: "PUT", body: { rules: parsed } }); + } else { + await call("/profile", { method: "PUT", body: { profile: body } }); + } + notice("已保存。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async "profile-extract"() { + resetNotice(); + if (state.profileTab !== "profile") return { toast: "先切到画像" }; + try { + const res = await call("/profile/extract", { method: "POST", body: {} }); + const draft = res?.draft ?? res ?? {}; + const text = + typeof draft.profile === "string" + ? draft.profile + : typeof draft.text === "string" + ? draft.text + : JSON.stringify(draft); + state.extractText = clip(`草稿:${text}`, 600); + } catch (error) { + const raw = errorText(error); + state.extractText = raw.includes("disabled") + ? "mneme 没开「画像提取草稿」,先到「设置 › 功能开关 › 写入」里打开它。" + : raw.includes("no-route") + ? "没有可用的模型路由,提取不了草稿。" + : raw.includes("no-input") + ? "记忆还太少,提取不出画像。" + : raw; + } + return {}; + }, + + async mode(scope, event) { + resetNotice(); + const next = String(valueOf(event, ["mode", "value"], "standard") ?? "standard"); + if (next !== "standard" && next !== "light") { + state.err = "未知的模式。"; + return {}; + } + try { + await call("/mode", { method: "PUT", body: { mode: next } }); + notice(`已切到${next === "light" ? "轻量" : "标准"}模式。`); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async reclaim() { + resetNotice(); + try { + const res = await call("/maintenance/reclaim"); + const report = res?.report ?? res ?? {}; + const rows = Array.isArray(report.candidates) ? report.candidates.length : undefined; + const total = rows ?? report.reclaimable ?? report.total ?? report.rows ?? null; + state.reclaimText = + total === null ? "已检查(dry-run,未改动)。" : `可回收 ${String(total)} 条(dry-run,未改动)。`; + } catch (error) { + state.reclaimText = errorText(error); + } + return {}; + }, + + "reclaim-prepare"() { + resetNotice(); + state.reclaimConfirm = true; + state.reclaimText = "回收会丢弃无法再召回的条目,确认后再执行。"; + return {}; + }, + + async "reclaim-run"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + try { + const res = await call("/maintenance/reclaim", { + method: "POST", + body: { confirm: true, vacuum: true }, + }); + const report = res?.report ?? res ?? {}; + state.reclaimText = `已回收 ${String(report.reclaimed ?? report.total ?? 0)} 条。`; + state.reclaimConfirm = false; + } catch (error) { + state.reclaimText = errorText(error); + } + return {}; + }, + + "conflict-pick-row"(scope, event) { + resetNotice(); + state.conflictId = String(valueOf(event, ["id"], "") ?? ""); + conflictBy.set(scopeKey(scope), state.conflictId); + state.conflictPick = ""; + state.page = "conflict"; + return {}; + }, + + "conflict-pick"(scope, event) { + resetNotice(); + const picked = String(valueOf(event, ["winner", "value"], "") ?? ""); + state.conflictPick = picked === "a" || picked === "b" ? picked : "none"; + state.page = "conflict"; + return {}; + }, + + async "conflict-win"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const id = state.conflictId; + if (id === "") return { toast: "先选一条冲突" }; + // 冲突 id 也是共享状态:只裁决本面板自己选中的那条(评审 #3)。 + if (conflictBy.get(scopeKey(scope)) !== id) { + return { toast: "这条不是当前打开的冲突,已取消" }; + } + const winner = state.conflictPick === "a" ? "a" : state.conflictPick === "b" ? "b" : null; + try { + await call("/conflicts/resolve", { method: "POST", body: { id, winner, apply: true } }); + conflictBy.delete(scopeKey(scope)); + state.conflictId = ""; + state.conflictPick = ""; + notice("已按裁决合并。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + async "wb-restore"(scope, event) { + resetNotice(); + const id = String(valueOf(event, ["id"], "") ?? ""); + if (id === "") return {}; + try { + await call("/update", { method: "POST", body: { id, archived: false } }); + notice("已恢复这条记忆。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + "cmd-pick"(scope, event) { + resetNotice(); + state.sCmdPick = String(valueOf(event, ["value"], "") ?? ""); + state.page = "settings"; + return {}; + }, + + "cmd-add"(scope, event) { + resetNotice(); + const value = event?.value; + const source = + value !== null && typeof value === "object" && !Array.isArray(value) + ? { sCmdName: value.sCmdName, sCmdDesc: value.sCmdDesc, sCmdIns: value.sCmdIns } + : {}; + const name = String(source.sCmdName ?? state.sCmdName ?? "").trim(); + const description = String(source.sCmdDesc ?? state.sCmdDesc ?? "").trim(); + const instruction = String(source.sCmdIns ?? state.sCmdIns ?? "").trim(); + if (name === "" || instruction === "") return { toast: "命令名和指令正文都不能空" }; + return call("/commands", { method: "POST", body: { name, description, instruction } }).then( + () => { + state.sCmdName = ""; + state.sCmdDesc = ""; + state.sCmdIns = ""; + notice(`已添加命令 ${name}。`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + async "cmd-delete"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const id = state.sCmdPick; + if (id === "") return { toast: "先选一条命令" }; + try { + await call(`/commands?id=${enc(id)}`, { method: "DELETE" }); + state.sCmdPick = ""; + notice("已删除命令。"); + } catch (error) { + state.err = errorText(error); + } + return {}; + }, + + "vector-toggle"(scope, event) { + resetNotice(); + // 没读到真实配置时 state.vector 只是默认值,整体 PUT 会覆盖服务端(评审 #2)。 + if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" }; + const value = event?.value; + state.vector.enabled = typeof value === "boolean" ? value : !state.vector.enabled; + return call("/vector-config", { + method: "PUT", + body: { enabled: state.vector.enabled, baseUrl: state.vector.baseUrl, model: state.vector.model }, + }).then( + () => { + notice(`向量检索:${state.vector.enabled ? "已开启" : "已关闭"}`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "vector-save"(scope, event) { + resetNotice(); + if (!state.vectorLoaded) return { toast: "还没读到向量配置,请刷新后再改" }; + const value = event?.value; + const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; + const baseUrl = String(source.sVecUrl ?? state.vector.baseUrl ?? "").trim(); + const model = String(source.sVecModel ?? state.vector.model ?? "").trim(); + state.vector.baseUrl = baseUrl; + state.vector.model = model; + return call("/vector-config", { + method: "PUT", + body: { enabled: state.vector.enabled, baseUrl, model }, + }).then( + () => { + notice("已保存向量配置。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "vector-reindex"() { + resetNotice(); + const run = call("/vector-reindex", { timeoutMs: 60000 }).then( + (res) => { + notice(`索引完成:新增 ${String(res?.indexed ?? 0)} 条,跳过 ${String(res?.skipped ?? 0)} 条。`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + return { + pending: { label: "正在重建向量索引", timeoutMs: 60000 }, + result: async () => run, + }; + }, + + "external-toggle"(scope, event) { + resetNotice(); + if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" }; + const value = event?.value; + state.external.enabled = typeof value === "boolean" ? value : !state.external.enabled; + return call("/external-api", { + method: "PUT", + body: { enabled: state.external.enabled, port: state.external.port, host: state.external.host }, + }).then( + () => { + notice(`外部访问:${state.external.enabled ? "已开启" : "已关闭"}`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "external-save"(scope, event) { + resetNotice(); + if (!state.externalLoaded) return { toast: "还没读到外部访问配置,请刷新后再改" }; + const value = event?.value; + const source = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; + const port = Math.round(num(source.sExtPort ?? state.external.port, 8790)); + const host = String(source.sExtHost ?? state.external.host ?? "").trim(); + if (port < 1 || port > 65535) return { toast: "端口要在 1–65535 之间" }; + if (host === "" || host.includes("://")) return { toast: "监听地址不能带协议前缀" }; + state.external.port = port; + state.external.host = host; + return call("/external-api", { + method: "PUT", + body: { enabled: state.external.enabled, port, host }, + }).then( + () => { + notice("已保存外部访问配置。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + async "check-version"() { + resetNotice(); + try { + const res = await call("/version-check"); + const status = String(res?.status ?? "unknown"); + state.versionNote = + status === "outdated" + ? `有新版本 ${String(res?.latest ?? "")}(当前 ${String(res?.version ?? "")})` + : status === "latest" + ? `已是最新版本 ${String(res?.version ?? "")}` + : "没能连上 npm registry,稍后再试。"; + if (status === "outdated" && res?.latest) notice(`mneme 有新版本 ${res.latest}`); + } catch (error) { + state.versionNote = errorText(error); + } + return {}; + }, + }; + + // One action per feature switch: the app tells us which action fired, and the + // new value arrives as `event.value` (boolean) or is derived by flipping the + // current effective value. + for (const key of FLAG_LABELS.keys()) { + actions[flagAction(key)] = async (scope, event) => { + resetNotice(); + const value = event?.value; + // state.flags 是宿主原样的扁平点号键(scopeState.enabled);嵌套形态只 + // 出现在视图绑定里(payload.flags = nestFlags(effective))。这里若按嵌套 + // 去取值会恒为 undefined,于是「已经打开的开关」永远翻转不回去(评审 #7)。 + const current = state.flags?.[key]; + // 布尔项翻转、枚举项(chorographyInject 的 light/full/off)直接用宿主给的 + // 字符串值:同一个动作 id 同时服务开关与下拉。 + const next = + typeof value === "boolean" || typeof value === "string" + ? value + : typeof current === "string" + ? current + : !(current === true); + try { + await call("/features", { method: "PUT", body: { [key]: next } }); + const features = await call("/features"); + state.flagOverrides = features?.overrides ?? {}; + state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : []; + state.flags = features?.effective ?? state.flags; + state.settingsDirty = true; + notice( + `${FLAG_LABELS.get(key) ?? key}:${ + typeof next === "string" ? next : next ? "已开启" : "已关闭" + }`, + ); + } catch (error) { + state.err = errorText(error); + } + // 开关只改一个绑定值:数据已经推过去了,别让 App 重建视图(否则设置页 + // 会被弹回第一个页签)。 + return { refresh: false }; + }; + } + + // ---- 「Agent 眼中的你」自动事实(0.11.0 的面板卡) ---------------------- + // 写入只有一个入口:POST /profile-facts/apply,op 形状与自动轮共用(add 必须 + // 带 content、update/archive 必须带 id)。这里不自己拼 SQL,也不碰 user_settings + // ——手填画像仍然优先。 + const applyFacts = (ops) => + call("/profile-facts/apply", { method: "POST", body: { agent: state.factsAgent, ops } }); + + Object.assign(actions, { + "facts-agent"(scope, event) { + resetNotice(); + state.factsAgent = String(valueOf(event, ["value", "agent"], "") ?? ""); + state.factEdit = null; + factBy.delete(scopeKey(scope)); + return {}; + }, + + "facts-edit"(scope, event) { + resetNotice(); + const id = String(valueOf(event, ["id"], "") ?? ""); + const fact = state.factsItems.find((item) => String(item.id) === id); + if (fact === undefined) return { toast: "这条事实已经不在了" }; + state.factEdit = { id, content: String(fact.content ?? "") }; + factBy.set(scopeKey(scope), id); + return {}; + }, + + "facts-add"(scope, event) { + resetNotice(); + const value = event?.value; + const source = value !== null && typeof value === "object" ? value : {}; + const text = String(source.sFactNew ?? state.form.factNew ?? "").trim(); + if (text === "") return { toast: "写一句话再添加" }; + return applyFacts([{ op: "add", content: text }]).then( + (result) => { + state.form.factNew = ""; + notice(`已加 ${String(result?.applied ?? 1)} 条事实。`); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "facts-save"(scope, event) { + resetNotice(); + const target = state.factEdit; + if (target === null) return { toast: "先点一条事实" }; + if (factBy.get(scopeKey(scope)) !== target.id) { + return { toast: "这条不是当前打开的事实,已取消" }; + } + const value = event?.value; + const source = value !== null && typeof value === "object" ? value : {}; + const text = String(source.sFactEdit ?? target.content ?? "").trim(); + if (text === "") return { toast: "事实正文不能空" }; + if (text === target.content) return { toast: "没有改动" }; + return applyFacts([{ op: "update", id: target.id, content: text }]).then( + (result) => { + const failed = Array.isArray(result?.failures) ? result.failures : []; + if (failed.length > 0) { + state.err = "这条事实已被改过或删掉了,刷新后再试。"; + return {}; + } + state.factEdit = null; + factBy.delete(scopeKey(scope)); + notice("事实已更新。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "facts-archive"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + const target = state.factEdit; + if (target === null) return { toast: "先点一条事实" }; + if (factBy.get(scopeKey(scope)) !== target.id) { + return { toast: "这条不是当前打开的事实,已取消" }; + } + return applyFacts([{ op: "archive", id: target.id }]).then( + () => { + state.factEdit = null; + factBy.delete(scopeKey(scope)); + notice("已归档这条事实。"); + return {}; + }, + (error) => { + state.err = errorText(error); + return {}; + }, + ); + }, + + "facts-run"(scope, event) { + resetNotice(); + // 维护轮要跑一次 LLM(模型读最近记忆、提 op、落库),慢且不可并发,所以 + // 走 pending:面板先挂一条「正在更新」,最长等 60 秒。 + const run = call("/profile-facts/run", { + method: "POST", + body: { agent: state.factsAgent }, + timeoutMs: 60000, + }).then( + (receipt) => { + const status = String(receipt?.status ?? "ok"); + state.factsRunText = + status === "ok" + ? `本轮改了 ${String(receipt?.opsApplied ?? 0)} 条事实。` + : status === "noop" + ? "没有新记忆可读,这轮没动。" + : `这轮结束了:${status}`; + notice("事实维护轮已结束。"); + return { toast: "事实已更新" }; + }, + (error) => { + const message = errorText(error); + // 409 = 功能关着,503 = 没配模型,502 = 模型答得不成形:三种都得说人话。 + state.err = /\b409\b/u.test(message) + ? "「Agent 眼中的你」还关着:先在上面把开关打开。" + : /\b503\b/u.test(message) + ? "还没配后台模型:先在 mneme 设置里选一个 dream 模型。" + : message; + return { toast: "这轮没成功" }; + }, + ); + return { pending: { label: "正在维护事实", timeoutMs: 60000 }, result: async () => run }; + }, + + // ---- 代码库地志(chorography)----------------------------------------- + "choro-repo"(scope, event) { + resetNotice(); + state.choroRepo = String(valueOf(event, ["value"], "") ?? ""); + state.choroPageText = ""; + return {}; + }, + + "choro-page"(scope, event) { + resetNotice(); + const slug = String(valueOf(event, ["slug"], "") ?? ""); + if (slug === "" || state.choroRepo === "") return { toast: "先选一个仓库" }; + return call(`/choro/page?repo=${encodeURIComponent(state.choroRepo)}&slug=${encodeURIComponent(slug)}`).then( + (res) => { + const page = res?.page; + state.choroPageText = + page === undefined + ? "这一页读不出来,可能刚被重建过。" + : clip(String(page?.content ?? page?.relPath ?? ""), 1600); + notice(`已读取 ${String(page?.title ?? slug)} 的骨架页。`); + return {}; + }, + (error) => { + const message = errorText(error); + state.err = /\b409\b/u.test(message) ? "索引还在、盘上没了:点「重建骨架」重跑一次。" : message; + return {}; + }, + ); + }, + + "choro-run"(scope, event) { + resetNotice(); + if (valueOf(event, ["confirmed"], false) !== true) return {}; + if (state.choroRepo === "") return { toast: "还没有登记任何仓库" }; + const run = call("/choro/run", { + method: "POST", + body: { repo: state.choroRepo }, + timeoutMs: 60000, + }).then( + (res) => { + const receipt = res?.receipt ?? {}; + state.choroPageText = ""; + notice( + `骨架已重建:${String(receipt.pagesWritten ?? receipt.modules ?? 0)} 页` + + (receipt.t1Loss === true ? "(已富化的语义段被退回骨架)" : ""), + ); + return { toast: "重建完成" }; + }, + (error) => { + state.err = errorText(error); + return { toast: "重建失败" }; + }, + ); + return { pending: { label: "正在重建骨架", timeoutMs: 60000 }, result: async () => run }; + }, + }); + + // 未收录的布尔旗标(「其它」组里的数据行):行里带着 key,点一下翻转。 + actions["flag-set"] = async (scope, event) => { + resetNotice(); + const key = String(valueOf(event, ["key", "value"], "") ?? ""); + if (key === "") { + state.err = "没有拿到要切换的键。"; + return {}; + } + const next = state.flags?.[key] !== true; + try { + await call("/features", { method: "PUT", body: { [key]: next } }); + const features = await call("/features"); + state.flagOverrides = features?.overrides ?? {}; + state.pendingRestart = Array.isArray(features?.pendingRestart) ? features.pendingRestart : []; + state.flags = features?.effective ?? state.flags; + state.settingsDirty = true; + notice(`${key}:${next ? "已开启" : "已关闭"}`); + } catch (error) { + state.err = errorText(error); + } + return { refresh: false }; + }; + + // 只改绑定值的动作不申请重画视图。App 收到 refresh 会重新拉一次视图树, + // 而顶层 tabs 的选中页签是 App 的本地状态:树一重建就回到第一个页签,用户在 + // 设置页翻开关会被弹回记忆页。这些动作的结果都在 data 里(开关值、列表行、 + // 文案),推一次数据就够了。 + const VALUE_ONLY = new Set([ + "mode", + "vector-toggle", + "vector-save", + "external-toggle", + "external-save", + "profile-save", + "facts-agent", + "facts-edit", + "facts-add", + "facts-save", + "facts-archive", + "facts-run", + "flag-set", + "choro-repo", + "choro-page", + "choro-run", + ]); + + // Every action may change what the panel shows, so push fresh data before + // asking the app to re-render (the refresh effect alone would re-render a + // stale feed). + const wrapped = {}; + for (const [id, handler] of Object.entries(actions)) { + wrapped[id] = async (scope, event) => { + let result; + try { + result = await handler(scope, event); + } catch (error) { + state.err = errorText(error); + result = undefined; + } + // 默认重画视图:多数动作会改变「哪些节点存在」(进子页、空态、列表)。 + // 只改一个绑定值的动作(开关、档位、外部访问的开关)要显式关掉重画: + // App 的 refresh 会让它重新拉一次视图树,而页签选择是 App 的本地状态, + // 重建后回到第一个页签——用户正在设置页翻开关,会被弹回记忆页。 + const wanted = result ?? {}; + const skip = wanted.refresh === false || VALUE_ONLY.has(id) || id.startsWith("flag."); + const effect = { ...wanted, refresh: !skip }; + // pending 动作(维护轮、地志重建)把真正的活儿放在 result 里,handler + // 返回时它还没跑:此时立刻 broadcast 推出去的仍是这一轮之前的事实与 + // 地志列表。改成等 result 跑完再推(评审 #15)。 + if (typeof wanted.result === "function") { + const inner = wanted.result; + effect.result = async (...args) => { + try { + return await inner(...args); + } finally { + await broadcast(); + } + }; + return effect; + } + await broadcast(); + return effect; + }; + } + + // ----------------------------------------------------------------- panel + ctx.dshGo.panel("mneme", { + view() { + return buildView(state); + }, + data(scope, emit) { + emitters.add(emit); + // 挂载时读全:之后每个动作只重读当前页那一块(见 load 的注释)。 + load({ full: true }).then( + (payload) => { + if (emitters.has(emit)) emit(payload); + }, + (error) => { + if (emitters.has(emit)) emit({ notice: state.notice, err: errorText(error) }); + }, + ); + return () => { + emitters.delete(emit); + }; + }, + actions: wrapped, + }); +} + +/** + * 清掉模块级状态与缓存。在引擎重新挂载面板、以及测试之间切换场景时用它: + * 这些表(列表缓存、按 scope 打开的记忆、提示语)是模块级的,跨 mount 复用会 + * 把上一个场景的残留带进下一个。 + */ +export function resetPanelState() { + cache.clear(); + emitters.clear(); + openedBy.clear(); + conflictBy.clear(); + factBy.clear(); + lastPayload = null; + Object.assign(state, defaultState()); +} diff --git a/dsh-mneme/src/dsh-go/text.js b/dsh-mneme/src/dsh-go/text.js new file mode 100644 index 00000000..87832363 --- /dev/null +++ b/dsh-mneme/src/dsh-go/text.js @@ -0,0 +1,342 @@ +// Labels, flag metadata and small formatting helpers shared by the panel pages. +export const TYPE_LABELS = { + preference: "偏好", + project: "项目", + decision: "决策", + history: "历史", + rejected_solution: "弃用方案", + pitfall: "教训", + constraint: "约束", + summary: "摘要", + document: "文档", + fact: "事实", + pattern: "模式", +}; + +// 编辑器只提供常用的几类(写库层接受更多,但新建表单不必铺满)。 +export const TYPE_OPTIONS = [ + "preference", + "project", + "decision", + "constraint", + "pitfall", + "history", + "rejected_solution", + "summary", +]; + +// 筛选要覆盖写库层接受的全部类型(store.TYPES 的 10 个):只给 8 个的话, +// 库里已有的 pattern / document 条目在面板上筛不出来(评审 #11)。 +export const FILTER_TYPE_OPTIONS = [ + "preference", + "project", + "decision", + "history", + "summary", + "pattern", + "rejected_solution", + "pitfall", + "constraint", + "document", +]; + +const TYPE_TONES = { + preference: "info", + decision: "good", + constraint: "warn", + pitfall: "bad", + project: "info", + history: "muted", +}; + +const TYPE_ICONS = { + preference: "star", + project: "folder", + decision: "check", + constraint: "flag", + pitfall: "warning", + history: "clock", + rejected_solution: "remove", + summary: "note", + document: "book", + fact: "pin", + pattern: "repeat", +}; + +// Entity types come from extraction, so this is a best-effort mapping with a +// neutral fallback rather than a closed enum. +const ENTITY_ICONS = { + person: "user", + people: "user", + agent: "robot", + project: "folder", + workspace: "workspace", + repo: "code", + technology: "code", + tech: "code", + tool: "wrench", + library: "puzzle", + concept: "lightbulb", + topic: "note", + event: "calendar", + place: "globe", + location: "globe", + organization: "team", + org: "team", + team: "team", + document: "book", +}; + +// Entity types are free-form (the extractor invents them), so unknown ones fall +// back to what the store returned instead of being hidden. +const ENTITY_TYPE_LABELS = { + person: "人物", + people: "人物", + agent: "智能体", + project: "项目", + workspace: "工作区", + repo: "仓库", + technology: "技术", + tech: "技术", + tool: "工具", + library: "库", + concept: "概念", + topic: "主题", + event: "事件", + place: "地点", + location: "地点", + organization: "组织", + org: "组织", + team: "团队", + document: "文档", +}; + +export const entityIcon = (type) => ENTITY_ICONS[String(type ?? "").toLowerCase()] ?? "link"; +export const entityTypeLabel = (type) => ENTITY_TYPE_LABELS[String(type ?? "").toLowerCase()] ?? String(type ?? "实体"); + +export const typeLabel = (type) => TYPE_LABELS[type] ?? String(type ?? "其它"); +export const typeTone = (type) => TYPE_TONES[type]; +export const typeIcon = (type) => TYPE_ICONS[type]; + +const TIME = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", +}); + +export function fmtTime(iso) { + if (typeof iso !== "string" || iso === "") return "—"; + const when = new Date(iso); + if (Number.isNaN(when.getTime())) return "—"; + return TIME.format(when); +} + +export function clip(text, max) { + const str = typeof text === "string" ? text : String(text ?? ""); + if (str.length <= max) return str; + return `${str.slice(0, max - 1)}…`; +} + +export function splitTags(value) { + if (typeof value !== "string") return []; + return value + .split(/[,,]/u) + .map((t) => t.trim()) + .filter((t) => t !== ""); +} + +export function joinTags(tags) { + return Array.isArray(tags) ? tags.join(", ") : ""; +} + +/** Error text short enough for a 32 KiB panel payload. */ +export function errorText(error) { + const raw = error instanceof Error ? error.message : String(error); + return clip(raw, 600); +} + +/** True for a dotted flag key, used as a nested `data.flags.*` binding path. */ +export const flagPath = (key) => `data.flags.${key}`; + +/** One action id per switch: the app only tells us which action fired. */ +export const flagAction = (key) => `flag.${key.replace(/[^A-Za-z0-9_-]/gu, "_")}`; + +// Mirrors mneme's own settings page grouping (lib/client.js FEATURE_GROUPS), +// trimmed to the switches this panel can flip on a phone. +export const FLAG_GROUPS = [ + { + key: "basic", + label: "常用", + note: "先动这几个总开关。", + items: [ + ["autoInject", "每轮自动带上相关记忆"], + ["autoSummarize", "对话结束自动提炼成记忆"], + ["autoDream", "后台自动合并、去重"], + ], + }, + { + key: "record", + label: "记录", + note: "它记住什么、写多细。", + items: [ + ["hotMemoryEnabled", "带上最近几轮原话"], + ["injectTimePrefix", "注入当天日期"], + ["injectGuidanceEnabled", "教 Agent 怎么用记忆"], + ["continuityRescueEnabled", "压缩前先存快照"], + ["memoryQualityFilter.enabled", "滤掉低质量条目"], + ["sensitiveScanEnabled", "写入前扫描敏感内容"], + ["documentMemoryEnabled", "长文档只存摘要卡"], + ["profileFactsEnabled", "自动维护「Agent 眼中的你」"], + ["codingRetrospect", "编码过程也沉淀"], + ["distillRecords.enabled", "保留蒸馏原文"], + ], + }, + { + key: "write", + label: "写入", + note: "它什么时候主动写。", + items: [ + ["entityExtractionEnabled", "抽取人物与项目"], + ["writeAdmission.enabled", "写入准入判定"], + ["writeAdmission.enforce", "准入不通过就拦下"], + ["preInjectGate.enabled", "注入前判定"], + ["preInjectGate.enforce", "判定不通过就不注入"], + ["profileExtractEnabled", "画像提取草稿"], + ], + }, + { + key: "workflow", + label: "工作流", + note: "后台整理与人格。", + items: [ + ["sleepModeEnabled", "空闲时整理记忆"], + ["actionPipelineEnabled", "M.O. 动作图谱"], + ["actionNudgeEnabled", "自动沉淀工作流"], + ["personaEnabled", "注入人格卡"], + ["personaStrictAddressing", "严格人格寻址"], + ["llmAudit.enabled", "记录后台模型用量"], + ["rerankEnabled", "召回结果精排"], + ["chorographyEnabled", "代码库地志(chorography)"], + ], + }, + { + key: "retrieval", + label: "检索", + note: "它怎么把记忆找回来。", + items: [ + ["hybridInject", "关键词 + 语义两路"], + ["selectiveInjectEnabled", "选择性注入"], + ["adaptiveThresholdEnabled", "自适应阈值"], + ["bm25SearchEnabled", "BM25 检索"], + ["searchSemanticDedup", "语义去重"], + ["heatEnabled", "热度衰减"], + ["conflictFreezeEnabled", "矛盾记忆先冻结"], + ["trustEpistemicWeighting", "可信度加权"], + ["reflectionUpdateEnabled", "反思式更新"], + ], + }, + { + key: "scope", + label: "作用域", + note: "不同会话之间怎么隔离。", + items: [ + ["scopeEnabled", "按会话作用域标注"], + ["strictScope", "严格按作用域过滤"], + ["scopeState.enabled", "作用域级暂停"], + ["scopeState.enforce", "暂停后真的不注入"], + ], + }, + { + key: "extra", + label: "其它", + // 上游不断加开关:这里按 /features 的 effective 动态列出「我们还没给中文名」 + // 的布尔项,避免新功能在手机面板上静默消失(视图结构不能靠数据分支,所以 + // 用 list + 数据行,点一行翻转一项)。 + note: "上游新增的开关,面板还没有中文名;点一行即可切换。", + items: [], + }, +]; + +// 枚举型旗标(不是开关,得用下拉):PUT /features 认字符串值。 +export const ENUM_FLAGS = [ + { + key: "chorographyInject", + label: "地志注入档位", + hint: "每轮往系统提示里塞多少代码库提纲:轻量只放前 6 个模块,完整放全部,关闭则一轮都不放。", + options: [ + { label: "轻量", value: "light" }, + { label: "完整", value: "full" }, + { label: "关闭", value: "off" }, + ], + }, +]; + +export const FLAG_LABELS = new Map(); +for (const group of FLAG_GROUPS) { + for (const [key, label] of group.items) FLAG_LABELS.set(key, label); +} +for (const flag of ENUM_FLAGS) FLAG_LABELS.set(flag.key, flag.label); + +export const MODE_OPTIONS = [ + { label: "标准", value: "standard" }, + { label: "轻量", value: "light" }, +]; + +// ---- memory list filters (mirrors mneme's own filter bar) ------------------- +export const RANGE_OPTIONS = [ + { label: "全部时间", value: "all" }, + { label: "近 7 天", value: "7d" }, + { label: "近 30 天", value: "30d" }, + { label: "近 90 天", value: "90d" }, +]; + +export const MINIMP_OPTIONS = [ + { label: "不限重要度", value: "0" }, + { label: "★3 以上", value: "3" }, + { label: "★4 以上", value: "4" }, + { label: "仅 ★5", value: "5" }, +]; + +export const SCOPE_OPTIONS = [ + { label: "全部来源", value: "all" }, + { label: "仅已沉淀", value: "deposited" }, + { label: "仅已归档", value: "archived" }, + { label: "仅外部导入", value: "foreign" }, +]; + +export const VIEW_OPTIONS = [ + { label: "平铺", value: "flat" }, + { label: "按月", value: "month" }, +]; + +export const RANGE_DAYS = { "7d": 7, "30d": 30, "90d": 90 }; + +const MONTH = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit" }); +const DAY = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit" }); + +/** `2026-11` group label for the month view. */ +export function fmtMonth(iso) { + if (typeof iso !== "string" || iso === "") return "未知时间"; + const when = new Date(iso); + if (Number.isNaN(when.getTime())) return "未知时间"; + return MONTH.format(when).replace(/\//gu, "-"); +} + +/** `07-12` for charts. */ +export function fmtDay(iso) { + if (typeof iso !== "string" || iso === "") return "—"; + const when = new Date(iso); + if (Number.isNaN(when.getTime())) return "—"; + return DAY.format(when); +} + +/** 归属 line for a memory row (agent/workspace scope). */ +export function scopeText(memo) { + const parts = []; + if (typeof memo?.agent_scope === "string" && memo.agent_scope !== "") parts.push(`Agent ${memo.agent_scope}`); + if (typeof memo?.workspace_scope === "string" && memo.workspace_scope !== "") + parts.push(`工作区 ${memo.workspace_scope}`); + return parts.length === 0 ? "全局" : parts.join(" · "); +} diff --git a/dsh-mneme/src/dsh-go/views/common.js b/dsh-mneme/src/dsh-go/views/common.js new file mode 100644 index 00000000..849e24e2 --- /dev/null +++ b/dsh-mneme/src/dsh-go/views/common.js @@ -0,0 +1,68 @@ +// Small builders shared by every panel section. +export const b = (path) => ({ $: path }); +export const text = (value, style) => ({ type: "text", text: value, ...(style === undefined ? {} : { style }) }); +export const heading = (value) => text(value, { size: "subtitle", weight: "semibold" }); +export const caption = (value) => text(value, { size: "caption" }); +export const mono = (value) => text(value, { size: "mono" }); +export const act = (label, id, args, icon) => ({ + label, + ...(icon === undefined ? {} : { icon }), + action: args === undefined ? { id } : { id, args }, +}); +export const actions = (...items) => ({ type: "actions", items }); +export const kv = (items) => ({ type: "keyValue", items }); +// 只接受两种来源:字面文案,或调用方显式构造好的绑定对象 { $: path }。 +// 曾经把 "data.err" 这样的路径当字符串传进来,这里又原样当字面量下发,于是 +// 面板把路径本身显示成一句提示(评审 #12)——绑定的构造统一由 b() 负责。 +export const callout = (src, tone) => ({ + type: "callout", + text: typeof src === "string" ? src : src, + tone, +}); +export const fold = (label, children, expanded = false) => ({ type: "collapsible", label, expanded, children }); +export const metric = (label, value, tone) => ({ + type: "metric", + label, + value: b(value), + ...(tone === undefined ? {} : { tone }), +}); +export const divider = () => ({ type: "divider" }); +// tone 只接受枚举值,不能绑定(schema 的 badgeV2.tone 是纯枚举),所以第二个 +// 参数只能是字面色,不能是 "data.xxx" 这样的路径;文案同理,绑定要传 b(path)。 +export const badge = (src, tone) => ({ + type: "badge", + text: src, + ...(tone === undefined ? {} : { tone }), +}); +export const empty = (title, message, icon, action) => ({ + type: "emptyState", + title, + ...(message === undefined ? {} : { message }), + ...(icon === undefined ? {} : { icon }), + ...(action === undefined ? {} : { action }), +}); +export const select = (label, bind, options, onChange) => ({ + type: "select", + label, + bind, + options, + onChange: { id: onChange }, +}); +export const toggle = (label, bind, onChange) => ({ type: "toggle", label, bind, onChange: { id: onChange } }); + +const NOTICE_TONES = { ok: "good", warn: "warn", bad: "bad" }; + +export function notices(state) { + if (state.err) return [callout(b("data.err"), "bad")]; + if (state.notice) return [callout(b("data.notice"), NOTICE_TONES[state.noticeTone] ?? "info")]; + return []; +} + +export const chart = (kind, categories, series, unit, captionText) => ({ + type: "chart", + kind, + categories, + series, + ...(unit === undefined ? {} : { unit }), + ...(captionText === undefined ? {} : { caption: captionText }), +}); diff --git a/dsh-mneme/src/dsh-go/views/entity.js b/dsh-mneme/src/dsh-go/views/entity.js new file mode 100644 index 00000000..6fde632e --- /dev/null +++ b/dsh-mneme/src/dsh-go/views/entity.js @@ -0,0 +1,48 @@ +// 实体 section: entity catalogue with a search box, plus one entity's +// attributes and relations (mneme's graph ego network, without the SVG map). +import { act, actions, b, badge, caption, fold, heading, kv, metric, select, text } from "./common.js"; + +const DEPTH_OPTIONS = [ + { label: "只看直接关系", value: "1" }, + { label: "再看两层", value: "2" }, +]; + +function entityList() { + // 这里曾经按 state.entityTotal === 0 换成一块空状态:视图结构在首屏就 + // 定死了,而数据是随后才推来的,于是实体已经有了、空状态块还挂在上面。 + // 现在结构固定,空库的提示由 data.entityStatus 这一行文案承载。 + return [ + { + type: "grid", + columns: 2, + children: [metric("实体总数", "data.entityTotalText"), metric("类型数", "data.entityTypeTotalText")], + }, + { + type: "form", + submit: { id: "entity-search" }, + submitLabel: "搜索实体", + children: [{ type: "textField", label: "按名字找", bind: "data.entityQuery", placeholder: "留空显示全部" }], + }, + { type: "list", items: b("data.entities") }, + caption(b("data.entityStatus")), + actions(act("刷新", "refresh", undefined, "refresh")), + ]; +} + +function entityDetail(state) { + return [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + { type: "row", children: [heading(b("data.entityName")), badge(b("data.entityType"), "info")] }, + caption(b("data.entityMentionText")), + select("关系深度", "data.entityDepth", DEPTH_OPTIONS, "entity-depth"), + fold("属性", [kv(b("data.entityAttrs"))], true), + fold("关系", [{ type: "list", items: b("data.entityRelations") }], true), + fold("提及它的记忆", [{ type: "list", items: b("data.entityMemories") }], false), + text(b("data.entityHint"), { size: "caption" }), + ]; +} + +export function entitySection(state) { + if (state.page === "entity") return entityDetail(state); + return entityList(state); +} diff --git a/dsh-mneme/src/dsh-go/views/memory.js b/dsh-mneme/src/dsh-go/views/memory.js new file mode 100644 index 00000000..768915ca --- /dev/null +++ b/dsh-mneme/src/dsh-go/views/memory.js @@ -0,0 +1,198 @@ +// 记忆 section: list (search + filters + month view), detail, editor, and the +// conflict queue that mneme's own drawer keeps one click away from a memory. +import { + FILTER_TYPE_OPTIONS, + MINIMP_OPTIONS, + RANGE_OPTIONS, + SCOPE_OPTIONS, + TYPE_OPTIONS, + VIEW_OPTIONS, + typeLabel, +} from "../text.js"; +import { act, actions, b, badge, caption, divider, empty, fold, heading, kv, select, text, toggle } from "./common.js"; + +function filterBar(state) { + return fold( + "筛选与视图", + [ + select( + "类型", + "data.filter", + [ + { label: "全部类型", value: "all" }, + ...FILTER_TYPE_OPTIONS.map((type) => ({ label: typeLabel(type), value: type })), + ], + "type-filter", + ), + select("时间范围", "data.fRange", RANGE_OPTIONS, "set-range"), + select("重要度", "data.fMin", MINIMP_OPTIONS, "set-minimp"), + select("来源", "data.fScope", SCOPE_OPTIONS, "set-scope"), + select("视图", "data.fView", VIEW_OPTIONS, "set-view"), + toggle("语义检索", "data.fSemantic", "toggle-semantic"), + toggle("热度优先", "data.fHeat", "toggle-heat"), + ], + state.filtersOpen, + ); +} + +function monthsView(state) { + const groups = Array.isArray(state.monthGroups) ? state.monthGroups : []; + if (groups.length === 0) return [{ type: "list", items: b("data.rows") }]; + return groups.map((group) => fold(group.label, [{ type: "list", items: group.rows }], true)); +} + +function memoryList(state) { + return [ + { + type: "form", + submit: { id: "search" }, + submitLabel: "搜索", + children: [{ type: "textField", label: "搜索记忆", bind: "data.query", placeholder: "标题或正文,留空返回全部" }], + }, + actions(act("新记忆", "new", undefined, "add"), act("刷新", "refresh", undefined, "refresh")), + filterBar(state), + ...(state.viewMode === "month" ? monthsView(state) : [{ type: "list", items: b("data.rows") }]), + // 翻页用 App 原生的两段 segmented:每段最少 80dp、两段正好铺满一行, + // 不会像按钮组那样折成上下两行(面板只有约 297dp 宽)。 + // 页码文案挂在 label 上(binding data.pageText),顺带替掉独立的说明行。 + // 空列表也走同一条路:视图结构在首屏就定死了,按数据决定「要不要这块」 + // 会留下错误的残影(列表已经推来 N 条,空状态块却还在),所以这里不放 + // 任何依赖数据的结构分支,只把「没有匹配」写进页码文案里。 + { + type: "segmented", + label: b("data.pageText"), + bind: "data.pagePick", + options: [ + { label: "上一页", value: "prev" }, + { label: "下一页", value: "next" }, + ], + onChange: { id: "page" }, + }, + ]; +} + +function memoryDetail(state) { + const archived = state.current?.archived === true; + return [ + actions( + act("返回", "back", undefined, "reply"), + act("编辑", "edit", undefined, "edit"), + archived ? act("恢复", "unarchive", undefined, "refresh") : act("归档", "archive", undefined, "archive"), + ), + text(b("data.detailTitle"), { size: "title", weight: "semibold" }), + { + type: "row", + children: [ + badge(b("data.detailType"), state.detailTypeTone ?? "info"), + text(b("data.detailStars"), { size: "caption", weight: "medium" }), + ], + }, + ...(state.detailConflict === true + ? [{ type: "callout", text: "这条记忆已进入冲突队列,可在「设置 › 状态 › 冲突队列」里裁决。", tone: "warn" }] + : []), + kv(b("data.detailMeta")), + fold("正文", [text(b("data.detailContent"))], true), + fold("相关实体", [{ type: "list", items: b("data.detailEntities") }], false), + fold( + "来源与溯源", + [ + actions(act("查看来源", "provenance", undefined, "history")), + text(b("data.provenanceText"), { size: "caption" }), + ], + false, + ), + actions(act("复制", "copy", undefined, "copy"), act("插入输入框", "insert", undefined, "paste")), + fold("删除这条记忆", [{ type: "confirm", id: "delete", confirmLabel: "删除", cancelLabel: "取消" }], false), + ]; +} + +function memoryEditor(state) { + return [ + actions(act("取消", "back")), + { + type: "form", + submit: { id: "save" }, + submitLabel: "保存", + children: [ + { type: "textField", label: "标题", bind: "data.form.title", required: true, maxLength: 200 }, + { type: "textField", label: "正文", bind: "data.form.content", multiline: true, maxLines: 6, maxLength: 2000 }, + ...(state.form?.id === "" || state.form?.id === undefined + ? [ + select( + "类型", + "data.form.type", + TYPE_OPTIONS.map((type) => ({ label: typeLabel(type), value: type })), + "form-type", + ), + ] + : [text(`类型:${typeLabel(state.form?.type)}(保存后不能改)`, { size: "caption" })]), + { type: "stepper", label: "重要度", bind: "data.form.importance", min: 1, max: 5 }, + { type: "textField", label: "标签", bind: "data.form.tags", placeholder: "逗号分隔" }, + ...(state.form?.id === "" || state.form?.id === undefined + ? [] + : [ + { type: "textField", label: "归属 Agent", bind: "data.form.agentScope", placeholder: "留空 = 全局" }, + { type: "textField", label: "归属工作区", bind: "data.form.workspaceScope", placeholder: "留空 = 全局" }, + ]), + ], + }, + ]; +} + +// ------------------------------------------------------------- conflict queue +export function conflictPage(state) { + const nodes = [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + heading("冲突队列"), + caption("冻结的矛盾记忆,等待人工确认"), + ]; + if (state.conflictCount === 0) { + nodes.push(empty("没有待裁决的冲突", undefined, "check")); + return nodes; + } + nodes.push({ type: "list", items: b("data.conflictRows") }); + if (state.conflictReady !== true) return nodes; + nodes.push(divider()); + nodes.push( + fold( + "版本 A", + [ + text(b("data.conflictATitle"), { size: "subtitle", weight: "semibold" }), + kv(b("data.conflictAMeta")), + text(b("data.conflictAContent")), + ], + true, + ), + ); + nodes.push( + fold( + "版本 B", + [ + text(b("data.conflictBTitle"), { size: "subtitle", weight: "semibold" }), + kv(b("data.conflictBMeta")), + text(b("data.conflictBContent")), + ], + false, + ), + ); + nodes.push( + actions( + act("采用 A", "conflict-pick", { winner: "a" }, "check"), + act("采用 B", "conflict-pick", { winner: "b" }, "check"), + act("都不采用", "conflict-pick", { winner: "none" }, "remove"), + ), + ); + if (state.conflictPick !== "") { + const label = state.conflictPick === "a" ? "采用 A" : state.conflictPick === "b" ? "采用 B" : "都不采用"; + nodes.push({ type: "confirm", id: "conflict-win", confirmLabel: label, cancelLabel: "取消" }); + nodes.push(caption("确认后:保留方正文追加已否决注记,另一方归档。")); + } + return nodes; +} + +export function memorySection(state) { + if (state.page === "conflict") return conflictPage(state); + if (state.page === "detail") return memoryDetail(state); + if (state.page === "editor") return memoryEditor(state); + return memoryList(state); +} diff --git a/dsh-mneme/src/dsh-go/views/root.js b/dsh-mneme/src/dsh-go/views/root.js new file mode 100644 index 00000000..a432e429 --- /dev/null +++ b/dsh-mneme/src/dsh-go/views/root.js @@ -0,0 +1,30 @@ +// View builder — a phone-first rebuild of mneme's own panel. +// +// Top level: three sections in the app's own `tabs` bar (equal columns, one +// line, local switching). The app's tab bar needs 80dp per tab, so four tabs +// wrap on a phone-width panel; three fit. 运行状态 lives inside the settings tab, +// and so does the conflict queue (that is where its entry point sits). +// Because tabs switch locally, every section's data is loaded and pushed together. +import { entitySection } from "./entity.js"; +import { memorySection } from "./memory.js"; +import { settingsTab } from "./settings.js"; +import { notices } from "./common.js"; + +export function buildView(state) { + return { + v: 2, + type: "stack", + accent: "app", + children: [ + ...notices(state), + { + type: "tabs", + items: [ + { label: "记忆", children: memorySection(state) }, + { label: "实体", children: entitySection(state) }, + { label: "设置", children: settingsTab(state) }, + ], + }, + ], + }; +} diff --git a/dsh-mneme/src/dsh-go/views/settings.js b/dsh-mneme/src/dsh-go/views/settings.js new file mode 100644 index 00000000..acd9833d --- /dev/null +++ b/dsh-mneme/src/dsh-go/views/settings.js @@ -0,0 +1,398 @@ +// 设置 / 状态: two sibling areas, mirroring mneme's own panel where 设置 and +// 状态 are separate top-level tabs. The app's tab bar only fits three tabs at +// 80dp on a phone, so the third tab holds a two-item switch (设置 | 状态) and +// each area keeps its own inner navigation: 设置 is a card menu (like mneme's +// Settings cards) and 状态 is mneme's Status page with its grouped cards. +import { ENUM_FLAGS, FLAG_GROUPS, MODE_OPTIONS, flagAction, flagPath } from "../text.js"; +import { + act, + actions, + b, + caption, + divider, + empty, + fold, + heading, + kv, + metric, + mono, + select, + text, + toggle, +} from "./common.js"; +import { conflictPage } from "./memory.js"; +import { statusSection } from "./status.js"; + +const SETTINGS_VIEWS = ["settings", "status"]; + +// 设置页的顺序与分组对齐 mneme 电脑端面板(个性化 / 记忆引擎 / 搜索 / 连接与 +// 安全 / 帮助)。功能开关是记忆引擎的一部分,所以不再藏进子页面,直接铺在设置 +// 页上;只有需要表单的子页(命令 / 向量 / 外部访问 / 工作动态 / 关于)才进入 +// 「点一行 → 子页面」的导航。 +const PAGES = [ + { key: "commands", title: "自定义指令", hint: "把常用提示词存成 / 命令,聊天时直接键入调用。", icon: "command" }, + { + key: "vector", + title: "向量检索", + hint: "搜索不再只认字面:接入任意 OpenAI 兼容的 embedding 接口后,意思相近的记忆也能被召回。", + icon: "chart", + }, + { + key: "external", + title: "外部访问", + hint: "开放一个本机接口,让其他工具(插件、命令行、桌面应用)也能读写这份记忆库,默认只允许本机连接。改动保存后重启 DSH 生效。", + icon: "globe", + }, + { + key: "choro", + title: "代码库地志", + hint: "给常用仓库生成一份代码骨架:模块划分、文件数、约定与构建方式,开会话时按档位注入。", + icon: "code", + }, + { key: "help", title: "关于", icon: "info" }, +]; + +const pageRow = (page) => ({ + title: page.title, + ...(page.hint === undefined ? {} : { subtitle: page.hint }), + icon: page.icon, + action: { id: "open-panel", args: { k: page.key } }, +}); + +const rows = (keys) => ({ + type: "list", + items: PAGES.filter((page) => keys.includes(page.key)).map(pageRow), +}); + +function personalRows() { + return { + type: "list", + items: [ + { + title: "用户画像", + subtitle: "写一段自我介绍(角色、背景、偏好),Agent 每轮对话都会自动带上。", + icon: "user", + action: { id: "open-profile", args: { k: "profile" } }, + }, + { + title: "规则", + subtitle: "给 Agent 立下必须遵守的规矩,随时增删,下一轮即生效。", + icon: "list", + action: { id: "open-profile", args: { k: "rules" } }, + }, + { + title: "Agent 眼中的你", + subtitle: "dream 巩固后自动维护的一份逐条事实清单;这里可以逐条改、加、归档。", + icon: "sparkle", + action: { id: "open-panel", args: { k: "facts" } }, + }, + { + title: "人格定义(beta)", + subtitle: "给这位 Agent 一张角色卡:名字、性格、回复要求。", + icon: "robot", + action: { id: "open-profile", args: { k: "persona" } }, + }, + ], + }; +} + +function settingsMenu(state) { + const group = FLAG_GROUPS.find((g) => g.key === state.flagGroup) ?? FLAG_GROUPS[0]; + return [ + heading("个性化"), + personalRows(), + heading("记忆引擎"), + { + type: "segmented", + label: "\u200b", + bind: "data.mode", + options: MODE_OPTIONS, + onChange: { id: "mode" }, + }, + caption( + "轻量模式只保留核心能力:记忆读写、自动注入、热记忆;巩固、实体抽取、语义搜索等全部关闭,适合低配机器或只想「记住偏好」的场景。标准模式开启全部功能。", + ), + rows(["choro"]), + heading("功能开关"), + select( + "分组", + "data.flagGroup", + FLAG_GROUPS.map((g) => ({ label: g.label, value: g.key })), + "switch-group", + ), + caption(group.note), + ...group.items.map(([key, label]) => toggle(label, flagPath(key), flagAction(key))), + // 枚举项不是开关:给一组下拉(目前只有地志注入档位,挂在工作流组下)。 + ...ENUM_FLAGS.filter((flag) => group.key === "workflow").flatMap((flag) => [ + select(flag.label, flagPath(flag.key), flag.options, flagAction(flag.key)), + caption(flag.hint), + ]), + // 「其它」组按 /features 的 effective 动态列出未收录的布尔旗标:视图结构 + // 不能靠数据分支,所以用 list + 数据行(点一行翻转一侧)。 + ...(group.key === "extra" ? [{ type: "list", items: b("data.extraRows") }] : []), + caption(b("data.flagsMeta")), + caption( + "控制插件在后台做哪些事。改动即时保存,重启 DSH 后生效;嵌套开关(如「质量过滤 · 启用」)在电脑端面板里按点号归类。", + ), + heading("搜索"), + rows(["vector"]), + heading("连接与安全"), + rows(["external"]), + heading("帮助"), + rows(["commands", "help"]), + divider(), + actions(act("刷新", "refresh", undefined, "refresh")), + ]; +} + +function workbenchPanel() { + const nodes = [ + kv(b("data.wbKv")), + heading("已沉淀"), + { type: "list", items: b("data.wbDeposited") }, + caption("沉淀 = 被 mneme 当成工作流或项目状态写下来的条目。"), + heading("归档"), + ]; + nodes.push({ type: "list", items: b("data.wbArchived") }); + nodes.push(caption("点按任意一条可以恢复它。")); + return nodes; +} + +function commandsPanel(state) { + const nodes = [{ type: "list", items: b("data.cmdRows") }]; + if (state.cmdCount === 0) nodes.push(empty("还没有自定义命令", "在下面填一条,之后就能随时叫它。", "command")); + nodes.push({ + type: "form", + submit: { id: "cmd-add" }, + submitLabel: "新增命令", + children: [ + { type: "textField", label: "命令名", bind: "data.sCmdName", required: true, maxLength: 40 }, + { type: "textField", label: "说明", bind: "data.sCmdDesc", maxLength: 120 }, + { type: "textField", label: "指令正文", bind: "data.sCmdIns", multiline: true, maxLines: 4, maxLength: 2000 }, + ], + }); + if (state.cmdCount > 0) { + nodes.push( + select( + "要删除的命令", + "data.sCmdPick", + [ + { label: "不删除", value: "" }, + ...state.commands.map((cmd) => ({ label: String(cmd.name ?? cmd.id), value: String(cmd.id) })), + ], + "cmd-pick", + ), + ); + if (state.sCmdPick !== "") { + nodes.push({ type: "confirm", id: "cmd-delete", confirmLabel: "删除命令", cancelLabel: "取消" }); + } + } + return nodes; +} + +function vectorPanel() { + return [ + kv(b("data.vectorKv")), + toggle("启用向量检索", "data.sVecEnabled", "vector-toggle"), + { + type: "form", + submit: { id: "vector-save" }, + submitLabel: "保存向量配置", + children: [ + { type: "textField", label: "接口地址", bind: "data.sVecUrl", placeholder: "https://…/v1", maxLength: 200 }, + { + type: "textField", + label: "模型", + bind: "data.sVecModel", + placeholder: "text-embedding-3-small", + maxLength: 80, + }, + ], + }, + actions(act("重建索引", "vector-reindex", undefined, "refresh")), + caption(b("data.vectorNote")), + ]; +} + +function externalPanel() { + return [ + toggle("开启外部访问", "data.sExtEnabled", "external-toggle"), + { + type: "form", + submit: { id: "external-save" }, + submitLabel: "保存外部访问", + children: [ + { type: "textField", label: "端口", bind: "data.sExtPort", placeholder: "8790", maxLength: 5 }, + { type: "textField", label: "监听地址", bind: "data.sExtHost", placeholder: "127.0.0.1", maxLength: 60 }, + ], + }, + caption("令牌由 mneme 自己管理,面板不读取也不显示明文;需要令牌时到电脑端的 mneme 设置里看。"), + ]; +} + +function helpPanel() { + return [ + kv(b("data.helpKv")), + actions(act("检测新版本", "check-version", undefined, "download")), + caption(b("data.versionNote")), + ]; +} + +const PANEL_TITLES = { + facts: "Agent 眼中的你", + choro: "代码库地志", + workbench: "工作台", + commands: "自定义命令", + vector: "向量检索", + external: "外部访问", + help: "关于", +}; + +function factsPanel() { + // 结构必须与数据无关(宿主先给视图、后推数据;按数据分支的地方会在首屏定形)。 + // 所以「编辑框 / 归档按钮」永远在树里,点的哪条事实由 data.factEditHint 说明、 + // 内容由 data.sFactEdit 填。 + return [ + select( + "哪张卡", + "data.factsAgent", + b("data.factsCardOptions"), + "facts-agent", + ), + { type: "list", items: b("data.factsRows") }, + { type: "textField", label: "修改这条事实", bind: "data.sFactEdit", multiline: true, maxLines: 3, maxLength: 300 }, + text(b("data.factsEditHint"), { size: "caption" }), + actions(act("保存修改", "facts-save", undefined, "save")), + { type: "confirm", id: "facts-archive", confirmLabel: "归档这条", cancelLabel: "取消" }, + { + type: "form", + submit: { id: "facts-add" }, + submitLabel: "添加一条", + children: [ + { type: "textField", label: "新事实(一句话)", bind: "data.sFactNew", required: true, maxLength: 200 }, + ], + }, + actions(act("立即更新", "facts-run", undefined, "sparkle")), + caption(b("data.factsRunText")), + caption("事实由模型在 dream 巩固后自己维护;手填的画像永远优先,事实不会覆盖它。"), + ]; +} + +function choroPanel(state) { + return [ + select("仓库", "data.choroRepo", b("data.choroRepoOptions"), "choro-repo"), + { type: "list", items: b("data.choroRows") }, + text(b("data.choroNote"), { size: "caption" }), + fold("模块页", [mono(b("data.choroPageText"))], false), + { type: "confirm", id: "choro-run", confirmLabel: "重建骨架", cancelLabel: "取消" }, + caption("重建会把已富化的模块页退回骨架;语义段要命令行 --t1 重新生成。"), + ]; +} + +function panelBody(state) { + switch (state.sPanel) { + case "facts": + return factsPanel(state); + case "choro": + return choroPanel(state); + case "workbench": + return workbenchPanel(); + case "commands": + return commandsPanel(state); + case "vector": + return vectorPanel(); + case "external": + return externalPanel(); + case "help": + return helpPanel(); + default: + return []; + } +} + +function panelPage(state) { + return [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + heading(PANEL_TITLES[state.sPanel] ?? "设置"), + ...panelBody(state), + ]; +} + +function settingsBody(state) { + if (state.page === "profile") return profilePage(state); + if (state.page === "conflict") return conflictPage(state); + if (state.sPanel !== "menu") return panelPage(state); + return settingsMenu(state); +} + +function statusBody(state) { + return [ + { + type: "grid", + columns: 2, + children: [ + metric("记忆总数", "data.totalText"), + metric("近 30 天注入", "data.injectedText"), + metric("注入命中率", "data.slotRateText", "good"), + metric("待确认冲突", "data.conflictsText", state.pendingConflicts > 0 ? "warn" : undefined), + ], + }, + ...statusSection(state), + actions(act("刷新", "refresh", undefined, "refresh")), + ]; +} + +export function settingsTab(state) { + const view = SETTINGS_VIEWS.includes(state.settingsView) ? state.settingsView : "settings"; + return [ + { + type: "segmented", + label: "\u200b", + bind: "data.settingsView", + options: [ + { label: "设置", value: "settings" }, + { label: "状态", value: "status" }, + ], + onChange: { id: "switch-settings-view" }, + }, + ...(view === "status" ? statusBody(state) : settingsBody(state)), + ]; +} + +export function profilePage(state) { + const nodes = [ + actions(act("返回", "back", undefined, "reply"), act("刷新", "refresh", undefined, "refresh")), + { + type: "segmented", + label: "\u200b", + bind: "data.profileTab", + options: [ + { label: "画像", value: "profile" }, + { label: "规则", value: "rules" }, + { label: "人格", value: "persona" }, + ], + onChange: { id: "profile-tab" }, + }, + ]; + if (state.profileTab === "persona") { + nodes.push(kv(b("data.previewItems"))); + nodes.push(fold("人格设定", [mono(b("data.personaText"))], false)); + return nodes; + } + nodes.push({ + type: "textField", + label: state.profileTab === "rules" ? "规则(JSON 数组)" : "用户画像", + bind: "data.editText", + multiline: true, + maxLines: 8, + maxLength: 4000, + }); + nodes.push(actions(act("保存", "profile-save", undefined, "save"))); + if (state.profileTab === "profile") { + nodes.push(actions(act("从记忆提取草稿", "profile-extract", undefined, "sparkle"))); + if (state.extractText !== "") nodes.push(text(b("data.extractText"), { size: "caption" })); + } + return nodes; +} + +export { panelPage }; diff --git a/dsh-mneme/src/dsh-go/views/status.js b/dsh-mneme/src/dsh-go/views/status.js new file mode 100644 index 00000000..27bf9b5b --- /dev/null +++ b/dsh-mneme/src/dsh-go/views/status.js @@ -0,0 +1,51 @@ +// 运行状态: the runtime picture mneme's own Status tab shows, grouped into +// collapsible blocks so the settings tab stays short. +import { act, actions, b, caption, chart, fold, heading, kv } from "./common.js"; + +export function statusSection(state) { + return [ + fold("库内一览", [kv(b("data.statusKv"))], true), + fold("注入与召回", [kv(b("data.injectKv")), heading("常被召回"), { type: "list", items: b("data.topRows") }], true), + fold("注入预览", [caption(b("data.injectPreviewText"))], false), + fold("后台巩固", [kv(b("data.dreamKv")), { type: "list", items: b("data.runRows") }], false), + fold( + "模型用量", + [ + { + type: "chart", + kind: "bar", + categories: b("data.tokenCategories"), + series: b("data.tokenSeries"), + unit: "次", + caption: "近 28 天", + }, + kv(b("data.tokenKv")), + ], + false, + ), + fold( + "热度分布", + [ + { type: "chart", kind: "bar", categories: ["热", "温", "冷"], series: b("data.heatSeries"), unit: "条" }, + caption(b("data.heatNote")), + ], + false, + ), + fold("冲突队列", [actions(act("打开冲突队列", "open-conflict", undefined, "warning"))], false), + fold("工作动态", [actions(act("打开工作动态", "open-panel", { k: "workbench" }, "todo"))], false), + fold( + "维护", + [ + actions( + act("检查可回收", "reclaim", undefined, "clear"), + act("真跑回收", "reclaim-prepare", undefined, "delete"), + ), + ...(state.reclaimConfirm === true + ? [{ type: "confirm", id: "reclaim-run", confirmLabel: "真跑回收", cancelLabel: "取消" }] + : []), + caption(b("data.reclaimText")), + ], + false, + ), + ]; +} diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index eb0baa0d..221bf74b 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -687,6 +687,16 @@ export const apply = (ctx, config) => { list: () => [] }, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance, extractProfileDraft, choro, profileFacts); disposers.push(api.dispose); + + // dsh-go 原生面板:只有宿主提供 dshGo 服务(dsh-go 手机 App)时才挂载。 + // 动态 import 是刻意的——静态导入会让「没有桥就整块不加载」这句承诺落空。 + // cfg.apiToken 一并交给面板:createApi 给 token / token-write 路由包了鉴权, + // 进程内调用不带令牌会被自家路由拦成 401,不为此在服务端开白名单。 + ctx.inject(["dshGo"], (dshCtx) => { + import("./dsh-go/panel.js") + .then((panel) => panel.mountDshGoPanel(dshCtx, { apiToken: cfg.apiToken })) + .catch((error) => ctx.logger?.warn?.(`[dsh-mneme] dsh-go 面板未加载:${error?.message ?? error}`)); + }); } // M.O. 自动沉淀(v0.9.3):项目里重复走的那套动作,自动变成 memoryDir 里的一条 diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 686864f8..6e904d45 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -4501,10 +4501,21 @@ export function createStore(path) { /** All entities (optionally name-filtered, newest first). Used by sleep phase 4 * orphan detection: an entity with zero relations is a candidate for relation * completion. */ - function listEntities({ limit = 1000 } = {}) { + function listEntities({ limit = 1000, q } = {}) { + const needle = typeof q === "string" ? q.trim() : ""; + if (needle === "") { + const rows = db.prepare( + "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?" + ).all(limit); + return rows.map(toEntity); + } + // 名字过滤放在 SQL 侧:调用方若在 LIMIT 之后过滤,排在 limit 之外的实体 + // 就永远搜不到(面板按 limit=200 取回再本地过滤,1.6 万实体时命中率约 1%)。 + // LIKE 的 % / _ 用 ESCAPE 转义,否则用户输入一个 "%" 就会把整库匹配出来。 + const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`); const rows = db.prepare( - "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?" - ).all(limit); + "SELECT * FROM entities WHERE name LIKE ? ESCAPE '\\' ORDER BY last_seen DESC, name ASC LIMIT ?" + ).all(`%${escaped}%`, limit); return rows.map(toEntity); } diff --git a/dsh-mneme/test/api-routes-gaps.test.js b/dsh-mneme/test/api-routes-gaps.test.js index 9d3f269a..a5a32078 100644 --- a/dsh-mneme/test/api-routes-gaps.test.js +++ b/dsh-mneme/test/api-routes-gaps.test.js @@ -271,6 +271,52 @@ test("external-api PUT applies valid config and echoes it", async () => { assert.ok(typeof cfg.token === "string"); }); +// 名字过滤必须下推到 store:面板的「搜索实体」此前是自己取回 limit 条再本地 +// 过滤,排在 limit 之外的实体永远搜不到。q 走 SQL LIKE,% 与 _ 要转义,否则 +// 用户输入一个 "%" 就把整库匹配出来。 +test("entities q filters by name in the store, not after the limit", async () => { + const { routes, service } = setup(); + service.createEntity({ name: "SQLite", type: "technology" }); + service.createEntity({ name: "记忆", type: "concept" }); + service.createEntity({ name: "postgres", type: "technology" }); + const route = handler(routes, "/api/dsh-mneme/entities"); + + let res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/entities?q=lite"), res); + let data = JSON.parse(res.body); + assert.deepEqual(data.entities.map((e) => e.name), ["SQLite"], "substring match"); + + // q 命中排在 limit 之外的那一条:这正是本地过滤拿不到的情形 + res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/entities?limit=1&q=postgres"), res); + data = JSON.parse(res.body); + assert.deepEqual(data.entities.map((e) => e.name), ["postgres"]); + + // 通配符必须被转义,不能变成「匹配一切」 + res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/entities?q=%25"), res); + assert.equal(JSON.parse(res.body).entities.length, 0, "% must not match everything"); +}); + +// 手机面板只需要 enabled/port/host 来渲染表单,但默认的 GET 会顺手 materialize +// 一个 Bearer 写进 user_settings —— 纯读变成写路径。?token=omit 让它只读。 +test("external-api GET with token=omit neither materializes nor returns a token", async () => { + const { routes, settings } = setup(); + const route = handler(routes, "/api/dsh-mneme/external-api"); + + const res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/external-api?token=omit"), res); + assert.equal(res.statusCode, 200); + const cfg = JSON.parse(res.body).config; + assert.equal(cfg.token, "", "no plaintext token on a read-only call"); + assert.equal(settings.getExternalApi()?.token ?? "", "", "nothing persisted either"); + + // 不带参数的老客户端行为不变:仍然发 token 并落库 + const res2 = new FakeRes(); + await route.handler(req("/api/dsh-mneme/external-api"), res2); + assert.ok(JSON.parse(res2.body).config.token.length >= 16); +}); + // --------------------------------------------------------------------------- // /api/dsh-mneme/create(#386 第 5 条:面板手动新增记忆) // --------------------------------------------------------------------------- diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 83a53ff4..d387ad8b 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -115,6 +115,33 @@ test("list total excludes forgotten entries", async () => { assert.equal(data.total, 1, "total matches visible items, forgotten excluded"); }); +test("list?id= also excludes forgotten entries", async () => { + // getById 是裸 SELECT,不套 list 的 forgotten 谓词;这条路由是 open 的, + // 漏掉过滤等于让本机任意进程按 id 读到遗忘记忆的全文(评审 #1)。 + const { routes, service } = setup(); + const forgotten = service.saveWithDedupe({ type: "preference", title: "遗忘", content: "隐藏" }); + service.setForget(forgotten.memory.id, true); + const route = routes.find((r) => r.path === "/api/dsh-mneme/list"); + const res = new FakeRes(); + await route.handler(req(`/api/dsh-mneme/list?id=${forgotten.memory.id}`), res); + assert.equal(res.statusCode, 200); + const data = JSON.parse(res.body); + assert.deepEqual(data.items, []); + assert.equal(data.total, 0); +}); + +test("list?id= still returns archived rows (archive is not forget)", async () => { + const { routes, service } = setup(); + const archived = service.saveWithDedupe({ type: "preference", title: "归档", content: "仍可比对" }); + service.setArchived(archived.memory.id, true); + const route = routes.find((r) => r.path === "/api/dsh-mneme/list"); + const res = new FakeRes(); + await route.handler(req(`/api/dsh-mneme/list?id=${archived.memory.id}`), res); + const data = JSON.parse(res.body); + assert.equal(data.items.length, 1); + assert.equal(data.items[0].title, "归档"); +}); + test("list honors limit/offset", async () => { const { routes, service } = setup(); service.saveWithDedupe({ type: "preference", title: "a", content: "1" }); diff --git a/dsh-mneme/test/dsh-go-host.test.js b/dsh-mneme/test/dsh-go-host.test.js new file mode 100644 index 00000000..cf586f85 --- /dev/null +++ b/dsh-mneme/test/dsh-go-host.test.js @@ -0,0 +1,134 @@ +// src/dsh-go/host.js —— 进程内派发的判据。 +// +// 这个文件锁的是「面板能不能拿到宿主真的答复」,而不是某一屏长什么样: +// - 请求体必须在 handler 挂好监听之后再 flush,否则 readBody 永远等不到 end; +// - handler 返回 undefined 但稍后才 res.end(/version-check 就是这样)时, +// 调用方必须等到响应真的结束,不能拿着空 body 当成结果; +// - 配了 apiToken 之后写路由要带令牌,否则被自家路由注册器拦成 401; +// - 超时、未注册路径都要抛错,而不是返回一个空对象让面板显示成「0 条」。 +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHostCall } from "../src/dsh-go/host.js"; + +function fakeServer(routes) { + return { + match(pathname) { + const handler = routes[pathname]; + return handler === undefined ? undefined : { kind: "exact", path: pathname, handler }; + }, + }; +} + +function readAll(req) { + return new Promise((resolve) => { + let data = ""; + req.on("data", (chunk) => { + data += String(chunk); + }); + req.on("end", () => resolve(data)); + }); +} + +const ctxWith = (routes) => ({ get: (name) => (name === "webServer" ? fakeServer(routes) : undefined) }); + +test("进程内调用能读到请求体,并且同步响应即可返回", async () => { + let seen = null; + const call = createHostCall( + ctxWith({ + "/api/dsh-mneme/echo": (req, res) => { + seen = { method: req.method, url: req.url }; + void readAll(req).then((body) => { + seen.body = body; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, body: JSON.parse(body) })); + }); + }, + }), + ); + const result = await call("/echo", { method: "POST", body: { title: "x" } }); + assert.deepEqual(result, { ok: true, body: { title: "x" } }); + assert.equal(seen.method, "POST"); + assert.equal(seen.body, JSON.stringify({ title: "x" })); +}); + +test("handler 同步返回 undefined、稍后 res.end 时仍然等到响应结束", async () => { + // /version-check 的真实形状:handler 立即 return,答复在 promise 回调里写完。 + const call = createHostCall( + ctxWith({ + "/api/dsh-mneme/version-check": (req, res) => { + setTimeout(() => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ version: "0.10.1", status: "latest" })); + }, 5); + return undefined; + }, + }), + ); + const result = await call("/version-check"); + assert.equal(result.version, "0.10.1"); + assert.equal(result.status, "latest"); +}); + +test("配了 apiToken 就把令牌放进 x-dsh-mneme-token 头", async () => { + const call = createHostCall( + ctxWith({ + "/api/dsh-mneme/update": (req, res) => { + const token = req.headers["x-dsh-mneme-token"] ?? ""; + res.writeHead(token === "secret-token" ? 200 : 401); + res.end(JSON.stringify(token === "secret-token" ? { ok: true } : { error: "unauthorized" })); + }, + }), + { apiToken: "secret-token" }, + ); + assert.deepEqual(await call("/update", { method: "POST", body: { id: "m1" } }), { ok: true }); +}); + +test("没有令牌时写路由的 401 会变成异常,而不是空结果", async () => { + const call = createHostCall( + ctxWith({ + "/api/dsh-mneme/update": (req, res) => { + res.writeHead(401); + res.end(JSON.stringify({ error: "unauthorized" })); + }, + }), + ); + await assert.rejects(call("/update", { method: "POST", body: { id: "m1" } }), /401/); +}); + +test("未注册的路径直接报错,不会静默返回空对象", async () => { + const call = createHostCall(ctxWith({})); + await assert.rejects(call("/list"), /mneme 未注册/); +}); + +test("宿主没有 webServer 时报错", async () => { + const call = createHostCall({ get: () => undefined }); + await assert.rejects(call("/list"), /webServer 不可用/); +}); + +test("超时抛错(避免面板永远等着)", async () => { + const call = createHostCall( + ctxWith({ + "/api/dsh-mneme/slow": () => { + // 永不响应 + }, + }), + ); + await assert.rejects(call("/slow", { timeoutMs: 30 }), /超时/); +}); + +test("空响应体归一成 {},非 JSON 归一成 { text }", async () => { + const call = createHostCall( + ctxWith({ + "/api/dsh-mneme/empty": (req, res) => { + res.writeHead(200); + res.end(""); + }, + "/api/dsh-mneme/plain": (req, res) => { + res.writeHead(200); + res.end("hello"); + }, + }), + ); + assert.deepEqual(await call("/empty"), {}); + assert.deepEqual(await call("/plain"), { text: "hello" }); +}); diff --git a/dsh-mneme/test/dsh-go-panel-actions.test.js b/dsh-mneme/test/dsh-go-panel-actions.test.js new file mode 100644 index 00000000..8d3d7d66 --- /dev/null +++ b/dsh-mneme/test/dsh-go-panel-actions.test.js @@ -0,0 +1,854 @@ +// src/dsh-go/panel.js —— 点按之后真的发生了什么。 +// +// 这里用假的 webServer / dshGo 把面板挂起来,直接调它的 actions,断言三件事: +// 发给宿主的请求是否只包含该发的字段、视图是否切到了该切的地方、以及那些 +// 「按共享状态猜参数」的地方有没有被核对。评审里指出的每一条都在下面有对应用例。 +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mountDshGoPanel, resetPanelState } from "../src/dsh-go/panel.js"; + +const TOKEN = "test-token"; + +function reply(res, status, value) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); +} + +function bodyOf(req) { + return new Promise((resolve) => { + let text = ""; + req.on("data", (chunk) => { + text += String(chunk); + }); + req.on("end", () => resolve(text === "" ? {} : JSON.parse(text))); + }); +} + +function makeWorld() { + const memories = [ + { + id: "m1", + type: "project", + title: "面板改造", + content: "正文一", + tags: [], + importance: 4, + source: "tool", + created_at: "2026-11-01T00:00:00.000Z", + updated_at: "2026-11-01T00:00:00.000Z", + archived: false, + }, + { + id: "m2", + type: "preference", + title: "单位同行同字号", + content: "正文二", + tags: ["ui"], + importance: 3, + source: "chat", + created_at: "2026-11-02T00:00:00.000Z", + updated_at: "2026-11-02T00:00:00.000Z", + archived: false, + }, + ]; + const writes = []; + const reads = []; + const features = { + overrides: {}, + effective: { heatEnabled: false, autoInject: true, "scopeState.enabled": false }, + pendingRestart: [], + }; + const external = { enabled: true, port: 8899, host: "0.0.0.0", token: "" }; + const state = { rules: [{ text: "已有一条规则" }] }; + const facts = { + items: [ + { + id: "f1", + agent_key: "", + content: "喜欢简洁的回复", + status: "active", + created_at: "2026-11-05T00:00:00.000Z", + updated_at: "2026-11-05T00:00:00.000Z", + }, + ], + runs: [], + }; + + const routes = { + "/api/dsh-mneme/info": (req, res) => reply(res, 200, { version: "0.10.1" }), + "/api/dsh-mneme/health": (req, res) => reply(res, 200, { mirror: { status: "ok", success_at: null } }), + "/api/dsh-mneme/dream-status": (req, res) => + reply(res, 200, { lastRun: {}, lastConsolidation: null, runs: [], pendingConflicts: 0, pendingMemoryIds: [] }), + "/api/dsh-mneme/inject-status": (req, res) => + reply(res, 200, { autoInject: true, agentPreset: "cordis", suppressed: false }), + "/api/dsh-mneme/recall-stats": (req, res) => + reply(res, 200, { injection: { injectedCount: 12, slotFillRate: 0.75 }, topRecalled: [] }), + "/api/dsh-mneme/inject-preview": (req, res) => reply(res, 200, { snapshot: null }), + "/api/dsh-mneme/semantic/llm-audit/stats": (req, res) => + reply(res, 200, { by_day: [], total_calls: 0, total_tokens: 0, total_cost_usd: 0 }), + "/api/dsh-mneme/conflicts": (req, res) => { + if (req.method === "GET") reply(res, 200, { items: [], total: 0 }); + }, + "/api/dsh-mneme/list": (req, res) => { + const url = new URL(req.url, "http://x"); + reads.push({ path: "/list", url: req.url }); + const id = url.searchParams.get("id"); + if (id !== null) { + const row = memories.find((item) => item.id === id); + return reply(res, 200, { items: row === undefined ? [] : [row], total: row === undefined ? 0 : 1 }); + } + const type = url.searchParams.get("type"); + const limit = Number(url.searchParams.get("limit") ?? 20); + const items = memories.filter((row) => type === null || row.type === type).slice(0, limit); + reply(res, 200, { items, total: memories.length }); + }, + "/api/dsh-mneme/search": (req, res) => { + // /search 不认识 type:它把两类都返回,类型筛选只能由面板自己再收一遍。 + reply(res, 200, { items: memories, total: memories.length }); + }, + "/api/dsh-mneme/entities": (req, res) => { + reads.push({ path: "/entities" }); + const url = new URL(req.url, "http://x"); + const needle = (url.searchParams.get("q") ?? "").toLowerCase(); + const all = [ + { name: "Alice", type: "person", mention_count: 3 }, + { name: "Bob", type: "agent", mention_count: 1 }, + ]; + const entities = needle === "" ? all : all.filter((row) => row.name.toLowerCase().includes(needle)); + reply(res, 200, { entities, total: entities.length, totalExact: true }); + }, + "/api/dsh-mneme/memories/entities": (req, res) => reply(res, 200, { entities: [] }), + "/api/dsh-mneme/memory/provenance": (req, res) => reply(res, 200, { windows: [] }), + "/api/dsh-mneme/features": (req, res) => { + if (req.method === "GET") return reply(res, 200, features); + return bodyOf(req).then((body) => { + writes.push({ path: "/features", method: req.method, body, token: req.headers["x-dsh-mneme-token"] }); + for (const [key, value] of Object.entries(body)) { + features.overrides[key] = value; + features.effective[key] = value; + } + reply(res, 200, { ok: true, effective: features.effective, overrides: features.overrides }); + }); + }, + "/api/dsh-mneme/mode": (req, res) => { + if (req.method === "GET") return reply(res, 200, { mode: "standard" }); + return bodyOf(req).then(() => reply(res, 200, { mode: "standard" })); + }, + "/api/dsh-mneme/rules": (req, res) => { + if (req.method === "GET") return reply(res, 200, { rules: state.rules }); + return bodyOf(req).then((body) => { + writes.push({ path: "/rules", method: req.method, body }); + state.rules = body.rules; + reply(res, 200, { rules: state.rules }); + }); + }, + "/api/dsh-mneme/profile": (req, res) => { + if (req.method === "GET") return reply(res, 200, { profile: "" }); + return bodyOf(req).then(() => reply(res, 200, { profile: "" })); + }, + "/api/dsh-mneme/persona": (req, res) => reply(res, 200, { persona: {}, preview: { text: "" } }), + "/api/dsh-mneme/commands": (req, res) => reply(res, 200, { commands: [] }), + "/api/dsh-mneme/vector-config": (req, res) => + reply(res, 200, { config: { enabled: false, baseUrl: "", model: "", apiKey: "" } }), + "/api/dsh-mneme/vector-reindex": (req, res) => reply(res, 200, { indexed: 1, skipped: 0 }), + "/api/dsh-mneme/external-api": (req, res) => { + if (req.method === "GET") { + const url = new URL(req.url, "http://x"); + reads.push({ path: "/external-api", omit: url.searchParams.get("token") === "omit" }); + // 真实路由不带 omit 时会顺手 materialize 一个 Bearer;这里照实模拟。 + const token = url.searchParams.get("token") === "omit" ? "" : "materialized-bearer"; + return reply(res, 200, { config: { ...external, token } }); + } + return bodyOf(req).then((body) => { + writes.push({ path: "/external-api", method: req.method, body }); + Object.assign(external, body); + reply(res, 200, { config: { ...external } }); + }); + }, + "/api/dsh-mneme/version-check": (req, res) => reply(res, 200, { version: "0.10.1", status: "latest" }), + "/api/dsh-mneme/create": (req, res) => + bodyOf(req).then((body) => { + writes.push({ path: "/create", method: req.method, body }); + reply(res, 201, { id: "m3", ...body, updated_at: "2026-11-03T00:00:00.000Z" }); + }), + "/api/dsh-mneme/update": (req, res) => + bodyOf(req).then((body) => { + writes.push({ path: "/update", method: req.method, body, token: req.headers["x-dsh-mneme-token"] }); + const memo = memories.find((row) => row.id === body.id); + if (memo === undefined) return reply(res, 404, { error: "not-found" }); + Object.assign(memo, body, { updated_at: "2026-11-04T00:00:00.000Z" }); + reply(res, 200, { ok: true, memory: memo }); + }), + "/api/dsh-mneme/profile-facts": (req, res) => { + const url = new URL(req.url, "http://x"); + reads.push({ path: "/profile-facts" }); + const agent = url.searchParams.get("agent") ?? ""; + reply(res, 200, { + cards: ["", "agent-a"], + agent, + facts: facts.items.filter((row) => row.agent_key === agent), + journal: [], + runs: facts.runs, + }); + }, + "/api/dsh-mneme/profile-facts/apply": (req, res) => + bodyOf(req).then((body) => { + writes.push({ path: "/profile-facts/apply", method: req.method, body }); + const applied = (body.ops ?? []).length; + for (const op of body.ops ?? []) { + if (op.op === "add") { + facts.items.push({ + id: `f${facts.items.length + 1}`, + agent_key: body.agent ?? "", + content: op.content, + status: "active", + created_at: "2026-11-06T00:00:00.000Z", + updated_at: "2026-11-06T00:00:00.000Z", + }); + } + if (op.op === "update") { + const row = facts.items.find((item) => item.id === op.id); + if (row !== undefined) row.content = op.content; + } + if (op.op === "archive") { + const row = facts.items.find((item) => item.id === op.id); + if (row !== undefined) row.status = "archived"; + } + } + reply(res, 200, { applied, failures: [], dupsSkipped: 0, facts: facts.items }); + }), + "/api/dsh-mneme/profile-facts/run": (req, res) => + bodyOf(req).then((body) => { + writes.push({ path: "/profile-facts/run", method: req.method, body }); + reply(res, 200, { status: "ok", opsApplied: 2, inputCount: 5 }); + }), + "/api/dsh-mneme/choro/repos": (req, res) => reply(res, 200, { repos: [{ id: "r1", name: "mneme" }] }), + "/api/dsh-mneme/choro/modules": (req, res) => + reply(res, 200, { + repo: { id: "r1", name: "mneme", gitBaseline: "abc1234def" }, + modules: [{ slug: "src", title: "src", file_count: 12, page_status: "skeleton" }], + edges: [], + }), + "/api/dsh-mneme/choro/page": (req, res) => + reply(res, 200, { page: { slug: "src", title: "src", content: "# src\n骨架正文" } }), + "/api/dsh-mneme/choro/run": (req, res) => + bodyOf(req).then((body) => { + writes.push({ path: "/choro/run", method: req.method, body }); + reply(res, 200, { ok: true, receipt: { pagesWritten: 3 } }); + }), + "/api/dsh-mneme/delete": (req, res) => + bodyOf(req).then((body) => { + writes.push({ path: "/delete", method: req.method, body }); + reply(res, 200, { ok: true }); + }), + }; + + return { memories, writes, reads, features, external, state, facts, routes }; +} + +function mount(world) { + const webServer = { + match(pathname) { + const handler = world.routes[pathname]; + return handler === undefined ? undefined : { kind: "exact", path: pathname, handler }; + }, + }; + let def; + const dshGo = { + panel(id, definition) { + assert.equal(id, "mneme"); + def = definition; + }, + }; + resetPanelState(); + mountDshGoPanel({ get: (name) => (name === "webServer" ? webServer : undefined), dshGo }, { apiToken: TOKEN }); + return def; +} + +const SCOPE_A = { sessionId: "s-a", turnId: "t-1", callId: "c-1" }; +const SCOPE_B = { sessionId: "s-b", turnId: "t-1", callId: "c-2" }; + +async function payloadOf(def, scope = SCOPE_A) { + const seen = []; + const cancel = def.data(scope, (payload) => seen.push(payload)); + for (let i = 0; i < 40 && seen.length === 0; i++) await new Promise((r) => setTimeout(r, 5)); + await new Promise((r) => setTimeout(r, 10)); + cancel(); + return seen.at(-1); +} + +// 动作之后的推送必须挂在同一个 emitter 上看:payloadOf 每次都会重新全量读 +// 一遍,既看不到「只推了局部」,也会让中间的 broadcast 因为没人听而被跳过 +// (评审 #7 / #8)。 +async function waitFor(predicate, label = "condition") { + for (let i = 0; i < 100; i++) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`timed out waiting for ${label}`); +} + +const viewText = (def) => JSON.stringify(def.view(SCOPE_A)); + +test("删除按面板 scope 核对:别的面板打开的条目不会被误删", async () => { + const world = makeWorld(); + const def = mount(world); + + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.open(SCOPE_B, { value: { id: "m2" } }); + + // A 面板的确认落到 B 打开的那条上:必须拒绝,并且不发 /delete。 + const refused = await def.actions.delete(SCOPE_A, { value: { confirmed: true } }); + assert.equal(refused.toast, "这条不是当前打开的记忆,已取消"); + assert.equal( + world.writes.filter((item) => item.path === "/delete").length, + 0, + ); + + // B 面板删自己那条:正常删除。 + await def.actions.delete(SCOPE_B, { value: { confirmed: true } }); + const deletes = world.writes.filter((item) => item.path === "/delete"); + assert.equal(deletes.length, 1); + assert.deepEqual(deletes[0].body, { id: "m2" }); +}); + +test("带点号的开关能连翻两次回到原值(state.flags 是扁平键)", async () => { + const world = makeWorld(); + const def = mount(world); + await payloadOf(def); + + const action = def.actions["flag.scopeState_enabled"]; + assert.equal(typeof action, "function"); + + await action(SCOPE_A, { value: {} }); + assert.equal(world.features.effective["scopeState.enabled"], true); + const first = world.writes.filter((item) => item.path === "/features").at(-1); + assert.deepEqual(first.body, { "scopeState.enabled": true }); + // 进程内写请求必须带令牌,否则配了 apiToken 的宿主会 401(评审 #2)。 + assert.equal(first.token, TOKEN); + + await action(SCOPE_A, { value: {} }); + assert.equal(world.features.effective["scopeState.enabled"], false); +}); + +test("搜索仍然遵守类型筛选(/search 不带 type)", async () => { + const world = makeWorld(); + const def = mount(world); + + await def.actions["type-filter"](SCOPE_A, { value: { value: "preference" } }); + await def.actions.search(SCOPE_A, { value: { query: "正文" } }); + const payload = await payloadOf(def); + assert.equal(payload.rows.length, 1); + assert.equal(payload.rows[0].title, "单位同行同字号"); +}); + +test("实体搜索走宿主的名字过滤,并且真的重读了实体清单", async () => { + const world = makeWorld(); + const def = mount(world); + + // 在同一个 emitter 上看推送:payloadOf 会重新全量读一遍,实体那一块 + // 永远是对的,正好把「搜索后推的还是旧列表」这个 bug 盖住(评审 #7)。 + const seen = []; + const cancel = def.data(SCOPE_A, (payload) => seen.push(payload)); + await waitFor(() => seen.length > 0, "initial payload"); + const before = seen.length; + + await def.actions["entity-search"](SCOPE_A, { value: { entityQuery: "bob" } }); + await waitFor(() => seen.length > before, "entity search push"); + + const payload = seen.at(-1); + assert.equal(payload.entities.length, 1); + assert.equal(payload.entities[0].title, "Bob"); + assert.equal( + world.reads.filter((item) => item.path === "/entities").length >= 2, + true, + "搜索后没有重读 /entities", + ); + cancel(); +}); + +test("规则页提交非数组不会把已有规则清空", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions["open-profile"](SCOPE_A, { value: { k: "rules" } }); + + const payload = await payloadOf(def); + assert.ok(payload.editText.includes("已有一条规则")); + + await def.actions["profile-save"](SCOPE_A, { value: { editText: "{}" } }); + assert.equal( + world.writes.filter((item) => item.path === "/rules").length, + 0, + ); + assert.deepEqual(world.state.rules, [{ text: "已有一条规则" }]); + const after = await payloadOf(def); + assert.match(after.err, /JSON 数组/); +}); + +test("从状态视图进冲突页会把视图切回设置", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions["switch-settings-view"](SCOPE_A, { value: { value: "status" } }); + assert.ok(viewText(def).includes("库内一览")); + + await def.actions["open-conflict"](SCOPE_A, { value: {} }); + const view = viewText(def); + assert.ok(view.includes("冲突队列")); + assert.ok(!view.includes("库内一览")); +}); + +test("外部访问页读的是宿主已存配置,且用 token=omit 读", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions["open-panel"](SCOPE_A, { value: { k: "external" } }); + const payload = await payloadOf(def); + + assert.equal(payload.sExtPort, "8899"); + assert.equal(payload.sExtHost, "0.0.0.0"); + const read = world.reads.filter((item) => item.path === "/external-api").at(-1); + assert.equal(read.omit, true); +}); + +test("保存只提交改过的字段", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.edit(SCOPE_A, { value: {} }); + await def.actions.save(SCOPE_A, { + value: { title: "面板改造 v2", content: "正文一", importance: 4, tags: "" }, + }); + + const update = world.writes.filter((item) => item.path === "/update").at(-1); + assert.deepEqual(Object.keys(update.body).sort(), ["id", "title"]); + assert.equal(update.body.title, "面板改造 v2"); +}); + +test("别处改过这条记忆时,保存不会覆盖对方", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.edit(SCOPE_A, { value: {} }); + + world.memories[0].updated_at = "2026-11-05T00:00:00.000Z"; + world.memories[0].content = "别处写入的新正文"; + + await def.actions.save(SCOPE_A, { + value: { title: "面板改造 v2", content: "正文一", importance: 4, tags: "" }, + }); + + assert.equal( + world.writes.filter((item) => item.path === "/update").length, + 0, + ); + const payload = await payloadOf(def); + assert.match(payload.err, /刚在别处被改过/); +}); + +test("重建索引的慢活带自己的超时,不沿用 5 秒默认值", async () => { + const world = makeWorld(); + const def = mount(world); + const result = await def.actions["vector-reindex"](SCOPE_A, { value: {} }); + assert.equal(result.pending.timeoutMs, 60000); + assert.deepEqual(await result.result(), {}); +}); + +test("新面板挂起来之后就有数据,且指标卡带单位", async () => { + const world = makeWorld(); + const def = mount(world); + const payload = await payloadOf(def); + assert.equal(payload.totalText, "2 条"); + assert.equal(payload.slotRateText, "75%"); + assert.equal(payload.statusKv.find((row) => row.label === "记忆").value, "2 条"); +}); + +test("翻页 chips:next 进第二页、prev 回来、第一页再 prev 不动", async () => { + const world = makeWorld(); + const offsets = []; + // 只有两条记忆,凑不出第二页:这里让 /list 报 57 条,并记下每次的 offset。 + world.routes["/api/dsh-mneme/list"] = (req, res) => { + const url = new URL(req.url, "http://x"); + const offset = Number(url.searchParams.get("offset") ?? 0); + // 状态页也会打 /list(limit=1 / limit=200)做统计,这里只认列表页那一次。 + if (Number(url.searchParams.get("limit") ?? 20) === 20) offsets.push(offset); + reply(res, 200, { items: world.memories.slice(0, 2), total: 57 }); + }; + const def = mount(world); + + const first = await payloadOf(def); + assert.equal(first.pageText, "57 条 · 1/3 页"); + // 绑定清空,选中的那枚胶囊不会一直带着勾。 + assert.equal(first.pagePick, ""); + + await def.actions.page(SCOPE_A, { value: "next" }); + const second = await payloadOf(def); + assert.equal(offsets.at(-1), 20); + assert.equal(second.pageText, "57 条 · 2/3 页"); + + await def.actions.page(SCOPE_A, { value: "prev" }); + const back = await payloadOf(def); + assert.equal(offsets.at(-1), 0); + assert.equal(back.pageText, "57 条 · 1/3 页"); + + // 第一页再往前:夹在 1,不会变成负 offset。 + await def.actions.page(SCOPE_A, { value: "prev" }); + await payloadOf(def); + assert.equal(offsets.at(-1), 0); +}); + +test("实体页空库用一行文案说,不再插空状态块", async () => { + const world = makeWorld(); + world.routes["/api/dsh-mneme/entities"] = (req, res) => reply(res, 200, { entities: [], total: 0 }); + const def = mount(world); + const payload = await payloadOf(def); + assert.match(payload.entityStatus, /还没有实体/); + assert.match(payload.entityStatus, /抽取人物与项目/); +}); + +// ---- 0.11.0 的新面板面:动态旗标、枚举档位、事实卡、地志 ---------------- + +test("「其它」组列出上游新增的布尔旗标,点一行就能翻转", async () => { + const world = makeWorld(); + // 一个我们没给中文名的布尔项 + 一个数值项:只有布尔项该出现在「其它」里。 + world.features.effective.entityRecallEnabled = false; + world.features.effective.maxInjectedItems = 4; + const def = mount(world); + + const payload = await payloadOf(def); + const titles = payload.extraRows.map((row) => row.title); + assert.deepEqual(titles, ["entityRecallEnabled"]); + assert.match(payload.extraRows[0].subtitle, /已关闭/u); + + await def.actions["flag-set"](SCOPE_A, { value: { key: "entityRecallEnabled" } }); + const write = world.writes.find((item) => item.path === "/features"); + assert.deepEqual(write.body, { entityRecallEnabled: true }); + assert.equal(write.token, TOKEN); +}); + +test("枚举旗标按字符串写入:地志注入档位 light/full/off", async () => { + const world = makeWorld(); + world.features.effective.chorographyInject = "light"; + const def = mount(world); + + await def.actions["flag.chorographyInject"](SCOPE_A, { value: "full" }); + const write = world.writes.find((item) => item.path === "/features"); + assert.deepEqual(write.body, { chorographyInject: "full" }); +}); + +test("事实卡:列出、逐条改、归档都走同一个 apply 入口", async () => { + const world = makeWorld(); + const def = mount(world); + + await def.actions["open-panel"](SCOPE_A, { value: { k: "facts" } }); + const payload = await payloadOf(def); + assert.equal(payload.factsRows.length, 1); + assert.match(payload.factsRows[0].title, /喜欢简洁/u); + assert.deepEqual(payload.factsCardOptions.map((item) => item.value), ["", "agent-a"]); + + await def.actions["facts-add"](SCOPE_A, { value: { sFactNew: "用中文回答" } }); + const added = world.writes.find((item) => item.path === "/profile-facts/apply"); + assert.deepEqual(added.body, { agent: "", ops: [{ op: "add", content: "用中文回答" }] }); + + await def.actions["facts-edit"](SCOPE_A, { value: { id: "f1" } }); + await def.actions["facts-save"](SCOPE_A, { value: { sFactEdit: "喜欢简短回复" } }); + const updated = world.writes.filter((item) => item.path === "/profile-facts/apply").at(-1); + assert.deepEqual(updated.body.ops, [{ op: "update", id: "f1", content: "喜欢简短回复" }]); + + // 归档要确认;没确认不发请求。 + const before = world.writes.length; + await def.actions["facts-archive"](SCOPE_A, { value: {} }); + assert.equal(world.writes.length, before); + await def.actions["facts-edit"](SCOPE_A, { value: { id: "f1" } }); + await def.actions["facts-archive"](SCOPE_A, { value: { confirmed: true } }); + const archived = world.writes.filter((item) => item.path === "/profile-facts/apply").at(-1); + assert.deepEqual(archived.body.ops, [{ op: "archive", id: "f1" }]); +}); + +test("事实维护轮是 pending 任务:最长 60 秒,结束后给一句人话", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions["open-panel"](SCOPE_A, { value: { k: "facts" } }); + + const effect = await def.actions["facts-run"](SCOPE_A, {}); + assert.equal(effect.pending.timeoutMs, 60000); + const result = await effect.result(); + assert.equal(result.toast, "事实已更新"); + const run = world.writes.find((item) => item.path === "/profile-facts/run"); + assert.deepEqual(run.body, { agent: "" }); +}); + +test("地志:重建骨架要先确认,模块页能读出来", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions["open-panel"](SCOPE_A, { value: { k: "choro" } }); + + const payload = await payloadOf(def); + assert.deepEqual(payload.choroRepoOptions, [{ label: "mneme", value: "r1" }]); + assert.equal(payload.choroRows[0].title, "src"); + assert.match(payload.choroNote, /1 个模块/u); + + const before = world.writes.length; + await def.actions["choro-run"](SCOPE_A, { value: {} }); + assert.equal(world.writes.length, before, "没确认就重建了骨架"); + + const effect = await def.actions["choro-run"](SCOPE_A, { value: { confirmed: true } }); + assert.equal(effect.pending.timeoutMs, 60000); + await effect.result(); + const run = world.writes.find((item) => item.path === "/choro/run"); + assert.deepEqual(run.body, { repo: "r1" }); + + await def.actions["choro-page"](SCOPE_A, { value: { slug: "src" } }); + const after = await payloadOf(def); + assert.match(after.choroPageText, /骨架正文/u); +}); + +test("翻开关不重画视图:只推数据,别把用户从设置页弹回记忆页", async () => { + const world = makeWorld(); + const def = mount(world); + + // 值类动作:不申请 refresh(App 收到 refresh 会重建视图树,页签选择跟着丢失)。 + const flipped = await def.actions["flag.heatEnabled"](SCOPE_A, { value: true }); + assert.equal(flipped.refresh, false, "翻开关不该触发视图重建"); + const after = await payloadOf(def); + assert.equal(after.flags.heatEnabled, true, "绑定值要跟着推过去"); + + const enumed = await def.actions["flag.chorographyInject"](SCOPE_A, { value: "full" }); + assert.equal(enumed.refresh, false); + assert.equal((await payloadOf(def)).flags.chorographyInject, "full"); + + // 结构类动作仍然要重画:进子页、换分区都靠它。 + const opened = await def.actions["open-panel"](SCOPE_A, { value: { k: "facts" } }); + assert.equal(opened.refresh, true, "进子页需要重建视图"); +}); + +// ---- 评审复审补的两个洞:跨面板归档、按 id 回读、局部加载 ---------------- + +test("归档按面板 scope 核对:别的面板打开的条目不会被归档", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.open(SCOPE_B, { value: { id: "m2" } }); + + // A 面板的归档落到 B 打开的那条上:必须拒绝,并且不发 /update。 + const refused = await def.actions.archive(SCOPE_A, { value: {} }); + assert.equal(refused.toast, "这条不是当前打开的记忆,已取消"); + assert.equal( + world.writes.filter((item) => item.path === "/update").length, + 0, + ); + + await def.actions.archive(SCOPE_B, { value: {} }); + const write = world.writes.filter((item) => item.path === "/update").at(-1); + assert.deepEqual(write.body, { id: "m2", archived: true }); +}); + +test("打开记忆按 id 回读单条,不再扫 500 条列表", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions.open(SCOPE_A, { value: { id: "m2" } }); + + const listReads = world.reads.filter((item) => item.path === "/list"); + assert.ok( + listReads.some((item) => item.url.includes("id=m2")), + "打开时没有按 id 向宿主要最新值", + ); + assert.ok( + !listReads.some((item) => item.url.includes("limit=500")), + "仍在扫 500 条列表再拿缓存兜底", + ); +}); + +test("冲突后按最新记忆重建比对基准,第二次保存仍做版本检查", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.edit(SCOPE_A, { value: {} }); + + world.memories[0].updated_at = "2026-11-05T00:00:00.000Z"; + world.memories[0].content = "别处写入的新正文"; + + await def.actions.save(SCOPE_A, { + value: { title: "面板改造 v2", content: "正文一", importance: 4, tags: "" }, + }); + assert.equal( + world.writes.filter((item) => item.path === "/update").length, + 0, + "冲突时不该提交", + ); + + // 冲突后表单已换成最新内容;用户只改了标题,再保存只应提交标题。 + await def.actions.save(SCOPE_A, { + value: { title: "面板改造 v2", content: "别处写入的新正文", importance: 4, tags: "" }, + }); + const update = world.writes.filter((item) => item.path === "/update").at(-1); + assert.deepEqual(Object.keys(update.body).sort(), ["id", "title"]); +}); + +test("列表页的动作只重读列表,不再顺手读实体与设置", async () => { + const world = makeWorld(); + const def = mount(world); + // 必须留一个活着的 emitter,否则 broadcast() 直接返回,这条用例就失去了 + // 检查「重读了什么」的意义(评审 #8)。 + const pushed = []; + const cancel = def.data(SCOPE_A, (payload) => pushed.push(payload)); + await waitFor(() => pushed.length > 0, "initial payload"); + + const seen = []; + for (const path of [ + "/api/dsh-mneme/entities", + "/api/dsh-mneme/features", + "/api/dsh-mneme/dream-status", + ]) { + const original = world.routes[path]; + world.routes[path] = (req, res, ...rest) => { + seen.push(path); + return original(req, res, ...rest); + }; + } + + const before = pushed.length; + await def.actions.page(SCOPE_A, { value: { value: "next" } }); + await waitFor(() => pushed.length > before, "page push"); + // dream-status 不是设置块:列表行上的「待确认」徽标要用它的 pendingMemoryIds, + // 所以它属于列表这一块;实体与设置则绝不能被列表动作顺手重读。 + assert.deepEqual(seen.filter((path) => !path.endsWith("/dream-status")), []); + assert.ok(seen.includes("/api/dsh-mneme/dream-status"), "列表行仍需要待确认标记"); + cancel(); +}); + +test("事实保存与归档也按面板 scope 核对", async () => { + const world = makeWorld(); + const def = mount(world); + // 留一个活着的 emitter:广播真的执行,设置块(含事实清单)才会重读。 + const seen = []; + const cancel = def.data(SCOPE_A, (payload) => seen.push(payload)); + await waitFor(() => seen.length > 0, "initial payload"); + + await def.actions["open-panel"](SCOPE_A, { value: { k: "facts" } }); + await def.actions["facts-edit"](SCOPE_A, { value: { id: "f1" } }); + + // B 面板加一条并打开它:state.factEdit 被换成 B 的那条。 + await def.actions["open-panel"](SCOPE_B, { value: { k: "facts" } }); + await def.actions["facts-add"](SCOPE_B, { value: { sFactNew: "新的偏好" } }); + const added = world.facts.items.find((item) => item.content === "新的偏好"); + await def.actions["facts-edit"](SCOPE_B, { value: { id: added.id } }); + + const before = world.writes.filter((item) => item.path === "/profile-facts/apply").length; + const refusedSave = await def.actions["facts-save"](SCOPE_A, { value: { sFactEdit: "别写这条" } }); + assert.equal(refusedSave.toast, "这条不是当前打开的事实,已取消"); + assert.equal(world.writes.filter((item) => item.path === "/profile-facts/apply").length, before); + + const refusedArchive = await def.actions["facts-archive"](SCOPE_A, { value: { confirmed: true } }); + assert.equal(refusedArchive.toast, "这条不是当前打开的事实,已取消"); + assert.equal(world.writes.filter((item) => item.path === "/profile-facts/apply").length, before); + cancel(); +}); + +test("pending 动作等活干完再推数据,不推这一轮之前的旧列表", async () => { + const world = makeWorld(); + const def = mount(world); + await def.actions["open-panel"](SCOPE_A, { value: { k: "facts" } }); + + // 挂一个活着的 emitter,否则 broadcast() 会因为没人听而直接返回。 + const seen = []; + const cancel = def.data(SCOPE_A, (payload) => seen.push(payload)); + for (let i = 0; i < 40 && seen.length === 0; i++) await new Promise((r) => setTimeout(r, 5)); + const before = world.reads.filter((item) => item.path === "/profile-facts").length; + + const effect = await def.actions["facts-run"](SCOPE_A, {}); + assert.equal( + world.reads.filter((item) => item.path === "/profile-facts").length, + before, + "任务还没跑完就推了旧数据", + ); + + await effect.result(); + assert.equal( + world.reads.filter((item) => item.path === "/profile-facts").length, + before + 1, + "任务跑完后没有重新推数据", + ); + cancel(); +}); + +test("保存也按面板 scope 核对:别的面板打开的条目不会被写进内容", async () => { + const world = makeWorld(); + const def = mount(world); + + // A 面板打开 m1 并进编辑;B 面板随后打开 m2 并进编辑,state.form 被换成 m2。 + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.edit(SCOPE_A, { value: {} }); + await def.actions.open(SCOPE_B, { value: { id: "m2" } }); + await def.actions.edit(SCOPE_B, { value: {} }); + + const before = world.writes.filter((item) => item.path === "/update").length; + const refused = await def.actions.save(SCOPE_A, { value: { title: "改标题", content: "正文一" } }); + assert.equal(refused.toast, "这条不是当前打开的记忆,已取消"); + assert.equal(world.writes.filter((item) => item.path === "/update").length, before); + assert.equal(world.memories[1].title, "单位同行同字号"); +}); + +test("外部访问配置读失败时不整体写回,避免覆盖 Web 面板里的端口", async () => { + const world = makeWorld(); + const def = mount(world); + world.routes["/api/dsh-mneme/external-api"] = (req, res) => reply(res, 500, { error: "boom" }); + + await def.actions["open-panel"](SCOPE_A, { value: { k: "external" } }); + const refused = await def.actions["external-save"](SCOPE_A, { + value: { sExtPort: "8888", sExtHost: "0.0.0.0" }, + }); + assert.match(String(refused.toast), /还没读到外部访问配置/); + assert.equal(world.writes.filter((item) => item.path === "/external-api").length, 0); +}); + +test("向量配置读失败时不整体写回", async () => { + const world = makeWorld(); + const def = mount(world); + world.routes["/api/dsh-mneme/vector-config"] = (req, res) => reply(res, 500, { error: "boom" }); + + await def.actions["open-panel"](SCOPE_A, { value: { k: "vector" } }); + const refused = await def.actions["vector-toggle"](SCOPE_A, { value: { value: true } }); + assert.match(String(refused.toast), /还没读到向量配置/); + assert.equal(world.writes.filter((item) => item.path === "/vector-config").length, 0); +}); + +test("溯源 409 用小写常量判断,能翻成「没开保留蒸馏原文」", async () => { + const world = makeWorld(); + const def = mount(world); + world.routes["/api/dsh-mneme/memory/provenance"] = (req, res) => + reply(res, 409, { error: "distill-records-disabled" }); + + await def.actions.open(SCOPE_A, { value: { id: "m1" } }); + await def.actions.provenance(SCOPE_A, {}); + const payload = await payloadOf(def); + assert.match(String(payload.provenanceText), /保留蒸馏原文/); +}); + +test("冲突裁决按面板 scope 核对,别的面板选中的冲突不会被合并", async () => { + const world = makeWorld(); + const def = mount(world); + + await def.actions["conflict-pick-row"](SCOPE_A, { value: { id: "c-a" } }); + await def.actions["conflict-pick-row"](SCOPE_B, { value: { id: "c-b" } }); + + const refused = await def.actions["conflict-win"](SCOPE_A, { value: { confirmed: true } }); + assert.equal(refused.toast, "这条不是当前打开的冲突,已取消"); + assert.equal(world.writes.filter((item) => item.path === "/conflicts/resolve").length, 0); +}); + +test("一个动作只推一次数据,内容就是它改的那块", async () => { + const world = makeWorld(); + const def = mount(world); + const seen = []; + const cancel = def.data(SCOPE_A, (payload) => seen.push(payload)); + await waitFor(() => seen.length > 0, "initial payload"); + const before = seen.length; + + await def.actions["flag-set"](SCOPE_A, { value: { key: "heatEnabled" } }); + await waitFor(() => seen.length > before, "flag push"); + // 再等一会儿:如果 broadcast 被调了两次,这里会多出一条。 + await new Promise((r) => setTimeout(r, 40)); + + assert.equal(seen.length, before + 1); + assert.equal(seen.at(-1).flags.heatEnabled, true); + cancel(); +}); diff --git a/dsh-mneme/test/dsh-go-panel.test.js b/dsh-mneme/test/dsh-go-panel.test.js new file mode 100644 index 00000000..d4dc19fa --- /dev/null +++ b/dsh-mneme/test/dsh-go-panel.test.js @@ -0,0 +1,251 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildView } from "../src/dsh-go/views/root.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = join(HERE, ".."); + +// 这组守的是**视图契约**,不是像素:dsh-go 只认 JSON,越界(节点数 / 层数 / 字节) +// 是会在用户手机上直接报错的硬失败,所以每加一屏都要过一遍预算。 +// 取数(src/dsh-go/host.js)与动作(src/dsh-go/panel.js)需要真宿主,端到端在 +// dsh-go-bridge 那一侧验证;这里只锁得到「视图能拼出来、拼得合规」。 + +const LIMITS = { nodes: 120, depth: 8, bytes: 32768 }; + +/** + * 与 bridge 的 viewStats 同口径:节点 = 带 `type` 的组件对象(bindings / options / + * actions 里的参数对象不算节点),层数 = 组件 children 的递归深度。 + */ +function stats(node, depth = 1) { + if (node === null || typeof node !== "object") return { nodes: 0, depth }; + if (Array.isArray(node)) { + let nodes = 0; + let deepest = depth; + for (const child of node) { + const s = stats(child, depth); + nodes += s.nodes; + deepest = Math.max(deepest, s.depth); + } + return { nodes, depth: deepest }; + } + const isComponent = typeof node.type === "string"; + let nodes = isComponent ? 1 : 0; + let deepest = isComponent ? depth : depth; + for (const value of Object.values(node)) { + if (value === null || typeof value !== "object") continue; + const s = stats(value, isComponent ? depth + 1 : depth); + nodes += s.nodes; + deepest = Math.max(deepest, s.depth); + } + return { nodes, depth: deepest }; +} + +/** 面板状态的默认形状(与 src/dsh-go/panel.js 的 state 对齐,覆盖视图读到的每个字段)。 */ +function state(over = {}) { + return { + page: "list", + flagGroup: "basic", + settingsView: "settings", + sPanel: "menu", + profileTab: "profile", + q: "", + type: "all", + range: "all", + minImportance: 0, + scope: "all", + semantic: false, + viewMode: "flat", + heatSort: false, + filtersOpen: false, + offset: 0, + size: 20, + current: null, + form: {}, + entityTotal: 12, + entityQuery: "", + entityName: "", + entityDepth: 1, + entityRelationCount: 0, + entityMemoryCount: 0, + flags: {}, + flagOverrides: {}, + pendingRestart: [], + pendingConflicts: 0, + pendingIds: new Set(), + rowsCount: 0, + monthGroups: [], + detailEntities: [], + detailEntityCount: 0, + detailConflict: false, + provenanceText: "", + conflictCount: 0, + conflictId: "", + conflictPick: "", + conflictA: null, + conflictB: null, + conflictReady: false, + wbKv: [], + wbDeposited: [], + wbArchived: [], + commands: [], + cmdCount: 0, + sCmdPick: "", + sCmdName: "", + sCmdDesc: "", + sCmdIns: "", + vector: { enabled: false, baseUrl: "", model: "" }, + external: { enabled: false, port: 8790, host: "127.0.0.1" }, + versionNote: "", + extractText: "", + injectPreviewText: "", + tokenCategories: [], + tokenSeries: [], + tokenKv: [], + heatSeries: [], + heatNote: "", + reclaimText: "", + reclaimConfirm: false, + notice: "", + noticeTone: "ok", + err: "", + ...over, + }; +} + +const SCREENS = [ + ["记忆列表", state()], + ["记忆列表·月视图", state({ viewMode: "month", monthGroups: [{ label: "2026-11", rows: [] }] })], + ["记忆详情", state({ page: "detail", current: { id: "m1", archived: false } })], + ["记忆编辑", state({ page: "editor", form: { id: "m1", type: "project" } })], + ["记忆新建", state({ page: "editor", form: {} })], + ["冲突队列", state({ page: "conflict" })], + ["冲突对照", state({ page: "conflict", conflictReady: true, conflictPick: "a", conflictCount: 1 })], + ["实体", state({ entityTotal: 0 })], + ["实体详情", state({ page: "entity" })], + ["设置首页", state()], + ["状态", state({ settingsView: "status" })], + ["画像", state({ page: "profile" })], + ["人格", state({ page: "profile", profileTab: "persona" })], + ["规则", state({ page: "profile", profileTab: "rules" })], + ["工作动态", state({ sPanel: "workbench" })], + ["自定义指令", state({ sPanel: "commands", cmdCount: 1, commands: [{ id: "c1", name: "x" }], sCmdPick: "c1" })], + ["向量检索", state({ sPanel: "vector" })], + ["外部访问", state({ sPanel: "external" })], + ["关于", state({ sPanel: "help" })], + ["Agent 眼中的你", state({ sPanel: "facts" })], + ["代码库地志", state({ sPanel: "choro" })], +]; + +for (const [name, fixture] of SCREENS) { + test(`dsh-go 视图在预算内:${name}`, () => { + const view = buildView(fixture); + assert.equal(view.v, 2); + const s = stats(view); + assert.ok(s.nodes <= LIMITS.nodes, `${name} 节点 ${s.nodes} > ${LIMITS.nodes}`); + assert.ok(s.depth <= LIMITS.depth, `${name} 层 ${s.depth} > ${LIMITS.depth}`); + const bytes = Buffer.byteLength(JSON.stringify(view), "utf8"); + assert.ok(bytes <= LIMITS.bytes, `${name} 体积 ${bytes} > ${LIMITS.bytes}`); + }); +} + +test("dsh-go 顶层是三个原生页签(App 页签条每格 80dp,第四格会折行)", () => { + const view = buildView(state()); + const tabs = view.children.find((node) => node.type === "tabs"); + assert.ok(tabs, "顶层没有 tabs"); + assert.deepEqual( + tabs.items.map((item) => item.label), + ["记忆", "实体", "设置"], + ); +}); + +test("功能开关铺在设置页正文里,不是子页面(#dsh-go 对齐电脑端分组)", () => { + const view = buildView(state()); + const flat = JSON.stringify(view); + assert.ok(flat.includes("功能开关"), "缺少功能开关标题"); + assert.ok(!flat.includes("功能开关(分组)"), "标题仍带着「(分组)」"); + assert.ok(flat.includes('"bind":"data.flagGroup"'), "分组选择器没有绑定"); + assert.ok(/"bind":"data\.flags\./.test(flat), "没有开关绑定到 data.flags.*"); + assert.ok(!flat.includes('"k":"flags"'), "还有指向 flags 子页面的入口"); +}); + +function walk(node, out = []) { + if (node === null || typeof node !== "object") return out; + if (Array.isArray(node)) { + for (const child of node) walk(child, out); + return out; + } + out.push(node); + for (const value of Object.values(node)) walk(value, out); + return out; +} + +test("设置页里不再出现自造的解释性小字(只留 mneme 原文案)", () => { + const flat = JSON.stringify(buildView(state())); + const invented = [ + "38 项开关", + "写成斜杠命令,随时调用", + "跨会话记住你是谁", + "接口地址、模型、重建索引", + "版本检测与数据来源", + "轻量模式少做后台整理", + "沉淀 =", + ]; + for (const phrase of invented) assert.ok(!flat.includes(phrase), `自造文案还在:${phrase}`); + assert.ok(flat.includes("控制插件在后台做哪些事"), "缺少 mneme 原文案(features.desc)"); + assert.ok(flat.includes("轻量模式只保留核心能力"), "缺少 mneme 原文案(mode.desc)"); +}); + +test("dsh-go 清单合法:一个面板、placement 只落 session.tab / features", () => { + const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")); + const manifest = pkg.dshGo; + assert.equal(manifest.v, 1); + const panels = manifest.contributes.panels; + assert.equal(panels.length, 1); + const [panel] = panels; + assert.equal(panel.id, "mneme"); + assert.ok(["session.tab", "features"].includes(panel.placement[0])); + // 面板里不出现密钥 / 令牌字样:清单是 App 侧可读的静态文件 + assert.ok(!JSON.stringify(manifest).includes("token")); +}); + +test("记忆页与实体页的树不随数据变:首屏和拿到数据后是同一棵树", () => { + // 面板的 view() 在 data() 之前就要交出整棵树,所以按数据决定「要不要这块」 + // 的地方,首屏会先按默认值定形,之后数据推来了也只是填绑定——空状态块就会 + // 和有数据的列表同时挂在屏幕上。这里把两棵树直接比对,锁住结构稳定。 + const empty = JSON.stringify(buildView(state({ rowsCount: 0, entityTotal: 0 }))); + const filled = JSON.stringify(buildView(state({ rowsCount: 20, entityTotal: 12 }))); + assert.equal(empty, filled, "视图结构仍然跟着数据走,首屏会留下残影"); + assert.ok(!empty.includes('"type":"emptyState"'), "记忆 / 实体页不再用数据决定空状态块"); +}); + +test("翻页是 App 原生的两段 segmented:一行铺满,不折行也不叠按钮", () => { + const nodes = walk(buildView(state())); + const pager = nodes.find((node) => node.type === "segmented" && node.bind === "data.pagePick"); + assert.ok(pager, "缺少翻页 segmented"); + assert.deepEqual( + pager.options.map((option) => option.label), + ["上一页", "下一页"], + ); + assert.deepEqual( + pager.options.map((option) => option.value), + ["prev", "next"], + ); + assert.equal(pager.onChange.id, "page"); + // 页码文案挂在 label 上(必绘),不再单独占一行。 + assert.deepEqual(pager.label, { $: "data.pageText" }); + + // 回归:曾经用两个 stack + chips 做左右对齐,右列在真机上渲染失败 + // (App 显示「这部分无法显示」),所以翻页只能是单个节点。 + assert.equal( + nodes.filter((node) => node.type === "chips" && node.bind === "data.pagePick").length, + 0, + "翻页又回到了 chips 方案", + ); + const folded = nodes.filter( + (node) => node.type === "actions" && (node.items ?? []).some((item) => item.action?.id === "page"), + ); + assert.equal(folded.length, 0, "翻页按钮还在 actions 里:窄屏会被折成上下两行"); +});