From d60c27cce27a26b2851b540dace02917ea99205d Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 12 Oct 2026 04:38:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(386):=20=E9=9D=A2=E6=9D=BF=E5=8F=AF?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=96=B0=E5=A2=9E=E8=AE=B0=E5=BF=86=EF=BC=8C?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=B8=8E=E5=8E=BB=E9=87=8D=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20memory=5Fsave=EF=BC=88#386=20=E7=AC=AC=205=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板此前只能读、改、删、导入 Markdown 镜像——想手记一条只能切回对话让模型调 memory_save,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动 落库。CLI add 与外部 API POST /memories 都能建行,面板是唯一没有写入口的那条。 新增后端路由 POST /api/dsh-mneme/create,与 /update、/delete 同名法;写入走 service.saveWithDedupe,与工具/CLI/外部 API 同一条路径——去重键仍是 (type, title, scope, sensitivity),所以面板建与工具建同标题会并入同一行而不是 长出两份。响应带 action: "created" | "merged",面板据此分别提示「已新建」与 「已并入同标题的既有条目」:两者对用户是不同的事实,都报「已保存」会让人以为 自己新写了一条、实际旧行被改。 三处刻意的口径差异(代码注释里都写了理由,免得下一个人以为抄漏): - content 要求 trim 非空,与 /update 一致而非 standalone 的宽松版:面板是人工 输入,空正文只可能是误操作;standalone 面对程序化调用,放宽是为兼容「先建壳 再补内容」的既有客户端。 - document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe 会 throw,这里提前给干净错误码而不是让它落成 500。 - 不接受 scope/sensitivity:面板的 scope 编辑在详情抽屉里,那条路带「放宽可见 性」二次确认;新建时就盖章会绕开那道确认。 写入准入(#254)今天对面板路径不生效——它无会话身份即放行,而面板路由恰好不带 _sessionKey——但路由仍显式处理 action === "denied" → 422 且理由随行:将来若把 会话身份接进面板,落回 toApiList([null]) 会把「被拒」伪装成「服务器出错」,两者 的处置完全不同。 面板侧:「更多操作」菜单加「新增记忆…」,弹层收类型/标题/正文/重要性/标签。类型 下拉刻意不含 document,与 memory_save 的 enum 逐字对齐(7 种);新建后既刷新列表 也选中新行——merged 时返回的 id 就是被并入那一行,两种情况都该滚进视野。 回归锁:test/api-routes-gaps.test.js 六条(鉴权 401/GET 404/六种 400 校验且库内 零写入/201 created/200 merged 且 count()===1/denied→422/落库异常 500)、 test/client.test.js 一条锁「入口真的挂在树上」(菜单项真的调 setCreateOpen(true)、 弹层真的被条件挂载、onCreated 真的刷新并选中、表单不得长出 scope/sensitivity、 类型下拉不得含 document)。变异检验两侧各七条逐条转红。 npm test:1627 tests / 1626 pass / 0 fail / 1 skipped;src↔lib 一致(57 个文件); 双 README 测试数已同步到 1627。 Refs #386(第 5 条;其余 7 条未动,不关整条 issue) --- README.md | 6 +- dsh-mneme/CHANGELOG.md | 4 + dsh-mneme/README.md | 6 +- dsh-mneme/lib/api.js | 91 ++++++++++++++- dsh-mneme/lib/client.js | 147 ++++++++++++++++++++++- dsh-mneme/src/api.js | 91 ++++++++++++++- dsh-mneme/test/api-routes-gaps.test.js | 155 +++++++++++++++++++++++++ dsh-mneme/test/client.test.js | 67 +++++++++++ 8 files changed, 558 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0e5c9356..72a770b7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -207,7 +207,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 1620 个测试 +npm test # 1627 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -414,7 +414,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 1620 tests +npm test # 1627 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 1ad9cad3..93b0ac39 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## 🆕 新增 + +- **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。两侧都做了变异检验:面板七条、后端七条,逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「表单长回 scope」「打错路由」这类只有肉眼才能发现的退化)。 + ## 🐛 修复 - **面板文字对比度全面达标 WCAG AA(issue #178)**:面板有 270 条 `color` 声明,此前 **70 条**在浅色或深色主题下达不到 AA(正文 4.5:1、大字号 3:1;浅色 65 条、深色 5 条,最差的两条 `.mneme-statfoot` 只有 1.26 / 1.64)。根因是面板直接拿宿主的**标签层** token 当正文色(`label-tertiary` 50 处、`label-dimmed` 4 处):这些是给分隔线/占位符用的低对比层,不是正文层。另有三处**图形**同病(不在文字口径内,按 3:1 复核):热度条与热度点的冷档用 `label-dimmed` 压在热度轨道上只有 1.14:1、关系图连线描边 1.26:1,等于把三档热度画成同一个看不见的灰。修法不是逐条换颜色,而是在四个渲染根上定义**面板自己的语义色层**:`.mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry{…}` 六条 `--mneme-*`(`fg-dim` / `graphic-dim` / `accent` / `danger` / `warn` / `ok`),每条都用 `color-mix()` 从宿主 token 混出——`fg-dim` = `label-primary` 60% + 透明(浅 4.94 / 深 6.30),其余同法。**四个根一个都不能少**:`.mneme-overlay` 与 `.mneme-topentry` 是 `createPortal` 出来的独立子树,`.mneme-triggerwrap` 挂在宿主 sidebar footer 插槽,只挂 `.mneme-x` 的话这三个根拿不到变量、静默退回旧色。顺带修掉 **5 个幻影 token**(`--dsw-alias-state-error` 19 处、`state-warning` 14 处、`state-success` 3 处、`border` 2 处、`state-warn` 1 处):这些名字宿主从未定义过,一直靠 CSS 兜底字面量在跑,宿主改配色时面板不会跟——现全部换成真名(`state-error-primary` / `static-amber-600` / `border-l1` 等)。另需分清:`label-dimmed`(4 处)与 `label-tertiary`(50 处)**都是真实存在的宿主 token**,问题不在名字而在用法——宿主把它们定位成分隔线/占位符的低对比层,面板却拿来当正文与图形色。三处 knockout 配色一并修:`.mneme-conflict-primary` 浅色主题下白字压品牌蓝 `#4176e6` 只有 4.23:1,底色换成 `--mneme-accent`(浅 5.32 / 深 7.67);`.mneme-entrybadge` 深色主题下 `bg-layer-1` 压 `#c33` 只有 3.06:1,底色换成 `--mneme-danger`;`.mneme-btndangerconfirm` 底色同步换成 `--mneme-danger`,并把写死的 `#fff` 换成 `var(--dsw-alias-bg-layer-1)`——它旧的写死白字只在旧底色上侥幸过关,底色一变浅就会在深色主题掉到 2.67:1。回归锁 `test/contrast.test.js` 五条:①把宿主 token 的浅/深取值快照硬编码进测试(CI 上没有宿主包,读不到 asar),逐条重算全部 `color` 声明的对比度,两主题都必须过,**解析不出的颜色记失败而不是跳过**(否则改成 `var(--mneme-unknown)` 就能静默溜过);②面板引用的每个 `--dsw-*` 必须在快照表里存在(幻影名回归锁);③语义色层的选择器必须恰好是那四个根(少了根主锁照样全绿——主锁只算颜色,不知道变量有没有继承到);④图形色(`background` / `stroke`,非 `color`)单独按 3:1 复核——热度条、热度点、打卡点、关系图连线都不在主锁的视野里;⑤虚线关系边不得靠 `opacity` 区分(无 `memory_id` 的边同时命中 `.mneme-gedge` 与 `.mneme-gedge-dashed`,后者的 `opacity:.7` 把 50% 描边压到浅色 2.28,而图形锁只读单条规则、看不见这种叠加)。变异检验七条:改淡 `fg-dim`、一处正文用回宿主标签色、塞回幻影名、渲染根少挂一个、热度冷档改回宿主 `label-dimmed`、一条 `color` 改成解析不出的 `var(--mneme-unknown)`、虚线边加回 `opacity:.7`,七条锁分别转红。 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 07425533..0eaa0851 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-1620%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-1627%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -613,7 +613,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 1620 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 1627 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -622,7 +622,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 1620 个测试 +npm test # 运行 1627 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index 96dfd6d2..b149ae52 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -1,6 +1,7 @@ import { URL } from "node:url"; import { timingSafeEqual, randomBytes } from "node:crypto"; import { FEATURE_FLAG_SPEC } from "./settings.js"; +import { TYPES } from "./store.js"; import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js"; import { langOf } from "./lang.js"; import { computeHeat } from "./heat.js"; @@ -602,6 +603,94 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- 新建一条记忆(面板写路径,与 /update、/delete 对称命名)--------------- + // #386 第 5 条:CLI `add` 与 standalone `POST /memories` 都能建行,面板此前只 + // 能看/改/删/导入。字段与 memory_save 工具对齐(type/title/content/importance/ + // tags),走同一条 saveWithDedupe 路径——去重键仍是 (type, title, scope, + // sensitivity),所以「面板建」与「工具建」同标题会并入同一行,不会长出两份。 + // + // 三处刻意的口径差异(都写在代码里,免得下一个人以为是抄漏): + // 1. content 要求 trim 后非空,与 /update 一致而非 standalone 的宽松版: + // 面板是人工输入,空正文只可能是误操作;standalone 面对的是程序化调用, + // 放宽是为了兼容「先建壳再补内容」的既有客户端。 + // 2. document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe + // 会 throw,这里提前给干净错误码而不是让它落成 500。 + // 3. 不接受 scope/sensitivity 字段——面板的 scope 编辑在详情抽屉里(带放宽 + // 确认的安全阀,见 lib/client.js 的 MemoryDrawer),新建时就盖章 explicit + // 会绕过那道确认。传了也不报错,只是不生效(与 /update 的「字段出现即校验」 + // 不同,因为这里根本没有该字段的语义)。 + register({ + kind: "exact", + path: "/api/dsh-mneme/create", + handler(req, res) { + try { + if (req.method !== "POST") { + sendJson(res, 404, { error: "not-found" }); + return; + } + if (!requireAuth(req, res, apiToken)) return; + return readBody(req).then((text) => { + const body = parseBody(text); + if (!TYPES.has(body.type)) { + sendJson(res, 400, { error: "invalid-type" }); + return; + } + if (body.type === "document") { + sendJson(res, 400, { error: "document-requires-register" }); + return; + } + if (typeof body.title !== "string" || !body.title.trim()) { + sendJson(res, 400, { error: "invalid-title" }); + return; + } + if (typeof body.content !== "string" || !body.content.trim()) { + sendJson(res, 400, { error: "invalid-content" }); + return; + } + if (body.importance !== undefined + && (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5)) { + sendJson(res, 400, { error: "invalid-importance" }); + return; + } + if (body.tags !== undefined && (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string"))) { + sendJson(res, 400, { error: "invalid-tags" }); + return; + } + try { + const result = service.saveWithDedupe({ + type: body.type, + title: body.title.trim(), + content: body.content.trim(), + importance: body.importance, + tags: body.tags ?? [], + // 面板不是工具也不是外部程序,来源要能在详情抽屉里一眼认出是谁写的。 + source: "panel" + }); + // #254 写入准入在无会话身份时放行(write-admission.js 首行早退),面板 + // 路由恰好不带 _sessionKey,所以今天到不了这个分支。仍然显式写出来: + // 将来若把会话身份接进面板,落到 toApiList([null]) 上会变成 500,把 + // 「被拒」伪装成「服务器出错」——两者的处置完全不同。 + if (result.action === "denied") { + sendJson(res, 422, { error: "write-rejected", reason: result.reason }); + return; + } + // action 随行透出(created/merged):面板据此提示「已并入同标题的既有条目」 + // 还是「新建成功」——两者对用户是不同的事实,不能都报「已保存」。 + sendJson(res, result.action === "created" ? 201 : 200, { + ...service.toApiList([result.memory])[0], + action: result.action + }); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] panel create failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + } + }); + } catch { + sendJson(res, 500, { error: "internal" }); + } + } + }); + // --- rules --- register({ kind: "exact", @@ -1429,7 +1518,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = }); return { - routes: 32, + routes: 33, dispose: () => { for (const dispose of disposers) dispose(); } diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 7988e1f4..f843219e 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -577,6 +577,20 @@ window.__ModuleLoader__.load({ "memory.explorer.imported": "已合并 {n} 条编辑", "memory.explorer.importFailed": "导入失败", "memory.explorer.importNoFile": "请先选择文件", + "memory.explorer.newMemory": "新增记忆…", + "memory.explorer.newTitle": "新增记忆", + "memory.explorer.newHint": "手动写一条记忆。同类型同标题会并入既有条目,而不是长出第二行。", + "memory.explorer.newType": "类型", + "memory.explorer.newTitlePlaceholder": "短标题,也是去重键", + "memory.explorer.newContentPlaceholder": "正文", + "memory.explorer.newTagsPlaceholder": "标签,逗号分隔(可留空)", + "memory.explorer.newConfirm": "新建", + "memory.explorer.newSaving": "保存中…", + "memory.explorer.newCreated": "已新建", + "memory.explorer.newMerged": "已并入同标题的既有条目", + "memory.explorer.newFailed": "新建失败", + "memory.explorer.newNoTitle": "请先填写标题", + "memory.explorer.newNoContent": "请先填写内容", "memory.explorer.exportFailed": "导出失败", "memory.explorer.conflictBadge": "冲突", "memory.explorer.archivedBadge": "已归档", @@ -1052,6 +1066,20 @@ window.__ModuleLoader__.load({ "memory.explorer.imported": "Merged {n} edits", "memory.explorer.importFailed": "Import failed", "memory.explorer.importNoFile": "Choose a file first", + "memory.explorer.newMemory": "New memory…", + "memory.explorer.newTitle": "New memory", + "memory.explorer.newHint": "Write one memory by hand. Same type + same title merges into the existing entry instead of minting a second row.", + "memory.explorer.newType": "Type", + "memory.explorer.newTitlePlaceholder": "Short title; also the dedupe key", + "memory.explorer.newContentPlaceholder": "Body", + "memory.explorer.newTagsPlaceholder": "Tags, comma separated (optional)", + "memory.explorer.newConfirm": "Create", + "memory.explorer.newSaving": "Saving…", + "memory.explorer.newCreated": "Created", + "memory.explorer.newMerged": "Merged into the existing entry with that title", + "memory.explorer.newFailed": "Create failed", + "memory.explorer.newNoTitle": "Title is required", + "memory.explorer.newNoContent": "Body is required", "memory.explorer.exportFailed": "Export failed", "memory.explorer.conflictBadge": "Conflict", "memory.explorer.archivedBadge": "Archived", @@ -4576,6 +4604,113 @@ window.__ModuleLoader__.load({ ); } + // #386 第 5 条:面板手动新增一条记忆。字段与 memory_save 工具对齐 + //(type/title/content/importance/tags),但**不放 scope/sensitivity**:归属的 + // 显式声明走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖 + // 章会绕开那道确认。后端的去重键仍是 (type, title, scope, sensitivity),所以 + // 同类型同标题会并入既有行——服务端回的 action 决定提示语说「已新建」还是 + // 「已并入」,两者对用户是不同的事实,不能都报「已保存」。 + function CreateDialog({ t, onClose, onCreated }) { + const [type, setType] = useState("preference"); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [importance, setImportance] = useState(3); + const [tags, setTags] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(""); + const [err, setErr] = useState(""); + const submit = () => { + // 前端先拦一次空标题/空正文:后端也会 400,但往返一趟只为报「标题不能 + // 为空」不值当。服务端校验仍是权威——它同时管着类型白名单与去重。 + if (!title.trim()) { setErr(t("memory.explorer.newNoTitle")); return; } + if (!content.trim()) { setErr(t("memory.explorer.newNoContent")); return; } + setBusy(true); + setErr(""); + apiFetch("/api/dsh-mneme/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + title: title.trim(), + content: content.trim(), + importance, + tags: tags.split(",").map((s) => s.trim()).filter(Boolean) + }) + }) + .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); }) + .then((d) => { + setMsg(d && d.action === "merged" ? t("memory.explorer.newMerged") : t("memory.explorer.newCreated")); + setBusy(false); + // 停 900ms 让提示可读,再关窗并把新建的行选中(见 onCreated)。 + setTimeout(() => { onCreated(d); onClose(); }, 900); + }) + .catch(() => { setBusy(false); setErr(t("memory.explorer.newFailed")); }); + }; + return h("div", { className: "mneme-impdialog", role: "region", "aria-label": t("memory.explorer.newTitle") }, + h("div", { className: "mneme-set-title" }, t("memory.explorer.newTitle")), + h("div", { className: "mneme-featsubhint", style: { marginTop: 4 } }, t("memory.explorer.newHint")), + h("div", { className: "mneme-improw" }, + h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.newType")), + h("select", { className: "mneme-select", value: type, onChange: (e) => setType(e.target.value) }, + // document 刻意不在列:它是 agent 注册长文档的指针行,铸造口唯一 + //(registerDocument,#230),后端路由也会 400。列出来只会让人点了 + // 才被拒。 + ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"].map((k) => + h("option", { key: k, value: k }, typeLabel(t, k)))) + ), + h("div", { className: "mneme-improw" }, + h("input", { + type: "text", + className: "mneme-set-input", + value: title, + placeholder: t("memory.explorer.newTitlePlaceholder"), + "aria-label": t("memory.explorer.newTitlePlaceholder"), + onChange: (e) => { setTitle(e.target.value); setErr(""); } + }) + ), + h("div", { className: "mneme-improw" }, + h("textarea", { + className: "mneme-set-input mneme-set-input--area", + value: content, + placeholder: t("memory.explorer.newContentPlaceholder"), + "aria-label": t("memory.explorer.newContentPlaceholder"), + rows: 4, + onChange: (e) => { setContent(e.target.value); setErr(""); } + }) + ), + h("div", { className: "mneme-improw" }, + h("input", { + type: "text", + className: "mneme-set-input", + value: tags, + placeholder: t("memory.explorer.newTagsPlaceholder"), + "aria-label": t("memory.explorer.newTagsPlaceholder"), + onChange: (e) => setTags(e.target.value) + }) + ), + h("div", { className: "mneme-improw" }, + h("span", { className: "mneme-featnumlabel" }, t("memory.explorer.importance")), + h("div", { className: "mneme-staredit", role: "radiogroup", "aria-label": t("memory.explorer.detail.editImportance") }, + [1, 2, 3, 4, 5].map((n) => h("button", { + key: n, + type: "button", + role: "radio", + "aria-checked": String(importance === n), + "aria-label": t("memory.explorer.detail.starLabel").replace("{n}", String(n)), + className: importance >= n ? "mneme-starbtn mneme-starbtn--on" : "mneme-starbtn", + onClick: () => setImportance(n) + }, h(StarGlyph, { size: 15, filled: importance >= n })))) + ), + err && h("div", { className: "mneme-featsubhint", style: { color: "var(--mneme-danger)", marginTop: 8 } }, err), + msg && h("div", { className: "mneme-saved", style: { marginTop: 8 } }, msg), + h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } }, + h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose }, t("memory.explorer.importCancel")), + h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: submit }, + busy ? t("memory.explorer.newSaving") : t("memory.explorer.newConfirm")) + ) + ); + } + function MemoryExplorer({ t }) { const [view, setView] = useState("memory"); // memory | entity | status | settings const [items, setItems] = useState([]); // loaded browse pages, chrono desc @@ -4611,6 +4746,7 @@ window.__ModuleLoader__.load({ const [pendingIds, setPendingIds] = useState(() => new Set()); const [menuOpen, setMenuOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const itemRefs = useRef(new Map()); const moreRef = useRef(null); @@ -4960,9 +5096,11 @@ window.__ModuleLoader__.load({ "aria-label": t("memory.explorer.more"), "aria-expanded": String(menuOpen), title: t("memory.explorer.more"), - onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); } + onClick: () => { setMenuOpen(!menuOpen); setImportOpen(false); setCreateOpen(false); } }, "⋯"), menuOpen && h("div", { className: "mneme-menu", role: "menu" }, + h("button", { type: "button", role: "menuitem", onClick: () => { setCreateOpen(true); setMenuOpen(false); } }, + t("memory.explorer.newMemory")), h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("json"); } }, t("memory.explorer.exportJson")), h("button", { type: "button", role: "menuitem", onClick: () => { setMenuOpen(false); exportData("markdown"); } }, @@ -4974,6 +5112,13 @@ window.__ModuleLoader__.load({ t, onClose: () => setImportOpen(false), onImported: () => setReloadKey((k) => k + 1) + }), + createOpen && h(CreateDialog, { + t, + onClose: () => setCreateOpen(false), + // 新建后既刷新列表也选中新行:并入既有条目时 action 是 merged, + // 返回的 id 就是被并入那一行——两种情况都该把它滚进视野。 + onCreated: (d) => { setReloadKey((k) => k + 1); if (d && d.id) setSelectedId(d.id); } }) ) ), diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index 96dfd6d2..b149ae52 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -1,6 +1,7 @@ import { URL } from "node:url"; import { timingSafeEqual, randomBytes } from "node:crypto"; import { FEATURE_FLAG_SPEC } from "./settings.js"; +import { TYPES } from "./store.js"; import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js"; import { langOf } from "./lang.js"; import { computeHeat } from "./heat.js"; @@ -602,6 +603,94 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = } }); + // --- 新建一条记忆(面板写路径,与 /update、/delete 对称命名)--------------- + // #386 第 5 条:CLI `add` 与 standalone `POST /memories` 都能建行,面板此前只 + // 能看/改/删/导入。字段与 memory_save 工具对齐(type/title/content/importance/ + // tags),走同一条 saveWithDedupe 路径——去重键仍是 (type, title, scope, + // sensitivity),所以「面板建」与「工具建」同标题会并入同一行,不会长出两份。 + // + // 三处刻意的口径差异(都写在代码里,免得下一个人以为是抄漏): + // 1. content 要求 trim 后非空,与 /update 一致而非 standalone 的宽松版: + // 面板是人工输入,空正文只可能是误操作;standalone 面对的是程序化调用, + // 放宽是为了兼容「先建壳再补内容」的既有客户端。 + // 2. document 直接 400:行铸造口唯一(registerDocument,#230),saveWithDedupe + // 会 throw,这里提前给干净错误码而不是让它落成 500。 + // 3. 不接受 scope/sensitivity 字段——面板的 scope 编辑在详情抽屉里(带放宽 + // 确认的安全阀,见 lib/client.js 的 MemoryDrawer),新建时就盖章 explicit + // 会绕过那道确认。传了也不报错,只是不生效(与 /update 的「字段出现即校验」 + // 不同,因为这里根本没有该字段的语义)。 + register({ + kind: "exact", + path: "/api/dsh-mneme/create", + handler(req, res) { + try { + if (req.method !== "POST") { + sendJson(res, 404, { error: "not-found" }); + return; + } + if (!requireAuth(req, res, apiToken)) return; + return readBody(req).then((text) => { + const body = parseBody(text); + if (!TYPES.has(body.type)) { + sendJson(res, 400, { error: "invalid-type" }); + return; + } + if (body.type === "document") { + sendJson(res, 400, { error: "document-requires-register" }); + return; + } + if (typeof body.title !== "string" || !body.title.trim()) { + sendJson(res, 400, { error: "invalid-title" }); + return; + } + if (typeof body.content !== "string" || !body.content.trim()) { + sendJson(res, 400, { error: "invalid-content" }); + return; + } + if (body.importance !== undefined + && (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5)) { + sendJson(res, 400, { error: "invalid-importance" }); + return; + } + if (body.tags !== undefined && (!Array.isArray(body.tags) || !body.tags.every((tag) => typeof tag === "string"))) { + sendJson(res, 400, { error: "invalid-tags" }); + return; + } + try { + const result = service.saveWithDedupe({ + type: body.type, + title: body.title.trim(), + content: body.content.trim(), + importance: body.importance, + tags: body.tags ?? [], + // 面板不是工具也不是外部程序,来源要能在详情抽屉里一眼认出是谁写的。 + source: "panel" + }); + // #254 写入准入在无会话身份时放行(write-admission.js 首行早退),面板 + // 路由恰好不带 _sessionKey,所以今天到不了这个分支。仍然显式写出来: + // 将来若把会话身份接进面板,落到 toApiList([null]) 上会变成 500,把 + // 「被拒」伪装成「服务器出错」——两者的处置完全不同。 + if (result.action === "denied") { + sendJson(res, 422, { error: "write-rejected", reason: result.reason }); + return; + } + // action 随行透出(created/merged):面板据此提示「已并入同标题的既有条目」 + // 还是「新建成功」——两者对用户是不同的事实,不能都报「已保存」。 + sendJson(res, result.action === "created" ? 201 : 200, { + ...service.toApiList([result.memory])[0], + action: result.action + }); + } catch (error) { + ctx.logger?.warn?.(`[dsh-mneme] panel create failed: ${String(error)}`); + sendJson(res, 500, { error: "internal" }); + } + }); + } catch { + sendJson(res, 500, { error: "internal" }); + } + } + }); + // --- rules --- register({ kind: "exact", @@ -1429,7 +1518,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = }); return { - routes: 32, + routes: 33, dispose: () => { for (const dispose of disposers) dispose(); } diff --git a/dsh-mneme/test/api-routes-gaps.test.js b/dsh-mneme/test/api-routes-gaps.test.js index a27e73d9..527bd7e8 100644 --- a/dsh-mneme/test/api-routes-gaps.test.js +++ b/dsh-mneme/test/api-routes-gaps.test.js @@ -216,3 +216,158 @@ test("external-api PUT applies valid config and echoes it", async () => { assert.equal(cfg.host, "127.0.0.1"); assert.ok(typeof cfg.token === "string"); }); + +// --------------------------------------------------------------------------- +// /api/dsh-mneme/create(#386 第 5 条:面板手动新增记忆) +// --------------------------------------------------------------------------- +// 这些用例锁的是「面板建行」与「工具建行」必须同语义:同一条 saveWithDedupe +// 路径、同一套去重键。历史缺口是面板只有看/改/删/导入,用户想手写一条必须开 +// 终端或调外部 API——两条旁路都绕开了面板的去重提示。 +test("create is auth-gated and rejects non-POST", async () => { + const { routes } = setup("secret-token"); + const route = handler(routes, "/api/dsh-mneme/create"); + assert.ok(route, "route must be registered"); + + // 未带 token → 401(写路由与 /update、/delete 同口径) + let res = new FakeRes(); + await route.handler(req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" }), res); + assert.equal(res.statusCode, 401); + assert.equal(JSON.parse(res.body).error, "unauthorized"); + + // 带 token 但 GET → 404(不是 405:与既有写路由一致,避免探测路由存在性) + res = new FakeRes(); + const ok = req("/api/dsh-mneme/create", "GET"); + ok.headers = { authorization: "Bearer secret-token" }; + await route.handler(ok, res); + assert.equal(res.statusCode, 404); +}); + +test("create validates type/title/content and refuses document rows", async () => { + const { routes, service } = setup("secret-token"); + const route = handler(routes, "/api/dsh-mneme/create"); + const post = async (body) => { + const r = req("/api/dsh-mneme/create", "POST", body); + r.headers = { authorization: "Bearer secret-token" }; + const res = new FakeRes(); + await route.handler(r, res); + return res; + }; + + // 未知类型 → 400(TYPES 是唯一真相源,面板不得自己维护第二份白名单) + let res = await post({ type: "not-a-type", title: "t", content: "c" }); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "invalid-type"); + + // document 是 registerDocument 的专属铸造口(#230)→ 干净 400,不落成 500 + res = await post({ type: "document", title: "t", content: "c" }); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "document-requires-register"); + + // 空白标题 / 空白正文 → 400(面板是人工输入,空正文只可能是误操作) + res = await post({ type: "project", title: " ", content: "c" }); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "invalid-title"); + res = await post({ type: "project", title: "t", content: " \n " }); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "invalid-content"); + + // importance 越界 / tags 非字符串数组 → 400,宁拒绝不静默纠正 + res = await post({ type: "project", title: "t", content: "c", importance: 9 }); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "invalid-importance"); + res = await post({ type: "project", title: "t", content: "c", tags: [1, 2] }); + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, "invalid-tags"); + + // 全部被拒后一条都没落库 + assert.equal(service.count(), 0); +}); + +test("create saves a memory and reports action=created with 201", async () => { + const { routes, service } = setup("secret-token"); + const route = handler(routes, "/api/dsh-mneme/create"); + + const r = req("/api/dsh-mneme/create", "POST", { + type: "constraint", + title: "面板手写一条", + content: "正文", + importance: 4, + tags: ["panel"] + }); + r.headers = { authorization: "Bearer secret-token" }; + const res = new FakeRes(); + await route.handler(r, res); + + assert.equal(res.statusCode, 201); + const body = JSON.parse(res.body); + assert.equal(body.action, "created"); + assert.equal(body.type, "constraint"); + assert.equal(body.title, "面板手写一条"); + assert.equal(body.importance, 4); + assert.deepEqual(body.tags, ["panel"]); + // source 必须能一眼认出是面板写的——详情抽屉把它当「来源」显示 + assert.equal(body.source, "panel"); + assert.ok(service.getById(body.id), "row really exists in the store"); +}); + +test("create merges into an existing row with the same (type, title) and says so", async () => { + const { routes, service } = setup("secret-token"); + const route = handler(routes, "/api/dsh-mneme/create"); + service.saveWithDedupe({ type: "project", title: "同标题", content: "原来的正文" }); + + const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "同标题", content: "追加的正文" }); + r.headers = { authorization: "Bearer secret-token" }; + const res = new FakeRes(); + await route.handler(r, res); + + // 200 而非 201:并入了既有行,没有新行诞生。面板据此提示「已并入」而不是 + // 「新建成功」——两者对用户是不同的事实。 + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.action, "merged"); + assert.equal(service.count(), 1, "no second row was minted"); + assert.ok(service.getById(body.id).content.includes("追加的正文")); +}); + +test("create maps a denied write to 422 instead of leaking a 500", async () => { + // #254 写入准入今天在无会话身份时放行(write-admission.js 首行早退),面板路由 + // 恰好不带 _sessionKey,所以这条分支当前到不了。仍然要锁:一旦有人把会话身份接 + // 进面板,落回 toApiList([null]) 会把「被拒」伪装成「服务器出错」——用户看到的 + // 是「新建失败」,实际是准入拒绝,两者的处置完全不同。 + const { routes, service } = setup("secret-token"); + const route = handler(routes, "/api/dsh-mneme/create"); + const original = service.saveWithDedupe; + service.saveWithDedupe = () => ({ action: "denied", reason: "too-many-writes", memory: null }); + try { + const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" }); + r.headers = { authorization: "Bearer secret-token" }; + const res = new FakeRes(); + await route.handler(r, res); + assert.equal(res.statusCode, 422); + const body = JSON.parse(res.body); + assert.equal(body.error, "write-rejected"); + assert.equal(body.reason, "too-many-writes", "the reason must survive: it is what makes the rejection actionable"); + } finally { + service.saveWithDedupe = original; + } +}); + +test("create returns 500 (not a leaked throw) when the store rejects the write", async () => { + const { routes, service } = setup("secret-token"); + const route = handler(routes, "/api/dsh-mneme/create"); + // 绕过路由校验直达 saveWithDedupe 的守卫:document 行会 throw。这里用打桩 + // 模拟「路由认为合法、存储层仍然拒绝」的那类情况(类型白名单漂移等)。 + const original = service.saveWithDedupe; + service.saveWithDedupe = () => { throw new Error("invalid memory type: whatever"); }; + try { + const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" }); + r.headers = { authorization: "Bearer secret-token" }; + const res = new FakeRes(); + await route.handler(r, res); + assert.equal(res.statusCode, 500); + assert.equal(JSON.parse(res.body).error, "internal"); + } finally { + service.saveWithDedupe = original; + } +}); + diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 9066b22a..bf75d8f5 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -1256,6 +1256,73 @@ test("conflict queue: expandable full text with decision metadata", () => { assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`); } }); + +// #386 第 5 条:面板手动新增记忆。这一块最容易悄悄退化——菜单项被删掉、弹层被 +// 从树里摘掉、或者表单又长回 scope/sensitivity 输入框(那会绕开详情抽屉的「放宽 +// 可见性」二次确认),面板表面上看不出坏,只是入口没了。所以既锁「入口存在」, +// 也锁「入口真的挂在树上」。 +test("panel can create a memory by hand: menu entry wired to the dialog", () => { + // 入口在「更多操作」菜单里,且菜单项真的调用 setCreateOpen(true)——只断言文案 + // 存在的话,把 onClick 摘掉测试照样绿。 + assert.ok(clientSource.includes('"memory.explorer.newMemory"'), "menu entry label must exist"); + assert.ok(/role: "menuitem", onClick: \(\) => \{ setCreateOpen\(true\); setMenuOpen\(false\); \}/.test(clientSource), + "the menu item must actually open the create dialog"); + assert.ok(clientSource.includes("const [createOpen, setCreateOpen] = useState(false)"), + "the dialog needs its own open state"); + // 弹层必须挂在树上并接上回调:onCreated 同时刷新列表与选中新行(并入既有条目时 + // 返回的 id 是被并入那一行,也该滚进视野)。 + assert.ok(/createOpen && h\(CreateDialog, \{/.test(clientSource), "the dialog must be mounted conditionally"); + assert.ok(/onCreated: \(d\) => \{ setReloadKey\(\(k\) => k \+ 1\); if \(d && d\.id\) setSelectedId\(d\.id\); \}/.test(clientSource), + "creating must refresh the list and select the resulting row"); + // 打后端的路由是 /create,不是 standalone 的 /memories:面板与工具必须走同一条 + // saveWithDedupe 路径,否则「同标题并入」的提示在面板上会失灵。 + assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/create"'), + "the dialog must post to the panel create route"); + assert.ok(/d\.action === "merged" \? t\("memory\.explorer\.newMerged"\) : t\("memory\.explorer\.newCreated"\)/.test(clientSource), + "merged and created are different facts and must be reported differently"); + // 表单字段与 memory_save 工具对齐;scope/sensitivity 刻意不放(见 CreateDialog 注释)。 + for (const field of ["type", "title", "content", "importance", "tags"]) { + assert.ok(clientSource.includes(`${field}`), `form must carry ${field}`); + } + assert.equal(/CreateDialog[\s\S]{0,4000}?newScope|CreateDialog[\s\S]{0,4000}?sensitivity/.test(clientSource), false, + "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard"); + // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。 + const dialogStart = clientSource.indexOf("function CreateDialog"); + const dialogEnd = clientSource.indexOf("function MemoryExplorer", dialogStart); + const dialogSource = clientSource.slice(dialogStart, dialogEnd); + assert.equal(dialogSource.includes('"document"'), false, + "document rows are minted only via registerDocument and must not be offered here"); + // 类型白名单与 src/tools.js 的 memory_save enum 逐字一致(缺一个就会有人点进去才被拒)。 + for (const k of ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]) { + assert.ok(dialogSource.includes(`"${k}"`), `type option ${k} must be offered`); + } + // 前端空值拦截只是省一次往返,服务端校验才是权威——但两者都得在,否则空标题会 + // 一路发出去再弹一个 400 给用户看。 + assert.ok(dialogSource.includes('if (!title.trim()) { setErr(t("memory.explorer.newNoTitle")); return; }'), + "empty title must be caught before the round trip"); + assert.ok(dialogSource.includes('if (!content.trim()) { setErr(t("memory.explorer.newNoContent")); return; }'), + "empty body must be caught before the round trip"); + // 十四个新键必须中英成对(与上面那条全局锁同口径,这里列出名字便于定位)。 + for (const key of [ + "memory.explorer.newMemory", + "memory.explorer.newTitle", + "memory.explorer.newHint", + "memory.explorer.newType", + "memory.explorer.newTitlePlaceholder", + "memory.explorer.newContentPlaceholder", + "memory.explorer.newTagsPlaceholder", + "memory.explorer.newConfirm", + "memory.explorer.newSaving", + "memory.explorer.newCreated", + "memory.explorer.newMerged", + "memory.explorer.newFailed", + "memory.explorer.newNoTitle", + "memory.explorer.newNoContent" + ]) { + const occurrences = clientSource.split(`"${key}"`).length - 1; + assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`); + } +}); // 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含), // 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进 // 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛 From e80ee0ae609dde14c751616da626076a6cc515af Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 12 Oct 2026 05:01:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(386):=20=E5=BB=BA=E5=AE=8C=E5=90=8E?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=B8=8D=E5=86=8D=E5=8F=AF=E7=82=B9=EF=BC=8C?= =?UTF-8?q?=E5=85=8D=E5=BE=97=E5=90=8C=E6=A0=87=E9=A2=98=E4=BA=8C=E6=AC=A1?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=8A=8A=E6=AD=A3=E6=96=87=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E4=B8=A4=E9=81=8D=EF=BC=88#408=20=E8=AF=84=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成功回调先 setBusy(false) 再等 900ms 关窗,这段时间「新建」按钮重新可点。 第二次同 type+title 会命中去重键落进同一行,service 走 appendContent 把正文 再追加一遍,提示还会从「已新建」翻成「已并入」——一次误触就写坏一条记忆。 现在成功路径不重置 busy,按钮全程 disabled 直到关窗;按钮文案同步改成 busy && !msg,否则写完了还写着「保存中…」。 test/client.test.js 的字段锁原先在整包上断言 clientSource.includes("title"), 恒真。改成在 CreateDialog 切片上匹配真实请求体写法,多一个字段(scope/ sensitivity)或少一个 trim 都匹配不上。 回归锁新增两条;变异检验四条全 RED(放回 setBusy(false)、文案只看 busy、 请求体多带 scope、title 不 trim)。 Refs #386 --- dsh-mneme/CHANGELOG.md | 2 +- dsh-mneme/lib/client.js | 8 ++++++-- dsh-mneme/test/client.test.js | 29 ++++++++++++++++++++++------- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 93b0ac39..99664f99 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -4,7 +4,7 @@ ## 🆕 新增 -- **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。两侧都做了变异检验:面板七条、后端七条,逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「表单长回 scope」「打错路由」这类只有肉眼才能发现的退化)。 +- **面板手动新增记忆(issue #386 第 5 条)**:面板此前只能读、改、删、导入,**没有任何手写入口**——想记一条只能切到对话里让模型调 `memory_save`,而模型看不见的东西(一次现场结论、一条口头约定)恰恰最需要手动落库。「更多操作」菜单新增「新增记忆…」,弹层收类型 / 标题 / 正文 / 重要性 / 标签,字段与 `memory_save` 工具对齐。类型下拉**刻意不含 `document`**:那是 agent 注册长文档的指针行,铸造口唯一(`registerDocument`,#230),列出来只会让人点了才被拒。**表单不放 scope / sensitivity**:显式归属走详情抽屉那条路,它带「放宽可见性」二次确认;在新建表单里顺手盖章会绕开那道确认。新增后端路由 `POST /api/dsh-mneme/create`,落库走 `service.saveWithDedupe`——去重键仍是 `(type, title, scope, sensitivity)`,所以**同类型同标题会并入既有条目而不是长出第二行**,服务端回 `action: "created" | "merged"` 决定提示语说「已新建」还是「已并入」:两者对用户是不同的事实,都报「已保存」会让人以为自己新写了一条、实际旧行被改。三处校验比 standalone 的 `POST /memories` 收紧(口径向 `/update` 看齐):`content` 要 trim 非空(面板是人工输入,空正文只可能是误操作)、`document` 提前 400、scope/sensitivity 不接受。写入准入(`write-admission.js`)对面板路径不生效——它无会话身份即放行——但路由仍显式处理 `action === "denied"` → 422,免得日后面板带上会话身份时静默吞掉拒绝。回归锁六条进 `test/api-routes-gaps.test.js`(①鉴权 401 且 GET 落 404;②六种 400 校验且库内零写入;③201 created 且 `source` 为 `panel`;④200 merged 且 `count() === 1`;⑤`denied` → 422 且理由随行;⑥落库异常 500),面板侧另有一条 `test/client.test.js` 锁「入口真的挂在树上」(菜单项确实调 `setCreateOpen(true)`、弹层确实被条件挂载、`onCreated` 确实刷新列表并选中新行、请求体逐字只有 type/title/content/importance/tags、表单不得长出 scope/sensitivity 输入框、类型下拉不得含 `document`)。评审期补的两处:**成功路径不得重置 `busy`**——提示要停 900ms 才关窗,那段时间按钮若重新可点,第二次同 type+title 会落进同一行并由 `appendContent` 把正文再追加一遍,提示还会从「已新建」翻成「已并入」,一次误触就写坏一条记忆;因此整个弹层只允许错误路径重置 `busy`,按钮文案也随之改成 `busy && !msg`,免得写完了还写着「保存中…」。另把字段锁从「整包里包含这些词」改成在 `CreateDialog` 源码切片上匹配真实请求体写法(在整包上断言 `clientSource.includes("title")` 恒真,等于没锁)。两侧都做了变异检验:面板/后端逐条把锁打回坏状态都转红(含「菜单项变哑」「弹层摘掉」「成功路径放回 setBusy(false)」「按钮文案只看 busy」「表单长回 scope」「不 trim title」「打错路由」这类只有肉眼才能发现的退化)。 ## 🐛 修复 diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index f843219e..a7886735 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -4640,7 +4640,11 @@ window.__ModuleLoader__.load({ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); }) .then((d) => { setMsg(d && d.action === "merged" ? t("memory.explorer.newMerged") : t("memory.explorer.newCreated")); - setBusy(false); + // 成功后**不**重置 busy。这 900ms 是留给提示的阅读时间,而「新建」按钮此刻 + // 若重新可点,第二次同 type+title 会落进同一行,service 走 appendContent 把 + // 正文再追加一遍,提示还会从「已新建」翻成「已并入」——一次误触就写坏一条 + // 记忆。保持 busy 直到关窗,按钮全程 disabled(成功路径组件随即卸载,没有 + // 需要复原的状态)。 // 停 900ms 让提示可读,再关窗并把新建的行选中(见 onCreated)。 setTimeout(() => { onCreated(d); onClose(); }, 900); }) @@ -4706,7 +4710,7 @@ window.__ModuleLoader__.load({ h("div", { className: "mneme-improw", style: { justifyContent: "flex-end" } }, h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: onClose }, t("memory.explorer.importCancel")), h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: submit }, - busy ? t("memory.explorer.newSaving") : t("memory.explorer.newConfirm")) + busy && !msg ? t("memory.explorer.newSaving") : t("memory.explorer.newConfirm")) ) ); } diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index bf75d8f5..acecb2fb 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -1280,16 +1280,31 @@ test("panel can create a memory by hand: menu entry wired to the dialog", () => "the dialog must post to the panel create route"); assert.ok(/d\.action === "merged" \? t\("memory\.explorer\.newMerged"\) : t\("memory\.explorer\.newCreated"\)/.test(clientSource), "merged and created are different facts and must be reported differently"); - // 表单字段与 memory_save 工具对齐;scope/sensitivity 刻意不放(见 CreateDialog 注释)。 - for (const field of ["type", "title", "content", "importance", "tags"]) { - assert.ok(clientSource.includes(`${field}`), `form must carry ${field}`); - } - assert.equal(/CreateDialog[\s\S]{0,4000}?newScope|CreateDialog[\s\S]{0,4000}?sensitivity/.test(clientSource), false, - "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard"); - // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。 + // 弹层源码切片:下面「表单里有什么 / 没有什么」的断言一律在这段上做。在整包上做 + // 等于没锁——这些字段名在 bundle 里到处都是,`clientSource.includes("title")` 恒真。 const dialogStart = clientSource.indexOf("function CreateDialog"); const dialogEnd = clientSource.indexOf("function MemoryExplorer", dialogStart); + assert.ok(dialogStart >= 0 && dialogEnd > dialogStart, "CreateDialog must be findable in the bundle"); const dialogSource = clientSource.slice(dialogStart, dialogEnd); + // 表单字段与 memory_save 工具对齐,且锁的是**真实请求体写法**:多一个字段(比如 + // scope/sensitivity)这段就匹配不上,少一个 trim 也匹配不上。锁裸字段名会恒真。 + assert.match(dialogSource, + /JSON\.stringify\(\{\s*type,\s*title: title\.trim\(\),\s*content: content\.trim\(\),\s*importance,\s*tags: tags\.split\(","\)\.map\(\(s\) => s\.trim\(\)\)\.filter\(Boolean\)\s*\}\)/, + "the create request body must carry exactly type/title/content/importance/tags"); + // 成功路径**不得**重置 busy:提示要停 900ms 才关窗,这期间按钮若重新可点,第二次 + // 同 type+title 会落进同一行并把正文再追加一遍(service 走 appendContent),一次 + // 误触就写坏一条记忆。所以全弹层只允许 catch 那条路重置 busy。 + assert.equal(dialogSource.split("setBusy(false)").length - 1, 1, + "only the error path may reset busy; the success path must stay disabled until the dialog closes"); + assert.match(dialogSource, /\.catch\(\(\) => \{ setBusy\(false\); setErr\(t\("memory\.explorer\.newFailed"\)\); \}\)/, + "the single busy reset must be the error path"); + // 按钮文案同理:成功后 busy 仍为 true,若只看 busy,写入早就完成了按钮还写着「保存 + // 中…」——既骗用户又和旁边那行「已新建」自相矛盾。msg 一置上就切回正常文案。 + assert.match(dialogSource, /busy && !msg \? t\("memory\.explorer\.newSaving"\) : t\("memory\.explorer\.newConfirm"\)/, + "once the write succeeded the button must stop claiming it is still saving"); + assert.equal(/newScope|sensitivity/.test(dialogSource), false, + "the create form must not offer scope/sensitivity: that path bypasses the widen-confirm guard"); + // document 不在类型下拉里:铸造口唯一(registerDocument,#230),后端也会 400。 assert.equal(dialogSource.includes('"document"'), false, "document rows are minted only via registerDocument and must not be offered here"); // 类型白名单与 src/tools.js 的 memory_save enum 逐字一致(缺一个就会有人点进去才被拒)。